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:
+15
-10
@@ -318,19 +318,24 @@ async def totp_login(
|
|||||||
if not user.totp_enabled or not user.totp_secret:
|
if not user.totp_enabled or not user.totp_secret:
|
||||||
raise HTTPException(400, "2FA nicht aktiv")
|
raise HTTPException(400, "2FA nicht aktiv")
|
||||||
|
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
redis_client = get_async_redis()
|
redis_client = get_async_redis()
|
||||||
# M-5: Lockout-Check vor TOTP-Verifikation
|
try:
|
||||||
await _check_totp_lockout(user_id, redis_client)
|
# M-5: Lockout-Check vor TOTP-Verifikation
|
||||||
|
await _check_totp_lockout(user_id, redis_client)
|
||||||
|
|
||||||
plain_secret = _totp_plain(user)
|
plain_secret = _totp_plain(user)
|
||||||
totp = pyotp.TOTP(plain_secret or "")
|
totp = pyotp.TOTP(plain_secret or "")
|
||||||
if not totp.verify(data.code, valid_window=1):
|
if not totp.verify(data.code, valid_window=1):
|
||||||
# M-5: Fehlversuch zählen
|
# M-5: Fehlversuch zählen
|
||||||
await _record_totp_failure(user_id, redis_client)
|
await _record_totp_failure(user_id, redis_client)
|
||||||
raise HTTPException(400, "Ungültiger Code")
|
raise HTTPException(400, "Ungültiger Code")
|
||||||
|
|
||||||
# M-5: Erfolg → Fehlversuche zurücksetzen
|
# M-5: Erfolg → Fehlversuche zurücksetzen
|
||||||
await _clear_totp_failures(user_id, redis_client)
|
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
|
from datetime import datetime, timezone
|
||||||
user.last_login = datetime.now(timezone.utc)
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
|||||||
@@ -111,79 +111,76 @@ class AuthService:
|
|||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
|
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.audit_log import AuditLog
|
||||||
from app.models.user import AuthProvider
|
from app.models.user import AuthProvider
|
||||||
from app.services.ldap_service import ldap_service
|
from app.services.ldap_service import ldap_service
|
||||||
|
|
||||||
client_ip = _get_client_ip(request)
|
client_ip = _get_client_ip(request)
|
||||||
|
|
||||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
redis_client = get_async_redis()
|
||||||
try:
|
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||||
# Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
await self._check_login_lockout(data.email, redis_client)
|
||||||
await self._check_login_lockout(data.email, redis_client)
|
|
||||||
|
|
||||||
user = await db.scalar(select(User).where(User.email == data.email))
|
user = await db.scalar(select(User).where(User.email == data.email))
|
||||||
if not user:
|
if not user:
|
||||||
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
|
# Fehlversuch zählen auch bei unbekannter E-Mail (kein User-ID-Leak)
|
||||||
await self._record_login_failure(data.email, redis_client)
|
await self._record_login_failure(data.email, redis_client)
|
||||||
db.add(AuditLog(
|
db.add(AuditLog(
|
||||||
company_id=None,
|
company_id=None,
|
||||||
user_id=None,
|
user_id=None,
|
||||||
action="login_failed",
|
action="login_failed",
|
||||||
entity_type="user",
|
entity_type="user",
|
||||||
entity_id=None,
|
entity_id=None,
|
||||||
new_value={"email": data.email},
|
new_value={"email": data.email},
|
||||||
ip=client_ip,
|
ip=client_ip,
|
||||||
))
|
))
|
||||||
await db.commit()
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Invalid email or password",
|
detail="LDAP authentication not available",
|
||||||
)
|
)
|
||||||
if not user.is_active:
|
auth_ok = ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password)
|
||||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
else:
|
||||||
|
auth_ok = bool(user.password_hash and verify_password(data.password, user.password_hash))
|
||||||
|
|
||||||
# Mandanten-Sperre: deaktivierte Firma → kein Login (Reseller/SUPER_ADMIN
|
if not auth_ok:
|
||||||
# haben company_id IS NULL und sind davon nicht betroffen).
|
await self._record_login_failure(data.email, redis_client)
|
||||||
if user.company_id is not None:
|
db.add(AuditLog(
|
||||||
company = await db.get(Company, user.company_id)
|
company_id=user.company_id,
|
||||||
if company is not None and not company.is_active:
|
user_id=user.id,
|
||||||
raise HTTPException(status_code=403, detail="Dieser Mandant ist deaktiviert.")
|
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
|
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||||
if user.auth_provider == AuthProvider.LDAP:
|
await self._clear_login_failures(data.email, redis_client)
|
||||||
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()
|
|
||||||
|
|
||||||
# AuditLog: Erfolgreicher Login
|
# AuditLog: Erfolgreicher Login
|
||||||
db.add(AuditLog(
|
db.add(AuditLog(
|
||||||
@@ -212,62 +209,59 @@ class AuthService:
|
|||||||
return await self._create_session(user, db, request=request)
|
return await self._create_session(user, db, request=request)
|
||||||
|
|
||||||
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
|
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
|
from app.models.audit_log import AuditLog
|
||||||
|
|
||||||
token_hash = hash_token(raw_token)
|
token_hash = hash_token(raw_token)
|
||||||
burned_key = f"burned_token:{token_hash}"
|
burned_key = f"burned_token:{token_hash}"
|
||||||
|
|
||||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
redis_client = get_async_redis()
|
||||||
try:
|
# Re-Use-Detection: prüfe ob Token bereits verbrannt wurde
|
||||||
# Re-Use-Detection: prüfe ob Token bereits verbrannt wurde
|
burned_user_id = await redis_client.get(burned_key)
|
||||||
burned_user_id = await redis_client.get(burned_key)
|
if burned_user_id:
|
||||||
if burned_user_id:
|
# Token wurde bereits einmal genutzt — möglicher Token-Diebstahl!
|
||||||
# Token wurde bereits einmal genutzt — möglicher Token-Diebstahl!
|
logger.warning(
|
||||||
logger.warning(
|
"Replay-Angriff erkannt: verbrannter Refresh-Token für User %s",
|
||||||
"Replay-Angriff erkannt: verbrannter Refresh-Token für User %s",
|
burned_user_id,
|
||||||
burned_user_id,
|
)
|
||||||
)
|
# Alle Sessions des betroffenen Users invalidieren
|
||||||
# Alle Sessions des betroffenen Users invalidieren
|
try:
|
||||||
try:
|
uid = uuid_mod.UUID(burned_user_id)
|
||||||
uid = uuid_mod.UUID(burned_user_id)
|
await db.execute(delete(Session).where(Session.user_id == uid))
|
||||||
await db.execute(delete(Session).where(Session.user_id == uid))
|
db.add(AuditLog(
|
||||||
db.add(AuditLog(
|
company_id=None,
|
||||||
company_id=None,
|
user_id=uid,
|
||||||
user_id=uid,
|
action="refresh_token_reuse",
|
||||||
action="refresh_token_reuse",
|
entity_type="session",
|
||||||
entity_type="session",
|
entity_id=uid,
|
||||||
entity_id=uid,
|
new_value={"token_hash_prefix": token_hash[:8], "action": "all_sessions_invalidated"},
|
||||||
new_value={"token_hash_prefix": token_hash[:8], "action": "all_sessions_invalidated"},
|
ip=None,
|
||||||
ip=None,
|
))
|
||||||
))
|
await db.commit()
|
||||||
await db.commit()
|
except Exception:
|
||||||
except Exception:
|
pass
|
||||||
pass
|
raise HTTPException(
|
||||||
raise HTTPException(
|
status_code=401,
|
||||||
status_code=401,
|
detail="Sicherheitsvorfall: Alle Sessions wurden invalidiert. Bitte erneut anmelden.",
|
||||||
detail="Sicherheitsvorfall: Alle Sessions wurden invalidiert. Bitte erneut anmelden.",
|
|
||||||
)
|
|
||||||
|
|
||||||
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 = await db.get(User, session.user_id)
|
session = await db.scalar(
|
||||||
if not user or not user.is_active:
|
select(Session).where(Session.refresh_token_hash == token_hash)
|
||||||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
)
|
||||||
|
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")
|
user_id_str = str(session.user_id)
|
||||||
await db.delete(session)
|
|
||||||
|
|
||||||
# Verbrannten Token-Hash 48h in Redis merken
|
# Session löschen (Token "verbrennen")
|
||||||
await redis_client.set(burned_key, user_id_str, ex=48 * 3600)
|
await db.delete(session)
|
||||||
finally:
|
|
||||||
await redis_client.aclose()
|
# 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)
|
return await self._create_session(user, db)
|
||||||
|
|
||||||
|
|||||||
@@ -82,41 +82,36 @@ class KioskAuthService:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
) -> tuple[User, str]:
|
) -> tuple[User, str]:
|
||||||
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
|
"""Authentifizierung per Personalnummer + PIN. Returns (user, session_token)."""
|
||||||
import redis.asyncio as aioredis
|
from app.core.redis import get_async_redis
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
# Redis für Brute-Force-Schutz (async)
|
redis_client = get_async_redis()
|
||||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
# 1. Lockout-Check vor DB-Abfrage (verhindert auch User-Enumeration via Timing)
|
||||||
try:
|
await self._check_pin_lockout(device_id, personnel_number, redis_client)
|
||||||
# 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(
|
user = await db.scalar(
|
||||||
select(User).where(
|
select(User).where(
|
||||||
User.company_id == company_id,
|
User.company_id == company_id,
|
||||||
User.personnel_number == personnel_number,
|
User.personnel_number == personnel_number,
|
||||||
User.is_active == True,
|
User.is_active == True,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if user is None:
|
)
|
||||||
# Fehlversuch zählen auch bei unbekannter Personalnummer
|
if user is None:
|
||||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
# Fehlversuch zählen auch bei unbekannter Personalnummer
|
||||||
raise HTTPException(status_code=401, detail="Personalnummer nicht gefunden.")
|
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:
|
if not user.kiosk_pin_hash:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||||
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
await self._record_pin_failure(device_id, personnel_number, redis_client)
|
||||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||||
|
|
||||||
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
# Erfolgreicher Login: Fehlversuche zurücksetzen
|
||||||
await self._clear_pin_failures(device_id, personnel_number, redis_client)
|
await self._clear_pin_failures(device_id, personnel_number, redis_client)
|
||||||
finally:
|
|
||||||
await redis_client.aclose()
|
|
||||||
|
|
||||||
session_token = await kiosk_session_service.create_session(
|
session_token = await kiosk_session_service.create_session(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -140,41 +135,37 @@ class KioskAuthService:
|
|||||||
Gerät existiert. Erzeugt KEINE Kiosk-Session – der Aufrufer legt eine
|
Gerät existiert. Erzeugt KEINE Kiosk-Session – der Aufrufer legt eine
|
||||||
separate öffentliche Kurz-Session an. Gibt nur den User zurück.
|
separate öffentliche Kurz-Session an. Gibt nur den User zurück.
|
||||||
"""
|
"""
|
||||||
import redis.asyncio as aioredis
|
from app.core.redis import get_async_redis
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
# Lockout-Key-Namespace klar vom Kiosk trennen
|
# Lockout-Key-Namespace klar vom Kiosk trennen
|
||||||
lock_id = f"public:{company_id}"
|
lock_id = f"public:{company_id}"
|
||||||
|
|
||||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
redis_client = get_async_redis()
|
||||||
try:
|
await self._check_pin_lockout(lock_id, personnel_number, redis_client)
|
||||||
await self._check_pin_lockout(lock_id, personnel_number, redis_client)
|
|
||||||
|
|
||||||
user = await db.scalar(
|
user = await db.scalar(
|
||||||
select(User).where(
|
select(User).where(
|
||||||
User.company_id == company_id,
|
User.company_id == company_id,
|
||||||
User.personnel_number == personnel_number,
|
User.personnel_number == personnel_number,
|
||||||
User.is_active == True,
|
User.is_active == True,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if user is None:
|
)
|
||||||
# Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration)
|
if user is None:
|
||||||
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
# Fehlversuch auch bei unbekannter Personalnummer (Anti-Enumeration)
|
||||||
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
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:
|
if not user.kiosk_pin_hash:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.",
|
detail="Kein PIN gesetzt. Bitte im Mitarbeiter-Portal einen Stempel-PIN vergeben.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||||
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
await self._record_pin_failure(lock_id, personnel_number, redis_client)
|
||||||
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
raise HTTPException(status_code=401, detail="Personalnummer oder PIN falsch.")
|
||||||
|
|
||||||
await self._clear_pin_failures(lock_id, personnel_number, redis_client)
|
await self._clear_pin_failures(lock_id, personnel_number, redis_client)
|
||||||
finally:
|
|
||||||
await redis_client.aclose()
|
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
@@ -195,37 +186,33 @@ class KioskAuthService:
|
|||||||
gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin,
|
gültigen Login. Nutzt denselben Lockout-Mechanismus wie login_pin,
|
||||||
keyed auf die NFC-UID statt Personalnummer.
|
keyed auf die NFC-UID statt Personalnummer.
|
||||||
"""
|
"""
|
||||||
import redis.asyncio as aioredis
|
from app.core.redis import get_async_redis
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
|
redis_client = get_async_redis()
|
||||||
try:
|
await self._check_pin_lockout(device_id, nfc_uid, redis_client)
|
||||||
await self._check_pin_lockout(device_id, nfc_uid, redis_client)
|
|
||||||
|
|
||||||
user = await db.scalar(
|
user = await db.scalar(
|
||||||
select(User).where(
|
select(User).where(
|
||||||
User.company_id == company_id,
|
User.company_id == company_id,
|
||||||
User.kiosk_nfc_uid == nfc_uid,
|
User.kiosk_nfc_uid == nfc_uid,
|
||||||
User.is_active == True,
|
User.is_active == True,
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if user is None:
|
)
|
||||||
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
if user is None:
|
||||||
raise HTTPException(status_code=401, detail="NFC-Karte nicht registriert.")
|
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:
|
if not user.kiosk_pin_hash:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
detail="Kein PIN gesetzt. Bitte in den Profileinstellungen einen PIN vergeben.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
if not bcrypt.checkpw(pin.encode(), user.kiosk_pin_hash.encode()):
|
||||||
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
await self._record_pin_failure(device_id, nfc_uid, redis_client)
|
||||||
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
raise HTTPException(status_code=401, detail="Falscher PIN.")
|
||||||
|
|
||||||
await self._clear_pin_failures(device_id, nfc_uid, redis_client)
|
await self._clear_pin_failures(device_id, nfc_uid, redis_client)
|
||||||
finally:
|
|
||||||
await redis_client.aclose()
|
|
||||||
|
|
||||||
session_token = await kiosk_session_service.create_session(
|
session_token = await kiosk_session_service.create_session(
|
||||||
user_id=user.id,
|
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).
|
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.
|
Token ist 5 min gültig und wird in Redis gespeichert.
|
||||||
"""
|
"""
|
||||||
from app.core.redis import get_redis_client
|
from app.core.redis import get_async_redis
|
||||||
redis = get_redis_client()
|
redis = get_async_redis()
|
||||||
if redis is None:
|
|
||||||
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
key = QR_TOKEN_PREFIX + token
|
key = QR_TOKEN_PREFIX + token
|
||||||
redis.setex(key, QR_TOKEN_TTL, json.dumps({
|
try:
|
||||||
"user_id": str(user_id),
|
await redis.set(key, json.dumps({
|
||||||
"company_id": str(company_id),
|
"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
|
return token
|
||||||
|
|
||||||
async def login_qr(
|
async def login_qr(
|
||||||
@@ -261,18 +248,18 @@ class KioskAuthService:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
) -> tuple[User, str]:
|
) -> tuple[User, str]:
|
||||||
"""Validiert QR-Token (einmalig) und erstellt Session."""
|
"""Validiert QR-Token (einmalig) und erstellt Session."""
|
||||||
from app.core.redis import get_redis_client
|
from app.core.redis import get_async_redis
|
||||||
redis = get_redis_client()
|
redis = get_async_redis()
|
||||||
if redis is None:
|
|
||||||
raise HTTPException(status_code=503, detail="QR-Login nicht verfügbar (Redis).")
|
|
||||||
|
|
||||||
key = QR_TOKEN_PREFIX + qr_token
|
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:
|
if data is None:
|
||||||
raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.")
|
raise HTTPException(status_code=401, detail="QR-Code abgelaufen oder ungültig.")
|
||||||
|
|
||||||
# Einmalig: Token sofort löschen
|
# Einmalig: Token sofort löschen
|
||||||
redis.delete(key)
|
await redis.delete(key)
|
||||||
|
|
||||||
payload = json.loads(data)
|
payload = json.loads(data)
|
||||||
if str(payload.get("company_id")) != str(company_id):
|
if str(payload.get("company_id")) != str(company_id):
|
||||||
|
|||||||
@@ -23,21 +23,9 @@ SESSION_KEY_PREFIX = "kiosk_session:"
|
|||||||
class KioskSessionService:
|
class KioskSessionService:
|
||||||
|
|
||||||
def _get_redis(self):
|
def _get_redis(self):
|
||||||
"""Redis-Client aus app.core.redis holen. Raises 503 wenn nicht verfügbar."""
|
"""Gepoolter async Redis-Client aus app.core.redis."""
|
||||||
try:
|
from app.core.redis import get_async_redis
|
||||||
from app.core.redis import get_redis_client
|
return get_async_redis()
|
||||||
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."
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create_session(
|
async def create_session(
|
||||||
self,
|
self,
|
||||||
@@ -58,7 +46,7 @@ class KioskSessionService:
|
|||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -71,7 +59,7 @@ class KioskSessionService:
|
|||||||
redis = self._get_redis()
|
redis = self._get_redis()
|
||||||
key = SESSION_KEY_PREFIX + session_token
|
key = SESSION_KEY_PREFIX + session_token
|
||||||
try:
|
try:
|
||||||
data = redis.get(key)
|
data = await redis.get(key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503,
|
status_code=503,
|
||||||
@@ -86,7 +74,7 @@ class KioskSessionService:
|
|||||||
redis = self._get_redis()
|
redis = self._get_redis()
|
||||||
key = SESSION_KEY_PREFIX + session_token
|
key = SESSION_KEY_PREFIX + session_token
|
||||||
try:
|
try:
|
||||||
redis.delete(key)
|
await redis.delete(key)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Best-effort
|
pass # Best-effort
|
||||||
|
|
||||||
@@ -95,7 +83,7 @@ class KioskSessionService:
|
|||||||
redis = self._get_redis()
|
redis = self._get_redis()
|
||||||
key = SESSION_KEY_PREFIX + session_token
|
key = SESSION_KEY_PREFIX + session_token
|
||||||
try:
|
try:
|
||||||
result = redis.expire(key, SESSION_TTL_SECONDS)
|
result = await redis.expire(key, SESSION_TTL_SECONDS)
|
||||||
return bool(result)
|
return bool(result)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -23,14 +23,8 @@ SESSION_KEY_PREFIX = "public_stamp_session:"
|
|||||||
class PublicStampSessionService:
|
class PublicStampSessionService:
|
||||||
|
|
||||||
def _get_redis(self):
|
def _get_redis(self):
|
||||||
from app.core.redis import get_redis_client
|
from app.core.redis import get_async_redis
|
||||||
client = get_redis_client()
|
return get_async_redis()
|
||||||
if client is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="Stempel-Service nicht verfügbar (Redis nicht erreichbar).",
|
|
||||||
)
|
|
||||||
return client
|
|
||||||
|
|
||||||
async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str:
|
async def create_session(self, user_id: uuid.UUID, company_id: uuid.UUID) -> str:
|
||||||
redis = self._get_redis()
|
redis = self._get_redis()
|
||||||
@@ -42,7 +36,7 @@ class PublicStampSessionService:
|
|||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
try:
|
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:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}")
|
raise HTTPException(status_code=503, detail=f"Session konnte nicht erstellt werden: {exc}")
|
||||||
return session_token
|
return session_token
|
||||||
@@ -50,7 +44,7 @@ class PublicStampSessionService:
|
|||||||
async def get_session(self, session_token: str) -> Optional[dict]:
|
async def get_session(self, session_token: str) -> Optional[dict]:
|
||||||
redis = self._get_redis()
|
redis = self._get_redis()
|
||||||
try:
|
try:
|
||||||
data = redis.get(SESSION_KEY_PREFIX + session_token)
|
data = await redis.get(SESSION_KEY_PREFIX + session_token)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}")
|
raise HTTPException(status_code=503, detail=f"Session-Lookup fehlgeschlagen: {exc}")
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -69,7 +63,7 @@ class PublicStampSessionService:
|
|||||||
async def invalidate_session(self, session_token: str) -> None:
|
async def invalidate_session(self, session_token: str) -> None:
|
||||||
redis = self._get_redis()
|
redis = self._get_redis()
|
||||||
try:
|
try:
|
||||||
redis.delete(SESSION_KEY_PREFIX + session_token)
|
await redis.delete(SESSION_KEY_PREFIX + session_token)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Best-effort
|
pass # Best-effort
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user