fix(g2): Session-Revocation bei Passwortaenderung auf beiden Pfaden

Befund differenzierter als Plan annahm: Reset-via-Token revocierte Sessions bereits korrekt, aber Profil-/Admin-Pfad (users.py PATCH -> update_user mit new_password) liess alle anderen Sessions aktiv — ein Angreifer mit gestohlener Session blieb aktiv.

Fix nach DRY: revoke_user_redis_sessions(user_id)-Helper in app/core/auth.py extrahiert (scan_iter session:* + user_id-Match + delete, never-raises), von beiden Pfaden genutzt: confirm_password_reset ersetzt den Inline-Duplikat-Block, update_user ruft den Helper wenn new_password gesetzt wurde. Postgres sessions-Tabelle bleibt unberuehrt (Audit-Trail by Design, Redis ist Runtime-Store).

Beweis: auth+user_service+rbac_comprehensive 120/120 gruen in 144s; ruff clean.
This commit is contained in:
Agent Zero
2026-08-26 00:00:19 +02:00
parent f4a5937a4b
commit 0baec2792c
3 changed files with 43 additions and 18 deletions
+32
View File
@@ -85,6 +85,38 @@ def generate_csrf_token() -> str:
return secrets.token_urlsafe(32)
async def revoke_user_redis_sessions(user_id: str | uuid.UUID) -> int:
"""Delete every active Redis session belonging to the user (G2).
Shared by both password-change paths (token reset + profile/admin change):
after a password change, stolen or lingering sessions must die.
Returns the number of deleted session keys. Never raises — a Redis outage
must not break the password change itself.
"""
try:
redis = get_redis()
deleted = 0
async for key in redis.scan_iter(match="session:*", count=100):
raw = await redis.get(key)
if raw is None:
continue
try:
import json
session_data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
if session_data.get("user_id") == str(user_id):
await redis.delete(key)
deleted += 1
logger.info("Deleted session %s for user %s", key, user_id)
return deleted
except Exception:
logger.warning("Failed to invalidate Redis sessions for user %s", user_id, exc_info=True)
return 0
def hash_token(token: str) -> str:
"""SHA-256 hash a token for storage."""
return hashlib.sha256(token.encode()).hexdigest()