feat: add auth subsystem with WebAuthn passkeys support

New modules: lib/auth, lib/auth_users, lib/db, lib/db_sqlite, lib/password,
lib/webauthn, daemon/handlers/auth, scripts/bootstrap_auth, tests/test_auth

Frontend: webui/api/auth, hoover/components/auth, pages/login, passkeys, users

Updates: daemon/iface and server, lib/common and nginx, pyproject.toml deps,
install script, server.py, app.js, and websocket/api clients
This commit is contained in:
2026-07-24 01:21:39 +00:00
parent 04417cf05c
commit 56b200d233
28 changed files with 4900 additions and 82 deletions
+60 -3
View File
@@ -10,10 +10,41 @@ import { refreshByTopic } from './model.js?v=9';
let _wsConn = null;
let _wsReconnectMs = 0;
let _wsFailCount = 0;
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
/** Direct onMessage handlers { topics, handler, unsubscribed }[] */
const _directHandlers = [];
/**
* Refresh the access token. Does NOT redirect on failure — the caller
* decides what to do when refresh fails.
*
* @returns {Promise<boolean>} true if token was refreshed
*/
async function _tryRefreshToken() {
const refresh = localStorage.getItem('vw:refresh');
if (!refresh) return false;
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ refresh_token: refresh }),
credentials: 'same-origin',
});
if (res.status !== 200) return false;
const json = await res.json();
if (!json.ok || !json.data?.tokens) return false;
const tokens = json.data.tokens;
window.__auth_token__ = tokens.access_token;
localStorage.setItem('vw:refresh', tokens.refresh_token);
localStorage.setItem('vw:access_ttl', String((json.data.access_ttl || 900) * 1000));
localStorage.setItem('vw:user', JSON.stringify(json.data.user));
return true;
} catch {
return false;
}
}
/**
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
* (useful for proxy setups). Falls back to port 9091 when the current
@@ -25,17 +56,43 @@ function _wsUrl() {
return proto + '//' + location.host + '/ws';
}
/** Attempt a WebSocket connection. */
/** Attempt a WebSocket connection.
* Passes the JWT in the WebSocket subprotocol header (Sec-WebSocket-Protocol)
* instead of a query parameter, keeping it out of logs and browser history.
*/
function _wsConnect() {
if (_wsConn && _wsConn.readyState <= 1) return;
_wsConn = new WebSocket(_wsUrl());
const token = window.__auth_token__;
if (token) {
_wsConn = new WebSocket(_wsUrl(), ['Bearer ' + token]);
} else {
_wsConn = new WebSocket(_wsUrl());
}
_wsConn.onopen = () => {
_wsReconnectMs = 0;
_wsFailCount = 0;
};
_wsConn.onclose = () => {
if (!window.__auth_token__) return;
_wsFailCount++;
if (_wsFailCount >= 3) {
// Attempt token refresh after repeated failures. No redirect
// on failure — the reconnect loop continues.
(async () => {
const ok = await _tryRefreshToken();
if (ok) {
_wsFailCount = 0;
_wsReconnectMs = 0;
_wsConn = null;
setTimeout(_wsConnect, 100);
}
})();
}
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
};