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:
parent
d25fb57f30
commit
47722dbb19
2 changed files with 74 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue