fix(redis): Pool-Rollout vervollständigen + TOTP-Lockout fail-closed mit 503
Redis-Review deckte auf, dass der Pool-Fix vom letzten Commit nur totp_login/kiosk_security erreichte. Login/Refresh (auth_service.py) und PIN/NFC/QR-Kiosk-Login (kiosk_auth_service.py) öffneten weiterhin pro Request eine neue aioredis-Verbindung. Zusätzlich nutzten kiosk_session_service.py und public_stamp_session_service.py den *sync* Redis-Client aus async-Code – blockierender Socket-Call im Event-Loop bei jedem Kiosk-/Stempel-Request. Alle auf get_async_redis() umgestellt. TOTP-Lockout wirft jetzt 503 statt eines ungefangenen 500 bei Redis-Ausfall (RedisError explizit gefangen, eigene HTTPExceptions unberührt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
This commit is contained in:
@@ -111,79 +111,76 @@ class AuthService:
|
||||
return tokens
|
||||
|
||||
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.redis import get_async_redis
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import AuthProvider
|
||||
from app.services.ldap_service import ldap_service
|
||||
|
||||
client_ip = _get_client_ip(request)
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||
await self._check_login_lockout(data.email, redis_client)
|
||||
redis_client = get_async_redis()
|
||||
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||
await self._check_login_lockout(data.email, redis_client)
|
||||
|
||||
user = await db.scalar(select(User).where(User.email == data.email))
|
||||
if not user:
|
||||
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
|
||||
await self._record_login_failure(data.email, redis_client)
|
||||
db.add(AuditLog(
|
||||
company_id=None,
|
||||
user_id=None,
|
||||
action="login_failed",
|
||||
entity_type="user",
|
||||
entity_id=None,
|
||||
new_value={"email": data.email},
|
||||
ip=client_ip,
|
||||
))
|
||||
await db.commit()
|
||||
user = await db.scalar(select(User).where(User.email == data.email))
|
||||
if not user:
|
||||
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
|
||||
await self._record_login_failure(data.email, redis_client)
|
||||
db.add(AuditLog(
|
||||
company_id=None,
|
||||
user_id=None,
|
||||
action="login_failed",
|
||||
entity_type="user",
|
||||
entity_id=None,
|
||||
new_value={"email": data.email},
|
||||
ip=client_ip,
|
||||
))
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
||||
|
||||
# Mandanten-Sperre: deaktivierte Firma → kein Login (Reseller/SUPER_ADMIN
|
||||
# haben company_id IS NULL und sind davon nicht betroffen).
|
||||
if user.company_id is not None:
|
||||
company = await db.get(Company, user.company_id)
|
||||
if company is not None and not company.is_active:
|
||||
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
|
||||
|
||||
auth_ok = False
|
||||
if user.auth_provider == AuthProvider.LDAP:
|
||||
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
||||
if not ldap_cfg or not ldap_cfg.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
detail="LDAP authentication not available",
|
||||
)
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
||||
auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
|
||||
else:
|
||||
auth_ok = bool(user.password_hash and verify_password(data.password, user.password_hash))
|
||||
|
||||
# Mandanten-Sperre: deaktivierte Firma → kein Login (Reseller/SUPER_ADMIN
|
||||
# haben company_id IS NULL und sind davon nicht betroffen).
|
||||
if user.company_id is not None:
|
||||
company = await db.get(Company, user.company_id)
|
||||
if company is not None and not company.is_active:
|
||||
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
|
||||
if not auth_ok:
|
||||
await self._record_login_failure(data.email, redis_client)
|
||||
db.add(AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action="login_failed",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
new_value={"email": data.email},
|
||||
ip=client_ip,
|
||||
))
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
auth_ok = False
|
||||
if user.auth_provider == AuthProvider.LDAP:
|
||||
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
||||
if not ldap_cfg or not ldap_cfg.enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="LDAP authentication not available",
|
||||
)
|
||||
auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
|
||||
else:
|
||||
auth_ok = bool(user.password_hash and verify_password(data.password, user.password_hash))
|
||||
|
||||
if not auth_ok:
|
||||
await self._record_login_failure(data.email, redis_client)
|
||||
db.add(AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action="login_failed",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
new_value={"email": data.email},
|
||||
ip=client_ip,
|
||||
))
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
)
|
||||
|
||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||
await self._clear_login_failures(data.email, redis_client)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||
await self._clear_login_failures(data.email, redis_client)
|
||||
|
||||
# AuditLog: Erfolgreicher Login
|
||||
db.add(AuditLog(
|
||||
@@ -212,62 +209,59 @@ class AuthService:
|
||||
return await self._create_session(user, db, request=request)
|
||||
|
||||
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.redis import get_async_redis
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
token_hash = hash_token(raw_token)
|
||||
burned_key = f"burned_token:{token_hash}"
|
||||
|
||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
||||
try:
|
||||
# Re-Use-Detection: prüfe ob Token bereits verbrannt wurde
|
||||
burned_user_id = await redis_client.get(burned_key)
|
||||
if burned_user_id:
|
||||
# Token wurde bereits einmal genutzt — möglicher Token-Diebstahl!
|
||||
logger.warning(
|
||||
"Replay-Angriff erkannt: verbrannter Refresh-Token für User %s",
|
||||
burned_user_id,
|
||||
)
|
||||
# Alle Sessions des betroffenen Users invalidieren
|
||||
try:
|
||||
uid = uuid_mod.UUID(burned_user_id)
|
||||
await db.execute(delete(Session).where(Session.user_id == uid))
|
||||
db.add(AuditLog(
|
||||
company_id=None,
|
||||
user_id=uid,
|
||||
action="refresh_token_reuse",
|
||||
entity_type="session",
|
||||
entity_id=uid,
|
||||
new_value={"token_hash_prefix": token_hash[:8], "action": "all_sessions_invalidated"},
|
||||
ip=None,
|
||||
))
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Sicherheitsvorfall: Alle Sessions wurden invalidiert. Bitte erneut anmelden.",
|
||||
)
|
||||
|
||||
session = await db.scalar(
|
||||
select(Session).where(Session.refresh_token_hash == token_hash)
|
||||
redis_client = get_async_redis()
|
||||
# Re-Use-Detection: prüfe ob Token bereits verbrannt wurde
|
||||
burned_user_id = await redis_client.get(burned_key)
|
||||
if burned_user_id:
|
||||
# Token wurde bereits einmal genutzt — möglicher Token-Diebstahl!
|
||||
logger.warning(
|
||||
"Replay-Angriff erkannt: verbrannter Refresh-Token für User %s",
|
||||
burned_user_id,
|
||||
)
|
||||
# Alle Sessions des betroffenen Users invalidieren
|
||||
try:
|
||||
uid = uuid_mod.UUID(burned_user_id)
|
||||
await db.execute(delete(Session).where(Session.user_id == uid))
|
||||
db.add(AuditLog(
|
||||
company_id=None,
|
||||
user_id=uid,
|
||||
action="refresh_token_reuse",
|
||||
entity_type="session",
|
||||
entity_id=uid,
|
||||
new_value={"token_hash_prefix": token_hash[:8], "action": "all_sessions_invalidated"},
|
||||
ip=None,
|
||||
))
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Sicherheitsvorfall: Alle Sessions wurden invalidiert. Bitte erneut anmelden.",
|
||||
)
|
||||
if not session or session.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
user = await db.get(User, session.user_id)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
session = await db.scalar(
|
||||
select(Session).where(Session.refresh_token_hash == token_hash)
|
||||
)
|
||||
if not session or session.expires_at < datetime.now(timezone.utc):
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
user_id_str = str(session.user_id)
|
||||
user = await db.get(User, session.user_id)
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||
|
||||
# Session löschen (Token "verbrennen")
|
||||
await db.delete(session)
|
||||
user_id_str = str(session.user_id)
|
||||
|
||||
# Verbrannten Token-Hash 48h in Redis merken
|
||||
await redis_client.set(burned_key, user_id_str, ex=48 * 3600)
|
||||
finally:
|
||||
await redis_client.aclose()
|
||||
# Session löschen (Token "verbrennen")
|
||||
await db.delete(session)
|
||||
|
||||
# Verbrannten Token-Hash 48h in Redis merken
|
||||
await redis_client.set(burned_key, user_id_str, ex=48 * 3600)
|
||||
|
||||
return await self._create_session(user, db)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user