feat(07.1): session revocation on privilege change — CR-01/CR-02/CR-03

- revoke_all_refresh_tokens: add skip_token_hash optional param (exclude
  current session while revoking others)
- change_password, enable_totp, disable_totp: call revoke with skip hash
  derived from refresh cookie; return sessions_revoked in response and
  write to audit log metadata_
- 3 new tests: test_{change_password,enable_totp,disable_totp}_revokes_other_sessions
  — all PASSED; 373 total passing, 0 regressions
- Frontend toasts: SettingsAccountTab + TotpEnrollment show
  "Other sessions have been terminated." when sessions_revoked > 0
- Companion fixes: rate_limiting get_client_ip refactor, deps/auth.py
  request.state.current_user, locustfile refresh-token task removal
- Version bump: 0.1.0 → 0.1.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-05 12:47:16 +02:00
co-authored by Claude Sonnet 4.6
parent 8d060a5da4
commit c38c6b1c01
14 changed files with 360 additions and 34 deletions
+20 -3
View File
@@ -489,6 +489,10 @@ async def change_password(
_ip = get_client_ip(request)
user = await session.get(User, current_user.id)
user.password_hash = auth_service.hash_password(body.new_password)
# Revoke other sessions; keep current one alive via skip_token_hash (CR-01)
raw_cookie = request.cookies.get("refresh_token")
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
# D-13: password changed event (flush within same transaction before commit)
await write_audit_log(
session,
@@ -497,10 +501,11 @@ async def change_password(
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": revoked},
)
await session.commit()
return {"message": "Password updated"}
return {"message": "Password updated", "sessions_revoked": revoked}
# ── Request models for new endpoints ─────────────────────────────────────────
@@ -575,6 +580,11 @@ async def enable_totp(
plain_codes = auth_service.generate_backup_codes(10)
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
# Revoke other sessions; keep current one alive via skip_token_hash (CR-02)
raw_cookie = request.cookies.get("refresh_token")
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
# D-13: TOTP enrolled event
_ip = get_client_ip(request)
await write_audit_log(
@@ -584,10 +594,11 @@ async def enable_totp(
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": revoked},
)
await session.commit()
return {"backup_codes": plain_codes}
return {"backup_codes": plain_codes, "sessions_revoked": revoked}
# ── DELETE /api/auth/totp ─────────────────────────────────────────────────────
@@ -610,6 +621,11 @@ async def disable_totp(
# Delete all backup codes for this user (including unused ones)
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))
# Revoke other sessions; keep current one alive via skip_token_hash (CR-03)
raw_cookie = request.cookies.get("refresh_token")
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
# D-13: TOTP revoked event
await write_audit_log(
session,
@@ -618,10 +634,11 @@ async def disable_totp(
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": revoked},
)
await session.commit()
return {"message": "TOTP disabled"}
return {"message": "TOTP disabled", "sessions_revoked": revoked}
# ── POST /api/auth/password-reset ─────────────────────────────────────────────