"""Tornado handlers for logging into the Jupyter Server.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import os import re import uuid from urllib.parse import urlparse from tornado.escape import url_escape from ..base.handlers import JupyterHandler from .decorator import allow_unauthenticated from .security import passwd_check, set_password class LoginFormHandler(JupyterHandler): """The basic tornado login handler accepts login form, passed to IdentityProvider.process_login_form. """ def _render(self, message=None): """Render the login form.""" self.write( self.render_template( "login.html", next=url_escape(self.get_argument("next", default=self.base_url)), message=message, ) ) def _redirect_safe(self, url, default=None): """Redirect if url is on our PATH Full-domain redirects are allowed if they pass our CORS origin checks. Otherwise use default (self.base_url if unspecified). """ if default is None: default = self.base_url # protect chrome users from mishandling unescaped backslashes. # \ is not valid in urls, but some browsers treat it as / # instead of %5C, causing `\\` to behave as `//` url = url.replace("\\", "%5C") # urllib and browsers interpret extra '/' in the scheme separator (`scheme:///host/path`) # differently. # urllib gives scheme=scheme, netloc='', path='/host/path', while # browsers get scheme=scheme, netloc='host', path='/path' # so make sure ':///*' collapses to '://' by splitting and stripping any additional leading slash # don't allow any kind of `:/` shenanigans by splitting on ':' only # and replacing `:/*` with exactly `://` if ":" in url: scheme, _, rest = url.partition(":") url = f"{scheme}://{rest.lstrip('/')}" parsed = urlparse(url) # full url may be `//host/path` (empty scheme == same scheme as request) # or `https://host/path` # or even `https:///host/path` (invalid, but accepted and ambiguously interpreted) if (parsed.scheme or parsed.netloc) or not (parsed.path + "/").startswith(self.base_url): # require that next_url be absolute path within our path allow = False # OR pass our cross-origin check if parsed.scheme or parsed.netloc: # if full URL, run our cross-origin check: origin = f"{parsed.scheme}://{parsed.netloc}" origin = origin.lower() if self.allow_origin: allow = self.allow_origin == origin elif self.allow_origin_pat: allow = bool(re.match(self.allow_origin_pat, origin)) if not allow: # not allowed, use default self.log.warning("Not allowing login redirect to %r" % url) url = default self.redirect(url) @allow_unauthenticated def get(self): """Get the login form.""" if self.current_user: next_url = self.get_argument("next", default=self.base_url) self._redirect_safe(next_url) else: self._render() @allow_unauthenticated def post(self): """Post a login.""" user = self.current_user = self.identity_provider.process_login_form(self) if user is None: self.set_status(401) self._render(message={"error": "Invalid credentials"}) return self.log.info(f"User {user.username} logged in.") self.identity_provider.set_login_cookie(self, user) next_url = self.get_argument("next", default=self.base_url) self._redirect_safe(next_url) class LegacyLoginHandler(LoginFormHandler): """Legacy LoginHandler, implementing most custom auth configuration. Deprecated in jupyter-server 2.0. Login configuration has moved to IdentityProvider. """ @property def hashed_password(self): return self.password_from_settings(self.settings) def passwd_check(self, a, b): """Check a passwd.""" return passwd_check(a, b) @allow_unauthenticated def post(self): """Post a login form.""" typed_password = self.get_argument("password", default="") new_password = self.get_argument("new_password", default="") if self.get_login_available(self.settings): if self.passwd_check(self.hashed_password, typed_password) and not new_password: self.set_login_cookie(self, uuid.uuid4().hex) elif self.token and self.token == typed_password: self.set_login_cookie(self, uuid.uuid4().hex) if new_password and getattr(self.identity_provider, "allow_password_change", False): config_dir = self.settings.get("config_dir", "") config_file = os.path.join(config_dir, "jupyter_server_config.json") if hasattr(self.identity_provider, "hashed_password"): self.identity_provider.hashed_password = self.settings["password"] = ( set_password(new_password, config_file=config_file) ) self.log.info("Wrote hashed password to %s" % config_file) else: self.set_status(401) self._render(message={"error": "Invalid credentials"}) return next_url = self.get_argument("next", default=self.base_url) self._redirect_safe(next_url) @classmethod def set_login_cookie(cls, handler, user_id=None): """Call this on handlers to set the login cookie for success""" cookie_options = handler.settings.get("cookie_options", {}) cookie_options.setdefault("httponly", True) # tornado <4.2 has a bug that considers secure==True as soon as # 'secure' kwarg is passed to set_secure_cookie if handler.settings.get("secure_cookie", handler.request.protocol == "https"): cookie_options.setdefault("secure", True) cookie_options.setdefault("path", handler.base_url) handler.set_secure_cookie(handler.cookie_name, user_id, **cookie_options) return user_id auth_header_pat = re.compile(r"token\s+(.+)", re.IGNORECASE) @classmethod def get_token(cls, handler): """Get the user token from a request Default: - in URL parameters: ?token= - in header: Authorization: token """ user_token = handler.get_argument("token", "") if not user_token: # get it from Authorization header m = cls.auth_header_pat.match(handler.request.headers.get("Authorization", "")) if m: user_token = m.group(1) return user_token @classmethod def should_check_origin(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" return not cls.is_token_authenticated(handler) @classmethod def is_token_authenticated(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" if getattr(handler, "_user_id", None) is None: # ensure get_user has been called, so we know if we're token-authenticated handler.current_user # noqa: B018 return getattr(handler, "_token_authenticated", False) @classmethod def get_user(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" # Can't call this get_current_user because it will collide when # called on LoginHandler itself. if getattr(handler, "_user_id", None): return handler._user_id token_user_id = cls.get_user_token(handler) cookie_user_id = cls.get_user_cookie(handler) # prefer token to cookie if both given, # because token is always explicit user_id = token_user_id or cookie_user_id if token_user_id: # if token-authenticated, persist user_id in cookie # if it hasn't already been stored there if user_id != cookie_user_id: cls.set_login_cookie(handler, user_id) # Record that the current request has been authenticated with a token. # Used in is_token_authenticated above. handler._token_authenticated = True if user_id is None: # If an invalid cookie was sent, clear it to prevent unnecessary # extra warnings. But don't do this on a request with *no* cookie, # because that can erroneously log you out (see gh-3365) if handler.get_cookie(handler.cookie_name) is not None: handler.log.warning("Clearing invalid/expired login cookie %s", handler.cookie_name) handler.clear_login_cookie() if not handler.login_available: # Completely insecure! No authentication at all. # No need to warn here, though; validate_security will have already done that. user_id = "anonymous" # cache value for future retrievals on the same request handler._user_id = user_id return user_id @classmethod def get_user_cookie(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" get_secure_cookie_kwargs = handler.settings.get("get_secure_cookie_kwargs", {}) user_id = handler.get_secure_cookie(handler.cookie_name, **get_secure_cookie_kwargs) if user_id: user_id = user_id.decode() return user_id @classmethod def get_user_token(cls, handler): """DEPRECATED in 2.0, use IdentityProvider API""" token = handler.token if not token: return None # check login token from URL argument or Authorization header user_token = cls.get_token(handler) authenticated = False if user_token == token: # token-authenticated, set the login cookie handler.log.debug( "Accepting token-authenticated connection from %s", handler.request.remote_ip, ) authenticated = True if authenticated: # token does not correspond to user-id, # which is stored in a cookie. # still check the cookie for the user id user_id = cls.get_user_cookie(handler) if user_id is None: # no cookie, generate new random user_id user_id = uuid.uuid4().hex handler.log.info( f"Generating new user_id for token-authenticated request: {user_id}" ) return user_id else: return None @classmethod def validate_security(cls, app, ssl_options=None): """DEPRECATED in 2.0, use IdentityProvider API""" if not app.ip: warning = "WARNING: The Jupyter server is listening on all IP addresses" if ssl_options is None: app.log.warning(f"{warning} and not using encryption. This is not recommended.") if not app.password and not app.token: app.log.warning( f"{warning} and not using authentication. " "This is highly insecure and not recommended." ) elif not app.password and not app.token: app.log.warning( "All authentication is disabled." " Anyone who can connect to this server will be able to run code." ) @classmethod def password_from_settings(cls, settings): """DEPRECATED in 2.0, use IdentityProvider API""" return settings.get("password", "") @classmethod def get_login_available(cls, settings): """DEPRECATED in 2.0, use IdentityProvider API""" return bool(cls.password_from_settings(settings) or settings.get("token")) # deprecated import, so deprecated implementations get the Legacy class instead LoginHandler = LegacyLoginHandler