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.
This commit is contained in:
sky5454 2026-04-04 06:20:29 +08:00
parent d8c5183d9a
commit d2924bc5ac
7 changed files with 304 additions and 66 deletions

View file

@ -1,6 +1,7 @@
package api package api
import ( import (
"context"
"crypto/subtle" "crypto/subtle"
"encoding/json" "encoding/json"
"io" "io"
@ -10,34 +11,43 @@ import (
"github.com/sipeed/picoclaw/web/backend/middleware" "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 { 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 DashboardToken string
SessionCookie string SessionCookie string
SecureCookie func(*http.Request) bool SecureCookie func(*http.Request) bool
// TokenHelp is returned on unauthenticated /api/auth/status responses (no secrets). // PasswordStore enables bcrypt-backed password persistence. When non-nil and
TokenHelp LauncherAuthTokenHelp // initialized, web-form login verifies against the stored hash instead of
} // the plaintext DashboardToken.
PasswordStore PasswordStore
// 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"`
} }
type launcherAuthLoginBody struct { 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 { type launcherAuthStatusResponse struct {
Authenticated bool `json:"authenticated"` Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help,omitempty"` 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) { func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts) {
secure := opts.SecureCookie secure := opts.SecureCookie
if secure == nil { if secure == nil {
@ -47,22 +57,32 @@ func RegisterLauncherAuthRoutes(mux *http.ServeMux, opts LauncherAuthRouteOpts)
token: opts.DashboardToken, token: opts.DashboardToken,
sessionCookie: opts.SessionCookie, sessionCookie: opts.SessionCookie,
secureCookie: secure, secureCookie: secure,
tokenHelp: opts.TokenHelp, store: opts.PasswordStore,
loginLimit: newLoginRateLimiter(), loginLimit: newLoginRateLimiter(),
} }
mux.HandleFunc("POST /api/auth/login", h.handleLogin) mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout) mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("GET /api/auth/status", h.handleStatus) mux.HandleFunc("GET /api/auth/status", h.handleStatus)
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
} }
type launcherAuthHandlers struct { type launcherAuthHandlers struct {
token string token string
sessionCookie string sessionCookie string
secureCookie func(*http.Request) bool secureCookie func(*http.Request) bool
tokenHelp LauncherAuthTokenHelp store PasswordStore
loginLimit *loginRateLimiter 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) { func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
var body launcherAuthLoginBody 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"}`)) _, _ = w.Write([]byte(`{"error":"too many login attempts"}`))
return return
} }
in := strings.TrimSpace(body.Token) in := strings.TrimSpace(body.Password)
if len(in) != len(h.token) || subtle.ConstantTimeCompare([]byte(in), []byte(h.token)) != 1 { 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.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"invalid token"}`)) _, _ = w.Write([]byte(`{"error":"invalid password"}`))
return 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) { func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
ok := false authed := false
if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil { if c, err := r.Cookie(middleware.LauncherDashboardCookieName); err == nil {
ok = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1 authed = subtle.ConstantTimeCompare([]byte(c.Value), []byte(h.sessionCookie)) == 1
}
if ok {
_, _ = w.Write([]byte(`{"authenticated":true}`))
return
} }
resp := launcherAuthStatusResponse{ resp := launcherAuthStatusResponse{
Authenticated: false, Authenticated: authed,
TokenHelp: &h.tokenHelp, Initialized: h.isStoreInitialized(r.Context()),
} }
enc, err := json.Marshal(resp) enc, err := json.Marshal(resp)
if err != nil { if err != nil {
@ -141,3 +174,66 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
} }
_, _ = w.Write(enc) _, _ = 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"}`))
}

View file

@ -23,12 +23,6 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok, DashboardToken: tok,
SessionCookie: sess, SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: "/tmp/launcher.log",
TrayCopyMenu: true,
ConsoleStdout: false,
},
}) })
t.Run("status_unauthenticated", func(t *testing.T) { t.Run("status_unauthenticated", func(t *testing.T) {
@ -38,23 +32,20 @@ func TestLauncherAuthLoginAndStatus(t *testing.T) {
t.Fatalf("status code = %d", rec.Code) t.Fatalf("status code = %d", rec.Code)
} }
var body struct { var body struct {
Authenticated bool `json:"authenticated"` Authenticated bool `json:"authenticated"`
TokenHelp *LauncherAuthTokenHelp `json:"token_help"` Initialized bool `json:"initialized"`
} }
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if body.Authenticated || body.TokenHelp == nil { if body.Authenticated {
t.Fatalf("unexpected body: %+v", body) t.Fatalf("unexpected authenticated=true: %+v", body)
}
if body.TokenHelp.EnvVarName != "PICOCLAW_LAUNCHER_TOKEN" || body.TokenHelp.LogFileAbs != "/tmp/launcher.log" {
t.Fatalf("token_help = %+v", body.TokenHelp)
} }
}) })
t.Run("login_ok", func(t *testing.T) { t.Run("login_ok", func(t *testing.T) {
rec := httptest.NewRecorder() 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.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345" req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req) mux.ServeHTTP(rec, req)
@ -91,7 +82,6 @@ func TestLauncherAuthLogoutRequiresPostAndJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok", DashboardToken: "tok",
SessionCookie: sess, SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "PICOCLAW_LAUNCHER_TOKEN"},
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -125,11 +115,10 @@ func TestLauncherAuthLoginRateLimit(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok, DashboardToken: tok,
SessionCookie: sess, SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
}) })
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON. // 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++ { for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody)) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
@ -187,7 +176,6 @@ func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok", DashboardToken: "tok",
SessionCookie: sess, SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
@ -206,7 +194,6 @@ func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{ RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok", DashboardToken: "tok",
SessionCookie: sess, SessionCookie: sess,
TokenHelp: LauncherAuthTokenHelp{EnvVarName: "X"},
}) })
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`)) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))

View file

@ -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`
)

View file

@ -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
}

View file

@ -27,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/api" "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/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/middleware"
"github.com/sipeed/picoclaw/web/backend/utils" "github.com/sipeed/picoclaw/web/backend/utils"
@ -66,6 +67,24 @@ func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, la
return launcherPath 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() { func main() {
port := flag.String("port", "18800", "Port to listen on") 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") 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) dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
launcherDashboardTokenForClipboard = 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 // Determine listen address
var addr string var addr string
if effectivePublic { if effectivePublic {
@ -222,20 +250,10 @@ func main() {
// Initialize Server components // Initialize Server components
mux := http.NewServeMux() mux := http.NewServeMux()
tokenLogFileAbs := ""
if fileLoggingEnabled {
tokenLogFileAbs = filepath.Join(picoHome, logPath, logFile)
}
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{ api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
DashboardToken: dashboardToken, DashboardToken: dashboardToken,
SessionCookie: dashboardSessionCookie, SessionCookie: dashboardSessionCookie,
TokenHelp: api.LauncherAuthTokenHelp{ PasswordStore: authStore,
EnvVarName: "PICOCLAW_LAUNCHER_TOKEN",
LogFileAbs: tokenLogFileAbs,
ConfigFileAbs: dashboardTokenConfigHelpPath(dashboardTokenSource, launcherPath),
TrayCopyMenu: trayOffersDashboardTokenCopy(),
ConsoleStdout: enableConsole,
},
}) })
// API Routes (e.g. /api/status) // API Routes (e.g. /api/status)
@ -284,23 +302,23 @@ func main() {
fmt.Println() fmt.Println()
switch dashboardTokenSource { switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceRandom: 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: 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: case launcherconfig.DashboardTokenSourceConfig:
fmt.Printf(" Dashboard token: %s (from %s)\n", dashboardToken, launcherPath) fmt.Printf(" Dashboard password: configured in %s\n", launcherPath)
} }
fmt.Println() fmt.Println()
} }
switch dashboardTokenSource { switch dashboardTokenSource {
case launcherconfig.DashboardTokenSourceEnv: case launcherconfig.DashboardTokenSourceEnv:
logger.InfoC("web", "Dashboard token: environment PICOCLAW_LAUNCHER_TOKEN") logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN")
case launcherconfig.DashboardTokenSourceConfig: 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: case launcherconfig.DashboardTokenSourceRandom:
if !enableConsole { if !enableConsole {
logger.InfoC("web", "Dashboard token (this run): "+dashboardToken) logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken))
} }
} }

View file

@ -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)
}
}
}

View file

@ -173,6 +173,8 @@ func isPublicLauncherDashboardPath(method, p string) bool {
return method == http.MethodPost return method == http.MethodPost
case "/api/auth/status": case "/api/auth/status":
return method == http.MethodGet return method == http.MethodGet
case "/api/auth/setup":
return method == http.MethodPost
} }
return false return false
} }
@ -183,7 +185,7 @@ func isPublicLauncherDashboardStatic(method, p string) bool {
if method != http.MethodGet && method != http.MethodHead { if method != http.MethodGet && method != http.MethodHead {
return false return false
} }
if p == "/launcher-login" { if p == "/launcher-login" || p == "/launcher-setup" {
return true return true
} }
if strings.HasPrefix(p, "/assets/") { if strings.HasPrefix(p, "/assets/") {