Files
patrick f2e997475e
Security Audit / Python Dependency Audit (push) Has been cancelled
Security Audit / Node.js Dependency Audit (push) Has been cancelled
security: N-1 uvicorn proxy-headers + N-2 Token-Reuse-Detection + N-3 XSS-Audit + N-4 Token-URL-Fragment + N-5 pip-audit CI
N-1: uvicorn --proxy-headers --forwarded-allow-ips=127.0.0.1
- timemaster.service: proxy-headers Flag gesetzt (beide Server)

N-2: Refresh-Token Re-Use-Detection
- auth_service.py: verbrauchter Token-Hash 48h in Redis (burned_token:<hash>)
- Bei erneutem Einsatz: alle Sessions invalidieren + AuditLog + HTTP 401

N-3: dangerouslySetInnerHTML-Audit
- Kein Vorkommen im Frontend gefunden — sauber

N-4: Reset/Invite-Token als URL-Fragment statt Query-Parameter
- email_service.py: ?token= → # (Fragment wird nicht in Referer gesendet)
- ResetPasswordPage.tsx: useSearchParams → window.location.hash.slice(1)
- Token-Lebensdauern geprüft: Reset 1h, Invite 7d — OK

N-5: Gitea CI Security-Workflow
- .gitea/workflows/security.yml: pip-audit + npm audit
- Trigger: push/PR auf main + wöchentlich montags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:55:41 +02:00

352 lines
13 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()}"
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()