fix(auth): enforce PIN authentication server-side on all API/WS routes

The PIN overlay was client-side only — deleting the DOM element bypassed
auth entirely. Add global chi middleware that returns 401 on protected
endpoints when no valid session cookie is present. Static files pass
through so the login page renders. During onboarding (no PIN set), all
routes remain open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-13 10:50:05 -04:00
parent b8aa830329
commit 3cd10f6f07
3 changed files with 53 additions and 7 deletions

View file

@ -475,7 +475,8 @@ func main() {
}
defer authHandler.Close()
authHandler.RegisterRoutes(&handleFuncAdapter{router: r})
log.Printf("[INFO] Auth handler registered at /api/auth/*")
r.Use(authHandler.Middleware)
log.Printf("[INFO] Auth handler registered at /api/auth/* (server-side enforcement enabled)")
// Create load shedder — single source of truth for load shedding state
shedder := loadshed.New()

View file

@ -671,14 +671,16 @@ func (h *Handler) GetInstallSecretForNodes() ([]byte, error) {
return h.GetInstallSecret()
}
// Helper function to check if a path should be excluded from auth
func isPublicPath(path string) bool {
// IsPublicPath checks if a path should be excluded from auth.
func IsPublicPath(path string) bool {
publicPaths := []string{
"/healthz",
"/api/auth/status",
"/api/auth/setup",
"/api/auth/login",
"/api/auth/logout",
"/api/provision",
"/ws/node",
}
for _, pp := range publicPaths {
@ -688,9 +690,51 @@ func isPublicPath(path string) bool {
}
// Firmware is served without auth (URL contains SHA256 for integrity)
if len(path) > 10 && path[:10] == "/firmware/" {
if strings.HasPrefix(path, "/firmware/") {
return true
}
return false
}
// IsPINConfigured returns true if a PIN has been set.
func (h *Handler) IsPINConfigured() bool {
var pinBcrypt sql.NullString
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
return err == nil && pinBcrypt.Valid
}
// Middleware returns chi-compatible middleware that enforces auth on API and
// WebSocket routes. Static files pass through so the login page can render.
// During onboarding (no PIN configured), all requests pass through.
func (h *Handler) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
if IsPublicPath(path) {
next.ServeHTTP(w, r)
return
}
// Static files and HTML pages pass through so the login UI renders
if !strings.HasPrefix(path, "/api/") && !strings.HasPrefix(path, "/ws/") {
next.ServeHTTP(w, r)
return
}
// During onboarding (no PIN set), allow everything
if !h.IsPINConfigured() {
next.ServeHTTP(w, r)
return
}
if !h.IsAuthenticated(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "authentication required"})
return
}
next.ServeHTTP(w, r)
})
}

View file

@ -407,17 +407,18 @@ func TestPublicPaths(t *testing.T) {
{"/api/auth/status", true},
{"/api/auth/setup", true},
{"/api/auth/login", true},
{"/api/auth/logout", true},
{"/api/provision", true},
{"/ws/node", true},
{"/firmware/spaxel-1.0.0.bin", true},
{"/api/settings", false},
{"/api/nodes", false},
{"/ws/dashboard", false},
{"/ws/node", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
result := isPublicPath(tt.path)
result := IsPublicPath(tt.path)
if result != tt.expected {
t.Errorf("isPublicPath(%q) = %v, want %v", tt.path, result, tt.expected)
}
@ -888,7 +889,7 @@ func TestHandler_ChangePIN_Unauthenticated(t *testing.T) {
// Try to change PIN without authentication (no cookie)
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
req := httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()