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:
@@ -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")
|
||||||
@@ -15,6 +15,9 @@ def _check_auth(f, required_role: str | None = None):
|
|||||||
if not user:
|
if not user:
|
||||||
session.clear()
|
session.clear()
|
||||||
return jsonify({"error": "Authentication required"}), 401
|
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:
|
if required_role and user.role != required_role:
|
||||||
return jsonify({"error": "Admin access required"}), 403
|
return jsonify({"error": "Admin access required"}), 403
|
||||||
g.user = user
|
g.user = user
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Index, Text
|
from sqlalchemy import Index, Integer, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from fabledassistant.models import Base
|
from fabledassistant.models import Base
|
||||||
@@ -14,6 +14,7 @@ class User(Base, CreatedAtMixin):
|
|||||||
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
|
oauth_sub: Mapped[str | None] = mapped_column(Text, unique=True, nullable=True)
|
||||||
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
|
role: Mapped[str] = mapped_column(Text, nullable=False, default="user")
|
||||||
|
session_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_users_username", "username"),
|
Index("ix_users_username", "username"),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ async def register():
|
|||||||
return jsonify({"error": "Username already taken"}), 409
|
return jsonify({"error": "Username already taken"}), 409
|
||||||
|
|
||||||
session["user_id"] = user.id
|
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())
|
await log_audit("register", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||||
return jsonify(user.to_dict()), 201
|
return jsonify(user.to_dict()), 201
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ async def login():
|
|||||||
return jsonify({"error": "Invalid username or password"}), 401
|
return jsonify({"error": "Invalid username or password"}), 401
|
||||||
|
|
||||||
session["user_id"] = user.id
|
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())
|
await log_audit("login", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||||
asyncio.create_task(notify_security_event(
|
asyncio.create_task(notify_security_event(
|
||||||
user.id, "login",
|
user.id, "login",
|
||||||
@@ -150,10 +152,12 @@ async def update_password():
|
|||||||
return jsonify({"error": "New password must be at least 8 characters"}), 400
|
return jsonify({"error": "New password must be at least 8 characters"}), 400
|
||||||
|
|
||||||
uid = get_current_user_id()
|
uid = get_current_user_id()
|
||||||
success = await change_password(uid, current, new_pw)
|
new_version = await change_password(uid, current, new_pw)
|
||||||
if not success:
|
if new_version is None:
|
||||||
return jsonify({"error": "Current password is incorrect"}), 403
|
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())
|
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=_client_ip())
|
||||||
asyncio.create_task(notify_security_event(
|
asyncio.create_task(notify_security_event(
|
||||||
uid, "password_change",
|
uid, "password_change",
|
||||||
@@ -279,6 +283,7 @@ async def register_with_invite():
|
|||||||
return jsonify({"error": "Invalid or expired invitation"}), 400
|
return jsonify({"error": "Invalid or expired invitation"}), 400
|
||||||
|
|
||||||
session["user_id"] = user.id
|
session["user_id"] = user.id
|
||||||
|
session["session_version"] = user.session_version
|
||||||
await log_audit(
|
await log_audit(
|
||||||
"register_with_invite",
|
"register_with_invite",
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -346,5 +351,6 @@ async def oauth_callback():
|
|||||||
|
|
||||||
user = await find_or_create_oauth_user(sub, email, preferred_username)
|
user = await find_or_create_oauth_user(sub, email, preferred_username)
|
||||||
session["user_id"] = user.id
|
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())
|
await log_audit("oauth_login", user_id=user.id, username=user.username, ip_address=_client_ip())
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|||||||
@@ -109,18 +109,19 @@ async def get_user_by_username(username: str) -> User | None:
|
|||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
|
async def change_password(user_id: int, current_password: str, new_password: str) -> int | None:
|
||||||
"""Change a user's password. Returns True on success, False if current password is wrong."""
|
"""Change a user's password. Returns new session_version on success, None if current password is wrong."""
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
user = await session.get(User, user_id)
|
user = await session.get(User, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return None
|
||||||
if not verify_password(current_password, user.password_hash):
|
if not verify_password(current_password, user.password_hash):
|
||||||
return False
|
return None
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.session_version += 1
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Password changed for user %d (%s)", user_id, user.username)
|
logger.info("Password changed for user %d (%s)", user_id, user.username)
|
||||||
return True
|
return user.session_version
|
||||||
|
|
||||||
|
|
||||||
async def is_registration_open() -> bool:
|
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
|
return None
|
||||||
|
|
||||||
user.password_hash = hash_password(new_password)
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.session_version += 1
|
||||||
reset_token.used = True
|
reset_token.used = True
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -12,7 +12,21 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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):**
|
**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.
|
- `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.
|
||||||
|
|||||||
Reference in New Issue
Block a user