f2e997475e
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>
352 lines
13 KiB
Python
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()
|