Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Ran gofmt across the entire mothership codebase to ensure consistent code formatting per Go standards. All tests pass after formatting.
797 lines
23 KiB
Go
797 lines
23 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) //nolint:errcheck // stdout write
|
|
|
|
return nil
|
|
}
|
|
|
|
// RegisterRoutes registers auth routes with the given router.
|
|
// The router must support Get/Post methods (e.g., chi.Router).
|
|
func (h *Handler) RegisterRoutes(r interface{}) {
|
|
// Type assertion for chi.Router
|
|
type chiRouter interface {
|
|
Get(pattern string, handlerFn http.HandlerFunc)
|
|
Post(pattern string, handlerFn http.HandlerFunc)
|
|
}
|
|
router, ok := r.(chiRouter)
|
|
if !ok {
|
|
// Fallback to HandleFunc interface (older pattern)
|
|
type handleFuncRouter interface {
|
|
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
|
|
}
|
|
if hfRouter, ok := r.(handleFuncRouter); ok {
|
|
hfRouter.HandleFunc("GET /api/auth/status", h.handleStatus)
|
|
hfRouter.HandleFunc("GET /api/auth/install-secret", h.handleInstallSecret)
|
|
hfRouter.HandleFunc("POST /api/auth/setup", h.handleSetup)
|
|
hfRouter.HandleFunc("POST /api/auth/login", h.handleLogin)
|
|
hfRouter.HandleFunc("POST /api/auth/logout", h.handleLogout)
|
|
hfRouter.HandleFunc("POST /api/auth/change-pin", h.RequireAuth(h.handleChangePIN))
|
|
return
|
|
}
|
|
log.Printf("[WARN] Auth handler: unsupported router type")
|
|
return
|
|
}
|
|
|
|
router.Get("/api/auth/status", h.handleStatus)
|
|
router.Get("/api/auth/install-secret", h.handleInstallSecret)
|
|
router.Post("/api/auth/setup", h.handleSetup)
|
|
router.Post("/api/auth/login", h.handleLogin)
|
|
router.Post("/api/auth/logout", h.handleLogout)
|
|
router.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{ //nolint:errcheck // HTTP response
|
|
"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{ //nolint:errcheck // HTTP response
|
|
"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"}) //nolint:errcheck // HTTP response
|
|
}
|
|
|
|
// 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"}) //nolint:errcheck // HTTP response
|
|
}
|
|
|
|
// 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"}) //nolint:errcheck // HTTP response
|
|
}
|
|
|
|
// 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(¤tHash)
|
|
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"}) //nolint:errcheck // HTTP response
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// 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 {
|
|
if path == pp {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Firmware is served without auth (URL contains SHA256 for integrity)
|
|
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
|
|
}
|
|
|
|
// isStaticAsset returns true for CSS, JS, and image files needed by the login page.
|
|
func isStaticAsset(path string) bool {
|
|
return strings.HasPrefix(path, "/js/") ||
|
|
strings.HasPrefix(path, "/css/") ||
|
|
strings.HasPrefix(path, "/images/") ||
|
|
strings.HasPrefix(path, "/favicon")
|
|
}
|
|
|
|
// loginPage is a minimal HTML page containing only the auth overlay.
|
|
// Deleting the overlay reveals a blank page, not the dashboard.
|
|
const loginPage = `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Spaxel</title>
|
|
<link rel="stylesheet" href="/css/panels.css">
|
|
<style>body{margin:0;background:#1a1a2e;color:#fff;font-family:system-ui,sans-serif}</style>
|
|
</head>
|
|
<body>
|
|
<script src="/js/auth.js"></script>
|
|
</body>
|
|
</html>`
|
|
|
|
// Middleware returns chi-compatible middleware that enforces auth on all routes.
|
|
// Static assets (JS/CSS) 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 assets always pass through (needed by login page)
|
|
if isStaticAsset(path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// During onboarding (no PIN set), allow everything
|
|
if !h.IsPINConfigured() {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
if h.IsAuthenticated(r) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Unauthenticated: API/WS get 401, page requests get a login-only page
|
|
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "authentication required"}) //nolint:errcheck // HTTP response
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
fmt.Fprint(w, loginPage)
|
|
})
|
|
}
|