fix: auth review fixes — token revocation, WS auth, seeding, and hardening
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.
This commit is contained in:
+3
-1
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
from flask import Blueprint, request
|
||||
|
||||
from daemon.client import delete, get, post
|
||||
from daemon.client import Conflict, delete, get, post
|
||||
from daemon.iface import (
|
||||
DELETE_AUTH_USER,
|
||||
DELETE_AUTH_WEBAUTHN_CREDENTIAL,
|
||||
@@ -167,6 +167,8 @@ def create_user():
|
||||
try:
|
||||
body = request.get_json(silent=True) or {}
|
||||
return _ok(post(POST_AUTH_USER_CREATE, body))
|
||||
except Conflict as exc:
|
||||
return _error(str(exc), 409)
|
||||
except Exception as exc:
|
||||
logger.error("Create user failed: %s", exc)
|
||||
return _error(str(exc), 400)
|
||||
|
||||
+1
-4
@@ -307,10 +307,7 @@ VENDOR_DIR = PROJECT_DIR / "vendor"
|
||||
@app.route("/")
|
||||
def spa_root():
|
||||
"""Serve the SPA entry point. No catch-all — client handles routing."""
|
||||
scheme = "wss" if request.is_secure else "ws"
|
||||
ws_url = f"{scheme}://{request.host}/ws"
|
||||
html = (SPA_DIR / "index.html").read_text()
|
||||
return html.replace("__WS_URL_PLACEHOLDER__", ws_url)
|
||||
return (SPA_DIR / "index.html").read_text()
|
||||
|
||||
|
||||
@app.route("/vendor/<path:filename>")
|
||||
|
||||
+3
-2
@@ -27,8 +27,9 @@ const _NavBase = [
|
||||
{ path: '/proxy', label: 'Proxy' },
|
||||
{ path: '/backends', label: 'Backends' },
|
||||
{ path: '/certs', label: 'Certs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
{ path: '/wireguard', label: 'WireGuard' },
|
||||
{ path: '/logs', label: 'Logs' },
|
||||
{ path: '/passkeys', label: 'Passkeys' },
|
||||
];
|
||||
|
||||
function getNav() {
|
||||
|
||||
@@ -12,6 +12,21 @@ import { getAuthToken, getAuthData, refreshAuth } from './auth_model.js';
|
||||
import { requestUpdate } from './reactivity.js';
|
||||
import { isModalProcessing, setModalProcessing, refreshModals } from './components/modal.js';
|
||||
|
||||
/**
|
||||
* Public auth endpoints that may legitimately 401 (bad credentials) while a
|
||||
* valid session exists elsewhere. 401 recovery (refresh → retry → logout)
|
||||
* is skipped for these so a failed login doesn't tear down a live session.
|
||||
*/
|
||||
const _PUBLIC_AUTH_URLS = new Set([
|
||||
'/api/auth/login',
|
||||
'/api/auth/webauthn/authenticate-begin',
|
||||
'/api/auth/webauthn/authenticate-finish',
|
||||
]);
|
||||
|
||||
function _isPublicAuthUrl(url) {
|
||||
return _PUBLIC_AUTH_URLS.has(String(url).split('?')[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
*
|
||||
@@ -49,7 +64,7 @@ export async function apiFetch(url, options = {}) {
|
||||
if (safeOpts.signal?.aborted) {
|
||||
return { ok: false, data: null, error: 'Aborted', status: 0 };
|
||||
}
|
||||
if (res.status === 401 && token) {
|
||||
if (res.status === 401 && token && !_isPublicAuthUrl(url)) {
|
||||
await refreshAuth();
|
||||
const auth = getAuthData();
|
||||
if (auth?.token) {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* - WebAuthn (passkey) ceremony helpers — not state management
|
||||
*/
|
||||
|
||||
import { apiFetch } from '../api.js';
|
||||
import { modelFetch } from '../model.js';
|
||||
import { getAuthData } from '../auth_model.js';
|
||||
|
||||
@@ -65,20 +64,6 @@ export function webauthnSupported() {
|
||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WebAuthn is enabled and available on the current domain.
|
||||
* Calls GET /api/auth/webauthn/capable to query the server.
|
||||
*
|
||||
* @returns {Promise<object>} { enabled, rp_id, rp_name, origin, reason? }
|
||||
*/
|
||||
export async function checkWebAuthnCapable() {
|
||||
const result = await apiFetch('/api/auth/webauthn/capable');
|
||||
if (!result.ok) {
|
||||
return { enabled: false, reason: 'Unable to check WebAuthn capability' };
|
||||
}
|
||||
return result.data || { enabled: false, reason: 'Server returned no data' };
|
||||
}
|
||||
|
||||
/* ─── Base64url helpers ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
|
||||
@@ -30,7 +30,7 @@ export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoa
|
||||
from './api.js';
|
||||
|
||||
/* ── UI Components: Auth ──────────────────────────────────────── */
|
||||
export { logout, doLogin, webauthnSupported, checkWebAuthnCapable,
|
||||
export { logout, doLogin, webauthnSupported,
|
||||
startRegistration, startAuthentication } from './components/auth.js';
|
||||
|
||||
/* ── Auth model ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -25,12 +25,10 @@ let _wsClosingHandled = false;
|
||||
const _directHandlers = [];
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||
* origin has no port (nginx fronting the WS on a different port).
|
||||
* Build the WebSocket URL from the current origin. nginx proxies /ws to
|
||||
* the daemon's WebSocket port.
|
||||
*/
|
||||
function _wsUrl() {
|
||||
if (window.__WS_URL__) return window.__WS_URL__;
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return proto + '//' + location.host + '/ws';
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal-root"></div>
|
||||
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user