diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index d3cca77911..c96b962078 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -5,10 +5,12 @@ .. versionadded:: 2.0 """ +import re from dataclasses import asdict, dataclass from typing import Any, Optional from tornado.web import RequestHandler +from traitlets import Unicode, default from traitlets.config import LoggingConfigurable # from dataclasses import field @@ -108,6 +110,8 @@ class IdentityProvider(LoggingConfigurable): .. versionadded:: 2.0 """ + cookie_name = Unicode(config=True) + def get_user(self, handler: RequestHandler) -> User: """Get the authenticated user for a request @@ -139,3 +143,179 @@ def get_handlers(self) -> list: For example, an OAuth callback handler. """ return [] + + def set_login_cookie(self, 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) + + def get_token(self, 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 = self.auth_header_pat.match(handler.request.headers.get("Authorization", "")) + if m: + user_token = m.group(1) + return user_token + + def should_check_origin(self, handler): + """Should the Handler check for CORS origin validation? + + Origin check should be skipped for token-authenticated requests. + + Returns: + - True, if Handler must check for valid CORS origin. + - False, if Handler should skip origin check since requests are token-authenticated. + """ + return not self.is_token_authenticated(handler) + + def is_token_authenticated(self, handler): + """Returns True if handler has been token authenticated. Otherwise, False. + + Login with a token is used to signal certain things, such as: + + - permit access to REST API + - xsrf protection + - skip origin-checks for scripts + """ + # ensure get_user has been called, so we know if we're token-authenticated + handler.current_user # noqa + return getattr(handler, "_token_authenticated", False) + + def get_user(self, handler): + """Called by handlers.get_current_user for identifying the current user. + + See tornado.web.RequestHandler.get_current_user for details. + """ + # 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 = self.get_user_token(handler) + cookie_user_id = self.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: + self.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 + + def get_user_cookie(self, handler): + """Get user-id from a cookie""" + 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 + + def get_user_token(self, handler): + """Identify the user based on a token in the URL or Authorization header + + Returns: + - uuid if authenticated + - None if not + """ + token = handler.token + if not token: + return + # check login token from URL argument or Authorization header + user_token = self.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 = self.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 + + def generate_user(self, hander): + """Generate a random""" + + def validate_security(self, app, ssl_options=None): + """Check the application's security. + + Show messages, or abort if necessary, based on the security configuration. + """ + 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." + ) + else: + if 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." + ) + + def get_login_available(self, settings): + """Whether this LoginHandler is needed - and therefore whether the login page should be displayed.""" + return False + + +class PasswordIdentityProvider(IdentityProvider): + + password = Unicode(config=True) + + @default("password") + def get_login_available(self, settings): + """Whether this LoginHandler is needed - and therefore whether the login page should be displayed.""" + return bool(self.password_from_settings(settings) or settings.get("token"))