diff --git a/secubox-companion/deploy/nginx-companion.conf b/secubox-companion/deploy/nginx-companion.conf new file mode 100644 index 00000000..cbb77b11 --- /dev/null +++ b/secubox-companion/deploy/nginx-companion.conf @@ -0,0 +1,26 @@ +# SecuBox Companion PWA — nginx vhost (static + same-origin API proxy). +# Deploy: copy www/ to /data/companion/www, drop this in sites-enabled, reload. +# Route companion.gk2 via HAProxy `webui_direct` (like admin.gk2) so sbxwaf does +# not strip the SSO session cookie — see issue #861 to remove that bypass. +server { + listen 9080; + server_name companion.gk2.secubox.in; + root /data/companion/www; + index index.html; + add_header X-Content-Type-Options nosniff always; + gzip on; gzip_types application/javascript text/css application/json image/svg+xml application/manifest+json; + + # AUTH must hit the canonical auth.sock (records the session jti in + # sessions.json) — NOT the aggregator copy, whose jti is never recorded → 401. + location /api/v1/auth/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/auth.sock:/api/v1/auth/; } + # own-socket modules + location /api/v1/podcaster/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/podcaster.sock:/; } + location /api/v1/billets/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/billets.sock:/; } + # everything else → aggregator (auth verify, waf, system, exposure, wireguard, certs, dns…) + location /api/v1/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/aggregator.sock:/api/v1/; } + + location = /sw.js { add_header Cache-Control "no-cache"; } + location = /manifest.webmanifest { default_type application/manifest+json; } + location ~* \.woff2$ { add_header Cache-Control "public, max-age=31536000, immutable"; } + location / { try_files $uri $uri/ /index.html; } +} diff --git a/secubox-companion/www/core/auth.js b/secubox-companion/www/core/auth.js index fbd4df31..e749bd34 100644 --- a/secubox-companion/www/core/auth.js +++ b/secubox-companion/www/core/auth.js @@ -9,23 +9,33 @@ import { store } from './store.js'; import { el, toast } from './ui.js'; -// The box login endpoint. Adjust to the real AUTH route if it differs. -const LOGIN_PATH = '/api/v1/auth/login'; // TODO(api): confirm exact AUTH login route/shape +// The canonical AUTH routes (must hit auth.sock — the companion vhost proxies +// /api/v1/auth there — so the session jti is written to sessions.json and +// require_jwt accepts the token/cookie). +const LOGIN_PATH = '/api/v1/auth/login'; +const MFA_PATH = '/api/v1/auth/login/mfa'; -async function login(url, username, password) { - const base = url.replace(/\/+$/, ''); +/** Step 1 — password. Returns the raw box response: + * {access_token} | {mfa_required, mfa_token} | {enrollment_required} | {setup_required} */ +async function loginPassword(base, username, password) { const r = await fetch(base + LOGIN_PATH, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password }), }); - if (!r.ok) throw new Error(r.status === 401 ? 'Invalid credentials' : `Login failed (HTTP ${r.status})`); - // secubox-auth is SSO-cookie based (#400): on success it sets the parent-domain - // `secubox_session` cookie, which require_jwt accepts on every module — so no - // bearer token is needed. Some builds ALSO return a token in the body; keep it - // if present, otherwise rely on the cookie (the browser sends it, credentials: - // 'include'). "No token" is NOT an error here. const d = await r.json().catch(() => ({})); - return d.token || d.access_token || d.jwt || d.sbx_token || ''; + if (!r.ok) throw new Error(d.detail || (r.status === 401 ? 'Invalid credentials' : `Login failed (HTTP ${r.status})`)); + return d; +} + +/** Step 2 — verify a TOTP code for an enrolled account → access_token. */ +async function loginMfa(base, mfaToken, code) { + const r = await fetch(base + MFA_PATH, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + mfaToken }, + credentials: 'include', body: JSON.stringify({ code }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.detail || 'Invalid code'); + return d.access_token; } function field(label, input) { return el('div', {}, [el('label', { text: label }), input]); } @@ -36,8 +46,20 @@ export function pairingScreen(root) { const url = el('input', { type: 'url', placeholder: 'https://box.example.in', value: store.pairedUrl() || location.origin, inputmode: 'url' }); const user = el('input', { type: 'text', placeholder: 'username', autocomplete: 'username' }); const pass = el('input', { type: 'password', placeholder: 'password', autocomplete: 'current-password' }); + const mfa = el('input', { type: 'text', placeholder: '000000', inputmode: 'numeric', autocomplete: 'one-time-code' }); + const mfaField = field('2FA code', mfa); mfaField.style.display = 'none'; const pin = el('input', { type: 'password', placeholder: 'choose a local PIN (unlocks this app)', inputmode: 'numeric', autocomplete: 'off' }); const btn = el('button.btn.primary', { type: 'submit', text: 'Pair with box' }); + let mfaToken = null; + + const base = () => url.value.replace(/\/+$/, ''); + async function finalize(token) { + // token is the box access_token; api uses it as Bearer (+ the SSO cookie + // the login also set). Sealed with the PIN. + await store.pair({ url: base(), token: token || '', pin: pin.value }); + toast('Paired ✓'); + resolve({ url: base(), token: token || '' }); + } const form = el('form.card', { style: 'max-width:420px;margin:8vh auto;', @@ -45,21 +67,32 @@ export function pairingScreen(root) { e.preventDefault(); if (!url.value || !user.value || !pass.value) return toast('URL + credentials required', 'err'); if (store.hasCrypto() && (pin.value || '').length < 4) return toast('PIN must be ≥ 4 characters', 'err'); - btn.disabled = true; btn.textContent = 'Pairing…'; + btn.disabled = true; btn.textContent = mfaToken ? 'Verifying…' : 'Pairing…'; try { - const token = await login(url.value, user.value, pass.value); - await store.pair({ url: url.value.replace(/\/+$/, ''), token, pin: pin.value }); - toast('Paired ✓'); - resolve({ url: url.value.replace(/\/+$/, ''), token }); + if (mfaToken) { // step 2: TOTP code + const token = await loginMfa(base(), mfaToken, mfa.value.trim()); + return finalize(token); + } + const d = await loginPassword(base(), user.value, pass.value); + if (d.access_token) return finalize(d.access_token); // password-only + if (d.mfa_required && d.mfa_token) { // enrolled → ask code + mfaToken = d.mfa_token; + mfaField.style.display = ''; mfa.value = ''; mfa.focus(); + btn.textContent = 'Verify code'; btn.disabled = false; + return toast('Enter your 2FA code'); + } + if (d.enrollment_required) throw new Error('Enroll 2FA in the SecuBox web UI first, then pair here.'); + if (d.setup_required) throw new Error('Password change required — use the SecuBox web UI first.'); + throw new Error(d.detail || 'Login failed'); } catch (err) { toast(err.message || 'Pairing failed', 'err'); - btn.disabled = false; btn.textContent = 'Pair with box'; + btn.disabled = false; btn.textContent = mfaToken ? 'Verify code' : 'Pair with box'; } }, }, [ el('div.panel-head', {}, [el('span.dot'), el('h2', { text: 'Pair SecuBox Companion' })]), - el('p.muted', { style: 'font-size:.8rem;margin-top:-4px', text: 'Outbound-only. Works over LAN, WAN, or WireGuard/MESH. Credentials are exchanged once for a token, then sealed under your PIN.' }), - field('Box URL', url), field('Username', user), field('Password', pass), + el('p.muted', { style: 'font-size:.8rem;margin-top:-4px', text: 'Outbound-only. Works over LAN, WAN, or WireGuard/MESH. One SecuBox login (with 2FA if enabled), sealed under your PIN.' }), + field('Box URL', url), field('Username', user), field('Password', pass), mfaField, store.hasCrypto() ? field('Local PIN', pin) : el('p.muted', { text: 'WebCrypto unavailable — token kept in memory only (session).' }), el('div.row', { style: 'margin-top:14px' }, [btn]), ]); diff --git a/secubox-companion/www/sw.js b/secubox-companion/www/sw.js index ba24eae1..4cd56db5 100644 --- a/secubox-companion/www/sw.js +++ b/secubox-companion/www/sw.js @@ -7,7 +7,7 @@ // Same-origin app assets → stale-while-revalidate. Box API (cross-origin) → // passthrough (the page handles offline reads/queue). -const VERSION = 'sbx-companion-v3'; +const VERSION = 'sbx-companion-v4'; const SHELL = [ './', './index.html', './manifest.webmanifest', './styles/charte.css', './styles/fonts.css',