- 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:
@@ -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
|
||||
@@ -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_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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user