fix(launcher): fall back to token auth on unsupported platforms (#2466)
Handle platforms where the dashboard password store is unavailable by treating legacy token auth as initialized, rejecting password setup, and adding platform-specific store stubs and tests.
This commit is contained in:
parent
7788ed4677
commit
795ec9af05
6 changed files with 163 additions and 7 deletions
|
|
@ -81,8 +81,13 @@ type launcherAuthHandlers struct {
|
||||||
loginLimit *loginRateLimiter
|
loginLimit *loginRateLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
|
||||||
|
return h.store == nil && h.storeErr == nil && h.token != ""
|
||||||
|
}
|
||||||
|
|
||||||
// isStoreInitialized safely queries the store.
|
// isStoreInitialized safely queries the store.
|
||||||
// Returns (false, nil) when no store is configured (storeErr also nil).
|
// Returns (true, nil) when legacy token auth is active without a password store.
|
||||||
|
// Returns (false, nil) when no store/token fallback is configured.
|
||||||
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
|
// Returns (false, err) on store errors — callers must treat this as a 5xx, not as
|
||||||
// "uninitialized", to keep auth fail-closed.
|
// "uninitialized", to keep auth fail-closed.
|
||||||
// Exception: handleLogin swallows storeErr and falls back to token auth so
|
// Exception: handleLogin swallows storeErr and falls back to token auth so
|
||||||
|
|
@ -95,6 +100,9 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er
|
||||||
"to recover, stop the application, delete the database file and restart ",
|
"to recover, stop the application, delete the database file and restart ",
|
||||||
h.storeErr)
|
h.storeErr)
|
||||||
}
|
}
|
||||||
|
if h.usesLegacyTokenAuth() {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
return h.store.IsInitialized(ctx)
|
return h.store.IsInitialized(ctx)
|
||||||
|
|
@ -129,7 +137,7 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if initialized {
|
if initialized && h.store != nil {
|
||||||
// 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)
|
||||||
|
|
@ -218,6 +226,14 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
||||||
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if h.usesLegacyTokenAuth() {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
_, _ = w.Write(
|
||||||
|
[]byte(`{"error":"password setup is unavailable on this platform; use the dashboard token instead"}`),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if h.store == nil {
|
if h.store == nil {
|
||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
|
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,67 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
const tok = "legacy-fallback-token"
|
||||||
|
sess := middleware.SessionCookieValue(key, tok)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: tok,
|
||||||
|
SessionCookie: sess,
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status code = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
Initialized bool `json:"initialized"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !body.Initialized {
|
||||||
|
t.Fatalf("initialized = false, want true in legacy token fallback mode")
|
||||||
|
}
|
||||||
|
if body.Authenticated {
|
||||||
|
t.Fatalf("unexpected authenticated=true: %+v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
sess := middleware.SessionCookieValue(key, "legacy-token")
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||||
|
DashboardToken: "legacy-token",
|
||||||
|
SessionCookie: sess,
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodPost,
|
||||||
|
"/api/auth/setup",
|
||||||
|
strings.NewReader(`{"password":"12345678","confirm":"12345678"}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNotImplemented {
|
||||||
|
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
|
||||||
key := make([]byte, 32)
|
key := make([]byte, 32)
|
||||||
sess := middleware.SessionCookieValue(key, "tok")
|
sess := middleware.SessionCookieValue(key, "tok")
|
||||||
|
|
|
||||||
7
web/backend/dashboardauth/platform.go
Normal file
7
web/backend/dashboardauth/platform.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package dashboardauth
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrUnsupportedPlatform reports that the SQLite-backed password store is not
|
||||||
|
// available for the current target platform.
|
||||||
|
var ErrUnsupportedPlatform = errors.New("dashboard password store is unavailable on this platform")
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
//go:build !mipsle && !netbsd && !(freebsd && arm)
|
||||||
|
|
||||||
// Package dashboardauth provides a bcrypt-backed SQLite store for the
|
// Package dashboardauth provides a bcrypt-backed SQLite store for the
|
||||||
// launcher dashboard password. The database contains a single row (id=1)
|
// launcher dashboard password. The database contains a single row (id=1)
|
||||||
// with the bcrypt hash; no plaintext is ever persisted.
|
// with the bcrypt hash; no plaintext is ever persisted.
|
||||||
|
|
|
||||||
60
web/backend/dashboardauth/store_unsupported.go
Normal file
60
web/backend/dashboardauth/store_unsupported.go
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
//go:build mipsle || netbsd || (freebsd && arm)
|
||||||
|
|
||||||
|
package dashboardauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store is unavailable on platforms where modernc sqlite/libc does not build.
|
||||||
|
type Store struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New reports that the password store is unavailable on this platform.
|
||||||
|
func New(dir string) (*Store, error) {
|
||||||
|
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 reports that the password store is unavailable on this platform.
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
return nil, unsupportedPlatformError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close is a no-op for unsupported platforms.
|
||||||
|
func (s *Store) Close() error { return nil }
|
||||||
|
|
||||||
|
// DBPath returns the configured path, if any.
|
||||||
|
func (s *Store) DBPath() string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.path
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsInitialized reports that the store is unavailable on this platform.
|
||||||
|
func (s *Store) IsInitialized(context.Context) (bool, error) {
|
||||||
|
return false, unsupportedPlatformError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPassword reports that the store is unavailable on this platform.
|
||||||
|
func (s *Store) SetPassword(context.Context, string) error {
|
||||||
|
return unsupportedPlatformError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword reports that the store is unavailable on this platform.
|
||||||
|
func (s *Store) VerifyPassword(context.Context, string) (bool, error) {
|
||||||
|
return false, unsupportedPlatformError()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsupportedPlatformError() error {
|
||||||
|
return fmt.Errorf("%w (%s/%s)", ErrUnsupportedPlatform, runtime.GOOS, runtime.GOARCH)
|
||||||
|
}
|
||||||
|
|
@ -229,11 +229,21 @@ func main() {
|
||||||
|
|
||||||
// Open the bcrypt password store (creates the DB file on first run).
|
// Open the bcrypt password store (creates the DB file on first run).
|
||||||
authStore, authStoreErr := dashboardauth.New(picoHome)
|
authStore, authStoreErr := dashboardauth.New(picoHome)
|
||||||
if authStoreErr != nil {
|
var passwordStore api.PasswordStore
|
||||||
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
if authStoreErr == nil {
|
||||||
authStore = nil
|
passwordStore = authStore
|
||||||
} else {
|
|
||||||
defer authStore.Close()
|
defer authStore.Close()
|
||||||
|
} else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
|
||||||
|
logger.InfoC(
|
||||||
|
"web",
|
||||||
|
fmt.Sprintf(
|
||||||
|
"Dashboard password store unavailable on this platform; falling back to token login: %v",
|
||||||
|
authStoreErr,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
authStoreErr = nil
|
||||||
|
} else {
|
||||||
|
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine listen address
|
// Determine listen address
|
||||||
|
|
@ -250,7 +260,7 @@ func main() {
|
||||||
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
||||||
DashboardToken: dashboardToken,
|
DashboardToken: dashboardToken,
|
||||||
SessionCookie: dashboardSessionCookie,
|
SessionCookie: dashboardSessionCookie,
|
||||||
PasswordStore: authStore,
|
PasswordStore: passwordStore,
|
||||||
StoreError: authStoreErr,
|
StoreError: authStoreErr,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue