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:
parent
d8c5183d9a
commit
d2924bc5ac
7 changed files with 304 additions and 66 deletions
|
|
@ -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"}`))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(`{}{}`))
|
||||||
|
|
|
||||||
24
web/backend/dashboardauth/sql.go
Normal file
24
web/backend/dashboardauth/sql.go
Normal 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`
|
||||||
|
)
|
||||||
83
web/backend/dashboardauth/store.go
Normal file
83
web/backend/dashboardauth/store.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/") {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue