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:
@@ -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 +++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+100
-8
@@ -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")
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user