diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index 9bb2510..9e0348b 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -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é. diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index d38fbf8..cbdcbeb 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -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, ¬Null, &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 +} diff --git a/frontend/js/app.js b/frontend/js/app.js index e53ca5a..82a47c3 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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() } },