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:
+40
-12
@@ -1,4 +1,4 @@
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=10';
|
||||
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive, initAuth, getAuthToken, checkSession } from '/static/hoover/index.js?v=10';
|
||||
|
||||
import DashboardPage from '/static/pages/dashboard.js?v=11';
|
||||
import InterfacesPage from '/static/pages/interfaces.js?v=9';
|
||||
@@ -12,9 +12,12 @@ import CertsPage from '/static/pages/certs.js?v=9';
|
||||
import WireguardPage from '/static/pages/wireguard.js?v=9';
|
||||
import LogsPage from '/static/pages/logs.js?v=9';
|
||||
import NotFoundPage from '/static/pages/notfound.js?v=9';
|
||||
import LoginPage from '/static/pages/login.js';
|
||||
import PasskeysPage from '/static/pages/passkeys.js';
|
||||
import UsersPage from '/static/pages/users.js';
|
||||
|
||||
/* ── Navigation items ──────────────────────────────────────── */
|
||||
const Nav = [
|
||||
const _NavBase = [
|
||||
{ path: '/dashboard', label: 'Dashboard' },
|
||||
{ path: '/interfaces', label: 'Interfaces' },
|
||||
{ path: '/zones', label: 'Zones' },
|
||||
@@ -28,6 +31,15 @@ const Nav = [
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
];
|
||||
|
||||
function getNav() {
|
||||
const nav = [..._NavBase];
|
||||
const perms = JSON.parse(localStorage.getItem('vw:permissions') || 'null');
|
||||
if (perms && perms.auth === 'rw') {
|
||||
nav.push({ path: '/users', label: 'Users' });
|
||||
}
|
||||
return nav;
|
||||
}
|
||||
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall',
|
||||
fetch: async () => {
|
||||
@@ -174,14 +186,17 @@ modelRegister('status', {
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Initial fetch ─────────────────────────────────────────── */
|
||||
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
||||
modelFetch(name);
|
||||
/* ── Initial fetch (after auth check) ───────────────────────── */
|
||||
function fetchInitialData() {
|
||||
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
||||
modelFetch(name);
|
||||
}
|
||||
modelFetch('logs', 'journal');
|
||||
}
|
||||
modelFetch('logs', 'journal');
|
||||
|
||||
/* ── Page map ──────────────────────────────────────────────── */
|
||||
const Pages = {
|
||||
login: LoginPage,
|
||||
dashboard: DashboardPage,
|
||||
interfaces: InterfacesPage,
|
||||
zones: ZonesPage,
|
||||
@@ -193,11 +208,14 @@ const Pages = {
|
||||
certs: CertsPage,
|
||||
wireguard: WireguardPage,
|
||||
logs: LogsPage,
|
||||
passkeys: PasskeysPage,
|
||||
users: UsersPage,
|
||||
};
|
||||
|
||||
/* ── Router ────────────────────────────────────────────────── */
|
||||
const router = {
|
||||
state: reactive({ path: location.hash.slice(1) || '/dashboard' }),
|
||||
isAuthenticated: false,
|
||||
component() {
|
||||
const name = this.state.path.replace(/^\//, '');
|
||||
const page = Pages[name] || NotFoundPage;
|
||||
@@ -213,10 +231,11 @@ window.addEventListener('hashchange', () => {
|
||||
/* ── Sidebar render root ───────────────────────────────────── */
|
||||
function Sidebar() {
|
||||
const current = router.state.path;
|
||||
const nav = getNav();
|
||||
return h('div', { class: 'sidebar' },
|
||||
h('div', { class: 'logo' }, 'Vacuum Wall'),
|
||||
h('nav', null,
|
||||
Nav.map(item =>
|
||||
nav.map(item =>
|
||||
Link({
|
||||
path: item.path,
|
||||
class: current === item.path ? 'active' : '',
|
||||
@@ -236,17 +255,26 @@ function MainContent() {
|
||||
}
|
||||
|
||||
/* ── Init ──────────────────────────────────────────────────── */
|
||||
export function initApp() {
|
||||
export async function initApp() {
|
||||
const sidebarEl = document.getElementById('sidebar');
|
||||
const mainEl = document.getElementById('main');
|
||||
if (sidebarEl && mainEl) {
|
||||
render(sidebarEl, Sidebar);
|
||||
render(mainEl, MainContent);
|
||||
}
|
||||
// Defer connect() after the first render microtask settles to prevent
|
||||
// the initial requestUpdate() from triggering a second commit while
|
||||
// the vnode tree is still being finalized.
|
||||
setTimeout(connect, 0);
|
||||
|
||||
// Check auth state before connecting WS
|
||||
const ok = await initAuth();
|
||||
if (ok) {
|
||||
router.isAuthenticated = true;
|
||||
fetchInitialData();
|
||||
setTimeout(connect, 0);
|
||||
} else {
|
||||
// No valid session — redirect to login
|
||||
if (router.state.path !== '/login') {
|
||||
window.location.hash = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
Reference in New Issue
Block a user