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
178
backend/internal/auth/jwt.go
Normal file
178
backend/internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// Package auth gère les tokens JWT RS256 pour les sessions utilisateurs.
|
||||
// Les clés RSA sont générées automatiquement au premier démarrage et stockées sur disque.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
accessTokenDuration = 15 * time.Minute
|
||||
refreshTokenDuration = 7 * 24 * time.Hour
|
||||
rsaKeySize = 2048
|
||||
)
|
||||
|
||||
// Claims représente le contenu d'un JWT d'accès ProxmoxPanel.
|
||||
type Claims struct {
|
||||
UserID int64 `json:"uid"`
|
||||
Username string `json:"sub"`
|
||||
IsAdmin bool `json:"admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// JWTManager gère la signature et la vérification des tokens JWT.
|
||||
type JWTManager struct {
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
// NewJWTManager charge ou génère les clés RSA, et retourne un JWTManager prêt à l'emploi.
|
||||
func NewJWTManager(dataDir string) (*JWTManager, error) {
|
||||
keysDir := filepath.Join(dataDir, "keys")
|
||||
if err := os.MkdirAll(keysDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("création répertoire clés : %w", err)
|
||||
}
|
||||
|
||||
privPath := filepath.Join(keysDir, "jwt.key")
|
||||
pubPath := filepath.Join(keysDir, "jwt.pub")
|
||||
|
||||
var privKey *rsa.PrivateKey
|
||||
|
||||
if _, err := os.Stat(privPath); os.IsNotExist(err) {
|
||||
// Générer une paire de clés RSA-2048
|
||||
privKey, err = rsa.GenerateKey(rand.Reader, rsaKeySize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("génération clés RSA : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder la clé privée en PEM
|
||||
privPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
if err := os.WriteFile(privPath, privPEM, 0600); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé privée : %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder la clé publique en PEM
|
||||
pubBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export clé publique : %w", err)
|
||||
}
|
||||
pubPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: pubBytes,
|
||||
})
|
||||
if err := os.WriteFile(pubPath, pubPEM, 0644); err != nil {
|
||||
return nil, fmt.Errorf("sauvegarde clé publique : %w", err)
|
||||
}
|
||||
} else {
|
||||
// Charger la clé privée existante
|
||||
privPEM, err := os.ReadFile(privPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lecture clé privée : %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(privPEM)
|
||||
if block == nil {
|
||||
return nil, errors.New("clé privée invalide (PEM)")
|
||||
}
|
||||
privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing clé privée : %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &JWTManager{
|
||||
privateKey: privKey,
|
||||
publicKey: &privKey.PublicKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateAccessToken crée un JWT d'accès signé RS256 (durée : 15 min).
|
||||
func (m *JWTManager) GenerateAccessToken(userID int64, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(accessTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "proxmoxpanel",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(m.privateKey)
|
||||
}
|
||||
|
||||
// GenerateRefreshToken crée un token de renouvellement (durée : 7 jours).
|
||||
// Ce token est plus simple — il ne contient que le userID et l'expiration.
|
||||
func (m *JWTManager) GenerateRefreshToken(userID int64) (string, error) {
|
||||
claims := jwt.RegisteredClaims{
|
||||
Subject: fmt.Sprintf("%d", userID),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(refreshTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "proxmoxpanel-refresh",
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(m.privateKey)
|
||||
}
|
||||
|
||||
// ValidateAccessToken vérifie et décode un JWT d'accès.
|
||||
func (m *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validation token : %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("token invalide")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken vérifie un token de renouvellement et retourne le userID.
|
||||
func (m *JWTManager) ValidateRefreshToken(tokenStr string) (int64, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("méthode de signature inattendue : %v", t.Header["alg"])
|
||||
}
|
||||
return m.publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("validation refresh token : %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*jwt.RegisteredClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, errors.New("refresh token invalide")
|
||||
}
|
||||
|
||||
if claims.Issuer != "proxmoxpanel-refresh" {
|
||||
return 0, errors.New("émetteur token invalide")
|
||||
}
|
||||
|
||||
var userID int64
|
||||
fmt.Sscanf(claims.Subject, "%d", &userID)
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// RefreshTokenDuration retourne la durée de validité du refresh token.
|
||||
func RefreshTokenDuration() time.Duration {
|
||||
return refreshTokenDuration
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue