Session invalidation on credential change

Add session_version to users table. Sessions now carry session_version
alongside user_id; @login_required rejects any session where the version
doesn't match the DB value.

- migration 0024: session_version INTEGER NOT NULL DEFAULT 1
- models/user.py: session_version Mapped[int] column
- auth.py: version mismatch → session.clear() + 401
- services/auth.py: change_password and reset_password_with_token both
  increment session_version, evicting all other live sessions
- routes/auth.py: login/register/oauth_callback store session_version;
  update_password rehydrates current session with new version so the
  user who changed their password stays logged in

Existing sessions will require one re-login after the migration runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 16:30:14 -05:00
parent 16ecd6bbeb
commit b33aa25fb7
6 changed files with 51 additions and 9 deletions
+8 -2
View File
@@ -76,6 +76,7 @@ async def register():
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip())
return jsonify(user.to_dict()), 201
@@ -106,6 +107,7 @@ async def login():
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
user.id, "login",
@@ -150,10 +152,12 @@ async def update_password():
return jsonify({"error": "New password must be at least 8 characters"}), 400
uid = get_current_user_id()
success = await change_password(uid, current, new_pw)
if not success:
new_version = await change_password(uid, current, new_pw)
if new_version is None:
return jsonify({"error": "Current password is incorrect"}), 403
# Keep the current session alive with the new version (other sessions are now invalidated)
session["session_version"] = new_version
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip())
asyncio.create_task(notify_security_event(
uid, "password_change",
@@ -279,6 +283,7 @@ async def register_with_invite():
return jsonify({"error": "Invalid or expired invitation"}), 400
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit(
"register_with_invite",
user_id=user.id,
@@ -346,5 +351,6 @@ async def oauth_callback():
user = await find_or_create_oauth_user(sub, email, preferred_username)
session["user_id"] = user.id
session["session_version"] = user.session_version
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
return redirect("/")