277 lines
8.7 KiB
Python
277 lines
8.7 KiB
Python
# backend/auth/router.py
|
|
# All /api/auth/* endpoints: login, logout, refresh, me, change-password,
|
|
# and admin user CRUD.
|
|
|
|
import re
|
|
import logging
|
|
from fastapi import APIRouter, HTTPException, Response, Request, Depends
|
|
from pydantic import BaseModel, validator
|
|
from typing import List, Optional
|
|
|
|
from .user_store import (
|
|
get_user, get_all_users, create_user, update_user, delete_user,
|
|
record_login_success, record_login_failure, is_locked, has_users,
|
|
)
|
|
from .jwt_handler import (
|
|
create_access_token, create_refresh_token, decode_token,
|
|
revoke_token, is_token_revoked,
|
|
ACCESS_TOKEN_EXPIRE_SECONDS,
|
|
)
|
|
from .middleware import require_auth, require_admin, is_auth_enabled
|
|
from .password import verify_password, hash_password
|
|
|
|
logger = logging.getLogger("obsigate.auth.router")
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
|
|
# ── Pydantic request models ──────────────────────────────────────────
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
remember_me: bool = False # True → refresh token 30d instead of 7d
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
@validator("new_password")
|
|
def password_strength(cls, v):
|
|
if len(v) < 8:
|
|
raise ValueError("Minimum 8 caractères")
|
|
return v
|
|
|
|
|
|
class CreateUserRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
display_name: Optional[str] = None
|
|
role: str = "user"
|
|
vaults: List[str] = []
|
|
|
|
@validator("username")
|
|
def username_valid(cls, v):
|
|
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
|
|
raise ValueError("2-32 caractères alphanumériques, _ ou -")
|
|
return v.lower()
|
|
|
|
@validator("role")
|
|
def role_valid(cls, v):
|
|
if v not in ("admin", "user"):
|
|
raise ValueError("Rôle invalide")
|
|
return v
|
|
|
|
|
|
class UpdateUserRequest(BaseModel):
|
|
display_name: Optional[str] = None
|
|
vaults: Optional[List[str]] = None
|
|
active: Optional[bool] = None
|
|
password: Optional[str] = None
|
|
role: Optional[str] = None
|
|
|
|
|
|
# ── Public endpoints ──────────────────────────────────────────────────
|
|
|
|
@router.get("/status")
|
|
async def auth_status():
|
|
"""Public endpoint: returns whether auth is enabled.
|
|
|
|
The frontend uses this to decide whether to show the login screen.
|
|
Also returns whether any users exist (for first-run detection).
|
|
"""
|
|
return {
|
|
"auth_enabled": is_auth_enabled(),
|
|
"has_users": has_users(),
|
|
}
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(request: LoginRequest, response: Response):
|
|
"""Authenticate a user. Returns access token and sets refresh cookie.
|
|
|
|
Implements timing-safe responses to prevent user enumeration:
|
|
a failed login with an unknown user takes the same time as one
|
|
with a known user (dummy hash is computed).
|
|
"""
|
|
user = get_user(request.username)
|
|
|
|
if not user:
|
|
# Timing-safe: simulate hash computation to prevent user enumeration
|
|
hash_password("dummy_timing_protection")
|
|
raise HTTPException(401, "Identifiants invalides")
|
|
|
|
if not user.get("active"):
|
|
raise HTTPException(403, "Compte désactivé")
|
|
|
|
if is_locked(request.username):
|
|
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
|
|
|
if not verify_password(request.password, user["password_hash"]):
|
|
attempts = record_login_failure(request.username)
|
|
remaining = max(0, 5 - attempts)
|
|
detail = "Identifiants invalides"
|
|
if 0 < remaining <= 2:
|
|
detail += f" ({remaining} tentative(s) restante(s))"
|
|
raise HTTPException(401, detail)
|
|
|
|
# Success — generate tokens
|
|
record_login_success(request.username)
|
|
|
|
access_token = create_access_token(user)
|
|
refresh_token, refresh_jti = create_refresh_token(request.username)
|
|
|
|
# Set refresh token as HttpOnly cookie (path-restricted to /api/auth/refresh)
|
|
max_age = 2592000 if request.remember_me else 604800 # 30d or 7d
|
|
import os
|
|
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=refresh_token,
|
|
max_age=max_age,
|
|
httponly=True,
|
|
samesite="strict",
|
|
secure=secure,
|
|
path="/api/auth/refresh",
|
|
)
|
|
|
|
logger.info(f"User '{request.username}' logged in")
|
|
|
|
return {
|
|
"access_token": access_token,
|
|
"token_type": "bearer",
|
|
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
|
"user": {
|
|
"username": user["username"],
|
|
"display_name": user["display_name"],
|
|
"role": user["role"],
|
|
"vaults": user["vaults"],
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/refresh")
|
|
async def refresh_token_endpoint(request: Request, response: Response):
|
|
"""Renew access token via refresh token cookie.
|
|
|
|
Called automatically by the frontend when the access token expires.
|
|
"""
|
|
refresh_tok = request.cookies.get("refresh_token")
|
|
if not refresh_tok:
|
|
raise HTTPException(401, "Refresh token manquant")
|
|
|
|
payload = decode_token(refresh_tok)
|
|
if not payload or payload.get("type") != "refresh":
|
|
raise HTTPException(401, "Refresh token invalide")
|
|
|
|
if is_token_revoked(payload["jti"]):
|
|
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
|
|
|
user = get_user(payload["sub"])
|
|
if not user or not user.get("active"):
|
|
raise HTTPException(401, "Utilisateur introuvable ou inactif")
|
|
|
|
new_access_token = create_access_token(user)
|
|
|
|
return {
|
|
"access_token": new_access_token,
|
|
"token_type": "bearer",
|
|
"expires_in": ACCESS_TOKEN_EXPIRE_SECONDS,
|
|
}
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(
|
|
request: Request,
|
|
response: Response,
|
|
current_user=Depends(require_auth),
|
|
):
|
|
"""Logout: revoke refresh token and delete cookie."""
|
|
refresh_tok = request.cookies.get("refresh_token")
|
|
if refresh_tok:
|
|
payload = decode_token(refresh_tok)
|
|
if payload:
|
|
revoke_token(payload["jti"])
|
|
|
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
|
logger.info(f"User '{current_user['username']}' logged out")
|
|
return {"message": "Déconnecté avec succès"}
|
|
|
|
|
|
@router.get("/me")
|
|
async def get_me(current_user=Depends(require_auth)):
|
|
"""Return current authenticated user info."""
|
|
return {
|
|
"username": current_user["username"],
|
|
"display_name": current_user["display_name"],
|
|
"role": current_user["role"],
|
|
"vaults": current_user["vaults"],
|
|
"last_login": current_user.get("last_login"),
|
|
}
|
|
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
req: ChangePasswordRequest,
|
|
current_user=Depends(require_auth),
|
|
):
|
|
"""Change own password."""
|
|
user = get_user(current_user["username"])
|
|
if not verify_password(req.current_password, user["password_hash"]):
|
|
raise HTTPException(400, "Mot de passe actuel incorrect")
|
|
update_user(current_user["username"], {"password": req.new_password})
|
|
return {"message": "Mot de passe mis à jour"}
|
|
|
|
|
|
# ── Admin endpoints ───────────────────────────────────────────────────
|
|
|
|
@router.get("/admin/users")
|
|
async def list_users(admin=Depends(require_admin)):
|
|
"""List all users (admin only). Password hashes are never included."""
|
|
return get_all_users()
|
|
|
|
|
|
@router.post("/admin/users")
|
|
async def create_user_endpoint(
|
|
req: CreateUserRequest,
|
|
admin=Depends(require_admin),
|
|
):
|
|
"""Create a new user (admin only)."""
|
|
try:
|
|
user = create_user(
|
|
req.username, req.password, req.role, req.vaults, req.display_name
|
|
)
|
|
return user
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
|
|
|
|
@router.patch("/admin/users/{username}")
|
|
async def update_user_endpoint(
|
|
username: str,
|
|
req: UpdateUserRequest,
|
|
admin=Depends(require_admin),
|
|
):
|
|
"""Update a user (admin only)."""
|
|
updates = req.dict(exclude_none=True)
|
|
try:
|
|
return update_user(username, updates)
|
|
except ValueError as e:
|
|
raise HTTPException(404, str(e))
|
|
|
|
|
|
@router.delete("/admin/users/{username}")
|
|
async def delete_user_endpoint(
|
|
username: str,
|
|
admin=Depends(require_admin),
|
|
):
|
|
"""Delete a user (admin only). Cannot delete own account."""
|
|
if username == admin["username"]:
|
|
raise HTTPException(400, "Impossible de supprimer son propre compte")
|
|
try:
|
|
delete_user(username)
|
|
return {"message": f"Utilisateur '{username}' supprimé"}
|
|
except ValueError as e:
|
|
raise HTTPException(404, str(e))
|