picoclaw/web/backend/api/auth_login_limiter.go
zeed zhao 6ea364e67d
feat(web): protect launcher dashboard with token and SPA login (#1953)
Add token-based authentication for the Launcher's embedded Web Dashboard.

- Ephemeral token generated in-memory each run (or via PICOCLAW_LAUNCHER_TOKEN env var)
- HMAC-SHA256 session cookie (HttpOnly, SameSite=Lax, Secure when HTTPS)
- Bearer token support for API/script access
- Rate limiting on login (10 attempts/IP/min)
- Referrer-Policy: no-referrer on all responses
- POST-only logout with JSON content-type (CSRF-safe)
- System tray "Copy dashboard token" action
- Login page shows contextual help (console/tray/log file path)
- Path traversal protection via path.Clean
- X-Forwarded-Host/Port/Proto support for reverse proxy deployments
- Full i18n support (English, Chinese)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 13:11:43 +08:00

59 lines
1.1 KiB
Go

package api
import (
"net"
"net/http"
"strings"
"sync"
"time"
)
const (
loginAttemptsPerIP = 10
loginAttemptWindow = time.Minute
logoutBodyMaxBytes = 4096
)
// loginRateLimiter limits POST /api/auth/login attempts per IP per minute.
type loginRateLimiter struct {
mu sync.Mutex
now func() time.Time
byIP map[string][]time.Time
}
func newLoginRateLimiter() *loginRateLimiter {
return &loginRateLimiter{
now: time.Now,
byIP: make(map[string][]time.Time),
}
}
// allow reserves a slot for this request; false means rate limit exceeded.
func (l *loginRateLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
cutoff := now.Add(-loginAttemptWindow)
times := l.byIP[ip]
var kept []time.Time
for _, ts := range times {
if ts.After(cutoff) {
kept = append(kept, ts)
}
}
if len(kept) >= loginAttemptsPerIP {
l.byIP[ip] = kept
return false
}
kept = append(kept, now)
l.byIP[ip] = kept
return true
}
func clientIPForLimiter(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}