login.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. """Tornado handlers for logging into the notebook."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import re
  5. import os
  6. try:
  7. from urllib.parse import urlparse, urlunparse # Py 3
  8. except ImportError:
  9. from urlparse import urlparse, urlunparse # Py 2
  10. import uuid
  11. from tornado.escape import url_escape
  12. from .security import passwd_check, set_password
  13. from ..base.handlers import IPythonHandler
  14. class LoginHandler(IPythonHandler):
  15. """The basic tornado login handler
  16. authenticates with a hashed password from the configuration.
  17. """
  18. def _render(self, message=None):
  19. self.write(self.render_template('login.html',
  20. next=url_escape(self.get_argument('next', default=self.base_url)),
  21. message=message,
  22. ))
  23. def _redirect_safe(self, url, default=None):
  24. """Redirect if url is on our PATH
  25. Full-domain redirects are allowed if they pass our CORS origin checks.
  26. Otherwise use default (self.base_url if unspecified).
  27. """
  28. if default is None:
  29. default = self.base_url
  30. # protect chrome users from mishandling unescaped backslashes.
  31. # \ is not valid in urls, but some browsers treat it as /
  32. # instead of %5C, causing `\\` to behave as `//`
  33. url = url.replace("\\", "%5C")
  34. parsed = urlparse(url)
  35. path_only = urlunparse(parsed._replace(netloc='', scheme=''))
  36. if url != path_only or not (parsed.path + '/').startswith(self.base_url):
  37. # require that next_url be absolute path within our path
  38. allow = False
  39. # OR pass our cross-origin check
  40. if url != path_only:
  41. # if full URL, run our cross-origin check:
  42. origin = '%s://%s' % (parsed.scheme, parsed.netloc)
  43. origin = origin.lower()
  44. if origin == '%s://%s' % (self.request.protocol, self.request.host):
  45. allow = True
  46. elif self.allow_origin:
  47. allow = self.allow_origin == origin
  48. elif self.allow_origin_pat:
  49. allow = bool(self.allow_origin_pat.match(origin))
  50. if not allow:
  51. # not allowed, use default
  52. self.log.warning("Not allowing login redirect to %r" % url)
  53. url = default
  54. self.redirect(url)
  55. def get(self):
  56. if self.current_user:
  57. next_url = self.get_argument('next', default=self.base_url)
  58. self._redirect_safe(next_url)
  59. else:
  60. self._render()
  61. @property
  62. def hashed_password(self):
  63. return self.password_from_settings(self.settings)
  64. def passwd_check(self, a, b):
  65. return passwd_check(a, b)
  66. def post(self):
  67. typed_password = self.get_argument('password', default=u'')
  68. new_password = self.get_argument('new_password', default=u'')
  69. if self.get_login_available(self.settings):
  70. if self.passwd_check(self.hashed_password, typed_password) and not new_password:
  71. self.set_login_cookie(self, uuid.uuid4().hex)
  72. elif self.token and self.token == typed_password:
  73. self.set_login_cookie(self, uuid.uuid4().hex)
  74. if new_password and self.settings.get('allow_password_change'):
  75. config_dir = self.settings.get('config_dir')
  76. config_file = os.path.join(config_dir, 'jupyter_notebook_config.json')
  77. set_password(new_password, config_file=config_file)
  78. self.log.info("Wrote hashed password to %s" % config_file)
  79. else:
  80. self.set_status(401)
  81. self._render(message={'error': 'Invalid credentials'})
  82. return
  83. next_url = self.get_argument('next', default=self.base_url)
  84. self._redirect_safe(next_url)
  85. @classmethod
  86. def set_login_cookie(cls, handler, user_id=None):
  87. """Call this on handlers to set the login cookie for success"""
  88. cookie_options = handler.settings.get('cookie_options', {})
  89. cookie_options.setdefault('httponly', True)
  90. # tornado <4.2 has a bug that considers secure==True as soon as
  91. # 'secure' kwarg is passed to set_secure_cookie
  92. if handler.settings.get('secure_cookie', handler.request.protocol == 'https'):
  93. cookie_options.setdefault('secure', True)
  94. cookie_options.setdefault('path', handler.base_url)
  95. handler.set_secure_cookie(handler.cookie_name, user_id, **cookie_options)
  96. return user_id
  97. auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE)
  98. @classmethod
  99. def get_token(cls, handler):
  100. """Get the user token from a request
  101. Default:
  102. - in URL parameters: ?token=<token>
  103. - in header: Authorization: token <token>
  104. """
  105. user_token = handler.get_argument('token', '')
  106. if not user_token:
  107. # get it from Authorization header
  108. m = cls.auth_header_pat.match(handler.request.headers.get('Authorization', ''))
  109. if m:
  110. user_token = m.group(1)
  111. return user_token
  112. @classmethod
  113. def should_check_origin(cls, handler):
  114. """Should the Handler check for CORS origin validation?
  115. Origin check should be skipped for token-authenticated requests.
  116. Returns:
  117. - True, if Handler must check for valid CORS origin.
  118. - False, if Handler should skip origin check since requests are token-authenticated.
  119. """
  120. return not cls.is_token_authenticated(handler)
  121. @classmethod
  122. def is_token_authenticated(cls, handler):
  123. """Returns True if handler has been token authenticated. Otherwise, False.
  124. Login with a token is used to signal certain things, such as:
  125. - permit access to REST API
  126. - xsrf protection
  127. - skip origin-checks for scripts
  128. """
  129. if getattr(handler, '_user_id', None) is None:
  130. # ensure get_user has been called, so we know if we're token-authenticated
  131. handler.get_current_user()
  132. return getattr(handler, '_token_authenticated', False)
  133. @classmethod
  134. def get_user(cls, handler):
  135. """Called by handlers.get_current_user for identifying the current user.
  136. See tornado.web.RequestHandler.get_current_user for details.
  137. """
  138. # Can't call this get_current_user because it will collide when
  139. # called on LoginHandler itself.
  140. if getattr(handler, '_user_id', None):
  141. return handler._user_id
  142. user_id = cls.get_user_token(handler)
  143. if user_id is None:
  144. get_secure_cookie_kwargs = handler.settings.get('get_secure_cookie_kwargs', {})
  145. user_id = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs )
  146. else:
  147. cls.set_login_cookie(handler, user_id)
  148. # Record that the current request has been authenticated with a token.
  149. # Used in is_token_authenticated above.
  150. handler._token_authenticated = True
  151. if user_id is None:
  152. # If an invalid cookie was sent, clear it to prevent unnecessary
  153. # extra warnings. But don't do this on a request with *no* cookie,
  154. # because that can erroneously log you out (see gh-3365)
  155. if handler.get_cookie(handler.cookie_name) is not None:
  156. handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name)
  157. handler.clear_login_cookie()
  158. if not handler.login_available:
  159. # Completely insecure! No authentication at all.
  160. # No need to warn here, though; validate_security will have already done that.
  161. user_id = 'anonymous'
  162. # cache value for future retrievals on the same request
  163. handler._user_id = user_id
  164. return user_id
  165. @classmethod
  166. def get_user_token(cls, handler):
  167. """Identify the user based on a token in the URL or Authorization header
  168. Returns:
  169. - uuid if authenticated
  170. - None if not
  171. """
  172. token = handler.token
  173. if not token:
  174. return
  175. # check login token from URL argument or Authorization header
  176. user_token = cls.get_token(handler)
  177. authenticated = False
  178. if user_token == token:
  179. # token-authenticated, set the login cookie
  180. handler.log.debug("Accepting token-authenticated connection from %s", handler.request.remote_ip)
  181. authenticated = True
  182. if authenticated:
  183. return uuid.uuid4().hex
  184. else:
  185. return None
  186. @classmethod
  187. def validate_security(cls, app, ssl_options=None):
  188. """Check the notebook application's security.
  189. Show messages, or abort if necessary, based on the security configuration.
  190. """
  191. if not app.ip:
  192. warning = "WARNING: The notebook server is listening on all IP addresses"
  193. if ssl_options is None:
  194. app.log.warning(warning + " and not using encryption. This "
  195. "is not recommended.")
  196. if not app.password and not app.token:
  197. app.log.warning(warning + " and not using authentication. "
  198. "This is highly insecure and not recommended.")
  199. else:
  200. if not app.password and not app.token:
  201. app.log.warning(
  202. "All authentication is disabled."
  203. " Anyone who can connect to this server will be able to run code.")
  204. @classmethod
  205. def password_from_settings(cls, settings):
  206. """Return the hashed password from the tornado settings.
  207. If there is no configured password, an empty string will be returned.
  208. """
  209. return settings.get('password', u'')
  210. @classmethod
  211. def get_login_available(cls, settings):
  212. """Whether this LoginHandler is needed - and therefore whether the login page should be displayed."""
  213. return bool(cls.password_from_settings(settings) or settings.get('token'))