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:
parent
60ada3ccb0
commit
e83b54a9ec
6 changed files with 2838 additions and 13 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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
509
dashboard/js/auth.js
Normal 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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
550
mothership/internal/auth/handler.go
Normal file
550
mothership/internal/auth/handler.go
Normal 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
|
||||
}
|
||||
425
mothership/internal/auth/handler_test.go
Normal file
425
mothership/internal/auth/handler_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue