fix: corriger bug multi-sessions (upsertUser wrong ID + schema repair + logs refresh)

- auth.go: upsertUser utilise toujours SELECT explicite au lieu de LastInsertId()
  qui retournait un rowid obsolète pour ON CONFLICT DO UPDATE sur ligne existante
- auth.go: vérifier l'erreur de l'INSERT refresh_tokens (était silencieusement ignorée)
- auth.go: logs détaillés dans Refresh handler pour diagnostiquer les 401
- db.go: repairSchema() ajoute les colonnes manquantes (ip, last_used_at) dans les
  bases où migration 002 était partiellement appliquée (ancien bug multi-statements)
- app.js: tryRefresh et fetchMe affichent le vrai message d'erreur du backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-22 01:32:01 +01:00
parent dc0c67b89c
commit 95757124de
3 changed files with 88 additions and 19 deletions

View file

@ -5,6 +5,7 @@ import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"time"
@ -107,10 +108,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// 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(`
if _, err := h.db.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip, last_used_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, userID, tokenHash, expiry, r.UserAgent(), clientIP(r))
`, userID, tokenHash, expiry, r.UserAgent(), clientIP(r)); err != nil {
log.Printf("[auth/login] ERREUR stockage refresh token — user=%s userID=%d err=%v", body.Username, userID, err)
JSONError(w, "Erreur création session", http.StatusInternalServerError)
return
}
// Mettre à jour la date de dernier login
h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
@ -177,14 +182,18 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
// 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) {
ip := clientIP(r)
cookie, err := r.Cookie("pxp_refresh")
if err != nil {
log.Printf("[auth/refresh] cookie absent — ip=%s err=%v", ip, err)
JSONError(w, "Refresh token manquant", http.StatusUnauthorized)
return
}
userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value)
if err != nil {
log.Printf("[auth/refresh] JWT invalide — ip=%s err=%v", ip, err)
JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized)
return
}
@ -194,10 +203,17 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
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 {
// Diagnostic : vérifier si le token existe mais avec un mauvais user_id
var anyCount int
h.db.QueryRow(`SELECT COUNT(*) FROM refresh_tokens WHERE token_hash = ?`, tokenHash).Scan(&anyCount)
log.Printf("[auth/refresh] token non trouvé en base — userID=%d tokenHash=%s anyMatch=%d ip=%s",
userID, tokenHash[:8], anyCount, ip)
JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized)
return
}
log.Printf("[auth/refresh] token valide — userID=%d ip=%s", userID, ip)
// Mettre à jour la date de dernière utilisation
h.db.Exec(`UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash)
@ -206,6 +222,7 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
var isAdmin int
err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin)
if err != nil {
log.Printf("[auth/refresh] utilisateur introuvable — userID=%d ip=%s err=%v", userID, ip, err)
JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized)
return
}
@ -304,21 +321,20 @@ func (h *AuthHandler) upsertUser(info *auth.UserInfo) (int64, error) {
}
// Mise à jour du statut admin à chaque connexion (peut changer côté Linux)
result, err := h.db.Exec(`
if _, 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 {
`, info.Username, isAdmin); 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)
// Toujours faire un SELECT explicite : avec ON CONFLICT DO UPDATE sur une ligne
// existante, LastInsertId() peut retourner un rowid obsolète (comportement SQLite).
var id int64
if err := h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id); err != nil {
return 0, fmt.Errorf("utilisateur introuvable après upsert: %w", err)
}
return id, err
return id, nil
}
// GetSessions retourne les sessions actives de l'utilisateur connecté.

View file

@ -53,6 +53,11 @@ func Open(dataDir string) (*DB, error) {
return nil, fmt.Errorf("migrations : %w", err)
}
// Réparer les colonnes manquantes (bases créées avant le fix multi-statements)
if err := db.repairSchema(); err != nil {
return nil, fmt.Errorf("réparation schéma : %w", err)
}
return db, nil
}
@ -191,3 +196,51 @@ func (db *DB) IsInstalled() (bool, error) {
}
return v == "true", nil
}
// repairSchema ajoute les colonnes manquantes dans les bases créées avant le fix
// multi-statements des migrations. Migration 002 était partiellement appliquée
// (seul user_agent ajouté) sur les bases existantes.
func (db *DB) repairSchema() error {
type col struct {
table, name, def string
}
needed := []col{
{"refresh_tokens", "user_agent", "TEXT NOT NULL DEFAULT ''"},
{"refresh_tokens", "ip", "TEXT NOT NULL DEFAULT ''"},
{"refresh_tokens", "last_used_at", "DATETIME"},
}
for _, c := range needed {
if err := db.ensureColumn(c.table, c.name, c.def); err != nil {
return err
}
}
return nil
}
// ensureColumn ajoute une colonne à une table si elle n'existe pas déjà.
func (db *DB) ensureColumn(table, column, definition string) error {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return fmt.Errorf("PRAGMA table_info(%s) : %w", table, err)
}
defer rows.Close()
for rows.Next() {
var cid int
var name, colType string
var notNull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &colType, &notNull, &dflt, &pk); err != nil {
return err
}
if name == column {
return nil // déjà présente
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
return err
}

View file

@ -177,9 +177,10 @@ document.addEventListener('alpine:init', () => {
await this.tryRefresh()
} else {
// Erreur inattendue (404, 500…) — signaler + tenter quand même
console.error(`[auth/me] HTTP ${res.status}`)
const body = await res.json().catch(() => ({}))
console.error(`[auth/me] HTTP ${res.status}`, body.error || '')
Alpine.store('toasts').error(
`Erreur serveur (${res.status}) sur /api/auth/me — le backend est-il démarré ?`
`Erreur ${res.status} sur /api/auth/me : ${body.error || 'voir console'}`
)
await this.tryRefresh()
}
@ -193,15 +194,14 @@ document.addEventListener('alpine:init', () => {
localStorage.setItem('pxp_token', data.access_token)
await this.fetchMe()
} else {
// Session expirée ou révoquée → notifier via sessionStorage (visible sur la page login)
// Lire le vrai message d'erreur du backend pour le diagnostic
const body = await res.json().catch(() => ({}))
const serverMsg = body.error || `HTTP ${res.status}`
const page = window.location.pathname.replace(/^\/|\.html$/g, '')
if (page !== 'login' && page !== 'install' && page !== 'index' && page !== '') {
sessionStorage.setItem('pxp_auth_notice',
res.status === 401
? 'Session expirée ou révoquée — veuillez vous reconnecter'
: `Erreur ${res.status} lors du renouvellement de session`
)
sessionStorage.setItem('pxp_auth_notice', `Refresh échoué : ${serverMsg}`)
}
console.error('[auth/tryRefresh]', res.status, serverMsg)
this.clear()
}
},