security: M-2 HttpOnly-Cookie + M-4 TrustedHost-Warning + M-5 TOTP-Lockout + M-7 zentraler get_client_ip()

M-2: Refresh-Token als HttpOnly SameSite=Strict Cookie
- auth.py: _set_refresh_cookie/_delete_refresh_cookie Helpers
- Alle Auth-Endpoints (login, totp/login, refresh, logout) nutzen Cookie
- schemas/auth.py: refresh_token in Request/Response optional
- AuthContext.tsx: kein refresh_token in localStorage
- api/client.ts: credentials:include, kein Token-Body beim Refresh

M-4: TrustedHostMiddleware Warning in Production
- main.py: Startup-Warning wenn is_production + kein ALLOWED_HOSTS

M-5: TOTP-Fehlversuche Redis-Lockout
- auth.py: _check/_record/_clear_totp_lockout; 5 Versuche → 15 min Sperre

M-7: Zentraler get_client_ip()-Helper
- core/dependencies.py: get_client_ip() mit X-Real-IP → X-Forwarded-For → client.host
- hours_payouts.py, absences.py, busylight.py: request.client.host ersetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:25:24 +02:00
parent f723c76ae5
commit 654258f13e
11 changed files with 183 additions and 39 deletions
+22
View File
@@ -1685,3 +1685,25 @@ Keine Commits in dieser Session.
- nginx.conf | 62 ++++++++--
---
## 2026-05-26 11:15 11:15 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
- f723c76 docs: DEVLOG aktualisiert
### Geänderte Dateien
- DEVLOG.md | 29 +++++++++++++++++++++++++++++
---
## 2026-05-26 11:16 11:16 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** timemaster
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 29 +++++++++++++++++++++++++++++
---
+17 -1
View File
@@ -1,7 +1,7 @@
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import text
@@ -14,6 +14,22 @@ from app.models.user import User, UserRole
bearer_scheme = HTTPBearer()
def get_client_ip(request: Request) -> str:
"""Liest die echte Client-IP auch hinter nginx-Proxy.
nginx setzt X-Real-IP auf die ursprüngliche Client-IP.
Ohne diesen Header würde request.client.host hinter nginx immer
127.0.0.1 zurückgeben, womit AuditLog-Einträge wertlos wären.
"""
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip.strip()
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
return forwarded_for.split(",")[0].strip()
return request.client.host if request.client else "unknown"
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
+8
View File
@@ -31,6 +31,14 @@ async def lifespan(app: FastAPI):
_log.info("Development-Modus: create_all ausgeführt.")
else:
_log.info("Production-Modus: create_all übersprungen — Alembic verwaltet das Schema.")
# M-4: Warnung wenn Production ohne ALLOWED_HOSTS läuft
if settings.is_production and not settings.allowed_hosts:
_log.warning(
"SICHERHEITSWARNUNG: ALLOWED_HOSTS ist nicht gesetzt. "
"In Production sollte ALLOWED_HOSTS in .env konfiguriert sein "
"um Host-Header-Injection zu verhindern."
)
yield
# Shutdown
await engine.dispose()
+2 -2
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import CurrentUser, require_role
from app.core.dependencies import CurrentUser, get_client_ip, require_role
from app.models.absence import AbsenceStatus
from app.models.user import User, UserRole
from app.models.overtime_balance import OvertimeBalance
@@ -412,7 +412,7 @@ async def update_balance(
"target_user_id": str(user_id),
"year": year,
},
ip_address=request.client.host if request.client else None,
ip_address=get_client_ip(request),
))
await db.commit()
+104 -12
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,6 +31,29 @@ class ChangePasswordRequest(BaseModel):
router = APIRouter(prefix="/auth", tags=["Auth"])
_COOKIE_NAME = "refresh_token"
_COOKIE_PATH = "/"
_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 Tage
def _set_refresh_cookie(response: Response, token: str) -> None:
"""Setzt den Refresh-Token als HttpOnly+SameSite=Strict Cookie."""
from app.core.config import settings
response.set_cookie(
key=_COOKIE_NAME,
value=token,
httponly=True,
secure=settings.is_production,
samesite="strict",
max_age=_COOKIE_MAX_AGE,
path=_COOKIE_PATH,
)
def _delete_refresh_cookie(response: Response) -> None:
"""Löscht den Refresh-Token-Cookie."""
response.delete_cookie(key=_COOKIE_NAME, path=_COOKIE_PATH)
@router.post("/register", response_model=TokenResponse, status_code=201)
@limiter.limit("3/hour")
@@ -41,20 +64,40 @@ async def register(request: Request, data: RegisterRequest, db: AsyncSession = D
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.login(data, db, request)
async def login(request: Request, response: Response, data: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await auth_service.login(data, db, request)
if result.refresh_token:
_set_refresh_cookie(response, result.refresh_token)
result.refresh_token = None # Nicht im JSON-Body zurückgeben
return result
@router.post("/refresh", response_model=TokenResponse)
@limiter.limit("30/minute")
async def refresh(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)):
return await auth_service.refresh(data.refresh_token, db)
async def refresh(request: Request, response: Response, data: RefreshRequest | None = None, db: AsyncSession = Depends(get_db)):
# Cookie bevorzugen, Body als Fallback (Rückwärtskompatibilität für API-Clients)
token = request.cookies.get(_COOKIE_NAME)
if not token and data:
token = data.refresh_token
if not token:
raise HTTPException(status_code=401, detail="Kein Refresh-Token")
result = await auth_service.refresh(token, db)
if result.refresh_token:
_set_refresh_cookie(response, result.refresh_token)
result.refresh_token = None # Nicht im JSON-Body zurückgeben
return result
@router.post("/logout", response_model=MessageResponse)
@limiter.limit("60/minute")
async def logout(request: Request, data: RefreshRequest, db: AsyncSession = Depends(get_db)):
await auth_service.logout(data.refresh_token, db)
async def logout(request: Request, response: Response, data: RefreshRequest | None = None, db: AsyncSession = Depends(get_db)):
# Cookie bevorzugen, Body als Fallback
token = request.cookies.get(_COOKIE_NAME)
if not token and data:
token = data.refresh_token
if token:
await auth_service.logout(token, db)
_delete_refresh_cookie(response)
return MessageResponse(message="Logged out successfully")
@@ -221,16 +264,49 @@ async def totp_disable(
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
TOTP_MAX_ATTEMPTS = 5
TOTP_LOCKOUT_SECONDS = 900 # 15 Minuten
async def _check_totp_lockout(user_id: str, redis) -> None:
"""Wirft HTTP 429 wenn TOTP-Login wegen zu vieler Fehlversuche gesperrt ist."""
key = f"totp_lockout:{user_id}"
if await redis.exists(key):
ttl = await redis.ttl(key)
wait_min = ttl // 60 + 1
raise HTTPException(429, detail=f"TOTP gesperrt. Bitte {wait_min} Minute(n) warten.")
async def _record_totp_failure(user_id: str, redis) -> None:
"""Zählt TOTP-Fehlversuch und setzt Lockout nach TOTP_MAX_ATTEMPTS Fehlversuchen."""
fail_key = f"totp_fails:{user_id}"
lock_key = f"totp_lockout:{user_id}"
fails = await redis.incr(fail_key)
await redis.expire(fail_key, TOTP_LOCKOUT_SECONDS)
if fails >= TOTP_MAX_ATTEMPTS:
await redis.set(lock_key, "1", ex=TOTP_LOCKOUT_SECONDS)
await redis.delete(fail_key)
async def _clear_totp_failures(user_id: str, redis) -> None:
"""Löscht TOTP-Fehlversuche nach erfolgreichem Login."""
await redis.delete(f"totp_fails:{user_id}")
await redis.delete(f"totp_lockout:{user_id}")
@router.post("/totp/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def totp_login(
request: Request,
response: Response,
data: TotpLoginRequest,
db: AsyncSession = Depends(get_db),
):
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
import pyotp
import redis.asyncio as aioredis
from uuid import UUID
from app.core.config import settings
from app.core.security import decode_partial_token
from app.models.user import User
from jose import JWTError
@@ -246,11 +322,27 @@ async def totp_login(
if not user.totp_enabled or not user.totp_secret:
raise HTTPException(400, "2FA nicht aktiv")
plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
raise HTTPException(400, "Ungültiger Code")
redis_client = aioredis.from_url(settings.redis_url, decode_responses=True)
try:
# M-5: Lockout-Check vor TOTP-Verifikation
await _check_totp_lockout(user_id, redis_client)
plain_secret = _totp_plain(user)
totp = pyotp.TOTP(plain_secret or "")
if not totp.verify(data.code, valid_window=1):
# M-5: Fehlversuch zählen
await _record_totp_failure(user_id, redis_client)
raise HTTPException(400, "Ungültiger Code")
# M-5: Erfolg → Fehlversuche zurücksetzen
await _clear_totp_failures(user_id, redis_client)
finally:
await redis_client.aclose()
from datetime import datetime, timezone
user.last_login = datetime.now(timezone.utc)
return await auth_service._create_session(user, db, request=request)
result = await auth_service._create_session(user, db, request=request)
if result.refresh_token:
_set_refresh_cookie(response, result.refresh_token)
result.refresh_token = None # Nicht im JSON-Body zurückgeben
return result
+3 -3
View File
@@ -15,7 +15,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.dependencies import get_client_ip, require_role
from app.core.limiter import limiter
from app.models.absence import Absence, AbsenceStatus
from app.models.absence_type import AbsenceType
@@ -71,7 +71,7 @@ async def rotate_busylight_token(
action="busylight_token_rotated",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
ip=get_client_ip(request),
))
await db.commit()
return BusylightTokenRotated(token=token, created_at=company.busylight_token_created_at)
@@ -95,7 +95,7 @@ async def delete_busylight_token(
action="busylight_token_revoked",
entity_type="company",
entity_id=company.id,
ip=request.client.host if request.client else None,
ip=get_client_ip(request),
))
await db.commit()
+3 -3
View File
@@ -7,7 +7,7 @@ from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import require_role
from app.core.dependencies import get_client_ip, require_role
from app.models.audit_log import AuditLog
from app.models.hours_payout import HoursPayout
from app.models.overtime_balance import OvertimeBalance
@@ -137,7 +137,7 @@ async def create_payout(
"period_month": data.period_month,
"note": data.note,
},
ip=request.client.host if request.client else None,
ip=get_client_ip(request),
))
await db.commit()
@@ -182,7 +182,7 @@ async def delete_payout(
"period_month": payout.period_month,
"note": payout.note,
},
ip=request.client.host if request.client else None,
ip=get_client_ip(request),
))
await db.delete(payout)
+2 -2
View File
@@ -24,7 +24,7 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str
refresh_token: str | None = None
class PasswordResetRequest(BaseModel):
@@ -47,7 +47,7 @@ class PasswordResetConfirm(BaseModel):
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
refresh_token: str | None = None # Nur für API-Clients; Browser nutzt HttpOnly-Cookie
token_type: str = "bearer"
totp_required: bool = False
partial_token: str | None = None
+5 -2
View File
@@ -26,12 +26,15 @@ 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-Forwarded-For hinter nginx-Proxy)."""
"""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 (nginx setzt X-Forwarded-For)
# Erstes Element = Original-Client-IP
return forwarded.split(",")[0].strip()
return request.client.host if request.client else None
+7 -10
View File
@@ -4,29 +4,25 @@ const BASE_URL = '/api/v1'
let _refreshing: Promise<string | null> | null = null
async function refreshAccessToken(): Promise<string | null> {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) return null
// M-2: Kein refresh_token aus localStorage Browser schickt HttpOnly-Cookie automatisch mit
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
credentials: 'include', // HttpOnly-Cookie mitschicken
})
if (!res.ok) {
// Refresh fehlgeschlagen → ausloggen
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
return null
}
const data = await res.json()
localStorage.setItem('access_token', data.access_token)
if (data.refresh_token) localStorage.setItem('refresh_token', data.refresh_token)
// refresh_token kommt nicht mehr im Body (HttpOnly-Cookie)
return data.access_token
} catch {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
window.location.href = '/login'
return null
}
@@ -40,7 +36,8 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers })
// credentials: 'include' damit der HttpOnly-Cookie (refresh_token) bei Auth-Requests mitgeschickt wird
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers, credentials: 'include' })
// 401 → Token-Refresh versuchen, dann Request wiederholen
if (res.status === 401 && !path.startsWith('/auth/')) {
@@ -52,7 +49,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
// Request mit neuem Token wiederholen
const retryHeaders = { ...headers, Authorization: `Bearer ${newToken}` }
const retry = await fetch(`${BASE_URL}${path}`, { ...options, headers: retryHeaders })
const retry = await fetch(`${BASE_URL}${path}`, { ...options, headers: retryHeaders, credentials: 'include' })
if (!retry.ok) {
const err = await retry.json().catch(() => ({ detail: retry.statusText }))
const msg = typeof err.detail === 'string'
@@ -85,7 +82,7 @@ async function requestForm<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {}
if (token) headers['Authorization'] = `Bearer ${token}`
// No Content-Type header browser sets multipart boundary automatically
const res = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: form, headers })
const res = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: form, headers, credentials: 'include' })
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
const msg = typeof err.detail === 'string'
+10 -4
View File
@@ -17,19 +17,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const navigate = useNavigate()
async function login(email: string, password: string) {
const data = await api.post<{ access_token: string; refresh_token: string }>(
// refresh_token kommt nicht mehr im Body nur noch als HttpOnly-Cookie
const data = await api.post<{ access_token: string; refresh_token?: string | null }>(
'/auth/login',
{ email, password },
)
localStorage.setItem('access_token', data.access_token)
localStorage.setItem('refresh_token', data.refresh_token)
// refresh_token NICHT mehr in localStorage speichern (M-2: HttpOnly-Cookie)
setToken(data.access_token)
navigate('/dashboard')
}
function logout() {
async function logout() {
// Cookie wird vom Backend gelöscht; kein refresh_token aus localStorage nötig
try {
await api.post('/auth/logout', {})
} catch {
// Logout-Fehler ignorieren lokal trotzdem ausloggen
}
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setToken(null)
navigate('/login')
}