diff --git a/steward/auth/middleware.py b/steward/auth/middleware.py index 48456cd..9a60628 100644 --- a/steward/auth/middleware.py +++ b/steward/auth/middleware.py @@ -145,6 +145,10 @@ async def refresh_session_user() -> None: if user is None or not user.is_active: session.clear() return + # The password was reset after this session began (#5427). + if user.sessions_since is not None and started < user.sessions_since.timestamp(): + session.clear() + return if session.get("user_role") != user.role.value: session["user_role"] = user.role.value # The zone times are written in (milestone 490): a change made on the diff --git a/steward/migrations/versions/0038_user_sessions_since.py b/steward/migrations/versions/0038_user_sessions_since.py new file mode 100644 index 0000000..6a6b32e --- /dev/null +++ b/steward/migrations/versions/0038_user_sessions_since.py @@ -0,0 +1,23 @@ +"""A password reset ends the account's other sessions (#5427): sessions that +began before users.sessions_since are over. + +Revision ID: 0038_user_sessions_since +Revises: 0037_user_invites +Create Date: 2026-10-08 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0038_user_sessions_since" +down_revision: Union[str, None] = "0037_user_invites" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("sessions_since", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "sessions_since") diff --git a/steward/models/users.py b/steward/models/users.py index d59ee82..69e3358 100644 --- a/steward/models/users.py +++ b/steward/models/users.py @@ -44,3 +44,6 @@ class User(Base): # 495). None follows its role's default (notify.. in settings). notify_alerts: Mapped[bool | None] = mapped_column(Boolean, nullable=True) notify_report: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + # Sessions that began before this are over (#5427): set when the password + # is reset, so a browser signed in with the old password is signed out. + sessions_since: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/steward/settings/users.py b/steward/settings/users.py index e16f122..d674475 100644 --- a/steward/settings/users.py +++ b/steward/settings/users.py @@ -8,6 +8,7 @@ so Steward always keeps someone who can get back in. Every change is audited. """ from __future__ import annotations +import time from datetime import datetime, timezone import bcrypt @@ -268,7 +269,7 @@ async def user_invite_revoke(invite_id: int): _DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.", - "password": "Password set. It works from their next sign-in.", + "password": "Password set. Anywhere they were signed in, they are signed out.", "disabled": "Account disabled. They are signed out at their next click.", "enabled": "Account enabled."} @@ -374,8 +375,12 @@ async def user_password(user_id: str): problem = f"This account signs in with {SOURCES[user.auth_source]}; it has no Steward password." if problem is None: user.password_hash = _hash(form.get("password")) + # Every browser signed in with the old password is signed out (#5427). + user.sessions_since = datetime.now(timezone.utc) if problem: return await _user_page(user_id, problem, 400) + if user_id == session.get("user_id"): + session["login_at"] = time.time() # this session carries on under the new password await audit_request("user.password_reset", "user", user_id, {"username": user.username}) return redirect(f"/settings/users/{user_id}?done=password") diff --git a/steward/templates/settings/user.html b/steward/templates/settings/user.html index 91e57d6..4d4f6a2 100644 --- a/steward/templates/settings/user.html +++ b/steward/templates/settings/user.html @@ -72,7 +72,7 @@
- At least {{ min_password }} characters. Give it to them yourself; Steward sends no email for it. + At least {{ min_password }} characters. Give it to them yourself; Steward sends no email for it. Anywhere they're signed in now, they're signed out.
diff --git a/tests/integration/test_users_page.py b/tests/integration/test_users_page.py index 3e881cb..849ad3f 100644 --- a/tests/integration/test_users_page.py +++ b/tests/integration/test_users_page.py @@ -161,6 +161,33 @@ def test_a_provider_account_s_role_is_not_edited_here(app): asyncio.run(_go()) +def test_a_password_reset_signs_out_the_old_sessions_but_not_the_admin_s_own(app): + """#5427: a browser signed in before the reset is signed out; the admin + who resets their own password stays signed in.""" + async def _go(): + admin, me = await _client(app) + them, uid = await _client(app, role="viewer") + async with them.session_transaction() as sess: + sess["login_at"] = time.time() - 60 # signed in a minute ago + assert (await them.get("/account/")).status_code == 200 + + resp = await admin.post(f"/settings/users/{uid}/password", headers=CSRF, + form={"password": "battery staple", "confirm": "battery staple"}) + assert resp.status_code == 302 and "signed out" in await (await admin.get(resp.headers["Location"])).get_data(as_text=True) + assert (await them.get("/account/")).status_code == 302 # back to sign-in + async with them.session_transaction() as sess: + assert "user_id" not in sess + + async with admin.session_transaction() as sess: + sess["login_at"] = time.time() - 60 + resp = await admin.post(f"/settings/users/{me}/password", headers=CSRF, + form={"password": "my own new one", "confirm": "my own new one"}) + assert resp.status_code == 302 + assert (await admin.get("/account/")).status_code == 200 # still signed in + + asyncio.run(_go()) + + def test_only_an_admin_reaches_users(app): async def _go(): client, _ = await _client(app, role="operator") diff --git a/tests/test_models.py b/tests/test_models.py index 62a7240..ef13541 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,7 +5,8 @@ def test_user_model_fields(): """User model has all required fields.""" cols = {c.key for c in User.__table__.columns} assert cols == {"id", "username", "email", "password_hash", "role", "created_at", "is_active", - "auth_source", "external_id", "time_zone", "notify_alerts", "notify_report"} + "auth_source", "external_id", "time_zone", "notify_alerts", "notify_report", + "sessions_since"} def test_user_role_enum():