From 0809b4d4160ba14498b4ad0d09c726fb7c2d5f07 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Tue, 7 Apr 2026 21:34:30 +0800 Subject: [PATCH] fix(auth): detail auth error handle --- web/backend/api/auth.go | 67 +++++++++++++++++++++++++----- web/backend/dashboardauth/store.go | 17 ++++++-- web/backend/main.go | 1 + 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index 94e260b1b..0790a6b76 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/json" + "fmt" "io" "net/http" "strings" @@ -31,6 +32,10 @@ type LauncherAuthRouteOpts struct { // initialized, web-form login verifies against the stored hash instead of // the plaintext DashboardToken. PasswordStore PasswordStore + // StoreError holds the error returned when opening the password store. When + // non-nil and PasswordStore is nil, the auth endpoints surface a recovery + // message instead of an opaque 501/503. + StoreError error } type launcherAuthLoginBody struct { @@ -58,6 +63,7 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) sessionCookie: opts.SessionCookie, secureCookie: secure, store: opts.PasswordStore, + storeErr: opts.StoreError, loginLimit: newLoginRateLimiter(), } mux.HandleFunc("POST /api/auth/login", h.handleLogin) @@ -71,16 +77,27 @@ type launcherAuthHandlers struct { sessionCookie string secureCookie func(*http.Request) bool store PasswordStore + storeErr error // set when the store failed to open; drives recovery messages loginLimit *loginRateLimiter } // isStoreInitialized safely queries the store. -func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) bool { +// Returns (false, nil) when no store is configured (storeErr also nil). +// Returns (false, err) on store errors — callers must treat this as a 5xx, not as +// "uninitialized", to keep auth fail-closed. +// Exception: handleLogin swallows storeErr and falls back to token auth so +// that a corrupt DB does not lock out all access. +func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, error) { if h.store == nil { - return false + if h.storeErr != nil { + return false, fmt.Errorf( + "password store unavailable (%w); "+ + "to recover, stop the application, delete the database file and restart ", + h.storeErr) + } + return false, nil } - ok, err := h.store.IsInitialized(ctx) - return err == nil && ok + return h.store.IsInitialized(ctx) } func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { @@ -100,13 +117,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques in := strings.TrimSpace(body.Password) var ok bool - if h.isStoreInitialized(r.Context()) { + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + if h.storeErr != nil { + // Store failed to open at startup — token login remains available. + initialized = false + } else { + w.WriteHeader(http.StatusInternalServerError) + writeErrorf(w, "%v", initErr) + return + } + } + + if initialized { // Bcrypt path: verify against the stored hash. var err error ok, err = h.store.VerifyPassword(r.Context(), in) if err != nil { w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"internal error"}`)) + writeErrorf(w, "password verification failed: %v", err) return } } else { @@ -162,14 +191,20 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 } + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } resp := launcherAuthStatusResponse{ Authenticated: authed, - Initialized: h.isStoreInitialized(r.Context()), + Initialized: initialized, } enc, err := json.Marshal(resp) if err != nil { w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"internal error"}`)) + writeErrorf(w, "marshal response failed: %v", err) return } _, _ = w.Write(enc) @@ -189,7 +224,12 @@ func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Reques return } - initialized := h.isStoreInitialized(r.Context()) + initialized, initErr := h.isStoreInitialized(r.Context()) + if initErr != nil { + w.WriteHeader(http.StatusServiceUnavailable) + writeErrorf(w, "%v", initErr) + return + } // If already initialized, require an active session (change-password flow). if initialized { @@ -230,10 +270,17 @@ func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Reques if err := h.store.SetPassword(r.Context(), pw); err != nil { w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(`{"error":"failed to save password"}`)) + writeErrorf(w, "failed to save password: %v", err) return } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } + +// writeErrorf writes a JSON error response with a formatted message. +// json.Marshal is used to safely escape the message string. +func writeErrorf(w http.ResponseWriter, format string, args ...any) { + msg, _ := json.Marshal(fmt.Sprintf(format, args...)) + _, _ = w.Write([]byte(`{"error":` + string(msg) + `}`)) +} diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go index 9421f0d2f..44605ba22 100644 --- a/web/backend/dashboardauth/store.go +++ b/web/backend/dashboardauth/store.go @@ -7,6 +7,7 @@ import ( "context" "database/sql" "errors" + "fmt" "path/filepath" "golang.org/x/crypto/bcrypt" @@ -15,13 +16,20 @@ import ( // Store holds a handle to the SQLite database that stores the bcrypt hash. type Store struct { - db *sql.DB + db *sql.DB + path string // absolute path to the SQLite file } // New opens (or creates) the database inside dir, using the package's // canonical filename. This is the preferred constructor for most callers. +// Any error is wrapped with the resolved path so callers get actionable output. func New(dir string) (*Store, error) { - return Open(filepath.Join(dir, DBFilename)) + path := filepath.Join(dir, DBFilename) + s, err := Open(path) + if err != nil { + return nil, fmt.Errorf("open %q: %w", path, err) + } + return s, nil } // Open opens (or creates) the SQLite database at path and migrates the schema. @@ -34,12 +42,15 @@ func Open(path string) (*Store, error) { _ = db.Close() return nil, err } - return &Store{db: db}, nil + return &Store{db: db, path: path}, nil } // Close releases the database handle. func (s *Store) Close() error { return s.db.Close() } +// DBPath returns the absolute path to the SQLite database file. +func (s *Store) DBPath() string { return s.path } + // IsInitialized reports whether a password hash has been stored. func (s *Store) IsInitialized(ctx context.Context) (bool, error) { var n int diff --git a/web/backend/main.go b/web/backend/main.go index 978936d1a..d9ea3474c 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -251,6 +251,7 @@ func main() { DashboardToken: dashboardToken, SessionCookie: dashboardSessionCookie, PasswordStore: authStore, + StoreError: authStoreErr, }) // API Routes (e.g. /api/status)