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>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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 select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -20,6 +22,8 @@ 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
|
||||
@@ -201,18 +205,63 @@ class AuthService:
|
||||
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)
|
||||
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")
|
||||
burned_key = f"burned_token:{token_hash}"
|
||||
|
||||
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")
|
||||
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()
|
||||
|
||||
await db.delete(session)
|
||||
return await self._create_session(user, db)
|
||||
|
||||
async def logout(self, raw_token: str, db: AsyncSession) -> None:
|
||||
|
||||
Reference in New Issue
Block a user