| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- """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=<token>
- - in header: Authorization: token <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
|