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:
enzo 2026-03-21 00:17:12 +01:00
parent 1886071922
commit d55ecdcd97
7 changed files with 111 additions and 16 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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é

View file

@ -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,

View file

@ -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)