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
+7 -5
View File
@@ -109,18 +109,19 @@ async def get_user_by_username(username: str) -> User | None:
return result.scalars().first()
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
"""Change a user's password. Returns True on success, False if current password is wrong."""
async def change_password(user_id: int, current_password: str, new_password: str) -> int | None:
"""Change a user's password. Returns new session_version on success, None if current password is wrong."""
async with async_session() as session:
user = await session.get(User, user_id)
if not user:
return False
return None
if not verify_password(current_password, user.password_hash):
return False
return None
user.password_hash = hash_password(new_password)
user.session_version += 1
await session.commit()
logger.info("Password changed for user %d (%s)", user_id, user.username)
return True
return user.session_version
async def is_registration_open() -> bool:
@@ -249,6 +250,7 @@ async def reset_password_with_token(raw_token: str, new_password: str) -> int |
return None
user.password_hash = hash_password(new_password)
user.session_version += 1
reset_token.used = True
await session.commit()