diff --git a/dashboard/css/panels.css b/dashboard/css/panels.css index a0d38b0..0208029 100644 --- a/dashboard/css/panels.css +++ b/dashboard/css/panels.css @@ -692,6 +692,542 @@ font-size: 14px; } +/* ----- Security Panel Components ----- */ + +/* Security Status Indicator */ +#security-status-indicator { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 20px; + cursor: pointer; + transition: background 0.2s; + font-size: 13px; + font-weight: 500; +} + +#security-status-indicator:hover { + background: rgba(255, 255, 255, 0.08); +} + +.security-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #666; + box-shadow: 0 0 8px currentColor; +} + +.security-status-indicator.mode-disarmed .security-status-dot { + background: #888; + box-shadow: 0 0 8px rgba(136, 136, 136, 0.5); +} + +.security-status-indicator.mode-learning .security-status-dot { + background: #ffa726; + box-shadow: 0 0 8px rgba(255, 167, 38, 0.5); +} + +.security-status-indicator.mode-armed .security-status-dot { + background: #66bb6a; + box-shadow: 0 0 8px rgba(102, 187, 106, 0.5); +} + +.security-status-indicator.mode-alert .security-status-dot { + background: #ef5350; + box-shadow: 0 0 8px rgba(239, 83, 80, 0.5); + animation: security-pulse 1s ease-in-out infinite; +} + +@keyframes security-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.security-status-text { + color: #ccc; +} + +.security-status-indicator.mode-alert .security-status-text { + color: #ef5350; +} + +/* Security Dialog Overlay */ +#security-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.3s ease; + pointer-events: none; +} + +#security-dialog-overlay.visible { + background: rgba(0, 0, 0, 0.7); + pointer-events: auto; +} + +#security-dialog { + background: #1e1e3a; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + width: 480px; + max-width: 90vw; + padding: 24px; + opacity: 0; + transform: scale(0.95); + transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +#security-dialog-overlay.visible #security-dialog { + opacity: 1; + transform: scale(1); +} + +#security-dialog h2 { + margin: 0 0 16px; + font-size: 20px; + font-weight: 600; + color: #eee; +} + +#security-dialog p { + margin: 0 0 20px; + font-size: 14px; + color: #ccc; + line-height: 1.5; +} + +#security-dialog .learning-warning { + background: rgba(255, 167, 38, 0.15); + border: 1px solid rgba(255, 167, 38, 0.3); + border-radius: 8px; + padding: 12px; + margin-bottom: 20px; + font-size: 13px; + color: #ffa726; + display: flex; + align-items: center; + gap: 10px; +} + +#security-dialog .learning-warning::before { + content: '⚠'; + font-size: 18px; +} + +#security-dialog-buttons { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +/* Alert Banner */ +#alert-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + background: linear-gradient(135deg, rgba(239, 83, 80, 0.95), rgba(244, 67, 54, 0.95)); + color: white; + padding: 16px 20px; + z-index: 5000; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + box-shadow: 0 4px 20px rgba(239, 83, 80, 0.4); + transform: translateY(-100%); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +#alert-banner.visible { + transform: translateY(0); +} + +.alert-banner-content { + flex: 1; + display: flex; + align-items: center; + gap: 16px; +} + +.alert-banner-icon { + font-size: 24px; + animation: alert-shake 0.5s ease-in-out infinite; +} + +@keyframes alert-shake { + 0%, 100% { transform: rotate(0deg); } + 25% { transform: rotate(-5deg); } + 75% { transform: rotate(5deg); } +} + +.alert-banner-message { + flex: 1; +} + +.alert-banner-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} + +.alert-banner-subtitle { + font-size: 13px; + opacity: 0.9; +} + +.alert-banner-actions { + display: flex; + gap: 10px; +} + +.alert-banner-btn { + padding: 10px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.2s; +} + +.alert-banner-btn-acknowledge { + background: rgba(255, 255, 255, 0.2); + color: white; +} + +.alert-banner-btn-acknowledge:hover { + background: rgba(255, 255, 255, 0.3); +} + +.alert-banner-btn-view { + background: white; + color: #ef5350; +} + +.alert-banner-btn-view:hover { + background: rgba(255, 255, 255, 0.9); +} + +/* Security Card Panel Content */ +.security-card-section { + margin-bottom: 24px; +} + +.security-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.security-card-title { + font-size: 14px; + font-weight: 600; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.security-status-display { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + margin-bottom: 16px; +} + +.security-status-badge { + flex: 1; +} + +.security-status-badge-label { + font-size: 12px; + color: #888; + margin-bottom: 4px; +} + +.security-status-badge-value { + font-size: 18px; + font-weight: 600; + color: #eee; +} + +.security-status-badge.value-disarmed { color: #888; } +.security-status-badge.value-learning { color: #ffa726; } +.security-status-badge.value-armed { color: #66bb6a; } +.security-status-badge.value-alert { color: #ef5350; } + +.security-status-icon { + font-size: 32px; +} + +/* Learning Progress Bar */ +.learning-progress-container { + margin-top: 16px; +} + +.learning-progress-label { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.learning-progress-text { + font-size: 12px; + color: #888; +} + +.learning-progress-percent { + font-size: 13px; + font-weight: 600; + color: #ffa726; +} + +.learning-progress-bar { + height: 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; +} + +.learning-progress-fill { + height: 100%; + background: linear-gradient(90deg, #ffa726, #ff9800); + border-radius: 4px; + transition: width 0.5s ease; + width: 0%; +} + +.learning-ready-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: rgba(102, 187, 106, 0.2); + border-radius: 12px; + font-size: 11px; + font-weight: 600; + color: #66bb6a; + margin-top: 8px; +} + +/* Anomaly Stats Grid */ +.security-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.security-stat-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.security-stat-value { + font-size: 24px; + font-weight: 600; + color: #4fc3f7; +} + +.security-stat-label { + font-size: 11px; + color: #888; + margin-top: 4px; + text-transform: uppercase; +} + +.security-last-event { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; +} + +.security-last-event-label { + font-size: 11px; + color: #888; + margin-bottom: 4px; +} + +.security-last-event-value { + font-size: 13px; + color: #ccc; +} + +.security-last-event-time { + font-size: 11px; + color: #888; + margin-top: 4px; +} + +/* Arm/Disarm Button */ +.security-arm-btn { + width: 100%; + padding: 14px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} + +.security-arm-btn.disarmed { + background: rgba(102, 187, 106, 0.2); + color: #66bb6a; + border: 1px solid rgba(102, 187, 106, 0.3); +} + +.security-arm-btn.disarmed:hover { + background: rgba(102, 187, 106, 0.3); +} + +.security-arm-btn.armed { + background: rgba(244, 67, 54, 0.2); + color: #ef5350; + border: 1px solid rgba(244, 67, 54, 0.3); +} + +.security-arm-btn.armed:hover { + background: rgba(244, 67, 54, 0.3); +} + +.security-arm-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Anomaly Timeline */ +.anomaly-timeline-controls { + display: flex; + gap: 8px; + margin-bottom: 16px; +} + +.anomaly-timeline-filter { + flex: 1; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + color: #ccc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.anomaly-timeline-filter:hover { + background: rgba(255, 255, 255, 0.08); +} + +.anomaly-timeline-filter.active { + background: rgba(79, 195, 247, 0.2); + border-color: rgba(79, 195, 247, 0.4); + color: #4fc3f7; +} + +.anomaly-timeline-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.anomaly-timeline-item { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; + border-left: 3px solid transparent; + cursor: pointer; + transition: background 0.2s; +} + +.anomaly-timeline-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.anomaly-timeline-item.type-unusual_hour { border-left-color: #ffa726; } +.anomaly-timeline-item.type-unknown_ble { border-left-color: #ab47bc; } +.anomaly-timeline-item.type-motion_during_away { border-left-color: #ef5350; } +.anomaly-timeline-item.type-unusual_dwell { border-left-color: #29b6f6; } + +.anomaly-item-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 6px; +} + +.anomaly-item-type { + font-size: 13px; + font-weight: 600; + color: #eee; +} + +.anomaly-item-time { + font-size: 11px; + color: #888; +} + +.anomaly-item-details { + font-size: 12px; + color: #ccc; + line-height: 1.4; +} + +.anomaly-item-status { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + margin-top: 6px; +} + +.anomaly-item-status.active { + background: rgba(239, 83, 80, 0.2); + color: #ef5350; +} + +.anomaly-item-status.acknowledged { + background: rgba(102, 187, 106, 0.2); + color: #66bb6a; +} + +.anomaly-timeline-empty { + text-align: center; + padding: 32px 20px; + color: #666; +} + +.anomaly-timeline-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.anomaly-timeline-empty-text { + font-size: 13px; +} + /* Responsive */ @media (max-width: 600px) { .panel-sidebar { @@ -727,4 +1263,575 @@ padding: 8px 12px; white-space: nowrap; } + + /* Security panel responsive adjustments */ + #security-dialog { + width: 100%; + max-width: 100%; + border-radius: 12px 12px 0 0; + margin: 0 auto; + position: fixed; + bottom: 0; + transform: translateY(100%); + } + + #security-dialog-overlay.visible #security-dialog { + transform: translateY(0); + } + + #alert-banner { + flex-direction: column; + align-items: stretch; + padding: 12px 16px; + } + + .alert-banner-content { + margin-bottom: 12px; + } + + .alert-banner-actions { + justify-content: stretch; + } + + .alert-banner-btn { + flex: 1; + } + + .security-stats-grid { + grid-template-columns: 1fr; + } + + .anomaly-timeline-controls { + flex-wrap: wrap; + } + + .anomaly-timeline-filter { + flex: 1 1 calc(50% - 4px); + } +} + +/* ============================================ + Authentication Overlay Styles + ============================================ */ + +#auth-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.auth-modal { + background: #1e1e3a; + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + width: 100%; + max-width: 440px; + padding: 40px; + text-align: center; +} + +.auth-header h1 { + margin: 0 0 8px; + font-size: 28px; + font-weight: 600; + color: #eee; +} + +.auth-header p { + margin: 0 0 32px; + font-size: 15px; + color: #aaa; +} + +.auth-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.auth-instruction { + font-size: 14px; + color: #ccc; + margin: 0 0 16px; +} + +.auth-hint { + font-size: 12px; + color: #666; + margin: 0; +} + +.auth-error { + background: rgba(239, 83, 80, 0.15); + border: 1px solid rgba(239, 83, 80, 0.3); + border-radius: 6px; + padding: 10px 16px; + font-size: 13px; + color: #ef5350; + margin: 0; +} + +.pin-inputs { + display: flex; + gap: 8px; + justify-content: center; +} + +.pin-digit { + width: 44px; + height: 56px; + background: rgba(255, 255, 255, 0.05); + border: 2px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + color: #eee; + font-size: 24px; + font-weight: 600; + text-align: center; + transition: all 0.2s; +} + +.pin-digit:focus { + outline: none; + border-color: #4fc3f7; + box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15); + background: rgba(255, 255, 255, 0.08); +} + +.pin-digit::placeholder { + color: #444; +} + +.auth-button { + padding: 14px 32px; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; + min-width: 160px; +} + +.auth-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.auth-button.primary { + background: #4fc3f7; + color: #1a1a2e; +} + +.auth-button.primary:hover:not(:disabled) { + background: #29b6f6; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(79, 195, 247, 0.3); +} + +.auth-button.secondary { + background: rgba(255, 255, 255, 0.1); + color: #ccc; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.auth-button.secondary:hover { + background: rgba(255, 255, 255, 0.15); + color: #eee; +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .auth-modal { + padding: 32px 24px; + } + + .auth-header h1 { + font-size: 24px; + } + + .pin-digit { + width: 38px; + height: 48px; + font-size: 20px; + } + + .auth-button { + width: 100%; + min-width: 0; + } +} + +/* ============================================ + Detection Explainability Panel Styles + ============================================ */ + +/* Explainability Loading */ +.explainability-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 40px; + gap: 12px; + color: #888; +} + +.explainability-loading .panel-loading-spinner { + margin-right: 0; +} + +/* Confidence Gauge */ +.explainability-confidence { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + margin-bottom: 20px; +} + +.confidence-gauge { + position: relative; + width: 64px; + height: 64px; + flex-shrink: 0; +} + +.confidence-ring { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.confidence-ring-bg { + fill: none; + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 3; +} + +.confidence-ring-fill { + fill: none; + stroke: #66bb6a; + stroke-width: 3; + stroke-linecap: round; + stroke-dasharray: 0 100; + transition: stroke-dasharray 0.5s ease, stroke 0.3s ease; +} + +.confidence-value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 16px; + font-weight: 600; + color: #eee; +} + +.confidence-label { + font-size: 13px; + color: #aaa; +} + +/* Section Styles */ +.explainability-section { + margin-bottom: 20px; +} + +.section-title { + font-size: 14px; + font-weight: 600; + color: #eee; + margin-bottom: 12px; +} + +.section-header { + font-size: 14px; + font-weight: 600; + color: #888; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.toggle-icon { + font-size: 12px; + color: #888; + transition: transform 0.2s ease; +} + +.explainability-section.collapsed .toggle-icon { + transform: rotate(-90deg); +} + +.section-content { + padding-top: 12px; +} + +/* Links Table */ +.links-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.links-table th { + text-align: left; + padding: 8px 10px; + color: #888; + font-weight: 500; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.links-table td { + padding: 8px 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.links-table tr:last-child td { + border-bottom: none; +} + +.links-table-detailed { + margin-top: 8px; + font-size: 11px; +} + +.link-cell { + font-family: monospace; +} + +.link-id { + color: #4fc3f7; + font-size: 11px; +} + +.deltarms-cell { + font-family: monospace; + color: #ccc; +} + +.zone-cell { + text-align: center; +} + +.zone-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + color: #1a1a2e; +} + +.weight-cell { + font-family: monospace; + color: #aaa; + text-align: right; +} + +.contribution-cell { + font-family: monospace; + color: #888; + text-align: right; +} + +.contributing-cell { + text-align: center; +} + +.contributing-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + background: rgba(102, 187, 106, 0.3); + color: #66bb6a; +} + +.contributing-badge.contributing-no { + background: rgba(136, 136, 136, 0.2); + color: #888; +} + +.links-table-detailed tr.contributing-yes { + background: rgba(102, 187, 106, 0.08); +} + +.links-table-detailed tr.contributing-no { + opacity: 0.7; +} + +/* BLE Match Card */ +.ble-match-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 12px; +} + +.ble-match-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.ble-match-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.ble-match-name { + flex: 1; + font-size: 15px; + font-weight: 600; + color: #eee; +} + +.ble-match-confidence { + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; + background: rgba(79, 195, 247, 0.2); + color: #4fc3f7; +} + +.ble-match-details { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ble-match-detail { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.ble-match-detail .detail-label { + color: #888; + min-width: 70px; +} + +.ble-match-detail .detail-value { + color: #ccc; + font-family: monospace; +} + +/* Footer */ +.explainability-footer { + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Blob Context Menu */ +.blob-context-menu { + position: fixed; + background: rgba(30, 30, 58, 0.98); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + padding: 8px 0; + min-width: 200px; + z-index: 10000; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + animation: context-menu-fade-in 0.15s ease-out; +} + +@keyframes context-menu-fade-in { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.blob-context-menu-item { + padding: 10px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #ccc; + transition: background 0.2s, color 0.2s; +} + +.blob-context-menu-item:hover { + background: rgba(79, 195, 247, 0.15); + color: #4fc3f7; +} + +.blob-context-menu-item:first-child { + border-radius: 8px 8px 0 0; +} + +.blob-context-menu-item:last-child { + border-radius: 0 0 8px 8px; +} + +.blob-context-menu-divider { + height: 1px; + background: rgba(255, 255, 255, 0.1); + margin: 4px 0; +} + +.blob-context-menu-icon { + font-size: 16px; + width: 20px; + text-align: center; +} + +/* Explainability Sidebar Specific */ +#explainability-sidebar { + background: #1e1e3a; +} + +/* Responsive */ +@media (max-width: 600px) { + .explainability-confidence { + flex-direction: column; + text-align: center; + } + + .confidence-gauge { + width: 80px; + height: 80px; + } + + .confidence-value { + font-size: 18px; + } + + .links-table { + font-size: 11px; + } + + .links-table th, + .links-table td { + padding: 6px 6px; + } } diff --git a/dashboard/index.html b/dashboard/index.html index 8faa4b3..946a31a 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -2096,6 +2096,8 @@ + + @@ -2132,6 +2134,10 @@ + + + +
diff --git a/dashboard/js/auth.js b/dashboard/js/auth.js new file mode 100644 index 0000000..b987f93 --- /dev/null +++ b/dashboard/js/auth.js @@ -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 = ` +
+
+

Welcome to Spaxel

+

Let's secure your dashboard with a PIN

+
+
+ ${authState.setupStep === 'enter' ? ` +

Enter a 4-8 digit PIN to secure your dashboard:

+
+ + + + + + + + +
+

Your PIN should be 4-8 digits

+ ${authState.setupError ? `

${authState.setupError}

` : ''} + + ` : ` +

Confirm your PIN by entering it again:

+
+ + + + + + + + +
+ ${authState.setupError ? `

${authState.setupError}

` : ''} + + + `} +
+
+ `; + + document.body.appendChild(authOverlay); + setupOverlayEvents(); + } + + function renderLoginOverlay() { + authOverlay = document.createElement('div'); + authOverlay.id = 'auth-overlay'; + authOverlay.innerHTML = ` +
+
+

Spaxel Dashboard

+

Enter your PIN to continue

+
+
+
+ + + + + + + + +
+ ${authState.loginError ? `

${authState.loginError}

` : ''} + +
+
+ `; + + 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(); + } + +})(); diff --git a/mothership/cmd/mothership/main.go b/mothership/cmd/mothership/main.go index 00b8e2b..51798b4 100644 --- a/mothership/cmd/mothership/main.go +++ b/mothership/cmd/mothership/main.go @@ -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 diff --git a/mothership/internal/auth/handler.go b/mothership/internal/auth/handler.go new file mode 100644 index 0000000..eb06d23 --- /dev/null +++ b/mothership/internal/auth/handler.go @@ -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 +} diff --git a/mothership/internal/auth/handler_test.go b/mothership/internal/auth/handler_test.go new file mode 100644 index 0000000..46ab5e0 --- /dev/null +++ b/mothership/internal/auth/handler_test.go @@ -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) + } + }) + } +}