diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..b843c99 --- /dev/null +++ b/auth.py @@ -0,0 +1,54 @@ +# ============================================================ +# auth.py — Authentification JWT + hachage bcrypt +# Data Sentinel +# ============================================================ + +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jose import jwt, JWTError +from passlib.context import CryptContext + +from config import Config + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def create_access_token(data: dict) -> str: + payload = data.copy() + payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=Config.TOKEN_EXPIRE_MINUTES) + return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.ALGORITHM) + + +def get_current_user(token: str = Depends(oauth2_scheme)) -> dict: + """Décode le JWT et retourne l'utilisateur courant, sinon 401.""" + creds_exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Identifiants invalides", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, Config.SECRET_KEY, algorithms=[Config.ALGORITHM]) + username = payload.get("sub") + if not username: + raise creds_exc + return {"username": username, "role": payload.get("role"), "id_user": payload.get("uid")} + except JWTError: + raise creds_exc + + +def require_admin(user: dict = Depends(get_current_user)) -> dict: + """Réserve l'accès aux administrateurs (403 sinon).""" + if user.get("role") != "Admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Accès réservé aux administrateurs") + return user diff --git a/main.py b/main.py index 404a35d..2476625 100644 --- a/main.py +++ b/main.py @@ -9,12 +9,22 @@ import os -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException, Query, APIRouter, Depends, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import OAuth2PasswordRequestForm +from pydantic import BaseModel from typing import Optional from datetime import date +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + from config import Config, get_cursor +from auth import ( + verify_password, hash_password, create_access_token, + get_current_user, require_admin, +) # Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod, # localhost par défaut en dev. @@ -34,13 +44,35 @@ app = FastAPI( description = Config.API_DESCRIPTION, ) +# Limiteur de débit (anti brute-force sur /auth/login). +limiter = Limiter(key_func=get_remote_address) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + app.add_middleware( CORSMiddleware, - allow_origins = CORS_ORIGINS, - allow_methods = ["GET"], - allow_headers = ["*"], + allow_origins = CORS_ORIGINS, + allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers = ["*"], + allow_credentials = True, ) + +# En-têtes de sécurité sur toutes les réponses. +@app.middleware("http") +async def security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + + +# Toutes les routes "données" passent par ce router, protégé par JWT. +# /health et /auth/* restent publics (déclarés sur `app`). +router = APIRouter(dependencies=[Depends(get_current_user)]) + # ============================================================ # Mapping id_monito → table SQL dédiée # Pour ajouter un monitoring : ajouter une entrée ici. @@ -112,7 +144,7 @@ def serialize_row(row: dict) -> dict: # RÉFÉRENTIELS # ============================================================ -@app.get("/categories", tags=["Référentiels"]) +@router.get("/categories", tags=["Référentiels"]) def get_categories(): """Toutes les catégories de monitoring.""" with get_cursor() as cursor: @@ -123,7 +155,7 @@ def get_categories(): return rows_to_list(cursor, cursor.fetchall()) -@app.get("/services", tags=["Référentiels"]) +@router.get("/services", tags=["Référentiels"]) def get_services(): """Tous les services.""" with get_cursor() as cursor: @@ -131,7 +163,7 @@ def get_services(): return rows_to_list(cursor, cursor.fetchall()) -@app.get("/contacts", tags=["Référentiels"]) +@router.get("/contacts", tags=["Référentiels"]) def get_contacts( id_service: Optional[int] = Query(None, description="Filtrer par service") ): @@ -155,7 +187,7 @@ def get_contacts( # NOMENCLATURE # ============================================================ -@app.get("/monitorings", tags=["Monitorings"]) +@router.get("/monitorings", tags=["Monitorings"]) def get_monitorings( id_service : Optional[int] = Query(None, description="Filtrer par service"), id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"), @@ -182,7 +214,7 @@ def get_monitorings( return rows_to_list(cursor, cursor.fetchall()) -@app.get("/monitorings/{id_monito}", tags=["Monitorings"]) +@router.get("/monitorings/{id_monito}", tags=["Monitorings"]) def get_monitoring_by_id(id_monito: int): """Détail d'un monitoring.""" with get_cursor() as cursor: @@ -202,7 +234,7 @@ def get_monitoring_by_id(id_monito: int): # DONNÉES DÉTAILLÉES — table dédiée par monitoring # ============================================================ -@app.get("/monitorings/{id_monito}/details", tags=["Monitorings"]) +@router.get("/monitorings/{id_monito}/details", tags=["Monitorings"]) def get_monitoring_details( id_monito : int, search : Optional[str] = Query( @@ -235,7 +267,7 @@ def get_monitoring_details( return [serialize_row(r) for r in rows] -@app.get("/monitorings/{id_monito}/count", tags=["Monitorings"]) +@router.get("/monitorings/{id_monito}/count", tags=["Monitorings"]) def get_monitoring_count(id_monito: int): """Nombre d'erreurs dans la table dédiée du monitoring.""" table = get_table_name(id_monito) @@ -248,7 +280,7 @@ def get_monitoring_count(id_monito: int): } -@app.get("/monitorings/{id_monito}/columns", tags=["Monitorings"]) +@router.get("/monitorings/{id_monito}/columns", tags=["Monitorings"]) def get_monitoring_columns(id_monito: int): """ Retourne les colonnes de la table dédiée. @@ -273,7 +305,7 @@ def get_monitoring_columns(id_monito: int): # DASHBOARD — VUE_CONSO # ============================================================ -@app.get("/dashboard", tags=["Dashboard"]) +@router.get("/dashboard", tags=["Dashboard"]) def get_dashboard( service : Optional[str] = Query(None, description="Filtrer par service"), categorie : Optional[str] = Query(None, description="Filtrer par catégorie"), @@ -299,7 +331,7 @@ def get_dashboard( return rows_to_list(cursor, cursor.fetchall()) -@app.get("/dashboard/summary", tags=["Dashboard"]) +@router.get("/dashboard/summary", tags=["Dashboard"]) def get_dashboard_summary(): """KPI globaux pour les 4 cartes du dashboard.""" with get_cursor() as cursor: @@ -328,7 +360,7 @@ def get_dashboard_summary(): # HISTORIQUE — TABLE_FINAL # ============================================================ -@app.get("/historique", tags=["Historique"]) +@router.get("/historique", tags=["Historique"]) def get_historique( id_monito : Optional[int] = Query(None), date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), @@ -363,7 +395,7 @@ def get_historique( return rows_to_list(cursor, cursor.fetchall()) -@app.get("/historique/{id_monito}/evolution", tags=["Historique"]) +@router.get("/historique/{id_monito}/evolution", tags=["Historique"]) def get_evolution( id_monito : int, date_debut : Optional[date] = Query(None), @@ -394,7 +426,7 @@ def get_evolution( return {"id_monito": id_monito, "points": len(data), "evolution": data} -@app.get("/historique/comparaison", tags=["Historique"]) +@router.get("/historique/comparaison", tags=["Historique"]) def get_comparaison( date_debut : Optional[date] = Query(None), date_fin : Optional[date] = Query(None), @@ -432,7 +464,7 @@ def get_comparaison( # SERVICE → regroupement par service (Contrat / Fournisseur) # ============================================================ -@app.get("/evolution/global", tags=["Évolution globale"]) +@router.get("/evolution/global", tags=["Évolution globale"]) def get_evolution_global( date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), @@ -469,7 +501,7 @@ def get_evolution_global( } -@app.get("/evolution/par-monitoring", tags=["Évolution globale"]) +@router.get("/evolution/par-monitoring", tags=["Évolution globale"]) def get_evolution_par_monitoring( date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), @@ -523,7 +555,7 @@ def get_evolution_par_monitoring( } -@app.get("/evolution/par-service", tags=["Évolution globale"]) +@router.get("/evolution/par-service", tags=["Évolution globale"]) def get_evolution_par_service( date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_fin : Optional[date] = Query(None, description="YYYY-MM-DD"), @@ -593,3 +625,178 @@ def health_check(): "version" : Config.API_VERSION, "nb_monitorings" : len(MONITO_TABLES), } + + +# ============================================================ +# AUTHENTIFICATION +# ============================================================ + +class UserCreate(BaseModel): + username: str + email: str + password: str + role: str + +class UserUpdate(BaseModel): + email: Optional[str] = None + role: Optional[str] = None + actif: Optional[bool] = None + +class PasswordReset(BaseModel): + password: str + + +@app.post("/auth/login", tags=["Auth"]) +@limiter.limit("5/minute") +def login(request: Request, form: OAuth2PasswordRequestForm = Depends()): + """Authentifie un utilisateur et retourne un JWT.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT id_user, username, password_hash, role, actif " + "FROM [USER] WHERE username = ?", form.username + ) + row = cursor.fetchone() + if not row or not row[4] or not verify_password(form.password, row[2]): + raise HTTPException(status_code=401, detail="Identifiants invalides") + ip = request.client.host if request.client else None + cursor.execute("UPDATE [USER] SET last_login = SYSDATETIME() WHERE id_user = ?", row[0]) + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, ip) VALUES (?, ?, 'LOGIN', ?)", + row[0], row[1], ip + ) + token = create_access_token({"sub": row[1], "role": row[3], "uid": row[0]}) + return {"access_token": token, "token_type": "bearer", + "user": {"username": row[1], "role": row[3]}} + + +@app.get("/auth/me", tags=["Auth"]) +def me(user: dict = Depends(get_current_user)): + """Profil de l'utilisateur connecté.""" + return user + + +# ============================================================ +# ADMINISTRATION (réservé Admin) +# ============================================================ + +@app.get("/admin/users", tags=["Admin"]) +def list_users(admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "SELECT id_user, username, email, role, actif, created_at, last_login " + "FROM [USER] ORDER BY id_user" + ) + return rows_to_list(cursor, cursor.fetchall()) + + +@app.post("/admin/users", tags=["Admin"], status_code=201) +def create_user(body: UserCreate, admin: dict = Depends(require_admin)): + if body.role not in ("Admin", "Superviseur", "Consultant"): + raise HTTPException(status_code=400, detail="Rôle invalide") + with get_cursor() as cursor: + cursor.execute( + "INSERT INTO [USER] (username, email, password_hash, role) VALUES (?, ?, ?, ?)", + body.username, body.email, hash_password(body.password), body.role + ) + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'CREATE_USER', ?)", + admin["id_user"], admin["username"], body.username + ) + return {"status": "created", "username": body.username} + + +@app.put("/admin/users/{id_user}", tags=["Admin"]) +def update_user(id_user: int, body: UserUpdate, admin: dict = Depends(require_admin)): + sets, params = [], [] + if body.email is not None: + sets.append("email = ?"); params.append(body.email) + if body.role is not None: + if body.role not in ("Admin", "Superviseur", "Consultant"): + raise HTTPException(status_code=400, detail="Rôle invalide") + sets.append("role = ?"); params.append(body.role) + if body.actif is not None: + sets.append("actif = ?"); params.append(1 if body.actif else 0) + if not sets: + raise HTTPException(status_code=400, detail="Aucun champ à modifier") + params.append(id_user) + with get_cursor() as cursor: + cursor.execute(f"UPDATE [USER] SET {', '.join(sets)} WHERE id_user = ?", *params) + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'UPDATE_USER', ?)", + admin["id_user"], admin["username"], str(id_user) + ) + return {"status": "updated", "id_user": id_user} + + +@app.delete("/admin/users/{id_user}", tags=["Admin"]) +def delete_user(id_user: int, admin: dict = Depends(require_admin)): + """Suppression douce (actif = 0).""" + with get_cursor() as cursor: + cursor.execute("UPDATE [USER] SET actif = 0 WHERE id_user = ?", id_user) + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'DELETE_USER', ?)", + admin["id_user"], admin["username"], str(id_user) + ) + return {"status": "deactivated", "id_user": id_user} + + +@app.post("/admin/users/{id_user}/reset-password", tags=["Admin"]) +def reset_password(id_user: int, body: PasswordReset, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute("UPDATE [USER] SET password_hash = ? WHERE id_user = ?", + hash_password(body.password), id_user) + cursor.execute( + "INSERT INTO JOURNAL_AUDIT (id_user, username, action, detail) VALUES (?, ?, 'RESET_PASSWORD', ?)", + admin["id_user"], admin["username"], str(id_user) + ) + return {"status": "password_reset", "id_user": id_user} + + +@app.get("/admin/journal", tags=["Admin"]) +def get_journal(limit: int = 200, admin: dict = Depends(require_admin)): + with get_cursor() as cursor: + cursor.execute( + "SELECT TOP (?) date_action, username, action, detail, ip " + "FROM JOURNAL_AUDIT ORDER BY date_action DESC", limit + ) + return rows_to_list(cursor, cursor.fetchall()) + + +# ============================================================ +# RGPD — droits de la personne +# ============================================================ + +@app.get("/me/data-export", tags=["RGPD"]) +def export_my_data(user: dict = Depends(get_current_user)): + """Droit à la portabilité (art. 20) : export des données de l'utilisateur.""" + with get_cursor() as cursor: + cursor.execute( + "SELECT id_user, username, email, role, actif, created_at, last_login " + "FROM [USER] WHERE username = ?", user["username"] + ) + u = cursor.fetchone() + profile = row_to_dict(cursor, u) if u else {} + cursor.execute( + "SELECT date_action, action, detail, ip FROM JOURNAL_AUDIT " + "WHERE username = ? ORDER BY date_action DESC", user["username"] + ) + journal = rows_to_list(cursor, cursor.fetchall()) + return {"profile": profile, "journal": journal} + + +@app.delete("/me", tags=["RGPD"]) +def delete_my_account(user: dict = Depends(get_current_user)): + """Droit à l'oubli (art. 17) : désactivation + anonymisation.""" + with get_cursor() as cursor: + cursor.execute( + "UPDATE [USER] SET actif = 0, " + "username = CONCAT('deleted_', id_user), " + "email = CONCAT('deleted_', id_user, '@deleted.local') " + "WHERE username = ?", user["username"] + ) + cursor.execute("UPDATE JOURNAL_AUDIT SET username = NULL WHERE username = ?", user["username"]) + return {"status": "account_deleted"} + + +# Enregistre les routes "données" protégées par JWT. +app.include_router(router) diff --git a/requirements.txt b/requirements.txt index 1e3bf06..7614bcc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,8 @@ fastapi==0.136.0 uvicorn==0.46.0 pyodbc==5.3.0 requests==2.33.1 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 +python-multipart==0.0.9 +slowapi==0.1.9