fix(security): F03 (Astra P1) — Sitzungswiderruf in beiden Session-Stores durchsetzen

Vorher (Astra-Finding): Widerruf war inkonsistent ueber vier Pfade:
- Deaktivierung invalidierte nur den Berechtigungscache — Sessions
  liefen bis TTL (8h) weiter
- Loeschung invalidierte GAR NICHTS
- Passwortwechsel loeschte nur Redis-Sessions — PostgreSQL-Fallback-
  Sessions ueberlebten jeden Redis-Ausfall
- Fehlende UserTenant-Mitgliedschaft wurde durchgewinkt statt
  abgewiesen

Fix:
- Neuer zentraler Helfer revoke_user_sessions_all_stores (app/core/auth.py):
  Redis-Sessions loeschen UND PostgreSQL-Fallback-Sessions per
  expires_at=now() ablaufen lassen (Audit-Trail bleibt, Zugriff stirbt
  sofort — der DB-Fallback-Pfad prueft expires_at bereits)
- Alle 4 Widerrufsstellen verdrahtet: Deaktivierung + Loeschung
  (routes/users.py), Passwortwechsel (user_service.py), Passwort-Reset
  (auth_service.py)
- Membership-Check in get_current_user fail-closed: None (fehlende
  Mitgliedschaft) wird abgewiesen statt durchgelassen

Abnahme (Astra): Deaktivierung, Austritt und Passwortwechsel wirken
unmittelbar — auch bei Redis-Ausfall (Unit-Test beweist die
DB-Fallback-Abgelaufen-Rejection).

Tests: test_s1_security_guards.py 10/10 (3 neue F03-Tests) +
test_auth.py 11/11 + ruff clean.
This commit is contained in:
Agent Zero
2026-09-18 07:59:31 +02:00
parent b8a556091e
commit 47432651f1
6 changed files with 195 additions and 8 deletions
+49
View File
@@ -117,6 +117,55 @@ async def revoke_user_redis_sessions(user_id: str | uuid.UUID) -> int:
return 0
async def revoke_user_sessions_all_stores(user_id: str | uuid.UUID) -> None:
"""F03 (Astra): revoke ALL sessions for a user in BOTH session stores.
Deactivation, deletion and password changes must take effect immediately —
including when Redis is down and requests fall back to the PostgreSQL
sessions table.
1. Redis runtime sessions are deleted (revoke_user_redis_sessions).
2. PostgreSQL session records are EXPIRED by setting ``expires_at = now()``
(not deleted — they stay as audit trail). The DB fallback path in
``get_session_data`` rejects sessions whose ``expires_at`` is past.
Never raises — best-effort per store, but errors are logged loudly.
"""
# 1. Redis runtime sessions
await revoke_user_redis_sessions(user_id)
# 2. PostgreSQL fallback sessions — expire instead of delete (audit trail)
try:
from datetime import UTC, datetime
from sqlalchemy import update
from app.core.db import get_session_factory
from app.models.session import Session as SessionModel
uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id))
factory = get_session_factory()
async with factory() as db:
result = await db.execute(
update(SessionModel)
.where(
SessionModel.user_id == uid,
SessionModel.expires_at > datetime.now(UTC),
)
.values(expires_at=datetime.now(UTC))
)
await db.commit()
if result.rowcount:
logger.info(
"F03: expired %d PostgreSQL fallback sessions for user %s",
result.rowcount, uid,
)
except Exception:
logger.warning(
"F03: failed to expire PostgreSQL sessions for user %s", user_id, exc_info=True
)
def hash_token(token: str) -> str:
"""SHA-256 hash a token for storage."""
return hashlib.sha256(token.encode()).hexdigest()