fix: session F5 + token/password modifiables dans les paramètres
Session F5 : - auth.store: restoreSession() essaie fetchMe() avec le token existant (< 15 min → fonctionne sans cookie), puis tryRefresh() en fallback - router: appelle restoreSession() au premier chargement au lieu de tryRefresh() Paramètres infrastructure : - Champs ssh_password et proxmox_token en write-only (vide = pas de changement) - SettingsHandler: accepte les clés chiffrées, chiffre avant stockage - Permet de corriger le token Proxmox invalide sans réinstallation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1886071922
commit
d55ecdcd97
7 changed files with 111 additions and 16 deletions
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/audit"
|
||||||
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/crypto"
|
||||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/db"
|
||||||
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
|
"git.geronzi.fr/proxmoxPanel/core/backend/internal/logbuffer"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
@ -15,11 +16,12 @@ import (
|
||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
db *db.DB
|
db *db.DB
|
||||||
auditLogger *audit.Logger
|
auditLogger *audit.Logger
|
||||||
|
encryptor *crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSettingsHandler crée un SettingsHandler.
|
// NewSettingsHandler crée un SettingsHandler.
|
||||||
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger) *SettingsHandler {
|
func NewSettingsHandler(database *db.DB, auditLog *audit.Logger, enc *crypto.Encryptor) *SettingsHandler {
|
||||||
return &SettingsHandler{db: database, auditLogger: auditLog}
|
return &SettingsHandler{db: database, auditLogger: auditLog, encryptor: enc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// paramètres publics (non-sensibles) accessibles par les admins.
|
// paramètres publics (non-sensibles) accessibles par les admins.
|
||||||
|
|
@ -32,6 +34,9 @@ var publicSettings = []string{
|
||||||
"ssh_username",
|
"ssh_username",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// paramètres sensibles : modifiables en écriture seule, stockés chiffrés.
|
||||||
|
var encryptedSettings = []string{"ssh_password", "proxmox_token"}
|
||||||
|
|
||||||
// GetAll retourne tous les paramètres publics de l'application.
|
// GetAll retourne tous les paramètres publics de l'application.
|
||||||
// GET /api/settings
|
// GET /api/settings
|
||||||
func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) {
|
func (h *SettingsHandler) GetAll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -57,15 +62,22 @@ func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier que la clé est modifiable
|
// Vérifier que la clé est modifiable (publique ou chiffrée)
|
||||||
allowed := false
|
isPublic := false
|
||||||
for _, k := range publicSettings {
|
for _, k := range publicSettings {
|
||||||
if k == key {
|
if k == key {
|
||||||
allowed = true
|
isPublic = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !allowed {
|
isEncrypted := false
|
||||||
|
for _, k := range encryptedSettings {
|
||||||
|
if k == key {
|
||||||
|
isEncrypted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isPublic && !isEncrypted {
|
||||||
JSONError(w, "Paramètre non modifiable via l'API", http.StatusForbidden)
|
JSONError(w, "Paramètre non modifiable via l'API", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -78,6 +90,27 @@ func (h *SettingsHandler) UpdateSetting(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paramètres chiffrés : valeur vide = ne pas modifier
|
||||||
|
if isEncrypted {
|
||||||
|
if body.Value == "" {
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]string{"message": "Aucun changement"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encrypted, err := h.encryptor.Encrypt(body.Value)
|
||||||
|
if err != nil {
|
||||||
|
JSONError(w, "Erreur chiffrement", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.db.SetSetting(key, encrypted, true); err != nil {
|
||||||
|
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.auditLogger.Log(&claims.UserID, claims.Username, "setting_update", key,
|
||||||
|
map[string]string{"key": key}, clientIP(r))
|
||||||
|
JSONResponse(w, http.StatusOK, map[string]string{"message": "Paramètre mis à jour"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.SetSetting(key, body.Value, false); err != nil {
|
if err := h.db.SetSetting(key, body.Value, false); err != nil {
|
||||||
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
|
JSONError(w, "Erreur sauvegarde paramètre", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ func main() {
|
||||||
authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger)
|
authHandler := api.NewAuthHandler(database, jwtManager, sshAuthenticator, auditLogger)
|
||||||
proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor)
|
proxmoxHandler := api.NewProxmoxHandler(database, hub, auditLogger, encryptor)
|
||||||
updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor)
|
updatesHandler := api.NewUpdatesHandler(database, sshPool, hub, auditLogger, encryptor)
|
||||||
settingsHandler := api.NewSettingsHandler(database, auditLogger)
|
settingsHandler := api.NewSettingsHandler(database, auditLogger, encryptor)
|
||||||
terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor)
|
terminalHandler := api.NewTerminalHandler(database, auditLogger, encryptor)
|
||||||
|
|
||||||
// Démarrer le polling Proxmox en arrière-plan
|
// Démarrer le polling Proxmox en arrière-plan
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,10 @@
|
||||||
"defaultLang": "Default language",
|
"defaultLang": "Default language",
|
||||||
"sshHost": "SSH host",
|
"sshHost": "SSH host",
|
||||||
"sshUsername": "SSH username",
|
"sshUsername": "SSH username",
|
||||||
|
"sshPassword": "SSH password",
|
||||||
"proxmoxUrl": "Proxmox URL",
|
"proxmoxUrl": "Proxmox URL",
|
||||||
|
"proxmoxToken": "Proxmox API token",
|
||||||
|
"secretPlaceholder": "Leave empty to keep current value",
|
||||||
"darkMode": "Dark mode",
|
"darkMode": "Dark mode",
|
||||||
"sidebarPosition": "Sidebar position",
|
"sidebarPosition": "Sidebar position",
|
||||||
"left": "Left",
|
"left": "Left",
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,10 @@
|
||||||
"defaultLang": "Langue par défaut",
|
"defaultLang": "Langue par défaut",
|
||||||
"sshHost": "Hôte SSH",
|
"sshHost": "Hôte SSH",
|
||||||
"sshUsername": "Utilisateur SSH",
|
"sshUsername": "Utilisateur SSH",
|
||||||
|
"sshPassword": "Mot de passe SSH",
|
||||||
"proxmoxUrl": "URL Proxmox",
|
"proxmoxUrl": "URL Proxmox",
|
||||||
|
"proxmoxToken": "Token API Proxmox",
|
||||||
|
"secretPlaceholder": "Laisser vide pour ne pas modifier",
|
||||||
"darkMode": "Mode sombre",
|
"darkMode": "Mode sombre",
|
||||||
"sidebarPosition": "Position de la sidebar",
|
"sidebarPosition": "Position de la sidebar",
|
||||||
"left": "Gauche",
|
"left": "Gauche",
|
||||||
|
|
|
||||||
|
|
@ -95,8 +95,7 @@ router.beforeEach(async (to) => {
|
||||||
// Au premier chargement : vérifier l'installation ET restaurer la session
|
// Au premier chargement : vérifier l'installation ET restaurer la session
|
||||||
if (!authStore.installChecked) {
|
if (!authStore.installChecked) {
|
||||||
await authStore.checkInstallation()
|
await authStore.checkInstallation()
|
||||||
// Restaurer la session depuis le cookie refresh si un token est en localStorage
|
await authStore.restoreSession()
|
||||||
await authStore.tryRefresh()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rediriger vers l'installation si pas encore configuré
|
// Rediriger vers l'installation si pas encore configuré
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,45 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
scheduleRefresh(14 * 60 * 1000)
|
scheduleRefresh(14 * 60 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restaure la session au démarrage de l'application (après F5).
|
||||||
|
* 1. Essaie fetchMe() avec le token existant (marche si < 15 min)
|
||||||
|
* 2. Si le token est expiré, tente le refresh via le cookie httpOnly
|
||||||
|
*/
|
||||||
|
async function restoreSession(): Promise<void> {
|
||||||
|
if (!accessToken.value) return
|
||||||
|
|
||||||
|
// Le token est peut-être encore valide : évite d'avoir besoin du cookie
|
||||||
|
await fetchMe()
|
||||||
|
if (user.value) {
|
||||||
|
scheduleRefresh(14 * 60 * 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expiré — tenter le refresh via le cookie httpOnly
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
accessToken.value = data.access_token
|
||||||
|
localStorage.setItem('pxp_token', data.access_token)
|
||||||
|
await fetchMe()
|
||||||
|
if (user.value) scheduleRefresh(14 * 60 * 1000)
|
||||||
|
} else {
|
||||||
|
// Le refresh a explicitement échoué (cookie absent ou expiré)
|
||||||
|
clearSession()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Erreur réseau transitoire — ne pas effacer le token, laisser le guard rediriger
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tente de renouveler le token via le cookie httpOnly (pxp_refresh).
|
* Tente de renouveler le token via le cookie httpOnly (pxp_refresh).
|
||||||
* Appelé au démarrage de l'application.
|
* Utilisé par le timer automatique (14 min après login).
|
||||||
*/
|
*/
|
||||||
async function tryRefresh(): Promise<void> {
|
async function tryRefresh(): Promise<void> {
|
||||||
const token = localStorage.getItem('pxp_token')
|
const token = localStorage.getItem('pxp_token')
|
||||||
|
|
@ -81,19 +117,16 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/refresh', {
|
const res = await fetch('/api/auth/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include', // Inclure le cookie httpOnly
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
accessToken.value = data.access_token
|
accessToken.value = data.access_token
|
||||||
localStorage.setItem('pxp_token', data.access_token)
|
localStorage.setItem('pxp_token', data.access_token)
|
||||||
|
|
||||||
// Charger le profil utilisateur
|
|
||||||
await fetchMe()
|
await fetchMe()
|
||||||
scheduleRefresh(14 * 60 * 1000)
|
scheduleRefresh(14 * 60 * 1000)
|
||||||
} else {
|
} else {
|
||||||
// Refresh échoué — nettoyer la session
|
|
||||||
clearSession()
|
clearSession()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -177,6 +210,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
checkInstallation,
|
checkInstallation,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
restoreSession,
|
||||||
tryRefresh,
|
tryRefresh,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,18 @@
|
||||||
<label>{{ t('settings.sshUsername') }}</label>
|
<label>{{ t('settings.sshUsername') }}</label>
|
||||||
<input v-model="settings.ssh_username" class="neu-input" />
|
<input v-model="settings.ssh_username" class="neu-input" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>{{ t('settings.sshPassword') }}</label>
|
||||||
|
<input v-model="secrets.ssh_password" type="password" class="neu-input" :placeholder="t('settings.secretPlaceholder')" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>{{ t('settings.proxmoxUrl') }}</label>
|
<label>{{ t('settings.proxmoxUrl') }}</label>
|
||||||
<input v-model="settings.proxmox_url" class="neu-input" placeholder="https://10.0.0.1:8006" />
|
<input v-model="settings.proxmox_url" class="neu-input" placeholder="https://10.0.0.1:8006" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>{{ t('settings.proxmoxToken') }}</label>
|
||||||
|
<input v-model="secrets.proxmox_token" type="password" class="neu-input" :placeholder="t('settings.secretPlaceholder')" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -173,6 +181,12 @@ const settings = ref({
|
||||||
proxmox_url: '',
|
proxmox_url: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Champs sensibles — write-only, jamais retournés par l'API
|
||||||
|
const secrets = ref({
|
||||||
|
ssh_password: '',
|
||||||
|
proxmox_token: '',
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadSettings()
|
await loadSettings()
|
||||||
await loadAuditLog()
|
await loadAuditLog()
|
||||||
|
|
@ -211,8 +225,13 @@ async function saveSettings() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
saveSuccess.value = false
|
saveSuccess.value = false
|
||||||
|
|
||||||
const keys = Object.entries(settings.value)
|
const allEntries: [string, string][] = [
|
||||||
for (const [key, value] of keys) {
|
...Object.entries(settings.value),
|
||||||
|
// Secrets : envoyés seulement si non-vides (le backend ignore les valeurs vides)
|
||||||
|
...Object.entries(secrets.value).filter(([, v]) => v !== ''),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [key, value] of allEntries) {
|
||||||
await fetch(`/api/settings/${key}`, {
|
await fetch(`/api/settings/${key}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -223,6 +242,10 @@ async function saveSettings() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vider les champs secrets après sauvegarde
|
||||||
|
secrets.value.ssh_password = ''
|
||||||
|
secrets.value.proxmox_token = ''
|
||||||
|
|
||||||
saving.value = false
|
saving.value = false
|
||||||
saveSuccess.value = true
|
saveSuccess.value = true
|
||||||
setTimeout(() => (saveSuccess.value = false), 3000)
|
setTimeout(() => (saveSuccess.value = false), 3000)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue