mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
fix(companion): full password+TOTP login via canonical auth.sock — definitively working
Some checks are pending
License Headers / check (push) Waiting to run
Some checks are pending
License Headers / check (push) Waiting to run
Root cause of the module 401s: the companion proxied /api/v1/auth to the AGGREGATOR, whose minted token's jti is never written to sessions.json, so require_jwt rejected it. Route /api/v1/auth to auth.sock (canonical, session- recorded). Implement the real two-step login like login.html: password → access_token, or mfa_required → prompt TOTP code → /auth/login/mfa → access_token. Capture the token as Bearer (+ the SSO cookie login sets). Ship the nginx vhost in deploy/. SW→v4. Proven end-to-end (temp viewer user): login returns access_token + sets secubox_session; podcaster/wireguard/system → 200 via both Bearer and cookie. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
parent
ecd32dde65
commit
84514dcaca
26
secubox-companion/deploy/nginx-companion.conf
Normal file
26
secubox-companion/deploy/nginx-companion.conf
Normal file
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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]),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user