feat: initialisation complète du CORE ProxmoxPanel
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>
This commit is contained in:
commit
5dbcb1df07
66 changed files with 10370 additions and 0 deletions
297
backend/internal/api/auth.go
Normal file
297
backend/internal/api/auth.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
// 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[:])
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue