diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c949f62..b9ec90a 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -318,19 +318,24 @@ async def totp_login( if not user.totp_enabled or not user.totp_secret: raise HTTPException(400, "2FA nicht aktiv") + from redis.exceptions import RedisError + redis_client = get_async_redis() - # M-5: Lockout-Check vor TOTP-Verifikation - await _check_totp_lockout(user_id, redis_client) + try: + # M-5: Lockout-Check vor TOTP-Verifikation + await _check_totp_lockout(user_id, redis_client) - plain_secret = _totp_plain(user) - totp = pyotp.TOTP(plain_secret or "") - if not totp.verify(data.code, valid_window=1): - # M-5: Fehlversuch zählen - await _record_totp_failure(user_id, redis_client) - raise HTTPException(400, "Ungültiger Code") + plain_secret = _totp_plain(user) + totp = pyotp.TOTP(plain_secret or "") + if not totp.verify(data.code, valid_window=1): + # M-5: Fehlversuch zählen + await _record_totp_failure(user_id, redis_client) + raise HTTPException(400, "Ungültiger Code") - # M-5: Erfolg → Fehlversuche zurücksetzen - await _clear_totp_failures(user_id, redis_client) + # M-5: Erfolg → Fehlversuche zurücksetzen + await _clear_totp_failures(user_id, redis_client) + except RedisError as exc: + raise HTTPException(503, "2FA-Login vorübergehend nicht verfügbar (Redis).") from exc from datetime import datetime, timezone user.last_login = datetime.now(timezone.utc) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index c046604..1e1695d 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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) diff --git a/backend/app/services/kiosk_auth_service.py b/backend/app/services/kiosk_auth_service.py index ed5ea49..2dc9f9b 100644 --- a/backend/app/services/kiosk_auth_service.py +++ b/backend/app/services/kiosk_auth_service.py @@ -82,41 +82,36 @@ class KioskAuthService: db: AsyncSession, ) -> tuple[User, str]: """Authentifizierung per Personalnummer + PIN. Returns (user, session_token).""" - import redis.asyncio as aioredis - from app.core.config import settings + from app.core.redis import get_async_redis - # Redis für Brute-Force-Schutz (async) - redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) - try: - # 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing) - await self._check_pin_lockout(device_id, personnel_number, redis_client) + redis_client = get_async_redis() + # 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing) + await self._check_pin_lockout(device_id, personnel_number, redis_client) - user = await db.scalar( - select(User).where( - User.company_id == company_id, - User.personnel_number == personnel_number, - User.is_active == True, - ) + user = await db.scalar( + select(User).where( + User.company_id == company_id, + User.personnel_number == personnel_number, + User.is_active == True, ) - if user is None: - # Fehlversuch zählen auch bei unbekannter Personalnummer - await self._record_pin_failure(device_id, personnel_number, redis_client) - raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.") + ) + if user is None: + # Fehlversuch zählen auch bei unbekannter Personalnummer + await self._record_pin_failure(device_id, personnel_number, redis_client) + raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.") - if not user.kiosk_pin_hash: - raise HTTPException( - status_code=401, - detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.", - ) + if not user.kiosk_pin_hash: + raise HTTPException( + status_code=401, + detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.", + ) - if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): - await self._record_pin_failure(device_id, personnel_number, redis_client) - raise HTTPException(status_code=401, detail="Falscher PIN.") + if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): + await self._record_pin_failure(device_id, personnel_number, redis_client) + raise HTTPException(status_code=401, detail="Falscher PIN.") - # Erfolgreicher Login: Fehlversuche zurücksetzen - await self._clear_pin_failures(device_id, personnel_number, redis_client) - finally: - await redis_client.aclose() + # Erfolgreicher Login: Fehlversuche zurücksetzen + await self._clear_pin_failures(device_id, personnel_number, redis_client) session_token = await kiosk_session_service.create_session( user_id=user.id, @@ -140,41 +135,37 @@ class KioskAuthService: Gerät existiert. Erzeugt KEINE Kiosk-Session – der Aufrufer legt eine separate öffentliche Kurz-Session an. Gibt nur den User zurück. """ - import redis.asyncio as aioredis - from app.core.config import settings + from app.core.redis import get_async_redis # Lockout-Key-Namespace klar vom Kiosk trennen lock_id = f"public:{company_id}" - redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) - try: - await self._check_pin_lockout(lock_id, personnel_number, redis_client) + redis_client = get_async_redis() + await self._check_pin_lockout(lock_id, personnel_number, redis_client) - user = await db.scalar( - select(User).where( - User.company_id == company_id, - User.personnel_number == personnel_number, - User.is_active == True, - ) + user = await db.scalar( + select(User).where( + User.company_id == company_id, + User.personnel_number == personnel_number, + User.is_active == True, ) - if user is None: - # Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration) - await self._record_pin_failure(lock_id, personnel_number, redis_client) - raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.") + ) + if user is None: + # Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration) + await self._record_pin_failure(lock_id, personnel_number, redis_client) + raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.") - if not user.kiosk_pin_hash: - raise HTTPException( - status_code=401, - detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.", - ) + if not user.kiosk_pin_hash: + raise HTTPException( + status_code=401, + detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.", + ) - if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): - await self._record_pin_failure(lock_id, personnel_number, redis_client) - raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.") + if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): + await self._record_pin_failure(lock_id, personnel_number, redis_client) + raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.") - await self._clear_pin_failures(lock_id, personnel_number, redis_client) - finally: - await redis_client.aclose() + await self._clear_pin_failures(lock_id, personnel_number, redis_client) return user @@ -195,37 +186,33 @@ class KioskAuthService: gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin, keyed auf die NFC-UID statt Personalnummer. """ - import redis.asyncio as aioredis - from app.core.config import settings + from app.core.redis import get_async_redis - redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) - try: - await self._check_pin_lockout(device_id, nfc_uid, redis_client) + redis_client = get_async_redis() + await self._check_pin_lockout(device_id, nfc_uid, redis_client) - user = await db.scalar( - select(User).where( - User.company_id == company_id, - User.kiosk_nfc_uid == nfc_uid, - User.is_active == True, - ) + user = await db.scalar( + select(User).where( + User.company_id == company_id, + User.kiosk_nfc_uid == nfc_uid, + User.is_active == True, ) - if user is None: - await self._record_pin_failure(device_id, nfc_uid, redis_client) - raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.") + ) + if user is None: + await self._record_pin_failure(device_id, nfc_uid, redis_client) + raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.") - if not user.kiosk_pin_hash: - raise HTTPException( - status_code=401, - detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.", - ) + if not user.kiosk_pin_hash: + raise HTTPException( + status_code=401, + detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.", + ) - if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): - await self._record_pin_failure(device_id, nfc_uid, redis_client) - raise HTTPException(status_code=401, detail="Falscher PIN.") + if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()): + await self._record_pin_failure(device_id, nfc_uid, redis_client) + raise HTTPException(status_code=401, detail="Falscher PIN.") - await self._clear_pin_failures(device_id, nfc_uid, redis_client) - finally: - await redis_client.aclose() + await self._clear_pin_failures(device_id, nfc_uid, redis_client) session_token = await kiosk_session_service.create_session( user_id=user.id, @@ -240,17 +227,17 @@ class KioskAuthService: Erzeugt einen einmaligen QR-Token (für die Web-App: User scannt QR am Kiosk). Token ist 5 min gültig und wird in Redis gespeichert. """ - from app.core.redis import get_redis_client - redis = get_redis_client() - if redis is None: - raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") - + from app.core.redis import get_async_redis + redis = get_async_redis() token = secrets.token_urlsafe(32) key = QR_TOKEN_PREFIX + token - redis.setex(key, QR_TOKEN_TTL, json.dumps({ - "user_id": str(user_id), - "company_id": str(company_id), - })) + try: + await redis.set(key, json.dumps({ + "user_id": str(user_id), + "company_id": str(company_id), + }), ex=QR_TOKEN_TTL) + except Exception as exc: + raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") from exc return token async def login_qr( @@ -261,18 +248,18 @@ class KioskAuthService: db: AsyncSession, ) -> tuple[User, str]: """Validiert QR-Token (einmalig) und erstellt Session.""" - from app.core.redis import get_redis_client - redis = get_redis_client() - if redis is None: - raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") - + from app.core.redis import get_async_redis + redis = get_async_redis() key = QR_TOKEN_PREFIX + qr_token - data = redis.get(key) + try: + data = await redis.get(key) + except Exception as exc: + raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).") from exc if data is None: raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.") # Einmalig: Token sofort löschen - redis.delete(key) + await redis.delete(key) payload = json.loads(data) if str(payload.get("company_id")) != str(company_id): diff --git a/backend/app/services/kiosk_session_service.py b/backend/app/services/kiosk_session_service.py index 7609581..f13ef48 100644 --- a/backend/app/services/kiosk_session_service.py +++ b/backend/app/services/kiosk_session_service.py @@ -23,21 +23,9 @@ SESSION_KEY_PREFIX = "kiosk_session:" class KioskSessionService: def _get_redis(self): - """Redis-Client aus app.core.redis holen. Raises 503 wenn nicht verfügbar.""" - try: - from app.core.redis import get_redis_client - client = get_redis_client() - if client is None: - raise HTTPException( - status_code=503, - detail="Session-Service nicht verfügbar (Redis nicht erreichbar)." - ) - return client - except ImportError: - raise HTTPException( - status_code=503, - detail="Session-Service nicht konfiguriert." - ) + """Gepoolter async Redis-Client aus app.core.redis.""" + from app.core.redis import get_async_redis + return get_async_redis() async def create_session( self, @@ -58,7 +46,7 @@ class KioskSessionService: "created_at": datetime.now(timezone.utc).isoformat(), } try: - redis.setex(key, SESSION_TTL_SECONDS, json.dumps(payload)) + await redis.set(key, json.dumps(payload), ex=SESSION_TTL_SECONDS) except Exception as exc: raise HTTPException( status_code=503, @@ -71,7 +59,7 @@ class KioskSessionService: redis = self._get_redis() key = SESSION_KEY_PREFIX + session_token try: - data = redis.get(key) + data = await redis.get(key) except Exception as exc: raise HTTPException( status_code=503, @@ -86,7 +74,7 @@ class KioskSessionService: redis = self._get_redis() key = SESSION_KEY_PREFIX + session_token try: - redis.delete(key) + await redis.delete(key) except Exception: pass # Best-effort @@ -95,7 +83,7 @@ class KioskSessionService: redis = self._get_redis() key = SESSION_KEY_PREFIX + session_token try: - result = redis.expire(key, SESSION_TTL_SECONDS) + result = await redis.expire(key, SESSION_TTL_SECONDS) return bool(result) except Exception: return False diff --git a/backend/app/services/public_stamp_session_service.py b/backend/app/services/public_stamp_session_service.py index e614f0c..157a321 100644 --- a/backend/app/services/public_stamp_session_service.py +++ b/backend/app/services/public_stamp_session_service.py @@ -23,14 +23,8 @@ SESSION_KEY_PREFIX = "public_stamp_session:" class PublicStampSessionService: def _get_redis(self): - from app.core.redis import get_redis_client - client = get_redis_client() - if client is None: - raise HTTPException( - status_code=503, - detail="Stempel-Service nicht verfügbar (Redis nicht erreichbar).", - ) - return client + from app.core.redis import get_async_redis + return get_async_redis() async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str: redis = self._get_redis() @@ -42,7 +36,7 @@ class PublicStampSessionService: "created_at": datetime.now(timezone.utc).isoformat(), } try: - redis.setex(key, PUBLIC_STAMP_SESSION_TTL, json.dumps(payload)) + await redis.set(key, json.dumps(payload), ex=PUBLIC_STAMP_SESSION_TTL) except Exception as exc: raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}") return session_token @@ -50,7 +44,7 @@ class PublicStampSessionService: async def get_session(self, session_token: str) -> Optional[dict]: redis = self._get_redis() try: - data = redis.get(SESSION_KEY_PREFIX + session_token) + data = await redis.get(SESSION_KEY_PREFIX + session_token) except Exception as exc: raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}") if data is None: @@ -69,7 +63,7 @@ class PublicStampSessionService: async def invalidate_session(self, session_token: str) -> None: redis = self._get_redis() try: - redis.delete(SESSION_KEY_PREFIX + session_token) + await redis.delete(SESSION_KEY_PREFIX + session_token) except Exception: pass # Best-effort