Backend Go 1.23+ : - API REST + WebSocket (chi, gorilla/websocket) - Authentification PAM via SSH + JWT RS256 - Chiffrement AES-256-GCM pour secrets SQLite - Pool SSH, client Proxmox REST, hub WebSocket pub/sub - Système de modules compilés à initialisation conditionnelle - Audit log, migrations SQLite versionnées Frontend Vue 3 + Vite + TypeScript : - Thème Neumorphism sombre/clair (CSS custom properties) - Wizard d'installation, Dashboard drag-drop, Terminal xterm.js - Toutes les vues CORE + stubs modules optionnels - i18n EN/FR (vue-i18n v11) Infrastructure : - Docker multi-stage (Go → alpine, Node → nginx) - docker-compose.yml, .gitattributes, LICENSE MIT, README Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
297 lines
9 KiB
Go
297 lines
9 KiB
Go
// 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[:])
|
|
}
|