0ed275835d
Refresh/logout and token robustness - drop the post-rotation refresh_tokens row delete in auth_refresh so logout blacklists the current (rotated) refresh token; remove the dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token - reject non-object JWT payloads in _extract_unverified_sub so crafted Authorization headers return 401 instead of crashing with 500 SQLite user store - make builtin-admin seeding idempotent: on a concurrent first start the losing seeder re-checks, finds the winner, and returns instead of raising IntegrityError - per-thread sqlite connections + busy_timeout so Flask worker threads don't hit cross-thread ProgrammingError / SQLITE_BUSY - add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both systemd units so the fallback admin password actually lands on disk Frontend - skip apiFetch 401-recovery for public auth endpoints so a failed login no longer logs out a valid session - add /passkeys to the nav (passkey registration was unreachable); remove the dead checkWebAuthnCapable export - drop the CSP-blocked inline WS-URL script and the __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location Daemon / WS - parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx fallback only — docstring and security docs corrected Install / system - bootstrap_auth.py is now idempotent: preserves existing auth config and syncs the admin password on re-runs (new reset_password helper) - WebUI server block renders auth_basic off (the UI is JWT-protected) - install.sh chown/chmod skips .git to avoid git dubious-ownership breakage - tolerate unreadable /etc/wireguard during system import Contracts / docs - create_user returns 409 on duplicate username per docs/api.md - correct docs/api.md response shapes, docs/security.md blacklist cleanup wording + one-refresh-per-user caveat, stale WS-URL references, and the .htpasswd description Tests: +7 regression tests (rotation/logout revocation, crafted-token 401, concurrent seeding); placeholder-substitution tests replaced with serve-as-is SPA root tests.
199 lines
7.0 KiB
JavaScript
199 lines
7.0 KiB
JavaScript
/**
|
|
* Hoover — auth.js (components)
|
|
*
|
|
* Thin ceremony layer over the auth model (hoover/auth_model.js), which is
|
|
* the single source of truth for token storage, refresh scheduling, session
|
|
* validation, and login/logout state transitions.
|
|
*
|
|
* This file only contains:
|
|
* - logout() — POST /api/auth/logout then drive the model to terminal
|
|
* - doLogin() — drive the model through the 'login' action, then navigate
|
|
* - WebAuthn (passkey) ceremony helpers — not state management
|
|
*/
|
|
|
|
import { modelFetch } from '../model.js';
|
|
import { getAuthData } from '../auth_model.js';
|
|
|
|
/**
|
|
* Logout: blacklist the current tokens server-side, then drive the model to
|
|
* the terminal all-nulls state. The model's onSuccess clears storage,
|
|
* redirects to #/login, and dispatches auth:logout (app.js closes the WS
|
|
* socket via disconnect() — no explicit WS close here). The server reads
|
|
* jti/username from the request context; refresh_token is sent in the body
|
|
* per the blueprint contract (webui/api/auth.py:75).
|
|
*/
|
|
export async function logout() {
|
|
const auth = getAuthData();
|
|
if (auth?.token) {
|
|
try {
|
|
await fetch('/api/auth/logout', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'Authorization': 'Bearer ' + auth.token,
|
|
'X-Session-Id': auth.session_id || '',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ refresh_token: auth.refresh || '' }),
|
|
});
|
|
} catch { /* ignore — we're clearing everything anyway */ }
|
|
}
|
|
modelFetch('auth', { action: 'logout' });
|
|
}
|
|
|
|
/**
|
|
* Handle a successful login: drive the auth model through the 'login'
|
|
* action (onSuccess persists the session, schedules the TTL refresh, and
|
|
* fires auth:login), then navigate.
|
|
*
|
|
* @param {object} data — login response data ({ tokens, user, permissions, access_ttl })
|
|
* @param {string} [redirectPath] — where to navigate after login
|
|
*/
|
|
export function doLogin(data, redirectPath = '/dashboard') {
|
|
modelFetch('auth', { action: 'login', payload: data });
|
|
window.location.hash = redirectPath;
|
|
}
|
|
|
|
/**
|
|
* Check if WebAuthn (passkeys) is supported in this browser.
|
|
*
|
|
* @returns {boolean}
|
|
*/
|
|
export function webauthnSupported() {
|
|
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
|
}
|
|
|
|
/* ─── Base64url helpers ──────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Convert base64url string to ArrayBuffer.
|
|
* @param {string} b64url
|
|
* @returns {ArrayBuffer}
|
|
*/
|
|
function b64urlToArrayBuffer(b64url) {
|
|
const bin = atob(b64url.replace(/-/g, '+').replace(/_/g, '/'));
|
|
const arr = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) {
|
|
arr[i] = bin.charCodeAt(i);
|
|
}
|
|
return arr.buffer;
|
|
}
|
|
|
|
/**
|
|
* Convert ArrayBuffer to base64url string.
|
|
* @param {ArrayBuffer} buffer
|
|
* @returns {string}
|
|
*/
|
|
function arrayBufferToB64url(buffer) {
|
|
const bytes = new Uint8Array(buffer);
|
|
const chunks = [];
|
|
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 0x8000)));
|
|
}
|
|
const bin = chunks.join('');
|
|
return btoa(bin)
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '');
|
|
}
|
|
|
|
/* ─── WebAuthn navigator wrappers ────────────────────────────────────── */
|
|
|
|
/**
|
|
* Start a WebAuthn registration ceremony.
|
|
*
|
|
* Calls ``navigator.credentials.create()`` with the provided options,
|
|
* then returns the credential response as a JSON-serializable dict
|
|
* suitable for sending to the server.
|
|
*
|
|
* @param {object} registrationOptions — options from /webauthn/register-begin
|
|
* @returns {Promise<object>} credential response (id, rawId, type, response)
|
|
*/
|
|
export async function startRegistration(registrationOptions) {
|
|
if (!webauthnSupported()) {
|
|
throw new Error('WebAuthn is not supported in this browser');
|
|
}
|
|
|
|
const publicKey = {
|
|
challenge: b64urlToArrayBuffer(registrationOptions.challenge),
|
|
rp: registrationOptions.rp,
|
|
user: {
|
|
id: b64urlToArrayBuffer(registrationOptions.user.id),
|
|
name: registrationOptions.user.name,
|
|
displayName: registrationOptions.user.displayName,
|
|
},
|
|
pubKeyCredParams: registrationOptions.pubKeyCredParams,
|
|
timeout: registrationOptions.timeout,
|
|
};
|
|
|
|
if (registrationOptions.excludeCredentials) {
|
|
publicKey.excludeCredentials = registrationOptions.excludeCredentials.map(c => ({
|
|
...c,
|
|
id: b64urlToArrayBuffer(c.id),
|
|
}));
|
|
}
|
|
if (registrationOptions.authenticatorSelection) {
|
|
publicKey.authenticatorSelection = registrationOptions.authenticatorSelection;
|
|
}
|
|
|
|
const credential = await navigator.credentials.create({ publicKey });
|
|
|
|
const { id, rawId, type, response } = credential;
|
|
|
|
return {
|
|
id: arrayBufferToB64url(rawId),
|
|
rawId: arrayBufferToB64url(rawId),
|
|
type,
|
|
response: {
|
|
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
|
|
attestationObject: arrayBufferToB64url(response.attestationObject),
|
|
transports: response.getTransports ? response.getTransports() : [],
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start a WebAuthn authentication ceremony.
|
|
*
|
|
* Calls ``navigator.credentials.get()`` with the provided options,
|
|
* then returns the assertion response as a JSON-serializable dict.
|
|
*
|
|
* @param {object} authenticationOptions — options from /webauthn/authenticate-begin
|
|
* @returns {Promise<object>} assertion response (id, rawId, type, response)
|
|
*/
|
|
export async function startAuthentication(authenticationOptions) {
|
|
if (!webauthnSupported()) {
|
|
throw new Error('WebAuthn is not supported in this browser');
|
|
}
|
|
|
|
const publicKey = {
|
|
challenge: b64urlToArrayBuffer(authenticationOptions.challenge),
|
|
timeout: authenticationOptions.timeout,
|
|
userVerification: authenticationOptions.userVerification || 'preferred',
|
|
};
|
|
|
|
if (authenticationOptions.allowCredentials) {
|
|
publicKey.allowCredentials = authenticationOptions.allowCredentials.map(c => ({
|
|
...c,
|
|
id: b64urlToArrayBuffer(c.id),
|
|
}));
|
|
}
|
|
|
|
const credential = await navigator.credentials.get({ publicKey });
|
|
|
|
const { id, rawId, type, response } = credential;
|
|
|
|
return {
|
|
id: arrayBufferToB64url(rawId),
|
|
rawId: arrayBufferToB64url(rawId),
|
|
type,
|
|
response: {
|
|
clientDataJSON: arrayBufferToB64url(response.clientDataJSON),
|
|
authenticatorData: arrayBufferToB64url(response.authenticatorData),
|
|
signature: arrayBufferToB64url(response.signature),
|
|
userHandle: response.userHandle ? arrayBufferToB64url(response.userHandle) : null,
|
|
},
|
|
};
|
|
}
|