From 84dc672180db99fc92eefe94d5bfc4217d04913b Mon Sep 17 00:00:00 2001 From: mwiegand Date: Mon, 11 May 2026 21:46:20 +0200 Subject: [PATCH] auth: stamp password_changed_at marker in session on login login_user now records the user's current password_changed_at on the session. The next commit will use this marker to invalidate sessions whose password has been rotated under them. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/auth.py | 3 ++- l4d2web/routes/auth_routes.py | 2 +- l4d2web/tests/test_auth.py | 15 +++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/l4d2web/auth.py b/l4d2web/auth.py index 8518c94..b3250e9 100644 --- a/l4d2web/auth.py +++ b/l4d2web/auth.py @@ -48,8 +48,9 @@ def current_user() -> User | None: return getattr(g, "user", None) -def login_user(user_id: int) -> None: +def login_user(user_id: int, password_changed_at) -> None: session["user_id"] = user_id + session["pw_changed_at"] = password_changed_at.isoformat() def logout_user() -> None: diff --git a/l4d2web/routes/auth_routes.py b/l4d2web/routes/auth_routes.py index cf7ef4a..afb7c63 100644 --- a/l4d2web/routes/auth_routes.py +++ b/l4d2web/routes/auth_routes.py @@ -49,7 +49,7 @@ def login() -> Response: # Same generic response for missing user, wrong password, or # deactivated account — no timing oracle for deactivation status. return Response("invalid credentials", status=401) - login_user(user.id) + login_user(user.id, user.password_changed_at) LOGIN_ATTEMPTS_BY_IP.pop(remote_addr, None) next_target = request.form.get("next", "") return redirect(next_target if is_safe_next(next_target) else "/dashboard") diff --git a/l4d2web/tests/test_auth.py b/l4d2web/tests/test_auth.py index 375970f..b7a89bc 100644 --- a/l4d2web/tests/test_auth.py +++ b/l4d2web/tests/test_auth.py @@ -134,6 +134,21 @@ def test_login_sets_session(client) -> None: assert sess.get("user_id") is not None +def test_login_stamps_password_changed_at_in_session(client) -> None: + with session_scope() as session: + session.add(User(username="alice", password_digest=hash_password("secret"))) + + response = client.post("/login", data={"username": "alice", "password": "secret"}) + assert response.status_code == 302 + + with client.session_transaction() as sess: + marker = sess.get("pw_changed_at") + assert marker is not None + with session_scope() as session: + user = session.query(User).filter_by(username="alice").one() + assert marker == user.password_changed_at.isoformat() + + def test_create_user_cli_uses_environment_password(tmp_path, monkeypatch) -> None: db_url = f"sqlite:///{tmp_path/'create_user.db'}" monkeypatch.setenv("DATABASE_URL", db_url)