feat: FZA Einzelstunden + Security-Fixes (K-1–K-5, H-2–H-4, M-1/M-3/M-6)
FZA Einzelstunden:
- Absence.fza_hours (Numeric 5,2) — FZA in Stunden statt Tagen
- Migration 0032: fza_hours Spalte in absences
- AbsenceCreate/AbsenceOut Schema um fza_hours erweitert
- absence_service: _deduct/_refund_overtime nutzt fza_hours direkt wenn gesetzt
- Frontend: Tage/Stunden-Toggle im FZA-Antrag-Modal
Security K-1: Privilege Escalation via PATCH /users/{id}.role
- user_service: Whitelist für Rollenänderungen, SUPER_ADMIN nur durch SUPER_ADMIN
- Letzter COMPANY_ADMIN gegen Selbst-Demotion gesichert
Security K-2: Kiosk-IP-Whitelist hinter nginx
- kiosk_security: _get_client_ip() liest X-Real-IP statt request.client.host
Security K-3: Kiosk-PIN Brute-Force-Schutz
- kiosk_auth_service: Redis-Lockout nach 5 Fehlversuchen (15 min)
Security K-4: TOTP-Setup-Hijacking
- auth router: /totp/setup abgelehnt wenn TOTP bereits aktiv
Security K-5: Separater Fernet-Key
- config: SECRET_KEY_DATA Feld (optional, Fallback auf SECRET_KEY)
- crypto: get_fernet_key() mit Warning bei fehlendem SECRET_KEY_DATA
Security H-2: Vacation Balance nur HR/Admin
- absences router: PATCH /balance nur noch HR/COMPANY_ADMIN/SUPER_ADMIN + AuditLog
Security H-3: Rate-Limits auf /auth/refresh + /auth/logout
- auth router: 30/min auf refresh, 60/min auf logout
Security H-4: Login-Failure-Logging + Lockout
- auth_service: Redis-Counter, Lockout nach 10 Versuchen (15 min)
- AuditLog für login_success und login_failed
Security M-1: Nginx Security-Header
- nginx.conf: X-Frame-Options, X-Content-Type-Options, CSP, Referrer-Policy, X-XSS-Protection, Permissions-Policy
Security M-3: AuditLog bei Rollenänderungen
- user_service: action=role_changed mit old/new role
Security M-6: create_all nur in Development
- main.py: Base.metadata.create_all nur wenn not settings.is_production
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ from app.models import Company, PasswordReset, Session, User, UserRole
|
||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||
from app.services.email_service import email_service
|
||||
|
||||
# Login-Lockout-Konfiguration
|
||||
FAILED_LOGIN_MAX = 10 # nach 10 Fehlversuchen → Lockout
|
||||
FAILED_LOGIN_LOCKOUT_SEC = 900 # 15 Minuten gesperrt
|
||||
|
||||
|
||||
def _get_client_ip(request: "Request | None") -> str | None:
|
||||
"""Gibt die echte Client-IP zurück (berücksichtigt X-Forwarded-For hinter nginx-Proxy)."""
|
||||
@@ -39,6 +43,32 @@ def _slugify(name: str) -> str:
|
||||
|
||||
class AuthService:
|
||||
|
||||
async def _check_login_lockout(self, email: str, redis) -> None:
|
||||
"""Wirft HTTP 429 wenn Account wegen zu vieler Fehlversuche gesperrt ist."""
|
||||
lockout_key = f"login_lockout:{email.lower()}"
|
||||
if await redis.exists(lockout_key):
|
||||
ttl = await redis.ttl(lockout_key)
|
||||
wait_min = ttl // 60 + 1
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Account temporär gesperrt. Bitte {wait_min} Minute(n) warten.",
|
||||
)
|
||||
|
||||
async def _record_login_failure(self, email: str, redis) -> None:
|
||||
"""Zählt Fehlversuch und setzt Lockout nach FAILED_LOGIN_MAX Fehlversuchen."""
|
||||
fail_key = f"login_fails:{email.lower()}"
|
||||
lockout_key = f"login_lockout:{email.lower()}"
|
||||
fails = await redis.incr(fail_key)
|
||||
await redis.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC)
|
||||
if fails >= FAILED_LOGIN_MAX:
|
||||
await redis.set(lockout_key, "1", ex=FAILED_LOGIN_LOCKOUT_SEC)
|
||||
await redis.delete(fail_key)
|
||||
|
||||
async def _clear_login_failures(self, email: str, redis) -> None:
|
||||
"""Löscht Fehlversuche nach erfolgreichem Login."""
|
||||
await redis.delete(f"login_fails:{email.lower()}")
|
||||
await redis.delete(f"login_lockout:{email.lower()}")
|
||||
|
||||
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
|
||||
existing = await db.scalar(select(User).where(User.email == data.email))
|
||||
if existing:
|
||||
@@ -74,42 +104,89 @@ class AuthService:
|
||||
return tokens
|
||||
|
||||
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
|
||||
import redis.asyncio as aioredis
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user import AuthProvider
|
||||
from app.services.ldap_service import ldap_service
|
||||
|
||||
user = await db.scalar(select(User).where(User.email == data.email))
|
||||
if not user:
|
||||
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")
|
||||
client_ip = _get_client_ip(request)
|
||||
|
||||
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",
|
||||
)
|
||||
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
|
||||
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)
|
||||
|
||||
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",
|
||||
)
|
||||
else:
|
||||
if not user.password_hash or not verify_password(data.password, user.password_hash):
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account is deactivated")
|
||||
|
||||
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()
|
||||
|
||||
# AuditLog: Erfolgreicher Login
|
||||
db.add(AuditLog(
|
||||
company_id=user.company_id,
|
||||
user_id=user.id,
|
||||
action="login_success",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
ip=client_ip,
|
||||
))
|
||||
|
||||
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
|
||||
if user.totp_enabled:
|
||||
from app.core.security import create_partial_token
|
||||
from app.schemas.auth import TokenResponse
|
||||
partial = create_partial_token(str(user.id))
|
||||
await db.commit()
|
||||
return TokenResponse(
|
||||
access_token="",
|
||||
refresh_token="",
|
||||
|
||||
Reference in New Issue
Block a user