login.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. """Tornado handlers for logging into the Jupyter Server."""
  2. # Copyright (c) Jupyter Development Team.
  3. # Distributed under the terms of the Modified BSD License.
  4. import os
  5. import re
  6. import uuid
  7. from urllib.parse import urlparse
  8. from tornado.escape import url_escape
  9. from ..base.handlers import JupyterHandler
  10. from .decorator import allow_unauthenticated
  11. from .security import passwd_check, set_password
  12. class LoginFormHandler(JupyterHandler):
  13. """The basic tornado login handler
  14. accepts login form, passed to IdentityProvider.process_login_form.
  15. """
  16. def _render(self, message=None):
  17. """Render the login form."""
  18. self.write(
  19. self.render_template(
  20. "login.html",
  21. next=url_escape(self.get_argument("next", default=self.base_url)),
  22. message=message,
  23. )
  24. )
  25. def _redirect_safe(self, url, default=None):
  26. """Redirect if url is on our PATH
  27. Full-domain redirects are allowed if they pass our CORS origin checks.
  28. Otherwise use default (self.base_url if unspecified).
  29. """
  30. if default is None:
  31. default = self.base_url
  32. # protect chrome users from mishandling unescaped backslashes.
  33. # \ is not valid in urls, but some browsers treat it as /
  34. # instead of %5C, causing `\\` to behave as `//`
  35. url = url.replace("\\", "%5C")
  36. # urllib and browsers interpret extra '/' in the scheme separator (`scheme:///host/path`)
  37. # differently.
  38. # urllib gives scheme=scheme, netloc='', path='/host/path', while
  39. # browsers get scheme=scheme, netloc='host', path='/path'
  40. # so make sure ':///*' collapses to '://' by splitting and stripping any additional leading slash
  41. # don't allow any kind of `:/` shenanigans by splitting on ':' only
  42. # and replacing `:/*` with exactly `://`
  43. if ":" in url:
  44. scheme, _, rest = url.partition(":")
  45. url = f"{scheme}://{rest.lstrip('/')}"
  46. parsed = urlparse(url)
  47. # full url may be `//host/path` (empty scheme == same scheme as request)
  48. # or `https://host/path`
  49. # or even `https:///host/path` (invalid, but accepted and ambiguously interpreted)
  50. if (parsed.scheme or parsed.netloc) or not (parsed.path + "/").startswith(self.base_url):
  51. # require that next_url be absolute path within our path
  52. allow = False
  53. # OR pass our cross-origin check
  54. if parsed.scheme or parsed.netloc:
  55. # if full URL, run our cross-origin check:
  56. origin = f"{parsed.scheme}://{parsed.netloc}"
  57. origin = origin.lower()
  58. if self.allow_origin:
  59. allow = self.allow_origin == origin
  60. elif self.allow_origin_pat:
  61. allow = bool(re.match(self.allow_origin_pat, origin))
  62. if not allow:
  63. # not allowed, use default
  64. self.log.warning("Not allowing login redirect to %r" % url)
  65. url = default
  66. self.redirect(url)
  67. @allow_unauthenticated
  68. def get(self):
  69. """Get the login form."""
  70. if self.current_user:
  71. next_url = self.get_argument("next", default=self.base_url)
  72. self._redirect_safe(next_url)
  73. else:
  74. self._render()
  75. @allow_unauthenticated
  76. def post(self):
  77. """Post a login."""
  78. user = self.current_user = self.identity_provider.process_login_form(self)
  79. if user is None:
  80. self.set_status(401)
  81. self._render(message={"error": "Invalid credentials"})
  82. return
  83. self.log.info(f"User {user.username} logged in.")
  84. self.identity_provider.set_login_cookie(self, user)
  85. next_url = self.get_argument("next", default=self.base_url)
  86. self._redirect_safe(next_url)
  87. class LegacyLoginHandler(LoginFormHandler):
  88. """Legacy LoginHandler, implementing most custom auth configuration.
  89. Deprecated in jupyter-server 2.0.
  90. Login configuration has moved to IdentityProvider.
  91. """
  92. @property
  93. def hashed_password(self):
  94. return self.password_from_settings(self.settings)
  95. def passwd_check(self, a, b):
  96. """Check a passwd."""
  97. return passwd_check(a, b)
  98. @allow_unauthenticated
  99. def post(self):
  100. """Post a login form."""
  101. typed_password = self.get_argument("password", default="")
  102. new_password = self.get_argument("new_password", default="")
  103. if self.get_login_available(self.settings):
  104. if self.passwd_check(self.hashed_password, typed_password) and not new_password:
  105. self.set_login_cookie(self, uuid.uuid4().hex)
  106. elif self.token and self.token == typed_password:
  107. self.set_login_cookie(self, uuid.uuid4().hex)
  108. if new_password and getattr(self.identity_provider, "allow_password_change", False):
  109. config_dir = self.settings.get("config_dir", "")
  110. config_file = os.path.join(config_dir, "jupyter_server_config.json")
  111. if hasattr(self.identity_provider, "hashed_password"):
  112. self.identity_provider.hashed_password = self.settings["password"] = (
  113. set_password(new_password, config_file=config_file)
  114. )
  115. self.log.info("Wrote hashed password to %s" % config_file)
  116. else:
  117. self.set_status(401)
  118. self._render(message={"error": "Invalid credentials"})
  119. return
  120. next_url = self.get_argument("next", default=self.base_url)
  121. self._redirect_safe(next_url)
  122. @classmethod
  123. def set_login_cookie(cls, handler, user_id=None):
  124. """Call this on handlers to set the login cookie for success"""
  125. cookie_options = handler.settings.get("cookie_options", {})
  126. cookie_options.setdefault("httponly", True)
  127. # tornado <4.2 has a bug that considers secure==True as soon as
  128. # 'secure' kwarg is passed to set_secure_cookie
  129. if handler.settings.get("secure_cookie", handler.request.protocol == "https"):
  130. cookie_options.setdefault("secure", True)
  131. cookie_options.setdefault("path", handler.base_url)
  132. handler.set_secure_cookie(handler.cookie_name, user_id, **cookie_options)
  133. return user_id
  134. auth_header_pat = re.compile(r"token\s+(.+)", re.IGNORECASE)
  135. @classmethod
  136. def get_token(cls, handler):
  137. """Get the user token from a request
  138. Default:
  139. - in URL parameters: ?token=<token>
  140. - in header: Authorization: token <token>
  141. """
  142. user_token = handler.get_argument("token", "")
  143. if not user_token:
  144. # get it from Authorization header
  145. m = cls.auth_header_pat.match(handler.request.headers.get("Authorization", ""))
  146. if m:
  147. user_token = m.group(1)
  148. return user_token
  149. @classmethod
  150. def should_check_origin(cls, handler):
  151. """DEPRECATED in 2.0, use IdentityProvider API"""
  152. return not cls.is_token_authenticated(handler)
  153. @classmethod
  154. def is_token_authenticated(cls, handler):
  155. """DEPRECATED in 2.0, use IdentityProvider API"""
  156. if getattr(handler, "_user_id", None) is None:
  157. # ensure get_user has been called, so we know if we're token-authenticated
  158. handler.current_user # noqa: B018
  159. return getattr(handler, "_token_authenticated", False)
  160. @classmethod
  161. def get_user(cls, handler):
  162. """DEPRECATED in 2.0, use IdentityProvider API"""
  163. # Can't call this get_current_user because it will collide when
  164. # called on LoginHandler itself.
  165. if getattr(handler, "_user_id", None):
  166. return handler._user_id
  167. token_user_id = cls.get_user_token(handler)
  168. cookie_user_id = cls.get_user_cookie(handler)
  169. # prefer token to cookie if both given,
  170. # because token is always explicit
  171. user_id = token_user_id or cookie_user_id
  172. if token_user_id:
  173. # if token-authenticated, persist user_id in cookie
  174. # if it hasn't already been stored there
  175. if user_id != cookie_user_id:
  176. cls.set_login_cookie(handler, user_id)
  177. # Record that the current request has been authenticated with a token.
  178. # Used in is_token_authenticated above.
  179. handler._token_authenticated = True
  180. if user_id is None:
  181. # If an invalid cookie was sent, clear it to prevent unnecessary
  182. # extra warnings. But don't do this on a request with *no* cookie,
  183. # because that can erroneously log you out (see gh-3365)
  184. if handler.get_cookie(handler.cookie_name) is not None:
  185. handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name)
  186. handler.clear_login_cookie()
  187. if not handler.login_available:
  188. # Completely insecure! No authentication at all.
  189. # No need to warn here, though; validate_security will have already done that.
  190. user_id = "anonymous"
  191. # cache value for future retrievals on the same request
  192. handler._user_id = user_id
  193. return user_id
  194. @classmethod
  195. def get_user_cookie(cls, handler):
  196. """DEPRECATED in 2.0, use IdentityProvider API"""
  197. get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {})
  198. user_id = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs)
  199. if user_id:
  200. user_id = user_id.decode()
  201. return user_id
  202. @classmethod
  203. def get_user_token(cls, handler):
  204. """DEPRECATED in 2.0, use IdentityProvider API"""
  205. token = handler.token
  206. if not token:
  207. return None
  208. # check login token from URL argument or Authorization header
  209. user_token = cls.get_token(handler)
  210. authenticated = False
  211. if user_token == token:
  212. # token-authenticated, set the login cookie
  213. handler.log.debug(
  214. "Accepting token-authenticated connection from %s",
  215. handler.request.remote_ip,
  216. )
  217. authenticated = True
  218. if authenticated:
  219. # token does not correspond to user-id,
  220. # which is stored in a cookie.
  221. # still check the cookie for the user id
  222. user_id = cls.get_user_cookie(handler)
  223. if user_id is None:
  224. # no cookie, generate new random user_id
  225. user_id = uuid.uuid4().hex
  226. handler.log.info(
  227. f"Generating new user_id for token-authenticated request: {user_id}"
  228. )
  229. return user_id
  230. else:
  231. return None
  232. @classmethod
  233. def validate_security(cls, app, ssl_options=None):
  234. """DEPRECATED in 2.0, use IdentityProvider API"""
  235. if not app.ip:
  236. warning = "WARNING: The Jupyter server is listening on all IP addresses"
  237. if ssl_options is None:
  238. app.log.warning(f"{warning} and not using encryption. This is not recommended.")
  239. if not app.password and not app.token:
  240. app.log.warning(
  241. f"{warning} and not using authentication. "
  242. "This is highly insecure and not recommended."
  243. )
  244. elif not app.password and not app.token:
  245. app.log.warning(
  246. "All authentication is disabled."
  247. " Anyone who can connect to this server will be able to run code."
  248. )
  249. @classmethod
  250. def password_from_settings(cls, settings):
  251. """DEPRECATED in 2.0, use IdentityProvider API"""
  252. return settings.get("password", "")
  253. @classmethod
  254. def get_login_available(cls, settings):
  255. """DEPRECATED in 2.0, use IdentityProvider API"""
  256. return bool(cls.password_from_settings(settings) or settings.get("token"))
  257. # deprecated import, so deprecated implementations get the Legacy class instead
  258. LoginHandler = LegacyLoginHandler