feat(auth): JWT + RBAC, RGPD, sécurité (P0)
Build & Deploy / build (push) Successful in 17s

- auth.py: JWT (python-jose) + bcrypt (passlib), get_current_user, require_admin
- endpoints protégés via APIRouter(dependencies=[get_current_user]); /health public
- /auth/login (rate-limit 5/min) + /auth/me
- /admin/users CRUD + reset-password + /admin/journal (require_admin)
- RGPD: /me/data-export (portabilité) + DELETE /me (oubli/anonymisation)
- en-têtes de sécurité, CORS tous verbes + credentials
- requirements: python-jose, passlib[bcrypt], bcrypt 4.0.1, python-multipart, slowapi
This commit is contained in:
neckfire
2026-06-20 12:49:01 +02:00
parent 51d041c0da
commit 8563e6daac
3 changed files with 286 additions and 20 deletions
+54
View File
@@ -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
+227 -20
View File
@@ -9,12 +9,22 @@
import os 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.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import date 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 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, # Origines autorisées : depuis CORS_ORIGINS (séparées par des virgules) en prod,
# localhost par défaut en dev. # localhost par défaut en dev.
@@ -34,13 +44,35 @@ app = FastAPI(
description = Config.API_DESCRIPTION, 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins = CORS_ORIGINS, allow_origins = CORS_ORIGINS,
allow_methods = ["GET"], allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers = ["*"], 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 # Mapping id_monito → table SQL dédiée
# Pour ajouter un monitoring : ajouter une entrée ici. # Pour ajouter un monitoring : ajouter une entrée ici.
@@ -112,7 +144,7 @@ def serialize_row(row: dict) -> dict:
# RÉFÉRENTIELS # RÉFÉRENTIELS
# ============================================================ # ============================================================
@app.get("/categories", tags=["Référentiels"]) @router.get("/categories", tags=["Référentiels"])
def get_categories(): def get_categories():
"""Toutes les catégories de monitoring.""" """Toutes les catégories de monitoring."""
with get_cursor() as cursor: with get_cursor() as cursor:
@@ -123,7 +155,7 @@ def get_categories():
return rows_to_list(cursor, cursor.fetchall()) return rows_to_list(cursor, cursor.fetchall())
@app.get("/services", tags=["Référentiels"]) @router.get("/services", tags=["Référentiels"])
def get_services(): def get_services():
"""Tous les services.""" """Tous les services."""
with get_cursor() as cursor: with get_cursor() as cursor:
@@ -131,7 +163,7 @@ def get_services():
return rows_to_list(cursor, cursor.fetchall()) return rows_to_list(cursor, cursor.fetchall())
@app.get("/contacts", tags=["Référentiels"]) @router.get("/contacts", tags=["Référentiels"])
def get_contacts( def get_contacts(
id_service: Optional[int] = Query(None, description="Filtrer par service") id_service: Optional[int] = Query(None, description="Filtrer par service")
): ):
@@ -155,7 +187,7 @@ def get_contacts(
# NOMENCLATURE # NOMENCLATURE
# ============================================================ # ============================================================
@app.get("/monitorings", tags=["Monitorings"]) @router.get("/monitorings", tags=["Monitorings"])
def get_monitorings( def get_monitorings(
id_service : Optional[int] = Query(None, description="Filtrer par service"), id_service : Optional[int] = Query(None, description="Filtrer par service"),
id_categorie : Optional[int] = Query(None, description="Filtrer par catégorie"), 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()) 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): def get_monitoring_by_id(id_monito: int):
"""Détail d'un monitoring.""" """Détail d'un monitoring."""
with get_cursor() as cursor: 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 # 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( def get_monitoring_details(
id_monito : int, id_monito : int,
search : Optional[str] = Query( search : Optional[str] = Query(
@@ -235,7 +267,7 @@ def get_monitoring_details(
return [serialize_row(r) for r in rows] 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): def get_monitoring_count(id_monito: int):
"""Nombre d'erreurs dans la table dédiée du monitoring.""" """Nombre d'erreurs dans la table dédiée du monitoring."""
table = get_table_name(id_monito) 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): def get_monitoring_columns(id_monito: int):
""" """
Retourne les colonnes de la table dédiée. Retourne les colonnes de la table dédiée.
@@ -273,7 +305,7 @@ def get_monitoring_columns(id_monito: int):
# DASHBOARD — VUE_CONSO # DASHBOARD — VUE_CONSO
# ============================================================ # ============================================================
@app.get("/dashboard", tags=["Dashboard"]) @router.get("/dashboard", tags=["Dashboard"])
def get_dashboard( def get_dashboard(
service : Optional[str] = Query(None, description="Filtrer par service"), service : Optional[str] = Query(None, description="Filtrer par service"),
categorie : Optional[str] = Query(None, description="Filtrer par catégorie"), categorie : Optional[str] = Query(None, description="Filtrer par catégorie"),
@@ -299,7 +331,7 @@ def get_dashboard(
return rows_to_list(cursor, cursor.fetchall()) return rows_to_list(cursor, cursor.fetchall())
@app.get("/dashboard/summary", tags=["Dashboard"]) @router.get("/dashboard/summary", tags=["Dashboard"])
def get_dashboard_summary(): def get_dashboard_summary():
"""KPI globaux pour les 4 cartes du dashboard.""" """KPI globaux pour les 4 cartes du dashboard."""
with get_cursor() as cursor: with get_cursor() as cursor:
@@ -328,7 +360,7 @@ def get_dashboard_summary():
# HISTORIQUE — TABLE_FINAL # HISTORIQUE — TABLE_FINAL
# ============================================================ # ============================================================
@app.get("/historique", tags=["Historique"]) @router.get("/historique", tags=["Historique"])
def get_historique( def get_historique(
id_monito : Optional[int] = Query(None), id_monito : Optional[int] = Query(None),
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
@@ -363,7 +395,7 @@ def get_historique(
return rows_to_list(cursor, cursor.fetchall()) 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( def get_evolution(
id_monito : int, id_monito : int,
date_debut : Optional[date] = Query(None), date_debut : Optional[date] = Query(None),
@@ -394,7 +426,7 @@ def get_evolution(
return {"id_monito": id_monito, "points": len(data), "evolution": data} 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( def get_comparaison(
date_debut : Optional[date] = Query(None), date_debut : Optional[date] = Query(None),
date_fin : Optional[date] = Query(None), date_fin : Optional[date] = Query(None),
@@ -432,7 +464,7 @@ def get_comparaison(
# SERVICE → regroupement par service (Contrat / Fournisseur) # SERVICE → regroupement par service (Contrat / Fournisseur)
# ============================================================ # ============================================================
@app.get("/evolution/global", tags=["Évolution globale"]) @router.get("/evolution/global", tags=["Évolution globale"])
def get_evolution_global( def get_evolution_global(
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
date_fin : 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( def get_evolution_par_monitoring(
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
date_fin : 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( def get_evolution_par_service(
date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"), date_debut : Optional[date] = Query(None, description="YYYY-MM-DD"),
date_fin : 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, "version" : Config.API_VERSION,
"nb_monitorings" : len(MONITO_TABLES), "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)
+5
View File
@@ -2,3 +2,8 @@ fastapi==0.136.0
uvicorn==0.46.0 uvicorn==0.46.0
pyodbc==5.3.0 pyodbc==5.3.0
requests==2.33.1 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