fix: auth redirect bug + cookie Secure + migration multi-statements

- fetchMe: handle ALL non-ok responses (not just 401) by calling tryRefresh
  → avoids user=null when backend returns 404/500/any error
- DOMContentLoaded guard: check isAuthenticated instead of localStorage token
  → immediate redirect if fetchMe+tryRefresh both fail, no more flash of dashboard
- Cookie Secure flag: check X-Forwarded-Proto header for Traefik/proxy setup
  → cookie gets Secure=true when behind TLS-terminating reverse proxy
- db.go migrate(): split SQL by ; and exec each statement separately
  → fixes SQLite multi-statement limitation (only first stmt was executed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
enzo 2026-03-21 22:29:22 +01:00
parent 97212b7ffa
commit 780e5ec81d
3 changed files with 18 additions and 7 deletions

View file

@ -116,12 +116,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
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)
// Cookie httpOnly pour le refresh token // Cookie httpOnly pour le refresh token
// Secure=true si TLS direct ou si derrière un proxy (Traefik) qui a terminé TLS
isHTTPS := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "pxp_refresh", Name: "pxp_refresh",
Value: refreshToken, Value: refreshToken,
Path: "/api/auth/refresh", Path: "/api/auth/refresh",
HttpOnly: true, HttpOnly: true,
Secure: r.TLS != nil, Secure: isHTTPS,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
Expires: expiry, Expires: expiry,
}) })

View file

@ -126,10 +126,18 @@ func (db *DB) migrate() error {
return fmt.Errorf("transaction migration %s : %w", m.name, err) return fmt.Errorf("transaction migration %s : %w", m.name, err)
} }
if _, err := tx.Exec(string(content)); err != nil { // Splitter par ";" pour exécuter chaque statement séparément
// (SQLite / database/sql n'exécute qu'un seul statement par Exec)
for _, stmt := range strings.Split(string(content), ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("exécution migration %s : %w", m.name, err) return fmt.Errorf("exécution migration %s : %w", m.name, err)
} }
}
// Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire) // Mettre à jour la version (la migration 001 l'insère elle-même, pas besoin de le refaire)
if m.version > 1 { if m.version > 1 {

View file

@ -118,7 +118,8 @@ document.addEventListener('alpine:init', () => {
const res = await apiFetch('/api/auth/me') const res = await apiFetch('/api/auth/me')
if (res.ok) { if (res.ok) {
this.user = await res.json() this.user = await res.json()
} else if (res.status === 401) { } else {
// Token expiré, invalide, ou toute autre erreur → tenter un refresh
await this.tryRefresh() await this.tryRefresh()
} }
}, },
@ -918,8 +919,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const publicPages = ['login', 'install', 'index', ''] const publicPages = ['login', 'install', 'index', '']
const currentPage = window.location.pathname.replace(/^\/|\.html$/g, '') || 'index' const currentPage = window.location.pathname.replace(/^\/|\.html$/g, '') || 'index'
// Guard rapide (synchrone) : si pas de token du tout, redirect immédiat // Guard auth : si pas authentifié (token absent ou invalid/expiré), redirect login
if (!publicPages.includes(currentPage) && !localStorage.getItem('pxp_token')) { if (!publicPages.includes(currentPage) && !Alpine.store('auth').isAuthenticated) {
window.location.href = '/login.html' window.location.href = '/login.html'
return return
} }