You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
810 lines
29 KiB
Python
810 lines
29 KiB
Python
"""Identity Provider interface
|
|
|
|
This defines the _authentication_ layer of Jupyter Server,
|
|
to be used in combination with Authorizer for _authorization_.
|
|
|
|
.. versionadded:: 2.0
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import binascii
|
|
import datetime
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import typing as t
|
|
import uuid
|
|
from dataclasses import asdict, dataclass
|
|
from http.cookies import Morsel
|
|
|
|
from tornado import escape, httputil, web
|
|
from traitlets import Bool, Dict, Enum, List, TraitError, Type, Unicode, default, validate
|
|
from traitlets.config import LoggingConfigurable
|
|
|
|
from jupyter_server.transutils import _i18n
|
|
|
|
from .security import passwd_check, set_password
|
|
from .utils import get_anonymous_username
|
|
|
|
_non_alphanum = re.compile(r"[^A-Za-z0-9]")
|
|
|
|
|
|
# Define the User properties that can be updated
|
|
UpdatableField = t.Literal["name", "display_name", "initials", "avatar_url", "color"]
|
|
|
|
|
|
@dataclass
|
|
class User:
|
|
"""Object representing a User
|
|
|
|
This or a subclass should be returned from IdentityProvider.get_user
|
|
"""
|
|
|
|
username: str # the only truly required field
|
|
|
|
# these fields are filled from username if not specified
|
|
# name is the 'real' name of the user
|
|
name: str = ""
|
|
# display_name is a shorter name for us in UI,
|
|
# if different from name. e.g. a nickname
|
|
display_name: str = ""
|
|
|
|
# these fields are left as None if undefined
|
|
initials: str | None = None
|
|
avatar_url: str | None = None
|
|
color: str | None = None
|
|
|
|
# TODO: extension fields?
|
|
# ext: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
|
|
def __post_init__(self):
|
|
self.fill_defaults()
|
|
|
|
def fill_defaults(self):
|
|
"""Fill out default fields in the identity model
|
|
|
|
- Ensures all values are defined
|
|
- Fills out derivative values for name fields fields
|
|
- Fills out null values for optional fields
|
|
"""
|
|
|
|
# username is the only truly required field
|
|
if not self.username:
|
|
msg = f"user.username must not be empty: {self}"
|
|
raise ValueError(msg)
|
|
|
|
# derive name fields from username -> name -> display name
|
|
if not self.name:
|
|
self.name = self.username
|
|
if not self.display_name:
|
|
self.display_name = self.name
|
|
|
|
|
|
def _backward_compat_user(got_user: t.Any) -> User:
|
|
"""Backward-compatibility for LoginHandler.get_user
|
|
|
|
Prior to 2.0, LoginHandler.get_user could return anything truthy.
|
|
|
|
Typically, this was either a simple string username,
|
|
or a simple dict.
|
|
|
|
Make some effort to allow common patterns to keep working.
|
|
"""
|
|
if isinstance(got_user, str):
|
|
return User(username=got_user)
|
|
elif isinstance(got_user, dict):
|
|
kwargs = {}
|
|
if "username" not in got_user and "name" in got_user:
|
|
kwargs["username"] = got_user["name"]
|
|
for field in User.__dataclass_fields__:
|
|
if field in got_user:
|
|
kwargs[field] = got_user[field]
|
|
try:
|
|
return User(**kwargs)
|
|
except TypeError:
|
|
msg = f"Unrecognized user: {got_user}"
|
|
raise ValueError(msg) from None
|
|
else:
|
|
msg = f"Unrecognized user: {got_user}"
|
|
raise ValueError(msg)
|
|
|
|
|
|
class IdentityProvider(LoggingConfigurable):
|
|
"""
|
|
Interface for providing identity management and authentication.
|
|
|
|
Two principle methods:
|
|
|
|
- :meth:`~jupyter_server.auth.IdentityProvider.get_user` returns a :class:`~.User` object
|
|
for successful authentication, or None for no-identity-found.
|
|
- :meth:`~jupyter_server.auth.IdentityProvider.identity_model` turns a :class:`~jupyter_server.auth.User` into a JSONable dict.
|
|
The default is to use :py:meth:`dataclasses.asdict`,
|
|
and usually shouldn't need override.
|
|
|
|
Additional methods can customize authentication.
|
|
|
|
.. versionadded:: 2.0
|
|
"""
|
|
|
|
cookie_name: str | Unicode[str, str | bytes] = Unicode(
|
|
"",
|
|
config=True,
|
|
help=_i18n("Name of the cookie to set for persisting login. Default: username-${Host}."),
|
|
)
|
|
|
|
cookie_options = Dict(
|
|
config=True,
|
|
help=_i18n(
|
|
"Extra keyword arguments to pass to `set_secure_cookie`."
|
|
" See tornado's set_secure_cookie docs for details."
|
|
),
|
|
)
|
|
|
|
secure_cookie: bool | Bool[bool | None, bool | int | None] = Bool(
|
|
None,
|
|
allow_none=True,
|
|
config=True,
|
|
help=_i18n(
|
|
"Specify whether login cookie should have the `secure` property (HTTPS-only)."
|
|
"Only needed when protocol-detection gives the wrong answer due to proxies."
|
|
),
|
|
)
|
|
|
|
get_secure_cookie_kwargs = Dict(
|
|
config=True,
|
|
help=_i18n(
|
|
"Extra keyword arguments to pass to `get_secure_cookie`."
|
|
" See tornado's get_secure_cookie docs for details."
|
|
),
|
|
)
|
|
|
|
token: str | Unicode[str, str | bytes] = Unicode(
|
|
"<generated>",
|
|
help=_i18n(
|
|
"""Token used for authenticating first-time connections to the server.
|
|
|
|
The token can be read from the file referenced by JUPYTER_TOKEN_FILE or set directly
|
|
with the JUPYTER_TOKEN environment variable.
|
|
|
|
When no password is enabled,
|
|
the default is to generate a new, random token.
|
|
|
|
Setting to an empty string disables authentication altogether, which is NOT RECOMMENDED.
|
|
|
|
Prior to 2.0: configured as ServerApp.token
|
|
"""
|
|
),
|
|
).tag(config=True)
|
|
|
|
login_handler_class = Type(
|
|
default_value="jupyter_server.auth.login.LoginFormHandler",
|
|
klass=web.RequestHandler,
|
|
config=True,
|
|
help=_i18n("The login handler class to use, if any."),
|
|
)
|
|
|
|
logout_handler_class = Type(
|
|
default_value="jupyter_server.auth.logout.LogoutHandler",
|
|
klass=web.RequestHandler,
|
|
config=True,
|
|
help=_i18n("The logout handler class to use."),
|
|
)
|
|
|
|
# Define the fields that can be updated
|
|
updatable_fields = List(
|
|
trait=Enum(list(t.get_args(UpdatableField))),
|
|
default_value=["color"], # Default updatable field
|
|
config=True,
|
|
help=_i18n("List of fields in the User model that can be updated."),
|
|
)
|
|
|
|
token_generated = False
|
|
|
|
@default("token")
|
|
def _token_default(self):
|
|
if os.getenv("JUPYTER_TOKEN"):
|
|
self.token_generated = False
|
|
return os.environ["JUPYTER_TOKEN"]
|
|
if os.getenv("JUPYTER_TOKEN_FILE"):
|
|
self.token_generated = False
|
|
with open(os.environ["JUPYTER_TOKEN_FILE"]) as token_file:
|
|
return token_file.read()
|
|
if not self.need_token:
|
|
# no token if password is enabled
|
|
self.token_generated = False
|
|
return ""
|
|
else:
|
|
self.token_generated = True
|
|
return binascii.hexlify(os.urandom(24)).decode("ascii")
|
|
|
|
@validate("updatable_fields")
|
|
def _validate_updatable_fields(self, proposal):
|
|
"""Validate that all fields in updatable_fields are valid."""
|
|
valid_updatable_fields = list(t.get_args(UpdatableField))
|
|
invalid_fields = [
|
|
field for field in proposal["value"] if field not in valid_updatable_fields
|
|
]
|
|
if invalid_fields:
|
|
msg = f"Invalid fields in updatable_fields: {invalid_fields}"
|
|
raise TraitError(msg)
|
|
return proposal["value"]
|
|
|
|
need_token: bool | Bool[bool, t.Union[bool, int]] = Bool(True)
|
|
|
|
def get_user(self, handler: web.RequestHandler) -> User | None | t.Awaitable[User | None]:
|
|
"""Get the authenticated user for a request
|
|
|
|
Must return a :class:`jupyter_server.auth.User`,
|
|
though it may be a subclass.
|
|
|
|
Return None if the request is not authenticated.
|
|
|
|
_may_ be a coroutine
|
|
"""
|
|
return self._get_user(handler)
|
|
|
|
# not sure how to have optional-async type signature
|
|
# on base class with `async def` without splitting it into two methods
|
|
|
|
async def _get_user(self, handler: web.RequestHandler) -> User | None:
|
|
"""Get the user."""
|
|
if getattr(handler, "_jupyter_current_user", None):
|
|
# already authenticated
|
|
return t.cast(User, handler._jupyter_current_user) # type:ignore[attr-defined]
|
|
_token_user: User | None | t.Awaitable[User | None] = self.get_user_token(handler)
|
|
if isinstance(_token_user, t.Awaitable):
|
|
_token_user = await _token_user
|
|
token_user: User | None = _token_user # need second variable name to collapse type
|
|
_cookie_user = self.get_user_cookie(handler)
|
|
if isinstance(_cookie_user, t.Awaitable):
|
|
_cookie_user = await _cookie_user
|
|
cookie_user: User | None = _cookie_user
|
|
# prefer token to cookie if both given,
|
|
# because token is always explicit
|
|
user = token_user or cookie_user
|
|
|
|
if user is not None and token_user is not None:
|
|
# if token-authenticated, persist user_id in cookie
|
|
# if it hasn't already been stored there
|
|
if user != cookie_user:
|
|
self.set_login_cookie(handler, user)
|
|
# Record that the current request has been authenticated with a token.
|
|
# Used in is_token_authenticated above.
|
|
handler._token_authenticated = True # type:ignore[attr-defined]
|
|
|
|
if user 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)
|
|
cookie_name = self.get_cookie_name(handler)
|
|
cookie = handler.get_cookie(cookie_name)
|
|
if cookie is not None:
|
|
self.log.warning(f"Clearing invalid/expired login cookie {cookie_name}")
|
|
self.clear_login_cookie(handler)
|
|
if not self.auth_enabled:
|
|
# Completely insecure! No authentication at all.
|
|
# No need to warn here, though; validate_security will have already done that.
|
|
user = self.generate_anonymous_user(handler)
|
|
# persist user on first request
|
|
# so the user data is stable for a given browser session
|
|
self.set_login_cookie(handler, user)
|
|
|
|
return user
|
|
|
|
def update_user(
|
|
self, handler: web.RequestHandler, user_data: dict[UpdatableField, str]
|
|
) -> User:
|
|
"""Update user information and persist the user model."""
|
|
self.check_update(user_data)
|
|
current_user = t.cast(User, handler.current_user)
|
|
updated_user = self.update_user_model(current_user, user_data)
|
|
self.persist_user_model(handler)
|
|
return updated_user
|
|
|
|
def check_update(self, user_data: dict[UpdatableField, str]) -> None:
|
|
"""Raises if some fields to update are not updatable."""
|
|
for field in user_data:
|
|
if field not in self.updatable_fields:
|
|
msg = f"Field {field} is not updatable"
|
|
raise ValueError(msg)
|
|
|
|
def update_user_model(self, current_user: User, user_data: dict[UpdatableField, str]) -> User:
|
|
"""Update user information."""
|
|
raise NotImplementedError
|
|
|
|
def persist_user_model(self, handler: web.RequestHandler) -> None:
|
|
"""Persist the user model (i.e. a cookie)."""
|
|
raise NotImplementedError
|
|
|
|
def identity_model(self, user: User) -> dict[str, t.Any]:
|
|
"""Return a User as an Identity model"""
|
|
# TODO: validate?
|
|
return asdict(user)
|
|
|
|
def get_handlers(self) -> list[tuple[str, object]]:
|
|
"""Return list of additional handlers for this identity provider
|
|
|
|
For example, an OAuth callback handler.
|
|
"""
|
|
handlers = []
|
|
if self.login_available:
|
|
handlers.append((r"/login", self.login_handler_class))
|
|
if self.logout_available:
|
|
handlers.append((r"/logout", self.logout_handler_class))
|
|
return handlers
|
|
|
|
def user_to_cookie(self, user: User) -> str:
|
|
"""Serialize a user to a string for storage in a cookie
|
|
|
|
If overriding in a subclass, make sure to define user_from_cookie as well.
|
|
|
|
Default is just the user's username.
|
|
"""
|
|
# default: username is enough
|
|
cookie = json.dumps(
|
|
{
|
|
"username": user.username,
|
|
"name": user.name,
|
|
"display_name": user.display_name,
|
|
"initials": user.initials,
|
|
"color": user.color,
|
|
}
|
|
)
|
|
return cookie
|
|
|
|
def user_from_cookie(self, cookie_value: str) -> User | None:
|
|
"""Inverse of user_to_cookie"""
|
|
user = json.loads(cookie_value)
|
|
return User(
|
|
user["username"],
|
|
user["name"],
|
|
user["display_name"],
|
|
user["initials"],
|
|
None,
|
|
user["color"],
|
|
)
|
|
|
|
def get_cookie_name(self, handler: web.RequestHandler) -> str:
|
|
"""Return the login cookie name
|
|
|
|
Uses IdentityProvider.cookie_name, if defined.
|
|
Default is to generate a string taking host into account to avoid
|
|
collisions for multiple servers on one hostname with different ports.
|
|
"""
|
|
if self.cookie_name:
|
|
return self.cookie_name
|
|
else:
|
|
return _non_alphanum.sub("-", f"username-{handler.request.host}")
|
|
|
|
def set_login_cookie(self, handler: web.RequestHandler, user: User) -> None:
|
|
"""Call this on handlers to set the login cookie for success"""
|
|
cookie_options = {}
|
|
cookie_options.update(self.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
|
|
secure_cookie = self.secure_cookie
|
|
if secure_cookie is None:
|
|
secure_cookie = handler.request.protocol == "https"
|
|
if secure_cookie:
|
|
cookie_options.setdefault("secure", True)
|
|
cookie_options.setdefault("path", handler.base_url) # type:ignore[attr-defined]
|
|
cookie_name = self.get_cookie_name(handler)
|
|
handler.set_secure_cookie(cookie_name, self.user_to_cookie(user), **cookie_options)
|
|
|
|
def _force_clear_cookie(
|
|
self, handler: web.RequestHandler, name: str, path: str = "/", domain: str | None = None
|
|
) -> None:
|
|
"""Deletes the cookie with the given name.
|
|
|
|
Tornado's cookie handling currently (Jan 2018) stores cookies in a dict
|
|
keyed by name, so it can only modify one cookie with a given name per
|
|
response. The browser can store multiple cookies with the same name
|
|
but different domains and/or paths. This method lets us clear multiple
|
|
cookies with the same name.
|
|
|
|
Due to limitations of the cookie protocol, you must pass the same
|
|
path and domain to clear a cookie as were used when that cookie
|
|
was set (but there is no way to find out on the server side
|
|
which values were used for a given cookie).
|
|
"""
|
|
name = escape.native_str(name)
|
|
expires = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(days=365)
|
|
|
|
morsel: Morsel[t.Any] = Morsel()
|
|
morsel.set(name, "", '""')
|
|
morsel["expires"] = httputil.format_timestamp(expires)
|
|
morsel["path"] = path
|
|
if domain:
|
|
morsel["domain"] = domain
|
|
handler.add_header("Set-Cookie", morsel.OutputString())
|
|
|
|
def clear_login_cookie(self, handler: web.RequestHandler) -> None:
|
|
"""Clear the login cookie, effectively logging out the session."""
|
|
cookie_options = {}
|
|
cookie_options.update(self.cookie_options)
|
|
path = cookie_options.setdefault("path", handler.base_url) # type:ignore[attr-defined]
|
|
cookie_name = self.get_cookie_name(handler)
|
|
handler.clear_cookie(cookie_name, path=path)
|
|
if path and path != "/":
|
|
# also clear cookie on / to ensure old cookies are cleared
|
|
# after the change in path behavior.
|
|
# N.B. This bypasses the normal cookie handling, which can't update
|
|
# two cookies with the same name. See the method above.
|
|
self._force_clear_cookie(handler, cookie_name)
|
|
|
|
def get_user_cookie(
|
|
self, handler: web.RequestHandler
|
|
) -> User | None | t.Awaitable[User | None]:
|
|
"""Get user from a cookie
|
|
|
|
Calls user_from_cookie to deserialize cookie value
|
|
"""
|
|
_user_cookie = handler.get_secure_cookie(
|
|
self.get_cookie_name(handler),
|
|
**self.get_secure_cookie_kwargs,
|
|
)
|
|
if not _user_cookie:
|
|
return None
|
|
user_cookie = _user_cookie.decode()
|
|
# TODO: try/catch in case of change in config?
|
|
try:
|
|
return self.user_from_cookie(user_cookie)
|
|
except Exception as e:
|
|
# log bad cookie itself, only at debug-level
|
|
self.log.debug(f"Error unpacking user from cookie: cookie={user_cookie}", exc_info=True)
|
|
self.log.error(f"Error unpacking user from cookie: {e}")
|
|
return None
|
|
|
|
auth_header_pat = re.compile(r"(token|bearer)\s+(.+)", re.IGNORECASE)
|
|
|
|
def get_token(self, handler: web.RequestHandler) -> str | None:
|
|
"""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 = self.auth_header_pat.match(handler.request.headers.get("Authorization", ""))
|
|
if m:
|
|
user_token = m.group(2)
|
|
return user_token
|
|
|
|
async def get_user_token(self, handler: web.RequestHandler) -> User | None:
|
|
"""Identify the user based on a token in the URL or Authorization header
|
|
|
|
Returns:
|
|
- uuid if authenticated
|
|
- None if not
|
|
"""
|
|
token = t.cast("str | None", handler.token) # type:ignore[attr-defined]
|
|
if not token:
|
|
return None
|
|
# 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
|
|
self.log.debug(
|
|
"Accepting token-authenticated request 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 = self.get_user_cookie(handler)
|
|
if isinstance(_user, t.Awaitable):
|
|
_user = await _user
|
|
user: User | None = _user
|
|
if user is None:
|
|
user = self.generate_anonymous_user(handler)
|
|
return user
|
|
else:
|
|
return None
|
|
|
|
def generate_anonymous_user(self, handler: web.RequestHandler) -> User:
|
|
"""Generate a random anonymous user.
|
|
|
|
For use when a single shared token is used,
|
|
but does not identify a user.
|
|
"""
|
|
user_id = uuid.uuid4().hex
|
|
moon = get_anonymous_username()
|
|
name = display_name = f"Anonymous {moon}"
|
|
initials = f"A{moon[0]}"
|
|
color = None
|
|
handler.log.debug(f"Generating new user for token-authenticated request: {user_id}") # type:ignore[attr-defined]
|
|
return User(user_id, name, display_name, initials, None, color)
|
|
|
|
def should_check_origin(self, handler: web.RequestHandler) -> bool:
|
|
"""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: web.RequestHandler) -> bool:
|
|
"""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: B018
|
|
return getattr(handler, "_token_authenticated", False)
|
|
|
|
def validate_security(
|
|
self,
|
|
app: t.Any,
|
|
ssl_options: dict[str, t.Any] | None = None,
|
|
) -> 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 self.auth_enabled:
|
|
app.log.warning(
|
|
f"{warning} and not using authentication. "
|
|
"This is highly insecure and not recommended."
|
|
)
|
|
elif not self.auth_enabled:
|
|
app.log.warning(
|
|
"All authentication is disabled."
|
|
" Anyone who can connect to this server will be able to run code."
|
|
)
|
|
|
|
def process_login_form(self, handler: web.RequestHandler) -> User | None:
|
|
"""Process login form data
|
|
|
|
Return authenticated User if successful, None if not.
|
|
"""
|
|
typed_password = handler.get_argument("password", default="")
|
|
user = None
|
|
if not self.auth_enabled:
|
|
self.log.warning("Accepting anonymous login because auth fully disabled!")
|
|
return self.generate_anonymous_user(handler)
|
|
|
|
if self.token and self.token == typed_password:
|
|
return t.cast(User, self.user_for_token(typed_password)) # type:ignore[attr-defined]
|
|
|
|
return user
|
|
|
|
@property
|
|
def auth_enabled(self):
|
|
"""Is authentication enabled?
|
|
|
|
Should always be True, but may be False in rare, insecure cases
|
|
where requests with no auth are allowed.
|
|
|
|
Previously: LoginHandler.get_login_available
|
|
"""
|
|
return True
|
|
|
|
@property
|
|
def login_available(self):
|
|
"""Whether a LoginHandler is needed - and therefore whether the login page should be displayed."""
|
|
return self.auth_enabled
|
|
|
|
@property
|
|
def logout_available(self):
|
|
"""Whether a LogoutHandler is needed."""
|
|
return True
|
|
|
|
|
|
class PasswordIdentityProvider(IdentityProvider):
|
|
"""A password identity provider."""
|
|
|
|
hashed_password = Unicode(
|
|
"",
|
|
config=True,
|
|
help=_i18n(
|
|
"""
|
|
Hashed password to use for web authentication.
|
|
|
|
To generate, type in a python/IPython shell:
|
|
|
|
from jupyter_server.auth import passwd; passwd()
|
|
|
|
The string should be of the form type:salt:hashed-password.
|
|
"""
|
|
),
|
|
)
|
|
|
|
password_required = Bool(
|
|
False,
|
|
config=True,
|
|
help=_i18n(
|
|
"""
|
|
Forces users to use a password for the Jupyter server.
|
|
This is useful in a multi user environment, for instance when
|
|
everybody in the LAN can access each other's machine through ssh.
|
|
|
|
In such a case, serving on localhost is not secure since
|
|
any user can connect to the Jupyter server via ssh.
|
|
|
|
"""
|
|
),
|
|
)
|
|
|
|
allow_password_change = Bool(
|
|
True,
|
|
config=True,
|
|
help=_i18n(
|
|
"""
|
|
Allow password to be changed at login for the Jupyter server.
|
|
|
|
While logging in with a token, the Jupyter server UI will give the opportunity to
|
|
the user to enter a new password at the same time that will replace
|
|
the token login mechanism.
|
|
|
|
This can be set to False to prevent changing password from the UI/API.
|
|
"""
|
|
),
|
|
)
|
|
|
|
@default("need_token")
|
|
def _need_token_default(self):
|
|
return not bool(self.hashed_password)
|
|
|
|
@default("updatable_fields")
|
|
def _default_updatable_fields(self):
|
|
return [
|
|
"name",
|
|
"display_name",
|
|
"initials",
|
|
"avatar_url",
|
|
"color",
|
|
]
|
|
|
|
@property
|
|
def login_available(self) -> bool:
|
|
"""Whether a LoginHandler is needed - and therefore whether the login page should be displayed."""
|
|
return self.auth_enabled
|
|
|
|
@property
|
|
def auth_enabled(self) -> bool:
|
|
"""Return whether any auth is enabled"""
|
|
return bool(self.hashed_password or self.token)
|
|
|
|
def update_user_model(self, current_user: User, user_data: dict[UpdatableField, str]) -> User:
|
|
"""Update user information."""
|
|
for field in self.updatable_fields:
|
|
if field in user_data:
|
|
setattr(current_user, field, user_data[field])
|
|
return current_user
|
|
|
|
def persist_user_model(self, handler: web.RequestHandler) -> None:
|
|
"""Persist the user model to a cookie."""
|
|
self.set_login_cookie(handler, handler.current_user)
|
|
|
|
def passwd_check(self, password):
|
|
"""Check password against our stored hashed password"""
|
|
return passwd_check(self.hashed_password, password)
|
|
|
|
def process_login_form(self, handler: web.RequestHandler) -> User | None:
|
|
"""Process login form data
|
|
|
|
Return authenticated User if successful, None if not.
|
|
"""
|
|
typed_password = handler.get_argument("password", default="")
|
|
new_password = handler.get_argument("new_password", default="")
|
|
user = None
|
|
if not self.auth_enabled:
|
|
self.log.warning("Accepting anonymous login because auth fully disabled!")
|
|
return self.generate_anonymous_user(handler)
|
|
|
|
if self.passwd_check(typed_password) and not new_password:
|
|
return self.generate_anonymous_user(handler)
|
|
elif self.token and self.token == typed_password:
|
|
user = self.generate_anonymous_user(handler)
|
|
if new_password and self.allow_password_change:
|
|
config_dir = handler.settings.get("config_dir", "")
|
|
config_file = os.path.join(config_dir, "jupyter_server_config.json")
|
|
self.hashed_password = set_password(new_password, config_file=config_file)
|
|
self.log.info(_i18n("Wrote hashed password to {file}").format(file=config_file))
|
|
|
|
return user
|
|
|
|
def validate_security(
|
|
self,
|
|
app: t.Any,
|
|
ssl_options: dict[str, t.Any] | None = None,
|
|
) -> None:
|
|
"""Handle security validation."""
|
|
super().validate_security(app, ssl_options)
|
|
if self.password_required and (not self.hashed_password):
|
|
self.log.critical(
|
|
_i18n("Jupyter servers are configured to only be run with a password.")
|
|
)
|
|
self.log.critical(_i18n("Hint: run the following command to set a password"))
|
|
self.log.critical(_i18n("\t$ python -m jupyter_server.auth password"))
|
|
sys.exit(1)
|
|
|
|
|
|
class LegacyIdentityProvider(PasswordIdentityProvider):
|
|
"""Legacy IdentityProvider for use with custom LoginHandlers
|
|
|
|
Login configuration has moved from LoginHandler to IdentityProvider
|
|
in Jupyter Server 2.0.
|
|
"""
|
|
|
|
# settings must be passed for
|
|
settings = Dict()
|
|
|
|
@default("settings")
|
|
def _default_settings(self):
|
|
return {
|
|
"token": self.token,
|
|
"password": self.hashed_password,
|
|
}
|
|
|
|
@default("login_handler_class")
|
|
def _default_login_handler_class(self):
|
|
from .login import LegacyLoginHandler
|
|
|
|
return LegacyLoginHandler
|
|
|
|
@property
|
|
def auth_enabled(self):
|
|
return self.login_available
|
|
|
|
def get_user(self, handler: web.RequestHandler) -> User | None:
|
|
"""Get the user."""
|
|
user = self.login_handler_class.get_user(handler) # type:ignore[attr-defined]
|
|
if user is None:
|
|
return None
|
|
return _backward_compat_user(user)
|
|
|
|
@property
|
|
def login_available(self) -> bool:
|
|
return bool(
|
|
self.login_handler_class.get_login_available( # type:ignore[attr-defined]
|
|
self.settings
|
|
)
|
|
)
|
|
|
|
def should_check_origin(self, handler: web.RequestHandler) -> bool:
|
|
"""Whether we should check origin."""
|
|
return bool(self.login_handler_class.should_check_origin(handler)) # type:ignore[attr-defined]
|
|
|
|
def is_token_authenticated(self, handler: web.RequestHandler) -> bool:
|
|
"""Whether we are token authenticated."""
|
|
return bool(self.login_handler_class.is_token_authenticated(handler)) # type:ignore[attr-defined]
|
|
|
|
def validate_security(
|
|
self,
|
|
app: t.Any,
|
|
ssl_options: dict[str, t.Any] | None = None,
|
|
) -> None:
|
|
"""Validate security."""
|
|
if self.password_required and (not self.hashed_password):
|
|
self.log.critical(
|
|
_i18n("Jupyter servers are configured to only be run with a password.")
|
|
)
|
|
self.log.critical(_i18n("Hint: run the following command to set a password"))
|
|
self.log.critical(_i18n("\t$ python -m jupyter_server.auth password"))
|
|
sys.exit(1)
|
|
self.login_handler_class.validate_security( # type:ignore[attr-defined]
|
|
app, ssl_options
|
|
)
|