From d2924bc5acb790101edb490afba39485f296a327 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Sat, 4 Apr 2026 06:20:29 +0800 Subject: [PATCH] feat(launcher): replace token-in-logs auth with standard HTTP login flow ## Problem Previously users had to find the one-time token from console logs or log files to access the dashboard - a non-standard, error-prone workflow with no clear path for changing credentials. ## Solution: standard HTTP API login with bcrypt-backed password store ### Auth flow (new) 1. First run: browser opens, session guard detects uninitialized state, redirects to /launcher-setup 2. User sets a password (min 8 chars) via POST /api/auth/setup {password, confirm}, bcrypt(cost=12) hash stored in ~/.picoclaw/launcher-auth.db (SQLite) 3. Subsequent logins: POST /api/auth/login {password}, HttpOnly cookie picoclaw_launcher_auth (HMAC-SHA256 signed, 7-day expiry) 4. 401 on any API call, frontend redirects to /launcher-login 5. Logout: POST /api/auth/logout, cookie cleared, redirect to login ### Backend changes - web/backend/api/auth.go: renamed Token to Password; added handleSetup; launcherAuthStatusResponse now includes Initialized bool; PasswordStore interface wires bcrypt store into handlers - web/backend/dashboardauth/: new package - Store with New(dir) / Open(path); SetPassword (bcrypt cost=12), VerifyPassword, IsInitialized - sql.go: all DB-layer constants (DBFilename, sqliteDriver, bcryptCost, four SQL query strings) - compile-time constants, zero runtime overhead - web/backend/middleware/launcher_dashboard_auth.go: /launcher-setup and /api/auth/setup added to public paths - web/backend/main.go: - dashboardauth.New(picoHome) replaces manual path construction - maskSecret(): suffix only revealed when >=5 chars hidden (length >= 12), preventing 8-char minimum passwords from leaking their tail - web/backend/main_test.go: TestMaskSecret updated with boundary cases ### Forward-compatibility: pkg/credential integration If the dashboard password is later reused as the enc:// passphrase, the bcrypt hash in launcher-auth.db becomes an offline oracle. Recommended mitigation (not yet implemented): derive two independent subkeys via HKDF before use: bcrypt(HKDF(password, info="picoclaw-dashboard-login-v1")) stored in DB HKDF(password, info="picoclaw-credential-enc-v1") passed to PassphraseProvider This isolates the two domains: cracking the bcrypt hash yields only the login subkey, which is computationally independent of the enc:// subkey. --- web/backend/api/auth.go | 154 ++++++++++++++---- web/backend/api/auth_test.go | 25 +-- web/backend/dashboardauth/sql.go | 24 +++ web/backend/dashboardauth/store.go | 83 ++++++++++ web/backend/main.go | 52 ++++-- web/backend/main_test.go | 28 ++++ .../middleware/launcher_dashboard_auth.go | 4 +- 7 files changed, 304 insertions(+), 66 deletions(-) create mode 100644 web/backend/dashboardauth/sql.go create mode 100644 web/backend/dashboardauth/store.go diff --git a/web/backend/api/auth.go b/web/backend/api/auth.go index 22f7ec2c2..a40f3c6fb 100644 --- a/web/backend/api/auth.go +++ b/web/backend/api/auth.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/subtle" "encoding/json" "io" @@ -10,34 +11,43 @@ import ( "github.com/sipeed/picoclaw/web/backend/middleware" ) -// LauncherAuthRouteOpts configures dashboard token login handlers. +// 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. +type PasswordStore interface { + IsInitialized(ctx context.Context) (bool, error) + SetPassword(ctx context.Context, plain string) error + VerifyPassword(ctx context.Context, plain string) (bool, error) +} + +// 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 - // TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets). - TokenHelp LauncherAuthTokenHelp -} - -// LauncherAuthTokenHelp tells the login UI where users can find the dashboard token. -type LauncherAuthTokenHelp struct { - EnvVarName string `json:"env_var_name"` - LogFileAbs string `json:"log_file,omitempty"` - ConfigFileAbs string `json:"config_file,omitempty"` - TrayCopyMenu bool `json:"tray_copy_menu"` - ConsoleStdout bool `json:"console_stdout"` + // PasswordStore enables bcrypt-backed password persistence. When non-nil and + // initialized, web-form login verifies against the stored hash instead of + // the plaintext DashboardToken. + PasswordStore PasswordStore } type launcherAuthLoginBody struct { - Token string `json:"token"` + Password string `json:"password"` +} + +type launcherAuthSetupBody struct { + Password string `json:"password"` + Confirm string `json:"confirm"` } type launcherAuthStatusResponse struct { - Authenticated bool `json:"authenticated"` - TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"` + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` } -// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status. +// RegisterLauncherAuthRoutes registers /api/auth/login|logout|status|setup. func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) { secure := opts.SecureCookie if secure == nil { @@ -47,22 +57,32 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) token: opts.DashboardToken, sessionCookie: opts.SessionCookie, secureCookie: secure, - tokenHelp: opts.TokenHelp, + store: opts.PasswordStore, loginLimit: newLoginRateLimiter(), } mux.HandleFunc("POST /api/auth/login", h.handleLogin) mux.HandleFunc("POST /api/auth/logout", h.handleLogout) mux.HandleFunc("GET /api/auth/status", h.handleStatus) + mux.HandleFunc("POST /api/auth/setup", h.handleSetup) } type launcherAuthHandlers struct { token string sessionCookie string secureCookie func(*http.Request) bool - tokenHelp LauncherAuthTokenHelp + store PasswordStore loginLimit *loginRateLimiter } +// isStoreInitialized safely queries the store. +func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) bool { + if h.store == nil { + return false + } + ok, err := h.store.IsInitialized(ctx) + return err == nil && ok +} + func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") var body launcherAuthLoginBody @@ -77,10 +97,27 @@ func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Reques _, _ = w.Write([]byte(`{"error":"too many login attempts"}`)) return } - in := strings.TrimSpace(body.Token) - if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 { + in := strings.TrimSpace(body.Password) + ok := false + + if h.isStoreInitialized(r.Context()) { + // 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"}`)) + return + } + } else { + // Fallback: constant-time compare against the plaintext token. + ok = len(in) == len(h.token) && + subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) == 1 + } + + if !ok { w.WriteHeader(http.StatusUnauthorized) - _, _ = w.Write([]byte(`{"error":"invalid token"}`)) + _, _ = w.Write([]byte(`{"error":"invalid password"}`)) return } @@ -121,17 +158,13 @@ func (h *launcherAuthHandlers) handleLogout(w http.ResponseWriter, r *http.Reque func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - ok := false + authed := false if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { - ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 - } - if ok { - _, _ = w.Write([]byte(`{"authenticated":true}`)) - return + authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 } resp := launcherAuthStatusResponse{ - Authenticated: false, - TokenHelp: &h.tokenHelp, + Authenticated: authed, + Initialized: h.isStoreInitialized(r.Context()), } enc, err := json.Marshal(resp) if err != nil { @@ -141,3 +174,66 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque } _, _ = w.Write(enc) } + +// handleSetup sets or changes the dashboard password. +// +// Rules: +// - If the store has no password yet, the endpoint is open (no session required). +// - 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.store == nil { + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte(`{"error":"password store not configured"}`)) + return + } + + initialized := h.isStoreInitialized(r.Context()) + + // If already initialized, require an active session (change-password flow). + if initialized { + authed := false + if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { + authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 + } + if !authed { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"must be authenticated to change password"}`)) + return + } + } + + var body launcherAuthSetupBody + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&body); err != nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid JSON"}`)) + return + } + + pw := strings.TrimSpace(body.Password) + if pw == "" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"password must not be empty"}`)) + return + } + if pw != strings.TrimSpace(body.Confirm) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"passwords do not match"}`)) + return + } + if len([]rune(pw)) < 8 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"password must be at least 8 characters"}`)) + return + } + + if err := h.store.SetPassword(r.Context(), pw); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"failed to save password"}`)) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} diff --git a/web/backend/api/auth_test.go b/web/backend/api/auth_test.go index d2624a440..58ffb823a 100644 --- a/web/backend/api/auth_test.go +++ b/web/backend/api/auth_test.go @@ -23,12 +23,6 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ DashboardToken: tok, SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{ - EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", - LogFileAbs: "/tmp/launcher.log", - TrayCopyMenu: true, - ConsoleStdout: false, - }, }) t.Run("status_unauthenticated", func(t *testing.T) { @@ -38,23 +32,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) { t.Fatalf("status code = %d", rec.Code) } var body struct { - Authenticated bool `json:"authenticated"` - TokenHelp *LauncherAuthTokenHelp `json:"token_help"` + Authenticated bool `json:"authenticated"` + Initialized bool `json:"initialized"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatal(err) } - if body.Authenticated || body.TokenHelp == nil { - t.Fatalf("unexpected body: %+v", body) - } - if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" { - t.Fatalf("token_help = %+v", body.TokenHelp) + if body.Authenticated { + t.Fatalf("unexpected authenticated=true: %+v", body) } }) t.Run("login_ok", func(t *testing.T) { rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"token":"`+tok+`"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"password":"`+tok+`"}`)) req.Header.Set("Content-Type", "application/json") req.RemoteAddr = "127.0.0.1:12345" mux.ServeHTTP(rec, req) @@ -91,7 +82,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) { RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ DashboardToken: "tok", SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"}, }) rec := httptest.NewRecorder() @@ -125,11 +115,10 @@ func TestLauncherAuthLoginRateLimit(t *testing.T) { RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ DashboardToken: tok, SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, }) // 11 failing logins by wrong token; each consumes allow() slot after valid JSON. - wrongBody := `{"token":"wrong"}` + wrongBody := `{"password":"wrong"}` for i := 0; i < loginAttemptsPerIP; i++ { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) @@ -187,7 +176,6 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) { RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ DashboardToken: "tok", SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) @@ -206,7 +194,6 @@ func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) { RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ DashboardToken: "tok", SessionCookie: sess, - TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) diff --git a/web/backend/dashboardauth/sql.go b/web/backend/dashboardauth/sql.go new file mode 100644 index 000000000..94886072b --- /dev/null +++ b/web/backend/dashboardauth/sql.go @@ -0,0 +1,24 @@ +package dashboardauth + +const ( + // DBFilename is the SQLite database file stored under the PicoClaw home directory. + DBFilename = "launcher-auth.db" + + sqliteDriver = "sqlite" + // bcryptCost is deliberately high enough to slow brute-force attempts. + bcryptCost = 12 + + sqlCreateTable = ` + CREATE TABLE IF NOT EXISTS dashboard_credentials ( + id INTEGER PRIMARY KEY CHECK (id = 1), + bcrypt_hash TEXT NOT NULL + )` + + sqlCountCredentials = `SELECT COUNT(*) FROM dashboard_credentials WHERE id = 1` + + sqlUpsertHash = ` + INSERT INTO dashboard_credentials (id, bcrypt_hash) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET bcrypt_hash = excluded.bcrypt_hash` + + sqlSelectHash = `SELECT bcrypt_hash FROM dashboard_credentials WHERE id = 1` +) diff --git a/web/backend/dashboardauth/store.go b/web/backend/dashboardauth/store.go new file mode 100644 index 000000000..9421f0d2f --- /dev/null +++ b/web/backend/dashboardauth/store.go @@ -0,0 +1,83 @@ +// Package dashboardauth provides a bcrypt-backed SQLite store for the +// launcher dashboard password. The database contains a single row (id=1) +// with the bcrypt hash; no plaintext is ever persisted. +package dashboardauth + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" // register "sqlite" driver +) + +// Store holds a handle to the SQLite database that stores the bcrypt hash. +type Store struct { + db *sql.DB +} + +// New opens (or creates) the database inside dir, using the package's +// canonical filename. This is the preferred constructor for most callers. +func New(dir string) (*Store, error) { + return Open(filepath.Join(dir, DBFilename)) +} + +// Open opens (or creates) the SQLite database at path and migrates the schema. +func Open(path string) (*Store, error) { + db, err := sql.Open(sqliteDriver, path) + if err != nil { + return nil, err + } + if _, err = db.Exec(sqlCreateTable); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +// Close releases the database handle. +func (s *Store) Close() error { return s.db.Close() } + +// IsInitialized reports whether a password hash has been stored. +func (s *Store) IsInitialized(ctx context.Context) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, sqlCountCredentials).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +// SetPassword hashes plain with bcrypt (cost 12) and stores (or replaces) it. +// The plaintext is never written to disk. +func (s *Store) SetPassword(ctx context.Context, plain string) error { + if len([]rune(plain)) == 0 { + return errors.New("password must not be empty") + } + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return err + } + _, err = s.db.ExecContext(ctx, sqlUpsertHash, string(hash)) + return err +} + +// VerifyPassword returns true iff plain matches the stored bcrypt hash. +// Returns (false, nil) when no password has been set yet. +func (s *Store) VerifyPassword(ctx context.Context, plain string) (bool, error) { + var hash string + err := s.db.QueryRowContext(ctx, sqlSelectHash).Scan(&hash) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return false, nil + } + return err == nil, err +} diff --git a/web/backend/main.go b/web/backend/main.go index 5e9f3315f..7de456bfd 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -27,6 +27,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/web/backend/api" + "github.com/sipeed/picoclaw/web/backend/dashboardauth" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/utils" @@ -66,6 +67,24 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la return launcherPath } +// maskSecret masks a secret for display. It always shows up to the first 3 +// runes. The last 4 runes are only appended when at least 5 runes remain +// hidden in the middle (i.e. string length >= 12), so an 8-char minimum +// password never exposes its tail. Strings of 3 chars or fewer are fully +// masked. +func maskSecret(s string) string { + runes := []rune(s) + n := len(runes) + const prefixLen, suffixLen, minHidden = 3, 4, 5 + if n < prefixLen+suffixLen+minHidden { + if n <= prefixLen { + return "**********" + } + return string(runes[:prefixLen]) + "**********" + } + return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:]) +} + func main() { port := flag.String("port", "18800", "Port to listen on") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") @@ -211,6 +230,15 @@ func main() { dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken) launcherDashboardTokenForClipboard = dashboardToken + // Open the bcrypt password store (creates the DB file on first run). + authStore, authStoreErr := dashboardauth.New(picoHome) + if authStoreErr != nil { + logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr)) + authStore = nil + } else { + defer authStore.Close() + } + // Determine listen address var addr string if effectivePublic { @@ -222,20 +250,10 @@ func main() { // Initialize Server components mux := http.NewServeMux() - tokenLogFileAbs := "" - if fileLoggingEnabled { - tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile) - } api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ DashboardToken: dashboardToken, SessionCookie: dashboardSessionCookie, - TokenHelp: api.LauncherAuthTokenHelp{ - EnvVarName: "PICOCLAW_LAUNCHER_TOKEN", - LogFileAbs: tokenLogFileAbs, - ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath), - TrayCopyMenu: trayOffersDashboardTokenCopy(), - ConsoleStdout: enableConsole, - }, + PasswordStore: authStore, }) // API Routes (e.g. /api/status) @@ -284,23 +302,23 @@ func main() { fmt.Println() switch dashboardTokenSource { case launcherconfig.DashboardTokenSourceRandom: - fmt.Printf(" Dashboard token (this run): %s\n", dashboardToken) + fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken)) case launcherconfig.DashboardTokenSourceEnv: - fmt.Printf(" Dashboard token: %s (from PICOCLAW_LAUNCHER_TOKEN)\n", dashboardToken) + fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n") case launcherconfig.DashboardTokenSourceConfig: - fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath) + fmt.Printf(" Dashboard password: configured in %s\n", launcherPath) } fmt.Println() } switch dashboardTokenSource { case launcherconfig.DashboardTokenSourceEnv: - logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") + logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN") case launcherconfig.DashboardTokenSourceConfig: - logger.InfoC("web", fmt.Sprintf("Dashboard token: configured in %s", launcherPath)) + logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath)) case launcherconfig.DashboardTokenSourceRandom: if !enableConsole { - logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) + logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken)) } } diff --git a/web/backend/main_test.go b/web/backend/main_test.go index f69705179..82bf12b40 100644 --- a/web/backend/main_test.go +++ b/web/backend/main_test.go @@ -67,3 +67,31 @@ func TestDashboardTokenConfigHelpPath(t *testing.T) { }) } } + +func TestMaskSecret(t *testing.T) { + tests := []struct { + input string + want string + }{ + // Long token (>=12 chars): first 3 + 10 stars + last 4 + {"sdhjflsjdflksdf", "sdh**********ksdf"}, + {"abcdefghijklmnopqrstuvwxyz", "abc**********wxyz"}, + // Exactly 12 chars (3+4+5 hidden): suffix shown + {"abcdefghijkl", "abc**********ijkl"}, + // 8 chars (minimum password length): suffix NOT shown — only prefix+stars + {"abcdefgh", "abc**********"}, + // 11 chars (one below threshold): suffix NOT shown + {"abcdefghijk", "abc**********"}, + // 4..3 chars: prefix shown, no suffix + {"abcdefg", "abc**********"}, + {"abcd", "abc**********"}, + // <=3 chars: fully masked + {"abc", "**********"}, + {"", "**********"}, + } + for _, tt := range tests { + if got := maskSecret(tt.input); got != tt.want { + t.Errorf("maskSecret(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/web/backend/middleware/launcher_dashboard_auth.go b/web/backend/middleware/launcher_dashboard_auth.go index 7e92fca22..c1c4c19c6 100644 --- a/web/backend/middleware/launcher_dashboard_auth.go +++ b/web/backend/middleware/launcher_dashboard_auth.go @@ -173,6 +173,8 @@ func isPublicLauncherDashboardPath(method, p string) bool { return method == http.MethodPost case "/api/auth/status": return method == http.MethodGet + case "/api/auth/setup": + return method == http.MethodPost } return false } @@ -183,7 +185,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool { if method != http.MethodGet && method != http.MethodHead { return false } - if p == "/launcher-login" { + if p == "/launcher-login" || p == "/launcher-setup" { return true } if strings.HasPrefix(p, "/assets/") {