// Handlers d'authentification : login PAM, logout, refresh token, profil utilisateur. package api import ( "crypto/sha256" "database/sql" "encoding/hex" "net/http" "time" "git.geronzi.fr/proxmoxPanel/core/backend/internal/audit" "git.geronzi.fr/proxmoxPanel/core/backend/internal/auth" "git.geronzi.fr/proxmoxPanel/core/backend/internal/db" ) // AuthHandler contient les handlers d'authentification. type AuthHandler struct { db *db.DB jwtManager *auth.JWTManager sshAuth *auth.SSHAuthenticator auditLogger *audit.Logger authLimiter *RateLimiter } // NewAuthHandler crée un AuthHandler. func NewAuthHandler(database *db.DB, jwtMgr *auth.JWTManager, sshAuth *auth.SSHAuthenticator, auditLog *audit.Logger) *AuthHandler { return &AuthHandler{ db: database, jwtManager: jwtMgr, sshAuth: sshAuth, auditLogger: auditLog, authLimiter: NewRateLimiter(5, time.Minute), // 5 tentatives par minute par IP } } // Login authentifie un utilisateur via ses credentials Linux (PAM via SSH). // POST /api/auth/login // Body: { "username": "enzo", "password": "..." } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { ip := clientIP(r) // Rate limiting sur le login if !h.authLimiter.Allow(ip) { h.auditLogger.Log(nil, "?", "login_rate_limited", "", nil, ip) JSONError(w, "Trop de tentatives de connexion, veuillez patienter", http.StatusTooManyRequests) return } var body struct { Username string `json:"username"` Password string `json:"password"` } if err := decodeJSON(r, &body); err != nil { JSONError(w, "Corps de requête invalide", http.StatusBadRequest) return } if body.Username == "" || body.Password == "" { JSONError(w, "Nom d'utilisateur et mot de passe requis", http.StatusBadRequest) return } // Authentification PAM via SSH userInfo, err := h.sshAuth.Authenticate(body.Username, body.Password) if err != nil { h.auditLogger.Log(nil, body.Username, "login_failed", "", map[string]string{"error": err.Error()}, ip) JSONError(w, "Identifiants invalides", http.StatusUnauthorized) return } // Créer ou mettre à jour le profil utilisateur en SQLite userID, err := h.upsertUser(userInfo) if err != nil { JSONError(w, "Erreur création profil utilisateur", http.StatusInternalServerError) return } // Générer les tokens JWT accessToken, err := h.jwtManager.GenerateAccessToken(userID, userInfo.Username, userInfo.IsAdmin) if err != nil { JSONError(w, "Erreur génération token", http.StatusInternalServerError) return } refreshToken, err := h.jwtManager.GenerateRefreshToken(userID) if err != nil { JSONError(w, "Erreur génération refresh token", http.StatusInternalServerError) return } // Stocker le hash du refresh token en base pour permettre la révocation tokenHash := hashToken(refreshToken) expiry := time.Now().Add(auth.RefreshTokenDuration()) h.db.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?) `, userID, tokenHash, expiry) // Mettre à jour la date de dernier login h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID) // Cookie httpOnly pour le refresh token http.SetCookie(w, &http.Cookie{ Name: "pxp_refresh", Value: refreshToken, Path: "/api/auth/refresh", HttpOnly: true, Secure: r.TLS != nil, SameSite: http.SameSiteStrictMode, Expires: expiry, }) h.auditLogger.Log(&userID, userInfo.Username, "login_success", "", nil, ip) JSONResponse(w, http.StatusOK, map[string]any{ "access_token": accessToken, "expires_in": 900, // 15 minutes en secondes "user": map[string]any{ "id": userID, "username": userInfo.Username, "is_admin": userInfo.IsAdmin, }, }) } // Logout invalide la session de l'utilisateur. // POST /api/auth/logout func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) // Supprimer tous les refresh tokens de cet utilisateur if claims != nil { h.db.Exec(`DELETE FROM refresh_tokens WHERE user_id = ?`, claims.UserID) h.auditLogger.Log(&claims.UserID, claims.Username, "logout", "", nil, clientIP(r)) } // Effacer le cookie de refresh http.SetCookie(w, &http.Cookie{ Name: "pxp_refresh", Value: "", Path: "/api/auth/refresh", HttpOnly: true, Expires: time.Unix(0, 0), MaxAge: -1, }) JSONResponse(w, http.StatusOK, map[string]string{"message": "Déconnexion réussie"}) } // Refresh renouvelle l'access token via le refresh token (cookie httpOnly). // POST /api/auth/refresh func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("pxp_refresh") if err != nil { JSONError(w, "Refresh token manquant", http.StatusUnauthorized) return } userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value) if err != nil { JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized) return } // Vérifier que le token est en base (non révoqué) tokenHash := hashToken(cookie.Value) var count int h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND token_hash = ? AND expires_at > CURRENT_TIMESTAMP`, userID, tokenHash).Scan(&count) if count == 0 { JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized) return } // Récupérer les infos utilisateur var username string var isAdmin int err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin) if err != nil { JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized) return } accessToken, err := h.jwtManager.GenerateAccessToken(userID, username, isAdmin == 1) if err != nil { JSONError(w, "Erreur génération token", http.StatusInternalServerError) return } JSONResponse(w, http.StatusOK, map[string]any{ "access_token": accessToken, "expires_in": 900, }) } // Me retourne le profil de l'utilisateur connecté. // GET /api/auth/me func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) if claims == nil { JSONError(w, "Non authentifié", http.StatusUnauthorized) return } var lang, theme, sidebarPos string var lastLogin sql.NullTime err := h.db.QueryRow(`SELECT lang, theme, sidebar_position, last_login_at FROM users WHERE id = ?`, claims.UserID). Scan(&lang, &theme, &sidebarPos, &lastLogin) if err != nil { JSONError(w, "Profil introuvable", http.StatusNotFound) return } resp := map[string]any{ "id": claims.UserID, "username": claims.Username, "is_admin": claims.IsAdmin, "lang": lang, "theme": theme, "sidebar_position": sidebarPos, } if lastLogin.Valid { resp["last_login_at"] = lastLogin.Time } JSONResponse(w, http.StatusOK, resp) } // UpdatePreferences met à jour les préférences de l'utilisateur connecté. // PATCH /api/auth/preferences func (h *AuthHandler) UpdatePreferences(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) var body struct { Lang *string `json:"lang"` Theme *string `json:"theme"` SidebarPosition *string `json:"sidebar_position"` } if err := decodeJSON(r, &body); err != nil { JSONError(w, "Corps de requête invalide", http.StatusBadRequest) return } if body.Lang != nil { if !isValidLang(*body.Lang) { JSONError(w, "Langue non supportée", http.StatusBadRequest) return } h.db.Exec(`UPDATE users SET lang = ? WHERE id = ?`, *body.Lang, claims.UserID) } if body.Theme != nil { if *body.Theme != "dark" && *body.Theme != "light" { JSONError(w, "Thème invalide (dark ou light)", http.StatusBadRequest) return } h.db.Exec(`UPDATE users SET theme = ? WHERE id = ?`, *body.Theme, claims.UserID) } if body.SidebarPosition != nil { if *body.SidebarPosition != "left" && *body.SidebarPosition != "right" { JSONError(w, "Position sidebar invalide (left ou right)", http.StatusBadRequest) return } h.db.Exec(`UPDATE users SET sidebar_position = ? WHERE id = ?`, *body.SidebarPosition, claims.UserID) } JSONResponse(w, http.StatusOK, map[string]string{"message": "Préférences mises à jour"}) } // upsertUser crée ou met à jour le profil utilisateur en SQLite. // Retourne l'ID de l'utilisateur. func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) { isAdmin := 0 if info.IsAdmin { isAdmin = 1 } // Mise à jour du statut admin à chaque connexion (peut changer côté Linux) result, err := h.db.Exec(` INSERT INTO users (username, is_admin) VALUES (?, ?) ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin `, info.Username, isAdmin) if err != nil { return 0, err } // Tenter de récupérer l'ID (insertions ou update) id, err := result.LastInsertId() if err != nil || id == 0 { // En cas de ON CONFLICT DO UPDATE, LastInsertId peut retourner 0 err = h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id) } return id, err } // hashToken crée un hash SHA-256 d'un token pour le stockage en base. func hashToken(token string) string { h := sha256.Sum256([]byte(token)) return hex.EncodeToString(h[:]) }