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
+15 -1
View File
@@ -12,7 +12,21 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-06 — DRY refactoring pass: TimestampMixin, route helpers, shared composables, ConfirmDialog, shared CSS.
2026-03-06 — Session invalidation on credential change (password reset + password change).
**Session invalidation on credential change:**
- `models/user.py`: added `session_version: Mapped[int]` column (default 1). `Integer` import added.
- `alembic/versions/0024_add_session_version.py`: migration adds `session_version INTEGER NOT NULL DEFAULT 1` to `users`.
- `auth.py` (`_check_auth`): after loading user, checks `session["session_version"] == user.session_version`; mismatch → `session.clear()` + 401 "Session expired. Please log in again."
- `services/auth.py`:
- `change_password` return type changed from `bool` to `int | None` (returns new `session_version` on success, `None` on failure). Increments `user.session_version` before commit.
- `reset_password_with_token`: increments `user.session_version` before commit (invalidates all live sessions on password reset).
- `routes/auth.py`:
- `login`, `register`, `register_with_invite`, `oauth_callback`: now also set `session["session_version"] = user.session_version` alongside `session["user_id"]`.
- `update_password`: receives `new_version` from `change_password`; updates `session["session_version"] = new_version` so the current user's own session stays valid while all other sessions are invalidated.
- **Effect:** All existing sessions will require re-login once after the migration runs (existing cookies lack `session_version`, so they'll mismatch `1`). Subsequent credential changes immediately evict all other active sessions.
---
**DRY refactoring pass (backend):**
- `src/fabledassistant/models/base.py` (new): `TimestampMixin` (`created_at` + `updated_at`) and `CreatedAtMixin` (`created_at` only) — SQLAlchemy 2.0 `Mapped[]` mixins. Applied to all 10+ models (`Note`, `Project`, `Milestone`, `Conversation`, `Message`, `User`, `PushSubscription`, `TaskLog`, `NoteDraft`, `NoteVersion`), removing ~60 lines of duplicated column definitions.