- GetSessions: retourne is_current=true pour la session correspondant au cookie courant - GetSessions: select token_hash pour la comparaison (non exposé dans le JSON) - profile.html: badge "Session actuelle" + désactive révoquer pour la session courante (utiliser le bouton Déconnexion à la place) - app.js: revokeSession utilise finally pour reset + isRevoking() helper - pages.css: styles .badge-current + .session-current Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
428 lines
14 KiB
Go
428 lines
14 KiB
Go
// 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[:])
|
|
}
|