fix(auth): detail auth error handle
This commit is contained in:
parent
ca05952677
commit
0809b4d416
3 changed files with 72 additions and 13 deletions
|
|
@ -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) + `}`))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ func main() {
|
|||
DashboardToken: dashboardToken,
|
||||
SessionCookie: dashboardSessionCookie,
|
||||
PasswordStore: authStore,
|
||||
StoreError: authStoreErr,
|
||||
})
|
||||
|
||||
// API Routes (e.g. /api/status)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue