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"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -31,6 +32,10 @@ type LauncherAuthRouteOpts struct {
|
||||||
// initialized, web-form login verifies against the stored hash instead of
|
// initialized, web-form login verifies against the stored hash instead of
|
||||||
// the plaintext DashboardToken.
|
// the plaintext DashboardToken.
|
||||||
PasswordStore PasswordStore
|
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 {
|
type launcherAuthLoginBody struct {
|
||||||
|
|
@ -58,6 +63,7 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
|
||||||
sessionCookie: opts.SessionCookie,
|
sessionCookie: opts.SessionCookie,
|
||||||
secureCookie: secure,
|
secureCookie: secure,
|
||||||
store: opts.PasswordStore,
|
store: opts.PasswordStore,
|
||||||
|
storeErr: opts.StoreError,
|
||||||
loginLimit: newLoginRateLimiter(),
|
loginLimit: newLoginRateLimiter(),
|
||||||
}
|
}
|
||||||
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
|
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
|
||||||
|
|
@ -71,16 +77,27 @@ type launcherAuthHandlers struct {
|
||||||
sessionCookie string
|
sessionCookie string
|
||||||
secureCookie func(*http.Request) bool
|
secureCookie func(*http.Request) bool
|
||||||
store PasswordStore
|
store PasswordStore
|
||||||
|
storeErr error // set when the store failed to open; drives recovery messages
|
||||||
loginLimit *loginRateLimiter
|
loginLimit *loginRateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// isStoreInitialized safely queries the store.
|
// 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 {
|
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 h.store.IsInitialized(ctx)
|
||||||
return err == nil && ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
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)
|
in := strings.TrimSpace(body.Password)
|
||||||
var ok bool
|
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.
|
// Bcrypt path: verify against the stored hash.
|
||||||
var err error
|
var err error
|
||||||
ok, err = h.store.VerifyPassword(r.Context(), in)
|
ok, err = h.store.VerifyPassword(r.Context(), in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
writeErrorf(w, "password verification failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -162,14 +191,20 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
||||||
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
|
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
|
||||||
authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
|
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{
|
resp := launcherAuthStatusResponse{
|
||||||
Authenticated: authed,
|
Authenticated: authed,
|
||||||
Initialized: h.isStoreInitialized(r.Context()),
|
Initialized: initialized,
|
||||||
}
|
}
|
||||||
enc, err := json.Marshal(resp)
|
enc, err := json.Marshal(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`{"error":"internal error"}`))
|
writeErrorf(w, "marshal response failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = w.Write(enc)
|
_, _ = w.Write(enc)
|
||||||
|
|
@ -189,7 +224,12 @@ func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Reques
|
||||||
return
|
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 already initialized, require an active session (change-password flow).
|
||||||
if initialized {
|
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 {
|
if err := h.store.SetPassword(r.Context(), pw); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
_, _ = w.Write([]byte(`{"error":"failed to save password"}`))
|
writeErrorf(w, "failed to save password: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = 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"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
@ -15,13 +16,20 @@ import (
|
||||||
|
|
||||||
// Store holds a handle to the SQLite database that stores the bcrypt hash.
|
// Store holds a handle to the SQLite database that stores the bcrypt hash.
|
||||||
type Store struct {
|
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
|
// New opens (or creates) the database inside dir, using the package's
|
||||||
// canonical filename. This is the preferred constructor for most callers.
|
// 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) {
|
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.
|
// 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()
|
_ = db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Store{db: db}, nil
|
return &Store{db: db, path: path}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases the database handle.
|
// Close releases the database handle.
|
||||||
func (s *Store) Close() error { return s.db.Close() }
|
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.
|
// IsInitialized reports whether a password hash has been stored.
|
||||||
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
|
func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
|
||||||
var n int
|
var n int
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ func main() {
|
||||||
DashboardToken: dashboardToken,
|
DashboardToken: dashboardToken,
|
||||||
SessionCookie: dashboardSessionCookie,
|
SessionCookie: dashboardSessionCookie,
|
||||||
PasswordStore: authStore,
|
PasswordStore: authStore,
|
||||||
|
StoreError: authStoreErr,
|
||||||
})
|
})
|
||||||
|
|
||||||
// API Routes (e.g. /api/status)
|
// API Routes (e.g. /api/status)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue