core/backend/internal/api/auth.go
enzo 780e5ec81d fix: auth redirect bug + cookie Secure + migration multi-statements
- fetchMe: handle ALL non-ok responses (not just 401) by calling tryRefresh
  → avoids user=null when backend returns 404/500/any error
- DOMContentLoaded guard: check isAuthenticated instead of localStorage token
  → immediate redirect if fetchMe+tryRefresh both fail, no more flash of dashboard
- Cookie Secure flag: check X-Forwarded-Proto header for Traefik/proxy setup
  → cookie gets Secure=true when behind TLS-terminating reverse proxy
- db.go migrate(): split SQL by ; and exec each statement separately
  → fixes SQLite multi-statement limitation (only first stmt was executed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:29:22 +01:00

390 lines
12 KiB
Go

// Handlers d'authentification : login PAM, logout, refresh token, profil utilisateur.
package api
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"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())
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))
// 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"
http.SetCookie(w, &http.Cookie{
Name: "pxp_refresh",
Value: refreshToken,
Path: "/api/auth/refresh",
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 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
}
// 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 {
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
}
// 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
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()
type Session struct {
ID int64 `json:"id"`
UserAgent string `json:"user_agent"`
IP string `json:"ip"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt time.Time `json:"expires_at"`
}
sessions := []Session{}
for rows.Next() {
var s Session
var lastUsed sql.NullTime
if err := rows.Scan(&s.ID, &s.UserAgent, &s.IP, &s.CreatedAt, &lastUsed, &s.ExpiresAt); err != nil {
continue
}
if lastUsed.Valid {
s.LastUsedAt = &lastUsed.Time
}
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[:])
}