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
+9 -5
View File
@@ -216,17 +216,21 @@ async def rotate_refresh_token(
async def revoke_all_refresh_tokens(
session: AsyncSession, user_id: uuid.UUID
session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None
) -> int:
"""Mark all active refresh tokens for user_id as revoked.
Returns the count of revoked tokens (supports sign-out-all-devices).
skip_token_hash: if set, the token with this hash is excluded (keep current session alive).
"""
conditions = [
RefreshToken.user_id == user_id,
RefreshToken.revoked.is_(False),
]
if skip_token_hash is not None:
conditions.append(RefreshToken.token_hash != skip_token_hash)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.revoked.is_(False),
)
select(RefreshToken).where(*conditions)
)
rows = result.scalars().all()
count = 0
+4 -3
View File
@@ -4,14 +4,15 @@ from __future__ import annotations
from fastapi import Request
from slowapi import Limiter
from deps.utils import get_client_ip
def _account_key(request: Request) -> str:
user = getattr(request.state, "current_user", None)
if user is not None:
return str(user.id)
if request.client:
return request.client.host
return "anonymous"
ip = get_client_ip(request)
return ip if ip is not None else "anonymous"
account_limiter = Limiter(key_func=_account_key)