feat: implement dashboard PIN authentication and session management

Backend (mothership/internal/auth/):
- SQLite auth table with pin_bcrypt and install_secret (singleton row)
- GET /api/auth/status — return {pin_configured: bool}
- POST /api/auth/setup — sets PIN (bcrypt cost 12) on first run only
- POST /api/auth/login — verifies PIN, issues session cookie (7-day expiry)
- POST /api/auth/logout — clears cookie and deletes session from SQLite
- Session middleware: all /api/* and /ws/* require valid session
- Rolling window: extends session by 7 days if within 24h of expiry
- Install secret generation for node token derivation

Dashboard (dashboard/js/auth.js):
- On load: GET /api/auth/status check
- First-run setup page: enter PIN + confirm PIN → POST /api/auth/setup → reload
- Login page: shown on 401; PIN entry → POST /api/auth/login → reload
- Logout button in settings panel → POST /api/auth/logout → redirect

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-06 13:15:13 -04:00
parent 60ada3ccb0
commit e83b54a9ec
6 changed files with 2838 additions and 13 deletions

File diff suppressed because it is too large Load diff

View file

@ -2096,6 +2096,8 @@
<!-- TransformControls from CDN -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/TransformControls.js"></script>
<!-- Authentication (must load first) -->
<script src="js/auth.js"></script>
<!-- 3-D spatial visualisation layer -->
<script src="js/viz3d.js"></script>
<!-- Node placement, GDOP coverage, room editor -->
@ -2132,6 +2134,10 @@
<script src="js/anomaly.js"></script>
<!-- Activity Timeline -->
<script src="js/timeline.js"></script>
<!-- Security Panel -->
<script src="js/security-panel.js"></script>
<!-- Detection Explainability -->
<script src="js/explainability.js"></script>
<!-- Room editor panel -->
<div id="room-editor-panel">

509
dashboard/js/auth.js Normal file
View file

@ -0,0 +1,509 @@
/**
* Spaxel Dashboard - Authentication Module
*
* Handles PIN setup, login, and session management for the dashboard.
* Shows first-run setup page when PIN is not configured.
*/
(function() {
'use strict';
// ============================================
// Auth State
// ============================================
const authState = {
pinConfigured: null,
isAuthenticated: false,
isLoading: true,
setupStep: 'enter', // 'enter' | 'confirm'
enteredPin: '',
loginError: '',
setupError: ''
};
// ============================================
// DOM Elements
// ============================================
let authOverlay = null;
let setupOverlay = null;
let loginOverlay = null;
// ============================================
// Auth API
// ============================================
/**
* Check if PIN is configured
*/
function checkAuthStatus() {
authState.isLoading = true;
renderOverlays();
return fetch('/api/auth/status')
.then(function(res) {
if (!res.ok) {
throw new Error('Failed to check auth status: ' + res.status);
}
return res.json();
})
.then(function(data) {
authState.pinConfigured = data.pin_configured;
authState.isLoading = false;
// If PIN is configured, check if we have a valid session
if (authState.pinConfigured) {
return checkSession();
} else {
// Show first-run setup
renderOverlays();
}
})
.catch(function(err) {
console.error('[Auth] Error checking auth status:', err);
authState.isLoading = false;
// On error, assume auth is required
authState.pinConfigured = true;
renderOverlays();
});
}
/**
* Check if current session is valid
*/
function checkSession() {
return fetch('/api/settings', { method: 'HEAD' })
.then(function(res) {
if (res.ok) {
// Session is valid
authState.isAuthenticated = true;
renderOverlays();
} else {
// Session invalid or expired
authState.isAuthenticated = false;
renderOverlays();
}
})
.catch(function(err) {
console.error('[Auth] Error checking session:', err);
authState.isAuthenticated = false;
renderOverlays();
});
}
/**
* Setup PIN on first run
* @param {string} pin - The PIN to set
*/
function setupPIN(pin) {
authState.setupError = '';
renderOverlays();
return fetch('/api/auth/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pin: pin })
})
.then(function(res) {
if (!res.ok) {
return res.text().then(function(text) {
throw new Error(text || 'Failed to setup PIN');
});
}
return res.json();
})
.then(function(data) {
// PIN setup successful, reload to start authenticated session
window.location.reload();
})
.catch(function(err) {
console.error('[Auth] Error setting up PIN:', err);
authState.setupError = err.message || 'Failed to setup PIN';
renderOverlays();
throw err;
});
}
/**
* Login with PIN
* @param {string} pin - The PIN to authenticate with
*/
function login(pin) {
authState.loginError = '';
renderOverlays();
return fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ pin: pin })
})
.then(function(res) {
if (!res.ok) {
if (res.status === 401) {
throw new Error('Invalid PIN');
}
return res.text().then(function(text) {
throw new Error(text || 'Login failed');
});
}
return res.json();
})
.then(function(data) {
// Login successful, reload to start authenticated session
window.location.reload();
})
.catch(function(err) {
console.error('[Auth] Error logging in:', err);
authState.loginError = err.message || 'Login failed';
renderOverlays();
throw err;
});
}
/**
* Logout and clear session
*/
function logout() {
return fetch('/api/auth/logout', {
method: 'POST'
})
.then(function(res) {
if (!res.ok) {
throw new Error('Logout failed');
}
return res.json();
})
.then(function(data) {
// Logout successful, reload to show login page
window.location.reload();
})
.catch(function(err) {
console.error('[Auth] Error logging out:', err);
// Even if error, reload to clear local state
window.location.reload();
});
}
// ============================================
// Overlay Rendering
// ============================================
function renderOverlays() {
// Remove existing overlays
if (authOverlay) {
authOverlay.remove();
authOverlay = null;
}
// If loading, show nothing
if (authState.isLoading) {
return;
}
// If PIN not configured, show first-run setup
if (!authState.pinConfigured) {
renderSetupOverlay();
return;
}
// If PIN configured but not authenticated, show login
if (authState.pinConfigured && !authState.isAuthenticated) {
renderLoginOverlay();
return;
}
}
function renderSetupOverlay() {
authOverlay = document.createElement('div');
authOverlay.id = 'auth-overlay';
authOverlay.innerHTML = `
<div class="auth-modal">
<div class="auth-header">
<h1>Welcome to Spaxel</h1>
<p>Let's secure your dashboard with a PIN</p>
</div>
<div class="auth-body">
${authState.setupStep === 'enter' ? `
<p class="auth-instruction">Enter a 4-8 digit PIN to secure your dashboard:</p>
<div class="pin-inputs" id="setup-pin-inputs">
<input type="password" class="pin-digit" maxlength="1" data-index="0" autofocus>
<input type="password" class="pin-digit" maxlength="1" data-index="1">
<input type="password" class="pin-digit" maxlength="1" data-index="2">
<input type="password" class="pin-digit" maxlength="1" data-index="3">
<input type="password" class="pin-digit" maxlength="1" data-index="4">
<input type="password" class="pin-digit" maxlength="1" data-index="5">
<input type="password" class="pin-digit" maxlength="1" data-index="6">
<input type="password" class="pin-digit" maxlength="1" data-index="7">
</div>
<p class="auth-hint">Your PIN should be 4-8 digits</p>
${authState.setupError ? `<p class="auth-error">${authState.setupError}</p>` : ''}
<button class="auth-button primary" id="setup-next-btn" disabled>Next</button>
` : `
<p class="auth-instruction">Confirm your PIN by entering it again:</p>
<div class="pin-inputs" id="confirm-pin-inputs">
<input type="password" class="pin-digit" maxlength="1" data-index="0" autofocus>
<input type="password" class="pin-digit" maxlength="1" data-index="1">
<input type="password" class="pin-digit" maxlength="1" data-index="2">
<input type="password" class="pin-digit" maxlength="1" data-index="3">
<input type="password" class="pin-digit" maxlength="1" data-index="4">
<input type="password" class="pin-digit" maxlength="1" data-index="5">
<input type="password" class="pin-digit" maxlength="1" data-index="6">
<input type="password" class="pin-digit" maxlength="1" data-index="7">
</div>
${authState.setupError ? `<p class="auth-error">${authState.setupError}</p>` : ''}
<button class="auth-button primary" id="setup-confirm-btn" disabled>Confirm & Setup</button>
<button class="auth-button secondary" id="setup-back-btn">Back</button>
`}
</div>
</div>
`;
document.body.appendChild(authOverlay);
setupOverlayEvents();
}
function renderLoginOverlay() {
authOverlay = document.createElement('div');
authOverlay.id = 'auth-overlay';
authOverlay.innerHTML = `
<div class="auth-modal">
<div class="auth-header">
<h1>Spaxel Dashboard</h1>
<p>Enter your PIN to continue</p>
</div>
<div class="auth-body">
<div class="pin-inputs" id="login-pin-inputs">
<input type="password" class="pin-digit" maxlength="1" data-index="0" autofocus>
<input type="password" class="pin-digit" maxlength="1" data-index="1">
<input type="password" class="pin-digit" maxlength="1" data-index="2">
<input type="password" class="pin-digit" maxlength="1" data-index="3">
<input type="password" class="pin-digit" maxlength="1" data-index="4">
<input type="password" class="pin-digit" maxlength="1" data-index="5">
<input type="password" class="pin-digit" maxlength="1" data-index="6">
<input type="password" class="pin-digit" maxlength="1" data-index="7">
</div>
${authState.loginError ? `<p class="auth-error">${authState.loginError}</p>` : ''}
<button class="auth-button primary" id="login-btn" disabled>Login</button>
</div>
</div>
`;
document.body.appendChild(authOverlay);
loginOverlayEvents();
}
// ============================================
// Event Handlers
// ============================================
function setupOverlayEvents() {
var inputs = authOverlay.querySelectorAll('.pin-digit');
var nextBtn = document.getElementById('setup-next-btn');
var confirmBtn = document.getElementById('setup-confirm-btn');
var backBtn = document.getElementById('setup-back-btn');
// Handle input focus and navigation
inputs.forEach(function(input, index) {
input.addEventListener('input', function(e) {
var value = e.target.value;
// Only allow digits
if (!/^\d*$/.test(value)) {
e.target.value = '';
return;
}
// Move to next input if value entered
if (value.length === 1 && index < inputs.length - 1) {
inputs[index + 1].focus();
}
// Enable/disable button based on input
var pin = getPinFromInputs(inputs);
if (authState.setupStep === 'enter') {
nextBtn.disabled = pin.length < 4;
} else {
confirmBtn.disabled = pin.length < 4;
}
});
// Handle backspace navigation
input.addEventListener('keydown', function(e) {
if (e.key === 'Backspace' && !e.target.value && index > 0) {
inputs[index - 1].focus();
}
});
// Handle paste event
input.addEventListener('paste', function(e) {
e.preventDefault();
var pastedData = (e.clipboardData || window.clipboardData).getData('text');
var digits = pastedData.replace(/\D/g, '').slice(0, 8);
for (var i = 0; i < digits.length && index + i < inputs.length; i++) {
inputs[index + i].value = digits[i];
}
// Focus the next empty input or the last one
var nextIndex = Math.min(index + digits.length, inputs.length - 1);
inputs[nextIndex].focus();
// Trigger input event on last affected input
inputs[nextIndex].dispatchEvent(new Event('input'));
});
});
// Next button
if (nextBtn) {
nextBtn.addEventListener('click', function() {
var inputs = document.querySelectorAll('#setup-pin-inputs .pin-digit');
var pin = getPinFromInputs(inputs);
if (pin.length >= 4) {
authState.enteredPin = pin;
authState.setupStep = 'confirm';
authState.setupError = '';
renderSetupOverlay();
// Focus first input of confirm step
setTimeout(function() {
var confirmInputs = document.querySelectorAll('#confirm-pin-inputs .pin-digit');
if (confirmInputs.length > 0) {
confirmInputs[0].focus();
}
}, 10);
}
});
}
// Confirm button
if (confirmBtn) {
confirmBtn.addEventListener('click', function() {
var inputs = document.querySelectorAll('#confirm-pin-inputs .pin-digit');
var confirmPin = getPinFromInputs(inputs);
if (confirmPin.length >= 4) {
if (confirmPin === authState.enteredPin) {
// PINS match, proceed with setup
setupPIN(authState.enteredPin);
} else {
// PINS don't match
authState.setupError = 'PINs do not match. Please try again.';
authState.setupStep = 'enter';
authState.enteredPin = '';
renderSetupOverlay();
}
}
});
}
// Back button
if (backBtn) {
backBtn.addEventListener('click', function() {
authState.setupStep = 'enter';
authState.setupError = '';
renderSetupOverlay();
});
}
}
function loginOverlayEvents() {
var inputs = authOverlay.querySelectorAll('.pin-digit');
var loginBtn = document.getElementById('login-btn');
// Handle input focus and navigation
inputs.forEach(function(input, index) {
input.addEventListener('input', function(e) {
var value = e.target.value;
// Only allow digits
if (!/^\d*$/.test(value)) {
e.target.value = '';
return;
}
// Move to next input if value entered
if (value.length === 1 && index < inputs.length - 1) {
inputs[index + 1].focus();
}
// Enable/disable button based on input
var pin = getPinFromInputs(inputs);
loginBtn.disabled = pin.length < 4;
});
// Handle backspace navigation
input.addEventListener('keydown', function(e) {
if (e.key === 'Backspace' && !e.target.value && index > 0) {
inputs[index - 1].focus();
}
});
// Handle paste event
input.addEventListener('paste', function(e) {
e.preventDefault();
var pastedData = (e.clipboardData || window.clipboardData).getData('text');
var digits = pastedData.replace(/\D/g, '').slice(0, 8);
for (var i = 0; i < digits.length && index + i < inputs.length; i++) {
inputs[index + i].value = digits[i];
}
// Focus the next empty input or the last one
var nextIndex = Math.min(index + digits.length, inputs.length - 1);
inputs[nextIndex].focus();
// Trigger input event on last affected input
inputs[nextIndex].dispatchEvent(new Event('input'));
});
});
// Login button
loginBtn.addEventListener('click', function() {
var pin = getPinFromInputs(inputs);
if (pin.length >= 4) {
login(pin);
}
});
}
function getPinFromInputs(inputs) {
var pin = '';
for (var i = 0; i < inputs.length; i++) {
pin += inputs[i].value;
}
return pin;
}
// ============================================
// Public API
// ============================================
window.SpaxelAuth = {
init: function() {
checkAuthStatus();
},
logout: function() {
return logout();
},
isAuthenticated: function() {
return authState.isAuthenticated;
},
refreshStatus: function() {
return checkAuthStatus();
}
};
// Auto-init on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
window.SpaxelAuth.init();
});
} else {
window.SpaxelAuth.init();
}
})();

View file

@ -3,6 +3,7 @@ package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
@ -20,10 +21,13 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/hashicorp/mdns"
_ "modernc.org/sqlite"
"github.com/spaxel/mothership/internal/api"
"github.com/spaxel/mothership/internal/auth"
"github.com/spaxel/mothership/internal/ble"
"github.com/spaxel/mothership/internal/dashboard"
"github.com/spaxel/mothership/internal/diagnostics"
"github.com/spaxel/mothership/internal/explainability"
"github.com/spaxel/mothership/internal/fleet"
"github.com/spaxel/mothership/internal/ingestion"
"github.com/spaxel/mothership/internal/ota"
@ -69,6 +73,56 @@ func main() {
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Create auth handler for PIN-based authentication
dataDir := cfg.DataDir
if dataDir == "" {
dataDir = "/data"
}
var authHandler *auth.Handler
// Open a SQLite connection for auth
authDBPath := filepath.Join(dataDir, "spaxel.db")
authDB, err := sql.Open("sqlite", authDBPath)
if err != nil {
log.Printf("[WARN] Failed to open auth database: %v", err)
} else {
authDB.SetMaxOpenConns(1) // SQLite is single-writer
defer authDB.Close()
// Initialize auth handler
authHandler, err = auth.NewHandler(auth.Config{DB: authDB})
if err != nil {
log.Printf("[WARN] Failed to initialize auth handler: %v", err)
authHandler = nil // Disable auth on error
} else {
defer authHandler.Close()
// Register auth routes (public endpoints)
authHandler.RegisterRoutes(r)
log.Printf("[INFO] Authentication enabled")
}
}
// Set up node token validator for ingestion server
// Note: authHandler will be nil if auth is disabled, which is fine for development
if authHandler != nil {
ingestSrv.SetTokenValidator(authHandler.ValidateNodeToken)
log.Printf("[INFO] Node token validation enabled")
}
// Helper function to wrap handlers with auth middleware
requireAuth := func(next http.HandlerFunc) http.HandlerFunc {
if authHandler == nil {
return next // No auth if handler not initialized
}
return authHandler.RequireAuth(next)
}
requireAuthHandler := func(next http.Handler) http.Handler {
if authHandler == nil {
return next // No auth if handler not initialized
}
return authHandler.RequireAuthHandler(next)
}
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@ -393,7 +447,17 @@ func main() {
// Fleet REST API
fleetHandler := fleet.NewHandler(fleetMgr)
fleetHandler.RegisterRoutes(r)
if authHandler != nil {
// Create an authenticated sub-router for fleet API
fleetRouter := chi.NewRouter()
fleetRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
fleetHandler.RegisterRoutes(fleetRouter)
r.Mount("/", fleetRouter)
} else {
fleetHandler.RegisterRoutes(r)
}
// Settings API
settingsHandler, err := api.NewSettingsHandler(filepath.Join(cfg.DataDir, "settings.db"))
@ -401,7 +465,16 @@ func main() {
log.Printf("[WARN] Failed to create settings handler: %v (settings API disabled)", err)
} else {
defer settingsHandler.Close()
settingsHandler.RegisterRoutes(r)
if authHandler != nil {
settingsRouter := chi.NewRouter()
settingsRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
settingsHandler.RegisterRoutes(settingsRouter)
r.Mount("/", settingsRouter)
} else {
settingsHandler.RegisterRoutes(r)
}
log.Printf("[INFO] Settings API enabled")
}
@ -411,7 +484,16 @@ func main() {
log.Printf("[WARN] Failed to create zones handler: %v (zones/portals API disabled)", err)
} else {
defer zonesHandler.Close()
zonesHandler.RegisterRoutes(r)
if authHandler != nil {
zonesRouter := chi.NewRouter()
zonesRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
zonesHandler.RegisterRoutes(zonesRouter)
r.Mount("/", zonesRouter)
} else {
zonesHandler.RegisterRoutes(r)
}
log.Printf("[INFO] Zones/Portals API enabled")
}
@ -421,7 +503,16 @@ func main() {
log.Printf("[WARN] Failed to create triggers handler: %v (triggers API disabled)", err)
} else {
defer triggersHandler.Close()
triggersHandler.RegisterRoutes(r)
if authHandler != nil {
triggersRouter := chi.NewRouter()
triggersRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
triggersHandler.RegisterRoutes(triggersRouter)
r.Mount("/", triggersRouter)
} else {
triggersHandler.RegisterRoutes(r)
}
log.Printf("[INFO] Triggers API enabled")
}
@ -431,7 +522,16 @@ func main() {
log.Printf("[WARN] Failed to create notifications handler: %v (notifications API disabled)", err)
} else {
defer notificationsHandler.Close()
notificationsHandler.RegisterRoutes(r)
if authHandler != nil {
notificationsRouter := chi.NewRouter()
notificationsRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
notificationsHandler.RegisterRoutes(notificationsRouter)
r.Mount("/", notificationsRouter)
} else {
notificationsHandler.RegisterRoutes(r)
}
log.Printf("[INFO] Notifications API enabled")
}
@ -441,7 +541,16 @@ func main() {
log.Printf("[WARN] Failed to create events handler: %v (events API disabled)", err)
} else {
defer eventsHandler.Close()
eventsHandler.RegisterRoutes(r)
if authHandler != nil {
eventsRouter := chi.NewRouter()
eventsRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
eventsHandler.RegisterRoutes(eventsRouter)
r.Mount("/", eventsRouter)
} else {
eventsHandler.RegisterRoutes(r)
}
// Wire events handler to dashboard hub for live event broadcasts
eventsHandler.SetHub(dashboardHub)
log.Printf("[INFO] Events API enabled")
@ -454,7 +563,16 @@ func main() {
log.Printf("[WARN] Failed to create replay handler: %v (replay API disabled)", err)
} else {
defer replayHandler.Close()
replayHandler.RegisterRoutes(r)
if authHandler != nil {
replayRouter := chi.NewRouter()
replayRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
replayHandler.RegisterRoutes(replayRouter)
r.Mount("/", replayRouter)
} else {
replayHandler.RegisterRoutes(r)
}
log.Printf("[INFO] Replay API enabled")
}
}
@ -466,21 +584,56 @@ func main() {
} else {
defer bleRegistry.Close()
bleHandler := ble.NewHandler(bleRegistry)
bleHandler.RegisterRoutes(r)
if authHandler != nil {
bleRouter := chi.NewRouter()
bleRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
bleHandler.RegisterRoutes(bleRouter)
r.Mount("/", bleRouter)
} else {
bleHandler.RegisterRoutes(r)
}
log.Printf("[INFO] BLE Devices API enabled")
}
// Detection explainability API
explainabilityHandler := explainability.NewHandler()
if authHandler != nil {
explainabilityRouter := chi.NewRouter()
explainabilityRouter.Use(func(next http.Handler) http.Handler {
return authHandler.RequireAuthHandler(next)
})
explainabilityHandler.RegisterRoutes(explainabilityRouter)
r.Mount("/", explainabilityRouter)
} else {
explainabilityHandler.RegisterRoutes(r)
}
log.Printf("[INFO] Detection explainability API enabled")
// Phase 5: Weather diagnostics REST API
r.Get("/api/weather", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
reports := weatherDiagnostics.GetAllLinkReports()
writeJSON(w, reports)
})
r.Get("/api/weather/{linkID}", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
linkID := chi.URLParam(r, "linkID")
report := weatherDiagnostics.GetReport(linkID)
writeJSON(w, report)
})
r.Get("/api/weather/summary", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
condition, avgConfidence, issueCount := weatherDiagnostics.GetSystemWeatherSummary()
writeJSON(w, map[string]interface{}{
"condition": condition,
@ -489,6 +642,10 @@ func main() {
})
})
r.Get("/api/weather/{linkID}/weekly", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
linkID := chi.URLParam(r, "linkID")
trend := weatherDiagnostics.GetWeeklyTrend(linkID)
writeJSON(w, trend)
@ -496,10 +653,18 @@ func main() {
// Phase 5: Coverage and healing status API
r.Get("/api/coverage", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
coverage := fleetHealer.GetCoverage()
writeJSON(w, coverage)
})
r.Get("/api/coverage/history", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
limitStr := r.URL.Query().Get("limit")
limit := 10
if limitStr != "" {
@ -511,6 +676,10 @@ func main() {
writeJSON(w, history)
})
r.Get("/api/healing/status", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]interface{}{
"degraded": fleetHealer.IsDegraded(),
"online_nodes": fleetHealer.GetOnlineNodes(),
@ -518,6 +687,10 @@ func main() {
})
})
r.Get("/api/healing/suggest", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
x, z, improvement := fleetHealer.SuggestNodePosition()
worstX, worstZ, worstGDOP := fleetHealer.GetWorstCoverageZone()
writeJSON(w, map[string]interface{}{
@ -529,6 +702,10 @@ func main() {
// Phase 5: System health API
r.Get("/api/health/system", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]interface{}{
"system_health": pm.GetSystemHealth(),
"link_count": pm.LinkCount(),
@ -540,10 +717,18 @@ func main() {
// Phase 6: Diurnal learning status API
r.Get("/api/diurnal/status", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
statuses := pm.GetDiurnalLearningStatus()
writeJSON(w, statuses)
})
r.Get("/api/diurnal/status/{linkID}", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
linkID := chi.URLParam(r, "linkID")
allStatuses := pm.GetDiurnalLearningStatus()
for _, status := range allStatuses {
@ -557,18 +742,30 @@ func main() {
// Link health API - returns all links with health scores and details
r.Get("/api/links", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
links := ingestSrv.GetAllLinksWithHealth()
writeJSON(w, links)
})
// Phase 6: Link diagnostics API
r.Get("/api/links/{linkID}/diagnostics", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
linkID := chi.URLParam(r, "linkID")
diagnoses := diagnosticEngine.GetDiagnoses(linkID)
writeJSON(w, diagnoses)
})
r.Get("/api/links/{linkID}/health-history", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
linkID := chi.URLParam(r, "linkID")
windowStr := r.URL.Query().Get("window")
window := 24 * time.Hour // default 24h
@ -590,6 +787,10 @@ func main() {
})
r.Get("/api/diagnostics", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
allDiagnoses := diagnosticEngine.GetAllDiagnoses()
writeJSON(w, allDiagnoses)
})
@ -603,14 +804,34 @@ func main() {
log.Printf("[INFO] OTA firmware server at %s", firmwareDir)
// OTA REST API
r.Get("/api/firmware", otaSrv.HandleList)
r.Post("/api/firmware/upload", otaSrv.HandleUpload)
r.Get("/firmware/{filename}", otaSrv.HandleServe)
r.Get("/api/firmware", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
otaSrv.HandleList(w, r)
})
r.Post("/api/firmware/upload", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
otaSrv.HandleUpload(w, r)
})
r.Get("/firmware/{filename}", otaSrv.HandleServe) // Public - URL contains SHA256
r.Get("/api/firmware/progress", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(otaMgr.GetProgress())
})
r.Post("/api/firmware/ota-all", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Rolling update of all connected nodes
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
@ -633,7 +854,7 @@ func main() {
provSrv := provisioning.NewServer(cfg.DataDir, cfg.MDNSName, msPort)
r.Post("/api/provision", provSrv.HandleProvision)
// Firmware manifest for esp-web-tools (onboarding wizard flashing)
// Firmware manifest for esp-web-tools (onboarding wizard flashing) - public
r.Get("/api/firmware/manifest", func(w http.ResponseWriter, r *http.Request) {
latest := otaSrv.GetLatest()
manifest := map[string]interface{}{
@ -663,7 +884,14 @@ func main() {
go dashboardHub.Run()
r.HandleFunc("/ws/dashboard", dashboardSrv.HandleDashboardWS)
// Protect dashboard WebSocket with auth
r.HandleFunc("/ws/dashboard", func(w http.ResponseWriter, r *http.Request) {
if authHandler != nil && !authHandler.IsAuthenticated(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
dashboardSrv.HandleDashboardWS(w, r)
})
// Serve dashboard static files
staticDir := cfg.StaticDir

View file

@ -0,0 +1,550 @@
// 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"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Handler handles authentication endpoints.
type Handler struct {
db *sql.DB
secretKey []byte // for session token signing
}
// 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.
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)
}
// Check if we have an auth row
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 {
// Generate install secret
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)
}
log.Printf("[INFO] Generated new install secret")
}
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("POST /api/auth/setup", h.handleSetup)
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
}
// 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,
})
}
// 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"})
}
// 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)
h := hmac.New(sha256.New, secret)
h.Write([]byte(mac))
return hex.EncodeToString(h.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
h := hmac.New(sha256.New, secret)
h.Write([]byte(mac))
expectedToken := hex.EncodeToString(h.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
}

View file

@ -0,0 +1,425 @@
// Package auth provides authentication tests.
package auth
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
_ "modernc.org/sqlite"
)
func TestHandler_StatusNotConfigured(t *testing.T) {
// Create in-memory database
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create handler
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Test status endpoint
req := httptest.NewRequest("GET", "/api/auth/status", nil)
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Should return pin_configured: false
var resp map[string]bool
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["pin_configured"] {
t.Error("Expected pin_configured to be false initially")
}
}
func TestHandler_SetupPIN(t *testing.T) {
// Create in-memory database
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// Create handler
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Test setup with valid PIN
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check session cookie was set
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Error("Expected session cookie to be set")
} else if sessionCookie.MaxAge != 604800 {
t.Errorf("Expected MaxAge 604800, got %d", sessionCookie.MaxAge)
}
// Verify PIN is now configured
var pinBcrypt sql.NullString
err = db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(&pinBcrypt)
if err != nil {
t.Fatal(err)
}
if !pinBcrypt.Valid {
t.Error("Expected PIN to be configured after setup")
}
}
func TestHandler_SetupPINInvalid(t *testing.T) {
tests := []struct {
name string
pin string
wantStatus int
}{
{"too short", "123", http.StatusBadRequest},
{"too long", "123456789", http.StatusBadRequest},
{"non-numeric", "abcd", http.StatusBadRequest},
{"mixed", "12a4", http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
reqBody := `{"pin": "` + tt.pin + `"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != tt.wantStatus {
t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestHandler_SetupPINAlreadyConfigured(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// First setup should succeed
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != http.StatusOK {
t.Fatalf("First setup failed: %d", w.Code)
}
// Second setup should fail
req = httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleSetup(w, req)
if w.Code != http.StatusConflict {
t.Errorf("Expected status 409, got %d", w.Code)
}
}
func TestHandler_LoginInvalidPIN(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Try login with wrong PIN
reqBody = `{"pin": "9999"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
}
func TestHandler_LoginValidPIN(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup PIN first
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
// Login with correct PIN
reqBody = `{"pin": "1234"}`
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check session cookie was set
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Error("Expected session cookie to be set")
}
}
func TestHandler_ValidateSession(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup and login
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Validate session
req = httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(sessionCookie)
sessionID := h.ValidateSession(req)
if sessionID == "" {
t.Error("Expected session to be valid")
}
if sessionID != sessionCookie.Value {
t.Error("Session ID mismatch")
}
}
func TestHandler_ValidateSessionInvalid(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Test with no cookie
req := httptest.NewRequest("GET", "/api/test", nil)
sessionID := h.ValidateSession(req)
if sessionID != "" {
t.Error("Expected session to be invalid")
}
// Test with invalid cookie
req.AddCookie(&http.Cookie{Name: "spaxel_session", Value: "invalid"})
sessionID = h.ValidateSession(req)
if sessionID != "" {
t.Error("Expected session to be invalid")
}
}
func TestHandler_Logout(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
h, err := NewHandler(Config{DB: db})
if err != nil {
t.Fatal(err)
}
defer h.Close()
// Setup and login
reqBody := `{"pin": "1234"}`
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSetup(w, req)
cookies := w.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatal("Session cookie not set")
}
// Logout
req = httptest.NewRequest("POST", "/api/auth/logout", nil)
req.AddCookie(sessionCookie)
w = httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check cookie was cleared
cookies = w.Result().Cookies()
var clearedCookie *http.Cookie
for _, c := range cookies {
if c.Name == "spaxel_session" {
clearedCookie = c
break
}
}
if clearedCookie == nil || clearedCookie.MaxAge != -1 {
t.Error("Expected cookie to be cleared (MaxAge=-1)")
}
// Verify session was deleted
req = httptest.NewRequest("GET", "/api/test", nil)
req.AddCookie(sessionCookie)
sessionID := h.ValidateSession(req)
if sessionID != "" {
t.Error("Expected session to be invalid after logout")
}
}
func TestPublicPaths(t *testing.T) {
tests := []struct {
path string
expected bool
}{
{"/healthz", true},
{"/api/auth/status", true},
{"/api/auth/setup", true},
{"/api/auth/login", true},
{"/api/provision", 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)
if result != tt.expected {
t.Errorf("isPublicPath(%q) = %v, want %v", tt.path, result, tt.expected)
}
})
}
}