diff --git a/DEVLOG.md b/DEVLOG.md index 3a5ab28..4e39fd5 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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 +++++++++++++++++++++++++++++ + +--- diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py index 15cee9c..d1c0aad 100644 --- a/backend/app/core/dependencies.py +++ b/backend/app/core/dependencies.py @@ -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)], diff --git a/backend/app/main.py b/backend/app/main.py index 5692215..adaa724 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/routers/absences.py b/backend/app/routers/absences.py index 49d766e..059c403 100644 --- a/backend/app/routers/absences.py +++ b/backend/app/routers/absences.py @@ -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() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 40cae94..e9583e7 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/busylight.py b/backend/app/routers/busylight.py index c37c8b8..4e92103 100644 --- a/backend/app/routers/busylight.py +++ b/backend/app/routers/busylight.py @@ -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() diff --git a/backend/app/routers/hours_payouts.py b/backend/app/routers/hours_payouts.py index 1a942e9..fa2a461 100644 --- a/backend/app/routers/hours_payouts.py +++ b/backend/app/routers/hours_payouts.py @@ -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) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 00be196..5e83639 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 81c11c9..34f62c9 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 679eb84..7213e1e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4,29 +4,25 @@ const BASE_URL = '/api/v1' let _refreshing: Promise | null = null async function refreshAccessToken(): Promise { - 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(path: string, options: RequestInit = {}): Promise { } 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(path: string, options: RequestInit = {}): Promise { // 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(path: string, form: FormData): Promise { const headers: Record = {} 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' diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 11a9363..4178f3f 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -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') }