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()}" 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: 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: 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 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) 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") 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="", 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: import redis.asyncio as aioredis 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) ) 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) finally: await redis_client.aclose() 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()