spaxel/mothership/internal/auth/handler.go
jedarden f99dc15a2d feat: complete crowd flow visualization implementation
- Fix Viz3D exports to include flow visualization functions
- Export setFlowLayerVisible, setDwellLayerVisible, setCorridorLayerVisible
- Export setFlowTimeFilter, setFlowData, setDwellData, setCorridorData
- Remove duplicate setDwellLayerVisible function definition

This completes the crowd flow visualization feature that was
already implemented in the backend (flow.go) and frontend
(crowdflow.js, viz3d.js) but had missing exports in the Viz3D module.
2026-04-11 07:27:21 -04:00

696 lines
20 KiB
Go

// Package auth provides PIN-based authentication and session management for the dashboard.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Handler handles authentication endpoints.
type Handler struct {
db *sql.DB
secretKey []byte // for session token signing
mothershipID string // cached mothership ID
}
// Config holds handler configuration.
type Config struct {
DB *sql.DB
SecretKey []byte
}
// NewHandler creates a new auth handler.
func NewHandler(cfg Config) (*Handler, error) {
if cfg.DB == nil {
return nil, fmt.Errorf("database is required")
}
// Generate random secret key if not provided
secretKey := cfg.SecretKey
if len(secretKey) == 0 {
secretKey = make([]byte, 32)
if _, err := rand.Read(secretKey); err != nil {
return nil, fmt.Errorf("generate secret key: %w", err)
}
}
h := &Handler{
db: cfg.DB,
secretKey: secretKey,
}
// Initialize auth schema and install secret
if err := h.initializeAuth(); err != nil {
return nil, fmt.Errorf("initialize auth: %w", err)
}
// Start session cleanup goroutine
go h.cleanupExpiredSessions()
return h, nil
}
// initializeAuth ensures the auth table has a singleton row and generates an install secret.
// On first run, prints the secret to stdout exactly once.
func (h *Handler) initializeAuth() error {
// Check if auth table exists and has a row
var count int
err := h.db.QueryRow("SELECT COUNT(*) FROM auth").Scan(&count)
if err != nil {
// Table might not exist yet, create it
_, err = h.db.Exec(`
CREATE TABLE IF NOT EXISTS auth (
id INTEGER PRIMARY KEY CHECK (id = 1),
install_secret BLOB NOT NULL,
pin_bcrypt TEXT,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
)
`)
if err != nil {
return fmt.Errorf("create auth table: %w", err)
}
}
// Create sessions table if it doesn't exist
_, err = h.db.Exec(`
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000),
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
)
`)
if err != nil {
return fmt.Errorf("create sessions table: %w", err)
}
// Create index on expires_at for efficient cleanup
_, err = h.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)
`)
if err != nil {
return fmt.Errorf("create sessions index: %w", err)
}
// Step 1: Check SPAXEL_INSTALL_SECRET env var — if set, use it directly
if envSecret := os.Getenv("SPAXEL_INSTALL_SECRET"); envSecret != "" {
secretBytes, err := hex.DecodeString(envSecret)
if err != nil {
return fmt.Errorf("decode SPAXEL_INSTALL_SECRET: %w", err)
}
if len(secretBytes) != 32 {
return fmt.Errorf("SPAXEL_INSTALL_SECRET must be 64 hex chars (32 bytes), got %d bytes", len(secretBytes))
}
// Upsert into auth table
_, err = h.db.Exec(`
INSERT OR REPLACE INTO auth (id, install_secret, pin_bcrypt, updated_at)
VALUES (1, ?, COALESCE((SELECT pin_bcrypt FROM auth WHERE id = 1), NULL), strftime('%s', 'now') * 1000)
`, secretBytes)
if err != nil {
return fmt.Errorf("store env install secret: %w", err)
}
log.Printf("[INFO] Using provided SPAXEL_INSTALL_SECRET")
return nil
}
// Step 2: Check if we already have an auth row with install_secret
err = h.db.QueryRow("SELECT COUNT(*) FROM auth WHERE id = 1").Scan(&count)
if err != nil {
return fmt.Errorf("check auth row: %w", err)
}
if count > 0 {
// Secret already exists in SQLite — load silently
log.Printf("[DEBUG] Install secret loaded from database")
return nil
}
// Step 3: No env var, no existing secret — generate a new one
installSecret := make([]byte, 32)
if _, err := rand.Read(installSecret); err != nil {
return fmt.Errorf("generate install secret: %w", err)
}
// Insert auth row
_, err = h.db.Exec(`
INSERT INTO auth (id, install_secret, pin_bcrypt)
VALUES (1, ?, NULL)
`, installSecret)
if err != nil {
return fmt.Errorf("insert auth row: %w", err)
}
// Print ONCE to stdout
secretHex := hex.EncodeToString(installSecret)
fmt.Fprintf(os.Stdout, "[SPAXEL] Installation secret: %s. Shown once — save to a safe place.\n", secretHex)
return nil
}
// RegisterRoutes registers auth routes with the given router.
func (h *Handler) RegisterRoutes(mux interface{ HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) }) {
mux.HandleFunc("GET /api/auth/status", h.handleStatus)
mux.HandleFunc("GET /api/auth/install-secret", h.handleInstallSecret)
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
mux.HandleFunc("POST /api/auth/change-pin", h.RequireAuth(h.handleChangePIN))
}
// handleStatus returns whether a PIN is configured.
// No authentication required.
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
var pinBcrypt sql.NullString
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to check PIN status: %v", err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{
"pin_configured": pinBcrypt.Valid,
})
}
// handleInstallSecret returns the installation secret hex.
// Requires admin session (authenticated) OR first-run state (no PIN configured).
func (h *Handler) handleInstallSecret(w http.ResponseWriter, r *http.Request) {
// Allow access on first-run (no PIN configured) OR with valid session
var pinBcrypt sql.NullString
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if pinBcrypt.Valid && !h.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var secret []byte
err = h.db.QueryRow("SELECT install_secret FROM auth WHERE id = 1").Scan(&secret)
if err != nil {
http.Error(w, "Failed to retrieve install secret", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to retrieve install secret: %v", err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"install_secret": hex.EncodeToString(secret),
})
}
// handleSetup sets a PIN on first run.
// No authentication required, but only works if PIN is not yet set.
func (h *Handler) handleSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Check if PIN is already configured
var pinBcrypt sql.NullString
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
if pinBcrypt.Valid {
http.Error(w, "PIN already configured", http.StatusConflict)
return
}
// Parse request
var req struct {
PIN string `json:"pin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Validate PIN
if len(req.PIN) < 4 || len(req.PIN) > 8 {
http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest)
return
}
// Ensure PIN is numeric
for _, c := range req.PIN {
if c < '0' || c > '9' {
http.Error(w, "PIN must contain only digits", http.StatusBadRequest)
return
}
}
// Hash PIN with bcrypt (cost 12)
hash, err := bcrypt.GenerateFromPassword([]byte(req.PIN), 12)
if err != nil {
http.Error(w, "Failed to hash PIN", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to hash PIN: %v", err)
return
}
// Store hash
_, err = h.db.Exec(`
UPDATE auth
SET pin_bcrypt = ?, updated_at = ?
WHERE id = 1
`, hash, time.Now().UnixMilli())
if err != nil {
http.Error(w, "Failed to store PIN", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to store PIN: %v", err)
return
}
log.Printf("[INFO] PIN configured successfully")
// Create session and set cookie
sessionID, err := h.createSession()
if err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to create session: %v", err)
return
}
h.setSessionCookie(w, sessionID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// handleLogin authenticates a user with their PIN.
// No authentication required.
func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse request
var req struct {
PIN string `json:"pin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Get stored PIN hash
var pinHash string
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinHash)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "PIN not configured", http.StatusNotFound)
} else {
http.Error(w, "Database error", http.StatusInternalServerError)
}
return
}
if pinHash == "" {
http.Error(w, "PIN not configured", http.StatusNotFound)
return
}
// Verify PIN
if err := bcrypt.CompareHashAndPassword([]byte(pinHash), []byte(req.PIN)); err != nil {
// Invalid PIN
http.Error(w, "Invalid PIN", http.StatusUnauthorized)
log.Printf("[WARN] Failed login attempt from %s", r.RemoteAddr)
return
}
// Create session
sessionID, err := h.createSession()
if err != nil {
http.Error(w, "Failed to create session", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to create session: %v", err)
return
}
h.setSessionCookie(w, sessionID)
log.Printf("[INFO] Successful login from %s", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// handleLogout clears the session cookie and deletes the session.
// Authentication required.
func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get session ID from cookie
cookie, err := r.Cookie("spaxel_session")
if err == nil && cookie.Value != "" {
// Delete session from database
_, _ = h.db.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value)
}
// Clear cookie by setting max-age to -1
http.SetCookie(w, &http.Cookie{
Name: "spaxel_session",
Value: "",
MaxAge: -1,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
log.Printf("[INFO] Logout from %s", r.RemoteAddr)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// handleChangePIN changes the user's PIN.
// Requires valid session (authenticated).
func (h *Handler) handleChangePIN(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse request
var req struct {
OldPIN string `json:"old_pin"`
NewPIN string `json:"new_pin"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Get current PIN hash
var currentHash string
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&currentHash)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to get current PIN: %v", err)
return
}
if currentHash == "" {
http.Error(w, "PIN not configured", http.StatusNotFound)
return
}
// Verify old PIN matches current hash
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPIN)); err != nil {
// Old PIN doesn't match
http.Error(w, "Incorrect current PIN", http.StatusForbidden)
log.Printf("[WARN] Failed PIN change attempt from %s: incorrect old PIN", r.RemoteAddr)
return
}
// Validate new PIN
if len(req.NewPIN) < 4 || len(req.NewPIN) > 8 {
http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest)
return
}
// Ensure new PIN is numeric
for _, c := range req.NewPIN {
if c < '0' || c > '9' {
http.Error(w, "PIN must contain only digits", http.StatusBadRequest)
return
}
}
// Hash new PIN with bcrypt (cost 12)
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPIN), 12)
if err != nil {
http.Error(w, "Failed to hash PIN", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to hash new PIN: %v", err)
return
}
// Update PIN in database
_, err = h.db.Exec(`
UPDATE auth
SET pin_bcrypt = ?, updated_at = ?
WHERE id = 1
`, newHash, time.Now().UnixMilli())
if err != nil {
http.Error(w, "Failed to update PIN", http.StatusInternalServerError)
log.Printf("[ERROR] Failed to update PIN: %v", err)
return
}
log.Printf("[INFO] PIN changed successfully from %s", r.RemoteAddr)
// Note: Existing sessions remain valid after PIN change
// (session tokens are independent of PIN)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
}
// createSession creates a new session and returns the session ID.
func (h *Handler) createSession() (string, error) {
// Generate 32-byte random session ID (64 hex chars)
sessionBytes := make([]byte, 32)
if _, err := rand.Read(sessionBytes); err != nil {
return "", fmt.Errorf("generate session ID: %w", err)
}
sessionID := hex.EncodeToString(sessionBytes)
// Calculate expiry (7 days from now)
expiresAt := time.Now().Add(7 * 24 * time.Hour).UnixMilli()
// Insert session
_, err := h.db.Exec(`
INSERT INTO sessions (session_id, created_at, expires_at, last_seen_at)
VALUES (?, ?, ?, ?)
`, sessionID, time.Now().UnixMilli(), expiresAt, time.Now().UnixMilli())
if err != nil {
return "", fmt.Errorf("insert session: %w", err)
}
return sessionID, nil
}
// setSessionCookie sets the session cookie on the response.
func (h *Handler) setSessionCookie(w http.ResponseWriter, sessionID string) {
// Detect if we're using HTTPS
isSecure := false // In production, check r.TLS != nil or X-Forwarded-Proto
http.SetCookie(w, &http.Cookie{
Name: "spaxel_session",
Value: sessionID,
MaxAge: 604800, // 7 days in seconds
Path: "/",
HttpOnly: true,
Secure: isSecure,
SameSite: http.SameSiteStrictMode,
})
}
// ValidateSession checks if a session is valid and extends it if near expiry.
// Returns the session ID if valid, empty string otherwise.
func (h *Handler) ValidateSession(r *http.Request) string {
cookie, err := r.Cookie("spaxel_session")
if err != nil || cookie.Value == "" {
return ""
}
sessionID := cookie.Value
// Check if session exists and is valid
var expiresAt int64
err = h.db.QueryRow(`
SELECT expires_at FROM sessions WHERE session_id = ?
`, sessionID).Scan(&expiresAt)
if err != nil {
if err != sql.ErrNoRows {
log.Printf("[ERROR] Failed to validate session: %v", err)
}
return ""
}
// Check if expired
now := time.Now().UnixMilli()
if now > expiresAt {
return ""
}
// Rolling session extension: if within 24h of expiry, extend by 7 days
if expiresAt-now < 24*60*60*1000 {
newExpiresAt := now + 7*24*60*60*1000
_, err = h.db.Exec(`
UPDATE sessions
SET expires_at = ?, last_seen_at = ?
WHERE session_id = ?
`, newExpiresAt, now, sessionID)
if err != nil {
log.Printf("[WARN] Failed to extend session: %v", err)
}
} else {
// Just update last_seen_at
_, err = h.db.Exec(`
UPDATE sessions SET last_seen_at = ? WHERE session_id = ?
`, now, sessionID)
if err != nil {
log.Printf("[WARN] Failed to update last_seen_at: %v", err)
}
}
return sessionID
}
// IsAuthenticated checks if the request is authenticated.
func (h *Handler) IsAuthenticated(r *http.Request) bool {
return h.ValidateSession(r) != ""
}
// RequireAuth is middleware that requires authentication.
// Returns 401 if not authenticated.
func (h *Handler) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !h.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
// RequireAuthHandler wraps a standard http.Handler with authentication.
func (h *Handler) RequireAuthHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !h.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// cleanupExpiredSessions runs periodically to delete expired sessions.
func (h *Handler) cleanupExpiredSessions() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
result, err := h.db.Exec(`
DELETE FROM sessions WHERE expires_at < ?
`, time.Now().UnixMilli())
if err != nil {
log.Printf("[ERROR] Failed to cleanup expired sessions: %v", err)
continue
}
if rowsAffected, _ := result.RowsAffected(); rowsAffected > 0 {
log.Printf("[INFO] Cleaned up %d expired sessions", rowsAffected)
}
}
}
// Close cleans up resources.
func (h *Handler) Close() error {
// Nothing to clean up currently
return nil
}
// GetInstallSecret retrieves the installation secret.
func (h *Handler) GetInstallSecret() ([]byte, error) {
var secret []byte
err := h.db.QueryRow("SELECT install_secret FROM auth WHERE id = 1").Scan(&secret)
if err != nil {
return nil, fmt.Errorf("get install secret: %w", err)
}
return secret, nil
}
// DeriveNodeToken derives a node token from the install secret and node MAC.
// Uses HMAC-SHA256(install_secret, mac) for secure token derivation.
func (h *Handler) DeriveNodeToken(mac string) (string, error) {
secret, err := h.GetInstallSecret()
if err != nil {
return "", err
}
// Normalize MAC to uppercase without colons
mac = strings.ToUpper(strings.ReplaceAll(mac, ":", ""))
// Compute HMAC-SHA256(install_secret, mac)
macHash := hmac.New(sha256.New, secret)
macHash.Write([]byte(mac))
return hex.EncodeToString(macHash.Sum(nil)), nil
}
// ValidateNodeToken checks if a node token is valid.
// Returns true if the token matches the expected HMAC-SHA256(install_secret, mac).
func (h *Handler) ValidateNodeToken(mac, token string) bool {
secret, err := h.GetInstallSecret()
if err != nil {
log.Printf("[ERROR] Failed to get install secret for token validation: %v", err)
return false
}
// Normalize MAC to uppercase without colons
mac = strings.ToUpper(strings.ReplaceAll(mac, ":", ""))
// Compute expected token
macHash := hmac.New(sha256.New, secret)
macHash.Write([]byte(mac))
expectedToken := hex.EncodeToString(macHash.Sum(nil))
// Use constant-time comparison to prevent timing attacks
return subtle.ConstantTimeCompare([]byte(expectedToken), []byte(token)) == 1
}
// GetInstallSecretForNodes returns the install secret for use by node validation.
// This is used by the ingestion server to validate node tokens.
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 {
publicPaths := []string{
"/healthz",
"/api/auth/status",
"/api/auth/setup",
"/api/auth/login",
"/api/provision",
}
for _, pp := range publicPaths {
if path == pp {
return true
}
}
// Firmware is served without auth (URL contains SHA256 for integrity)
if len(path) > 10 && path[:10] == "/firmware/" {
return true
}
return false
}