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:
parent
dc0c67b89c
commit
95757124de
3 changed files with 88 additions and 19 deletions
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"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
|
// Stocker le hash du refresh token en base pour permettre la révocation
|
||||||
tokenHash := hashToken(refreshToken)
|
tokenHash := hashToken(refreshToken)
|
||||||
expiry := time.Now().Add(auth.RefreshTokenDuration())
|
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)
|
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip, last_used_at)
|
||||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
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
|
// Mettre à jour la date de dernier login
|
||||||
h.db.Exec(`UPDATE users SET last_login_at = CURRENT_TIMESTAMP WHERE id = ?`, userID)
|
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).
|
// Refresh renouvelle l'access token via le refresh token (cookie httpOnly).
|
||||||
// POST /api/auth/refresh
|
// POST /api/auth/refresh
|
||||||
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ip := clientIP(r)
|
||||||
|
|
||||||
cookie, err := r.Cookie("pxp_refresh")
|
cookie, err := r.Cookie("pxp_refresh")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[auth/refresh] cookie absent — ip=%s err=%v", ip, err)
|
||||||
JSONError(w, "Refresh token manquant", http.StatusUnauthorized)
|
JSONError(w, "Refresh token manquant", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value)
|
userID, err := h.jwtManager.ValidateRefreshToken(cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[auth/refresh] JWT invalide — ip=%s err=%v", ip, err)
|
||||||
JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized)
|
JSONError(w, "Refresh token invalide ou expiré", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -194,10 +203,17 @@ func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||||
var count int
|
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)
|
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 {
|
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)
|
JSONError(w, "Session expirée ou révoquée", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[auth/refresh] token valide — userID=%d ip=%s", userID, ip)
|
||||||
|
|
||||||
// Mettre à jour la date de dernière utilisation
|
// Mettre à jour la date de dernière utilisation
|
||||||
h.db.Exec(`UPDATE refresh_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?`, tokenHash)
|
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
|
var isAdmin int
|
||||||
err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin)
|
err = h.db.QueryRow(`SELECT username, is_admin FROM users WHERE id = ?`, userID).Scan(&username, &isAdmin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf("[auth/refresh] utilisateur introuvable — userID=%d ip=%s err=%v", userID, ip, err)
|
||||||
JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized)
|
JSONError(w, "Utilisateur introuvable", http.StatusUnauthorized)
|
||||||
return
|
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)
|
// 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 (?, ?)
|
INSERT INTO users (username, is_admin) VALUES (?, ?)
|
||||||
ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin
|
ON CONFLICT(username) DO UPDATE SET is_admin = excluded.is_admin
|
||||||
`, info.Username, isAdmin)
|
`, info.Username, isAdmin); err != nil {
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenter de récupérer l'ID (insertions ou update)
|
// Toujours faire un SELECT explicite : avec ON CONFLICT DO UPDATE sur une ligne
|
||||||
id, err := result.LastInsertId()
|
// existante, LastInsertId() peut retourner un rowid obsolète (comportement SQLite).
|
||||||
if err != nil || id == 0 {
|
var id int64
|
||||||
// En cas de ON CONFLICT DO UPDATE, LastInsertId peut retourner 0
|
if err := h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id); err != nil {
|
||||||
err = h.db.QueryRow(`SELECT id FROM users WHERE username = ?`, info.Username).Scan(&id)
|
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é.
|
// GetSessions retourne les sessions actives de l'utilisateur connecté.
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ func Open(dataDir string) (*DB, error) {
|
||||||
return nil, fmt.Errorf("migrations : %w", err)
|
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
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,3 +196,51 @@ func (db *DB) IsInstalled() (bool, error) {
|
||||||
}
|
}
|
||||||
return v == "true", nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,9 +177,10 @@ document.addEventListener('alpine:init', () => {
|
||||||
await this.tryRefresh()
|
await this.tryRefresh()
|
||||||
} else {
|
} else {
|
||||||
// Erreur inattendue (404, 500…) — signaler + tenter quand même
|
// 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(
|
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()
|
await this.tryRefresh()
|
||||||
}
|
}
|
||||||
|
|
@ -193,15 +194,14 @@ document.addEventListener('alpine:init', () => {
|
||||||
localStorage.setItem('pxp_token', data.access_token)
|
localStorage.setItem('pxp_token', data.access_token)
|
||||||
await this.fetchMe()
|
await this.fetchMe()
|
||||||
} else {
|
} 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, '')
|
const page = window.location.pathname.replace(/^\/|\.html$/g, '')
|
||||||
if (page !== 'login' && page !== 'install' && page !== 'index' && page !== '') {
|
if (page !== 'login' && page !== 'install' && page !== 'index' && page !== '') {
|
||||||
sessionStorage.setItem('pxp_auth_notice',
|
sessionStorage.setItem('pxp_auth_notice', `Refresh échoué : ${serverMsg}`)
|
||||||
res.status === 401
|
|
||||||
? 'Session expirée ou révoquée — veuillez vous reconnecter'
|
|
||||||
: `Erreur ${res.status} lors du renouvellement de session`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
console.error('[auth/tryRefresh]', res.status, serverMsg)
|
||||||
this.clear()
|
this.clear()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue