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