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
@@ -0,0 +1,16 @@
"""Add session_version to users for session invalidation on credential change."""
from alembic import op
revision = "0024"
down_revision = "0023"
def upgrade() -> None:
op.execute(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS session_version INTEGER NOT NULL DEFAULT 1"
)
def downgrade() -> None:
op.execute("ALTER TABLE users DROP COLUMN IF EXISTS session_version")
+3
View File
@@ -15,6 +15,9 @@ def _check_auth(f, required_role: str | None = None):
if not user:
session.clear()
return jsonify({"error": "Authentication required"}), 401
if session.get("session_version") != user.session_version:
session.clear()
return jsonify({"error": "Session expired. Please log in again."}), 401
if required_role and user.role != required_role:
return jsonify({"error": "Admin access required"}), 403
g.user = user
+2 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Index, Text
from sqlalchemy import Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
@@ -14,6 +14,7 @@ class User(Base, CreatedAtMixin):
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
session_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
__table_args__ = (
Index("ix_users_username", "username"),
+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("/")
+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()
+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.