Files
timemaster/backend/app/services/auth_service.py
T
patrick 62c4e742ab security: 9 Findings aus Security-Audit behoben (CRITICAL + HIGH + MEDIUM)
CRITICAL:
- C-1: LDAP tls_verify Default False → True (MITM-Schutz)
- C-2: TOTP-Secret Fernet-verschlüsselt in DB (statt Plaintext)
  - core/crypto.py: encrypt_value() / decrypt_value() helper
  - Migration 0026: totp_secret VARCHAR(64→500), ldap tls_verify default=true
  - _totp_plain() helper mit Legacy-Fallback für bestehende Werte

HIGH:
- H-1: Kiosk Nonce-Cache asyncio.Lock (Race Condition behoben)
- H-2: File-Upload-Limit 10 MB (import_kimai.py + users.py CSV-Import)
- H-3: CORS allow_methods/allow_headers explizit eingeschränkt (war *)
- H-4: TrustedHostMiddleware aktiviert wenn ALLOWED_HOSTS gesetzt

MEDIUM:
- M-1: IP-Logging nutzt X-Forwarded-For hinter nginx-Proxy
- M-4: Audit-Log für password_changed, totp_enabled, totp_disabled
- M-5: CalDAV verify_ssl in Production erzwungen (_effective_verify_ssl)

152/152 Tests grün

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:45:09 +02:00

223 lines
8.2 KiB
Python

import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
from fastapi import HTTPException, Request, status
from sqlalchemy import 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
def _get_client_ip(request: "Request | None") -> str | None:
"""Gibt die echte Client-IP zurück (berücksichtigt X-Forwarded-For hinter nginx-Proxy)."""
if not request:
return None
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
# Erstes Element = Original-Client-IP (nginx setzt X-Forwarded-For)
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 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.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")
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):
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):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
# 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))
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:
token_hash = hash_token(raw_token)
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")
await db.delete(session)
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()