// Handlers d'authentification : login PAM, logout, refresh token, profil utilisateur. package api import ( "crypto/sha256" "database/sql" "encoding/hex" "fmt" "log" "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" "github.com/go-chi/chi/v5" ) // 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 } // Lire le host SSH depuis la DB à chaque login. // L'authenticator global est créé au démarrage avec host="" (avant installation) // et ne se met pas à jour automatiquement après configuration. sshHost, _, _ := h.db.GetSetting("ssh_host") if sshHost == "" { log.Printf("[auth/login] SSH non configuré (ssh_host vide en base) — user=%s ip=%s", body.Username, ip) JSONError(w, "SSH non configuré, veuillez vérifier l'installation", http.StatusServiceUnavailable) return } log.Printf("[auth/login] Tentative — user=%s ip=%s ssh_host=%s", body.Username, ip, sshHost) authenticator := auth.NewSSHAuthenticator(sshHost) userInfo, err := authenticator.Authenticate(body.Username, body.Password) if err != nil { log.Printf("[auth/login] Échec auth SSH — user=%s ssh_host=%s erreur=%v", body.Username, sshHost, err) h.auditLogger.Log(nil, body.Username, "login_failed", "", map[string]string{"error": err.Error()}, ip) JSONError(w, "Identifiants invalides", http.StatusUnauthorized) return } log.Printf("[auth/login] Succès — user=%s admin=%v ssh_host=%s", body.Username, userInfo.IsAdmin, sshHost) // 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()) if _, err := h.db.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip, last_used_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `, userID, tokenHash, expiry, r.UserAgent(), clientIP(r)); err != nil { log.Printf("[auth/login] ERREUR stockage refresh token — user=%s userID=%d err=%v", body.Username, userID, err) JSONError(w, "Erreur création session", http.StatusInternalServerError) return } // 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 // Secure=true si TLS direct ou si derrière un proxy (Traefik) qui a terminé TLS isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" // Path élargi à /api/auth/ pour que le cookie soit envoyé au logout aussi http.SetCookie(w, &http.Cookie{ Name: "pxp_refresh", Value: refreshToken, Path: "/api/auth/", HttpOnly: true, Secure: isHTTPS, 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 courante de l'utilisateur. // POST /api/auth/logout func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) if claims != nil { // Supprimer uniquement le token de CETTE session (via cookie pxp_refresh) // Le cookie a Path=/api/auth/ donc il est bien envoyé sur ce endpoint. if cookie, err := r.Cookie("pxp_refresh"); err == nil { tokenHash := hashToken(cookie.Value) h.db.Exec(`DELETE FROM refresh_tokens WHERE token_hash = ? AND user_id = ?`, tokenHash, claims.UserID) } else { // Pas de cookie (session dégradée ou ancien cookie path) → supprimer toutes les sessions 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/", 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) { ip := clientIP(r) cookie, err := r.Cookie("pxp_refresh") if err != nil { log.Printf("[auth/refresh] cookie absent — ip=%s err=%v", ip, err) JSONError(w, "Refresh token manquant", http.StatusUnauthorized) return } userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value) if err != nil { log.Printf("[auth/refresh] JWT invalide — ip=%s err=%v", ip, err) 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 { // Diagnostic : vérifier si le token existe mais avec un mauvais user_id var anyCount int h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = ?`, tokenHash).Scan(&anyCount) log.Printf("[auth/refresh] token non trouvé en base — userID=%d tokenHash=%s anyMatch=%d ip=%s", userID, tokenHash[:8], anyCount, ip) JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized) return } log.Printf("[auth/refresh] token valide — userID=%d ip=%s", userID, ip) // Mettre à jour la date de dernière utilisation h.db.Exec(`UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash) // 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 { log.Printf("[auth/refresh] utilisateur introuvable — userID=%d ip=%s err=%v", userID, ip, err) 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) if _, 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); err != nil { return 0, err } // Toujours faire un SELECT explicite : avec ON CONFLICT DO UPDATE sur une ligne // existante, LastInsertId() peut retourner un rowid obsolète (comportement SQLite). var id int64 if err := h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id); err != nil { return 0, fmt.Errorf("utilisateur introuvable après upsert: %w", err) } return id, nil } // GetSessions retourne les sessions actives de l'utilisateur connecté. // GET /api/auth/sessions func (h *AuthHandler) GetSessions(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) if claims == nil { JSONError(w, "Non authentifié", http.StatusUnauthorized) return } rows, err := h.db.Query(` SELECT id, user_agent, ip, created_at, last_used_at, expires_at, token_hash FROM refresh_tokens WHERE user_id = ? AND expires_at > CURRENT_TIMESTAMP ORDER BY COALESCE(last_used_at, created_at) DESC `, claims.UserID) if err != nil { JSONError(w, "Erreur récupération sessions", http.StatusInternalServerError) return } defer rows.Close() // Hash du cookie courant pour marquer "session actuelle" currentHash := "" if cookie, err := r.Cookie("pxp_refresh"); err == nil { currentHash = hashToken(cookie.Value) } type Session struct { ID int64 `json:"id"` UserAgent string `json:"user_agent"` IP string `json:"ip"` CreatedAt string `json:"created_at"` LastUsedAt *string `json:"last_used_at"` ExpiresAt string `json:"expires_at"` IsCurrent bool `json:"is_current"` } sessions := []Session{} for rows.Next() { var s Session var tokenHash string var createdAt, expiresAt sql.NullString var lastUsedAt sql.NullString if err := rows.Scan(&s.ID, &s.UserAgent, &s.IP, &createdAt, &lastUsedAt, &expiresAt, &tokenHash); err != nil { log.Printf("[GetSessions] scan error userID=%d: %v", claims.UserID, err) continue } s.CreatedAt = createdAt.String s.ExpiresAt = expiresAt.String if lastUsedAt.Valid && lastUsedAt.String != "" { s.LastUsedAt = &lastUsedAt.String } s.IsCurrent = currentHash != "" && tokenHash == currentHash sessions = append(sessions, s) } JSONResponse(w, http.StatusOK, sessions) } // RevokeSession révoque une session (refresh token) de l'utilisateur connecté. // DELETE /api/auth/sessions/{id} func (h *AuthHandler) RevokeSession(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r) if claims == nil { JSONError(w, "Non authentifié", http.StatusUnauthorized) return } sessionID := chi.URLParam(r, "id") res, err := h.db.Exec(`DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?`, sessionID, claims.UserID) if err != nil { JSONError(w, "Erreur révocation session", http.StatusInternalServerError) return } n, _ := res.RowsAffected() if n == 0 { JSONError(w, "Session introuvable", http.StatusNotFound) return } h.auditLogger.Log(&claims.UserID, claims.Username, "session_revoked", sessionID, nil, clientIP(r)) JSONResponse(w, http.StatusOK, map[string]string{"message": "Session révoquée"}) } // 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[:]) }