diff --git a/jupyter_server/auth/identity.py b/jupyter_server/auth/identity.py index fc4b02992..25d5e7c96 100644 --- a/jupyter_server/auth/identity.py +++ b/jupyter_server/auth/identity.py @@ -14,6 +14,7 @@ import os import re import sys +import time import typing as t import uuid from dataclasses import asdict, dataclass @@ -350,6 +351,7 @@ def user_to_cookie(self, user: User) -> str: "display_name": user.display_name, "initials": user.initials, "color": user.color, + "cookie_creation_time": time.time(), } ) return cookie @@ -357,6 +359,20 @@ def user_to_cookie(self, user: User) -> str: def user_from_cookie(self, cookie_value: str) -> User | None: """Inverse of user_to_cookie""" user = json.loads(cookie_value) + cookie_creation_time = user.get("cookie_creation_time", None) + + if not cookie_creation_time: + self.clear_login_cookie() + raise ValueError("No cookie_creation_time in cookie; must recreate cookie") + secret_creation_time = self.parent._cookie_secret_creation_time + if not secret_creation_time: + raise ValueError("Secret creation time not set") + if cookie_creation_time < secret_creation_time: + raise ValueError( + f"Stale cookie created at {cookie_creation_time};" + f" but server secret created {secret_creation_time}" + ) + return User( user["username"], user["name"], @@ -448,6 +464,7 @@ def get_user_cookie( ) if not _user_cookie: return None + user_cookie = _user_cookie.decode() # TODO: try/catch in case of change in config? try: @@ -722,6 +739,8 @@ def process_login_form(self, handler: web.RequestHandler) -> User | None: 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)) + self.parent._write_cookie_secret_file(self.parent.cookie_secret) + self.log.info(_i18n("Touched cookie secret file to update server secret time")) return user diff --git a/jupyter_server/auth/login.py b/jupyter_server/auth/login.py index 451212753..559ef40b2 100644 --- a/jupyter_server/auth/login.py +++ b/jupyter_server/auth/login.py @@ -214,7 +214,6 @@ def get_user(cls, handler): # 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. diff --git a/jupyter_server/serverapp.py b/jupyter_server/serverapp.py index 1afbef4d0..f892787ad 100644 --- a/jupyter_server/serverapp.py +++ b/jupyter_server/serverapp.py @@ -610,7 +610,11 @@ def start(self): """Start the password app.""" from jupyter_server.auth.security import set_password + if self.parent is None: + raise ValueError("Unable to change password without parent app") set_password(config_file=self.config_file) + self.parent._write_cookie_secret_file(self.parent.cookie_secret) + self.log.info(_i18n("Touched cookie secret file to update server secret time")) self.log.info("Wrote hashed password to %s" % self.config_file) @@ -1159,6 +1163,13 @@ def _default_cookie_secret_file(self) -> str: """, ) + # If the server side cookie secret is changed/created then we should + # not trust cookies created before that. We can use this as a way + # to invalidate old cookies when a password is changed by rewriting + # the cookie secret again (without necessarily changing it). + # See https://github.com/jupyter-server/jupyter_server/issues/1566 + _cookie_secret_creation_time = 0 + @default("cookie_secret") def _default_cookie_secret(self) -> bytes: if os.path.exists(self.cookie_secret_file): @@ -1167,6 +1178,7 @@ def _default_cookie_secret(self) -> bytes: else: key = encodebytes(os.urandom(32)) self._write_cookie_secret_file(key) + self._cookie_secret_creation_time = os.stat(self.cookie_secret_file).st_mtime h = hmac.new(key, digestmod=hashlib.sha256) h.update(self.password.encode()) return h.digest() @@ -1183,6 +1195,7 @@ def _write_cookie_secret_file(self, secret: bytes) -> None: self.cookie_secret_file, e, ) + self._cookie_secret_creation_time = os.stat(self.cookie_secret_file).st_mtime _token_set = False