refactor(web): switch dashboard auth from tokens to passwords (#2608)

- replace token-based launcher auth with password-based login and sessions
- migrate legacy launcher_token values into bcrypt-backed password storage
- add one-shot local auto-login bootstrap
- update config UI, i18n strings, docs, and auth-related tests
This commit is contained in:
wenjie
2026-04-21 18:04:15 +08:00
committed by GitHub
parent a5379d5fff
commit 71c877a67f
34 changed files with 1188 additions and 585 deletions
+30 -62
View File
@@ -12,9 +12,8 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
// PasswordStore is the interface for bcrypt-backed dashboard password persistence.
// Implemented by dashboardauth.Store; a nil value falls back to the legacy
// static-token comparison.
// PasswordStore is the interface for dashboard password persistence.
// Implemented by dashboardauth.Store and launcherconfig.PasswordStore.
type PasswordStore interface {
IsInitialized(ctx context.Context) (bool, error)
SetPassword(ctx context.Context, plain string) error
@@ -23,18 +22,13 @@ type PasswordStore interface {
// LauncherAuthRouteOpts configures dashboard auth handlers.
type LauncherAuthRouteOpts struct {
// DashboardToken is the fallback plaintext token used when PasswordStore is
// nil or not yet initialized (env-var / config-file source, and ?token= auto-login).
DashboardToken string
SessionCookie string
SecureCookie func(*http.Request) bool
// PasswordStore enables bcrypt-backed password persistence. When non-nil and
// initialized, web-form login verifies against the stored hash instead of
// the plaintext DashboardToken.
SessionCookie string
SecureCookie func(*http.Request) bool
// PasswordStore enables password login. It must be non-nil for auth to work.
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.
// non-nil and PasswordStore is nil, auth endpoints fail closed with a
// recovery message.
StoreError error
}
@@ -59,7 +53,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
secure = middleware.DefaultLauncherDashboardSecureCookie
}
h := &launcherAuthHandlers{
token: opts.DashboardToken,
sessionCookie: opts.SessionCookie,
secureCookie: secure,
store: opts.PasswordStore,
@@ -73,7 +66,6 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
}
type launcherAuthHandlers struct {
token string
sessionCookie string
secureCookie func(*http.Request) bool
store PasswordStore
@@ -81,29 +73,18 @@ type launcherAuthHandlers struct {
loginLimit *loginRateLimiter
}
func (h *launcherAuthHandlers) usesLegacyTokenAuth() bool {
return h.store == nil && h.storeErr == nil && h.token != ""
}
// isStoreInitialized safely queries the store.
// 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
// "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.storeErr != nil {
return false, fmt.Errorf(
"password store unavailable (%w); "+
"to recover, stop the application, delete the database file and restart ",
"to recover, stop the application, reset dashboard password storage, and restart",
h.storeErr)
}
if h.usesLegacyTokenAuth() {
return true, nil
}
return false, nil
return false, fmt.Errorf("password store not configured")
}
return h.store.IsInitialized(ctx)
}
@@ -123,35 +104,25 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques
return
}
in := strings.TrimSpace(body.Password)
var ok bool
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
}
w.WriteHeader(http.StatusServiceUnavailable)
writeErrorf(w, "%v", initErr)
return
}
if !initialized {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"password has not been set"}`))
return
}
if initialized && h.store != nil {
// 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)
writeErrorf(w, "password verification failed: %v", err)
return
}
} else {
// Fallback: constant-time compare against the plaintext token.
ok = len(in) == len(h.token) &&
subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1
ok, err := h.store.VerifyPassword(r.Context(), in)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
writeErrorf(w, "password verification failed: %v", err)
return
}
if !ok {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid password"}`))
@@ -221,22 +192,19 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
// handleSetup sets or changes the dashboard password.
//
// Rules:
// - If the store has no password yet, the endpoint is open (no session required).
// - If the store has no password yet, anyone who can reach the setup endpoint
// may initialize the password.
// - If a password is already set, the caller must hold a valid session cookie.
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
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 {
w.WriteHeader(http.StatusNotImplemented)
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
w.WriteHeader(http.StatusServiceUnavailable)
if h.storeErr != nil {
writeErrorf(w, "password store unavailable: %v", h.storeErr)
} else {
_, _ = w.Write([]byte(`{"error":"password store not configured"}`))
}
return
}
+142 -44
View File
@@ -2,7 +2,9 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -12,17 +14,43 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLauncherAuthLoginAndStatus(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 0x55
type fakePasswordStore struct {
initialized bool
password string
err error
}
func (s *fakePasswordStore) IsInitialized(context.Context) (bool, error) {
if s.err != nil {
return false, s.err
}
const tok = "dashboard-test-token-9"
sess := middleware.SessionCookieValue(key, tok)
return s.initialized, nil
}
func (s *fakePasswordStore) SetPassword(_ context.Context, plain string) error {
if s.err != nil {
return s.err
}
s.password = plain
s.initialized = true
return nil
}
func (s *fakePasswordStore) VerifyPassword(_ context.Context, plain string) (bool, error) {
if s.err != nil {
return false, s.err
}
return s.initialized && plain == s.password, nil
}
func TestLauncherAuthLoginAndStatus(t *testing.T) {
const password = "dashboard-test-password"
const sess = "session-cookie-value"
store := &fakePasswordStore{initialized: true, password: password}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
SessionCookie: sess,
PasswordStore: store,
})
t.Run("status_unauthenticated", func(t *testing.T) {
@@ -45,7 +73,7 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`))
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+password+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
@@ -75,14 +103,13 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
})
}
func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
key := make([]byte, 32)
const tok = "legacy-fallback-token"
sess := middleware.SessionCookieValue(key, tok)
func TestLauncherAuthUninitializedStoreRequiresSetup(t *testing.T) {
const sess = "session-cookie-value"
store := &fakePasswordStore{}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
SessionCookie: sess,
PasswordStore: store,
})
rec := httptest.NewRecorder()
@@ -98,29 +125,80 @@ func TestLauncherAuthLegacyTokenFallbackReportsInitialized(t *testing.T) {
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.Initialized {
t.Fatalf("initialized = true, want false before setup")
}
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 := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"not-set-yet"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("login before setup code = %d body=%s", rec.Code, rec.Body.String())
}
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.StatusOK {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
t.Fatalf("setup code = %d body=%s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"12345678"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login after setup code = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "legacy-token")
func TestLauncherAuthSetupRequiresSessionWhenInitialized(t *testing.T) {
const sess = "session-cookie-value"
store := &fakePasswordStore{initialized: true, password: "old-password"}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "legacy-token",
SessionCookie: sess,
SessionCookie: sess,
PasswordStore: store,
})
body := strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("setup without session code = %d body=%s", rec.Code, rec.Body.String())
}
body = strings.NewReader(`{"password":"new-password","confirm":"new-password"}`)
req = httptest.NewRequest(http.MethodPost, "/api/auth/setup", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("setup with session code = %d body=%s", rec.Code, rec.Body.String())
}
if store.password != "new-password" {
t.Fatalf("password = %q, want new-password", store.password)
}
}
func TestLauncherAuthInitialSetupAllowsDirectSetup(t *testing.T) {
store := &fakePasswordStore{}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
SessionCookie: "session-cookie-value",
PasswordStore: store,
})
rec := httptest.NewRecorder()
@@ -131,18 +209,46 @@ func TestLauncherAuthSetupRejectedInLegacyTokenFallback(t *testing.T) {
)
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())
if rec.Code != http.StatusOK {
t.Fatalf("setup without grant code = %d body=%s", rec.Code, rec.Body.String())
}
}
func TestLauncherAuthStoreUnavailableFailsClosed(t *testing.T) {
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
SessionCookie: "session-cookie-value",
StoreError: errors.New("open auth store"),
})
for _, tc := range []struct {
name string
method string
path string
body string
}{
{name: "status", method: http.MethodGet, path: "/api/auth/status"},
{name: "login", method: http.MethodPost, path: "/api/auth/login", body: `{"password":"password"}`},
{name: "setup", method: http.MethodPost, path: "/api/auth/setup", body: `{"password":"12345678","confirm":"12345678"}`},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d body=%s", rec.Code, rec.Body.String())
}
})
}
}
func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
@@ -169,16 +275,14 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
}
func TestLauncherAuthLoginRateLimit(t *testing.T) {
key := make([]byte, 32)
const tok = "rate-limit-tok-xxxxxxxx"
sess := middleware.SessionCookieValue(key, tok)
store := &fakePasswordStore{initialized: true, password: "correct-password"}
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
SessionCookie: "session-cookie-value",
PasswordStore: store,
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
// 11 failing logins by wrong password; each consumes allow() slot after valid JSON.
wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
@@ -231,12 +335,9 @@ func TestReferrerPolicyMiddleware(t *testing.T) {
}
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@@ -249,12 +350,9 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
}
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
SessionCookie: "session-cookie-value",
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
+17 -18
View File
@@ -4,16 +4,14 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
)
type launcherConfigPayload struct {
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
LauncherToken string `json:"launcher_token"`
Port int `json:"port"`
Public bool `json:"public"`
AllowedCIDRs []string `json:"allowed_cidrs"`
}
func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) {
@@ -50,10 +48,9 @@ func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}
@@ -64,12 +61,15 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
return
}
cfg := launcherconfig.Config{
Port: payload.Port,
Public: payload.Public,
AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...),
LauncherToken: strings.TrimSpace(payload.LauncherToken),
cfg, err := h.loadLauncherConfig()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError)
return
}
cfg.Port = payload.Port
cfg.Public = payload.Public
cfg.AllowedCIDRs = append([]string(nil), payload.AllowedCIDRs...)
cfg.LegacyLauncherToken = ""
if err := launcherconfig.Validate(cfg); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -82,9 +82,8 @@ func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Requ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(launcherConfigPayload{
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
LauncherToken: cfg.LauncherToken,
Port: cfg.Port,
Public: cfg.Public,
AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...),
})
}
+15 -7
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -34,9 +35,6 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
if got.Port != 19999 || !got.Public {
t.Fatalf("response = %+v, want port=19999 public=true", got)
}
if got.LauncherToken != "" {
t.Fatalf("response launcher_token = %q, want empty", got.LauncherToken)
}
if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs)
}
@@ -44,6 +42,14 @@ func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) {
func TestPutLauncherConfigPersists(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
path := launcherconfig.PathForAppConfig(configPath)
if err := os.WriteFile(
path,
[]byte(`{"port":18800,"public":false,"dashboard_password_hash":"saved-hash","launcher_token":"legacy-token"}`),
0o600,
); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
@@ -54,7 +60,7 @@ func TestPutLauncherConfigPersists(t *testing.T) {
http.MethodPut,
"/api/system/launcher-config",
strings.NewReader(
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"],"launcher_token":"saved-token"}`,
`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`,
),
)
req.Header.Set("Content-Type", "application/json")
@@ -64,7 +70,6 @@ func TestPutLauncherConfigPersists(t *testing.T) {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
path := launcherconfig.PathForAppConfig(configPath)
cfg, err := launcherconfig.Load(path, launcherconfig.Default())
if err != nil {
t.Fatalf("launcherconfig.Load() error = %v", err)
@@ -72,8 +77,11 @@ func TestPutLauncherConfigPersists(t *testing.T) {
if cfg.Port != 18080 || !cfg.Public {
t.Fatalf("saved config = %+v, want port=18080 public=true", cfg)
}
if cfg.LauncherToken != "saved-token" {
t.Fatalf("saved launcher_token = %q, want %q", cfg.LauncherToken, "saved-token")
if cfg.DashboardPasswordHash != "saved-hash" {
t.Fatalf("saved dashboard_password_hash = %q, want saved-hash", cfg.DashboardPasswordHash)
}
if cfg.LegacyLauncherToken != "" {
t.Fatalf("saved legacy launcher_token = %q, want empty", cfg.LegacyLauncherToken)
}
if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" {
t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs)