Login-, TOTP- und Kiosk-PIN-Lockout zählten Fehlversuche mit separaten INCR/EXPIRE-Calls. Zwischen beiden konnte der Fail-Counter-Key kurzzeitig ohne TTL bestehen bleiben (Crash-Fenster) - nicht atomar. Jetzt per redis.pipeline(transaction=True) als MULTI/EXEC. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Ahyx6D3r7G1EuAc42nezn
357 lines
14 KiB
Python
357 lines
14 KiB
Python
import logging
|
||
import re
|
||
import uuid as uuid_mod
|
||
from datetime import datetime, timedelta, timezone
|
||
from uuid import UUID
|
||
|
||
from fastapi import HTTPException, Request, status
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.config import settings
|
||
from app.core.security import (
|
||
create_access_token,
|
||
create_refresh_token,
|
||
generate_invite_token,
|
||
generate_reset_token,
|
||
hash_password,
|
||
hash_token,
|
||
verify_password,
|
||
)
|
||
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
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 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-Real-IP / X-Forwarded-For hinter nginx-Proxy)."""
|
||
if not request:
|
||
return None
|
||
real_ip = request.headers.get("X-Real-IP")
|
||
if real_ip:
|
||
return real_ip.strip()
|
||
forwarded = request.headers.get("X-Forwarded-For")
|
||
if forwarded:
|
||
# Erstes Element = Original-Client-IP
|
||
return forwarded.split(",")[0].strip()
|
||
return request.client.host if request.client else None
|
||
|
||
|
||
def _slugify(name: str) -> str:
|
||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||
return slug[:80]
|
||
|
||
|
||
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()}"
|
||
# INCR+EXPIRE atomar (MULTI/EXEC) – sonst könnte fail_key zwischen beiden
|
||
# Calls kurzzeitig ohne TTL bestehen (Crash-Fenster).
|
||
async with redis.pipeline(transaction=True) as pipe:
|
||
pipe.incr(fail_key)
|
||
pipe.expire(fail_key, FAILED_LOGIN_LOCKOUT_SEC)
|
||
fails, _ = await pipe.execute()
|
||
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:
|
||
raise HTTPException(status_code=400, detail="Email already registered")
|
||
|
||
base_slug = _slugify(data.company_name)
|
||
slug = base_slug
|
||
counter = 1
|
||
while await db.scalar(select(Company).where(Company.slug == slug)):
|
||
slug = f"{base_slug}-{counter}"
|
||
counter += 1
|
||
|
||
company = Company(name=data.company_name, slug=slug)
|
||
db.add(company)
|
||
await db.flush()
|
||
|
||
user = User(
|
||
company_id=company.id,
|
||
email=data.email,
|
||
password_hash=hash_password(data.password),
|
||
first_name=data.first_name,
|
||
last_name=data.last_name,
|
||
role=UserRole.COMPANY_ADMIN,
|
||
)
|
||
db.add(user)
|
||
await db.flush()
|
||
|
||
from app.services.absence_service import absence_service
|
||
await absence_service.create_defaults_for_company(company.id, db)
|
||
|
||
tokens = await self._create_session(user, db)
|
||
await email_service.send_welcome(user, db)
|
||
return tokens
|
||
|
||
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
|
||
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 = 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()
|
||
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="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)
|
||
|
||
# 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="",
|
||
totp_required=True,
|
||
partial_token=partial,
|
||
)
|
||
|
||
user.last_login = datetime.now(timezone.utc)
|
||
return await self._create_session(user, db, request=request)
|
||
|
||
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
|
||
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 = 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.",
|
||
)
|
||
|
||
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)
|
||
if not user or not user.is_active:
|
||
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||
|
||
user_id_str = str(session.user_id)
|
||
|
||
# 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)
|
||
|
||
async def logout(self, raw_token: str, db: AsyncSession) -> None:
|
||
token_hash = hash_token(raw_token)
|
||
session = await db.scalar(
|
||
select(Session).where(Session.refresh_token_hash == token_hash)
|
||
)
|
||
if session:
|
||
await db.delete(session)
|
||
|
||
async def request_password_reset(self, email: str, db: AsyncSession) -> str | None:
|
||
"""
|
||
Gibt None zurück (lokale User) oder 'ldap' wenn der User LDAP-Auth nutzt.
|
||
Die aufrufende Route entscheidet, was dem Client mitgeteilt wird.
|
||
"""
|
||
from app.models.user import AuthProvider
|
||
user = await db.scalar(select(User).where(User.email == email))
|
||
if not user:
|
||
return None # Security: kein Hinweis ob E-Mail existiert
|
||
|
||
if user.auth_provider == AuthProvider.LDAP:
|
||
return "ldap"
|
||
|
||
old_resets = await db.scalars(
|
||
select(PasswordReset).where(
|
||
PasswordReset.user_id == user.id,
|
||
PasswordReset.used_at.is_(None),
|
||
)
|
||
)
|
||
for r in old_resets:
|
||
await db.delete(r)
|
||
|
||
raw, hashed = generate_reset_token()
|
||
reset = PasswordReset(
|
||
user_id=user.id,
|
||
token_hash=hashed,
|
||
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||
)
|
||
db.add(reset)
|
||
await email_service.send_password_reset(user, raw, db)
|
||
return None
|
||
|
||
async def confirm_password_reset(self, token: str, new_password: str, db: AsyncSession) -> None:
|
||
token_hash = hash_token(token)
|
||
reset = await db.scalar(
|
||
select(PasswordReset).where(
|
||
PasswordReset.token_hash == token_hash,
|
||
PasswordReset.used_at.is_(None),
|
||
)
|
||
)
|
||
if not reset or reset.expires_at < datetime.now(timezone.utc):
|
||
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||
|
||
user = await db.get(User, reset.user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=400, detail="User not found")
|
||
|
||
user.password_hash = hash_password(new_password)
|
||
reset.used_at = datetime.now(timezone.utc)
|
||
|
||
sessions = await db.scalars(select(Session).where(Session.user_id == user.id))
|
||
for s in sessions:
|
||
await db.delete(s)
|
||
|
||
async def _create_session(
|
||
self,
|
||
user: User,
|
||
db: AsyncSession,
|
||
request: Request | None = None,
|
||
) -> TokenResponse:
|
||
raw_refresh, hashed_refresh = create_refresh_token()
|
||
session = Session(
|
||
user_id=user.id,
|
||
refresh_token_hash=hashed_refresh,
|
||
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
|
||
device=request.headers.get("User-Agent", "")[:255] if request else None,
|
||
ip=_get_client_ip(request),
|
||
)
|
||
db.add(session)
|
||
access_token = create_access_token(
|
||
str(user.id),
|
||
extra={"role": user.role, "company_id": str(user.company_id)},
|
||
)
|
||
return TokenResponse(access_token=access_token, refresh_token=raw_refresh)
|
||
|
||
|
||
auth_service = AuthService()
|