profile: happy-path + cross-session invalidation tests

Verifies that on a successful change the digest rotates, the
password_changed_at advances, this session keeps working with the
re-stamped marker, and a parallel session forged from the
pre-change marker is rejected by load_current_user.

profile_password_change now writes a naive password_changed_at so
the in-memory marker matches what SQLite returns on next read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-11 21:58:26 +02:00
parent d25fb57f30
commit 47722dbb19
No known key found for this signature in database
2 changed files with 74 additions and 1 deletions

View file

@ -83,7 +83,9 @@ def profile_password_change() -> Response:
if user is None or not verify_password(current_password, user.password_digest):
return _redirect_with_error("wrong_current")
user.password_digest = hash_password(new_password)
user.password_changed_at = now_utc()
# Strip tz so the marker matches what a subsequent DB read returns
# (SQLite DateTime columns don't preserve tzinfo).
user.password_changed_at = now_utc().replace(tzinfo=None)
new_marker = user.password_changed_at.isoformat()
session["pw_changed_at"] = new_marker

View file

@ -160,3 +160,74 @@ def test_post_password_wrong_current(app_and_user):
assert response.status_code == 302
assert "/profile?error=wrong_current" in response.headers["Location"]
assert _digest_of("alice") == before
def test_post_password_happy_path_rotates_and_keeps_current_session(app_and_user):
app, uid, marker = app_and_user
client = _logged_in_client(app, uid, marker)
before_digest = _digest_of("alice")
with session_scope() as db:
before_marker = db.scalar(select(User).where(User.id == uid)).password_changed_at
response = _post_pw(
client,
current_password="currentpass",
new_password="newpass12",
confirm_new_password="newpass12",
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/profile?success=1")
after_digest = _digest_of("alice")
assert after_digest != before_digest
with session_scope() as db:
u = db.scalar(select(User).where(User.id == uid))
assert u.password_changed_at > before_marker
new_marker = u.password_changed_at.isoformat()
follow = client.get("/dashboard")
assert follow.status_code == 200
with client.session_transaction() as sess:
assert sess["pw_changed_at"] == new_marker
def test_other_session_dies_after_password_change(app_and_user):
app, uid, marker = app_and_user
primary = _logged_in_client(app, uid, marker)
other = _logged_in_client(app, uid, marker)
pre = other.get("/dashboard")
assert pre.status_code == 200
response = _post_pw(
primary,
current_password="currentpass",
new_password="newpass12",
confirm_new_password="newpass12",
)
assert response.status_code == 302
post = other.get("/dashboard", follow_redirects=False)
assert post.status_code == 302
assert "/login" in post.headers["Location"]
def test_new_password_works_for_login(app_and_user):
app, uid, marker = app_and_user
client = _logged_in_client(app, uid, marker)
_post_pw(
client,
current_password="currentpass",
new_password="newpass12",
confirm_new_password="newpass12",
)
fresh = app.test_client()
response = fresh.post(
"/login",
data={"username": "alice", "password": "newpass12"},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("/dashboard")