Files
picoclaw/web/backend/api/auth_test.go
T
sky5454 06023c79fa feat(launcher): standard HTTP login/setup/logout flow for dashboard, frontend and backend impl. and fix windows pid lock for ws (#2339)
* 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.

* fix(auth): replace wastedassign ok := false with var ok bool

* refactor(tray): remove copy-token clipboard feature

Dashboard login now uses standard web auth (bcrypt + session cookie).
The system tray 'Copy dashboard token' menu item is no longer needed.

- Delete tray_offers_copy.go and tray_offers_copy_stub.go
- Remove mCopyTok menu item and clipboard handler from systray.go
- Remove launcherDashboardTokenForClipboard var from main.go
- Remove MenuCopyToken/MenuCopyTokenHint keys from i18n.go

* feat(launcher-ui): standard HTTP login/setup/logout flow for dashboard

Replaces the previous "find token in logs" workflow with a proper
browser-based authentication UI backed by the new /api/auth/* endpoints.

### New pages
- /launcher-setup: first-run password initialization form (password +
  confirm, min 8 chars); calls POST /api/auth/setup; redirects to login
  on success
- /launcher-login: standard password login form; calls POST /api/auth/login;
  sets HttpOnly session cookie on success

### Session guard (src/routes/__root.tsx)
A useEffect on every non-auth page load calls GET /api/auth/status:
- initialized=false  -> redirect to /launcher-setup
- authenticated=false -> redirect to /launcher-login
This ensures the setup/login UI is shown even when the ?token= URL
mechanism auto-logs in (first-run case).

### Logout button (src/components/app-header.tsx)
IconLogout button added to the header with a confirm AlertDialog;
calls POST /api/auth/logout then redirects to /launcher-login.

### API layer
- src/api/launcher-auth.ts: LauncherAuthStatus gains initialized bool;
  postLauncherDashboardSetup() added; LauncherAuthTokenHelp removed
- src/api/http.ts: 401 guard uses isLauncherAuthPathname() (covers both
  /launcher-login and /launcher-setup) to prevent redirect loops
- src/lib/launcher-login-path.ts: isLauncherSetupPathname() and
  isLauncherAuthPathname() added

### Routing
- src/routeTree.gen.ts: /launcher-setup route registered throughout
- src/routes/launcher-login.tsx: tokenHelp UI removed; useEffect added
  to redirect to setup when initialized=false

### i18n
- en.json / zh.json: launcherSetup block added; launcherLogin keys
  updated to use passwordLabel/passwordPlaceholder

* fix(lint): ts lint fixed 1

* fix(auth): detail auth error handle

* fix(login):  frontend web auth error handle

* fix(frontend): auth error handler 5xx
2026-04-08 21:43:51 +08:00

206 lines
6.5 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/web/backend/middleware"
)
func TestLauncherAuthLoginAndStatus(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 0x55
}
const tok = "dashboard-test-token-9"
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
})
t.Run("status_unauthenticated", func(t *testing.T) {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/status", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
Initialized bool `json:"initialized"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.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(`{"password":"`+tok+`"}`))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "127.0.0.1:12345"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("login code = %d body=%s", rec.Code, rec.Body.String())
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != middleware.LauncherDashboardCookieName {
t.Fatalf("cookies = %#v", cookies)
}
})
t.Run("status_authenticated", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/auth/status", nil)
req.AddCookie(&http.Cookie{Name: middleware.LauncherDashboardCookieName, Value: sess})
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status code = %d", rec.Code)
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"authenticated":true`)) {
t.Fatalf("body = %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "token_help") {
t.Fatalf("authenticated response should omit token_help: %s", 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,
})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/logout", nil))
if rec.Code != http.StatusMethodNotAllowed && rec.Code != http.StatusNotFound {
t.Fatalf("GET logout: code = %d (expected 404 or 405)", rec.Code)
}
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
mux.ServeHTTP(rec2, req2)
if rec2.Code != http.StatusUnsupportedMediaType {
t.Fatalf("wrong content-type: code = %d body=%s", rec2.Code, rec2.Body.String())
}
rec3 := httptest.NewRecorder()
req3 := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}`))
req3.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("POST json logout: code = %d", rec3.Code)
}
}
func TestLauncherAuthLoginRateLimit(t *testing.T) {
key := make([]byte, 32)
const tok = "rate-limit-tok-xxxxxxxx"
sess := middleware.SessionCookieValue(key, tok)
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: tok,
SessionCookie: sess,
})
// 11 failing logins by wrong token; each consumes allow() slot after valid JSON.
wrongBody := `{"password":"wrong"}`
for i := 0; i < loginAttemptsPerIP; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.5.5:9999"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("iter %d: want 401 got %d %s", i, rec.Code, rec.Body.String())
}
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(wrongBody))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.168.5.5:9999"
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("11th attempt: want 429 got %d %s", rec.Code, rec.Body.String())
}
}
func TestLoginRateLimiterWindow(t *testing.T) {
l := newLoginRateLimiter()
t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
l.now = func() time.Time { return t0 }
for i := 0; i < loginAttemptsPerIP; i++ {
if !l.allow("ip") {
t.Fatalf("want allow at %d", i)
}
}
if l.allow("ip") {
t.Fatal("want deny on 11th")
}
l.now = func() time.Time { return t0.Add(loginAttemptWindow + time.Second) }
if !l.allow("ip") {
t.Fatal("want allow after window")
}
}
func TestReferrerPolicyMiddleware(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
h := middleware.ReferrerPolicyNoReferrer(next)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if got := rec.Header().Get("Referrer-Policy"); got != "no-referrer" {
t.Fatalf("Referrer-Policy = %q", got)
}
}
func TestLauncherAuthLogoutEmptyBody(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.Header.Set("Content-Type", "application/json")
req.Body = http.NoBody
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("code = %d", rec.Code)
}
}
func TestLauncherAuthLogoutRejectsTrailingJSON(t *testing.T) {
key := make([]byte, 32)
sess := middleware.SessionCookieValue(key, "tok")
mux := http.NewServeMux()
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
DashboardToken: "tok",
SessionCookie: sess,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", strings.NewReader(`{}{}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d %s", rec.Code, rec.Body.String())
}
}