feat(hub): login.html state-machine for v2 auth — setup / enrol TOTP / MFA (ref #120)

Removes `required` on password input so the must_change_password setup flow
can be triggered with an empty submit. Switches to the canonical
/api/v1/auth/login URL.

JS now branches on the four secubox-auth response shapes:
- setup_required → inline "set initial password" form, then auto-resubmits
- enrollment_required → "Add this secret to your authenticator app" panel
  with otpauth:// link, then verify code
- mfa_required → 6-digit code input (accepts backup codes too)
- access_token → existing localStorage + redirect

Backup codes are shown ONCE with a clear warning, gated behind an
explicit acknowledgement button before the redirect.

Live-deployed to /usr/share/secubox/www/login.html on gk2 (md5 verified).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-13 10:21:23 +02:00
parent 10d3272ddd
commit eaefe99f09

View File

@ -201,14 +201,57 @@
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required>
<input type="password" id="password" name="password" autocomplete="current-password">
</div>
<button type="submit" id="submitBtn">Access System</button>
</form>
<div class="hint">
Demo credentials: <strong>admin</strong> / <strong>secubox</strong>
<!-- Step 2: must_change_password — set initial password -->
<form id="setupForm" style="display:none">
<div class="form-group">
<label>Set initial password</label>
<input type="password" id="newpass1" placeholder="New password (min 12 chars)" autocomplete="new-password">
</div>
<div class="form-group">
<input type="password" id="newpass2" placeholder="Confirm" autocomplete="new-password">
</div>
<button type="submit" id="setupBtn">Set Password</button>
</form>
<!-- Step 3: role=admin without TOTP — enroll TOTP -->
<div id="enrollPanel" style="display:none">
<p style="color:#00ff41;font-size:12px;letter-spacing:2px;margin-bottom:10px">TOTP ENROLLMENT REQUIRED</p>
<p style="color:#e8e6d9;font-size:12px;margin-bottom:14px">Add this secret to your authenticator app, then enter the 6-digit code:</p>
<div id="totpSecretBox" style="background:rgba(0,0,0,0.5);border:1px solid #333;border-radius:4px;padding:10px;font-size:11px;word-break:break-all;margin-bottom:14px;color:#c9a84c"></div>
<a id="totpUriLink" href="#" style="display:block;color:#00d4ff;font-size:11px;margin-bottom:14px;text-decoration:underline">Open in authenticator app</a>
<form id="confirmForm">
<div class="form-group">
<input type="text" id="totpCode" placeholder="6-digit code" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code">
</div>
<button type="submit" id="confirmBtn">Verify and Enable</button>
</form>
</div>
<!-- Step 4: TOTP-enabled user — challenge -->
<form id="mfaForm" style="display:none">
<div class="form-group">
<label>Authenticator code</label>
<input type="text" id="mfaCode" placeholder="6-digit code or backup code" inputmode="numeric" autocomplete="one-time-code">
</div>
<button type="submit" id="mfaBtn">Verify</button>
</form>
<!-- Step 5: backup codes one-time display -->
<div id="backupPanel" style="display:none">
<p style="color:#e63946;font-size:12px;letter-spacing:2px;margin-bottom:10px">SAVE THESE BACKUP CODES</p>
<p style="color:#e8e6d9;font-size:11px;margin-bottom:14px">Shown only once. Each code is single-use. Without your authenticator AND backup codes, recovery requires SSH access.</p>
<pre id="backupCodes" style="background:rgba(0,0,0,0.5);border:1px solid #c9a84c;border-radius:4px;padding:12px;color:#c9a84c;font-size:12px;line-height:1.8;margin-bottom:14px;text-align:center"></pre>
<button id="backupAck">I've saved them — continue</button>
</div>
<div class="hint" id="hintBox">
First-time setup: leave password empty and click <strong>Access System</strong>.
</div>
<div class="auth-mode standard" id="auth-mode">
@ -219,52 +262,186 @@
</div>
<script>
const form = document.getElementById('loginForm');
const errorDiv = document.getElementById('error');
const submitBtn = document.getElementById('submitBtn');
const $ = (id) => document.getElementById(id);
const errorDiv = $('error');
const hintBox = $('hintBox');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
// Hide all step panels, show one. Errors persist across switches.
const PANELS = ['loginForm', 'setupForm', 'enrollPanel', 'mfaForm', 'backupPanel'];
function showPanel(id) {
PANELS.forEach(p => $(p).style.display = (p === id ? '' : 'none'));
}
function showError(msg) {
errorDiv.textContent = msg;
errorDiv.classList.add('show');
}
function clearError() {
errorDiv.classList.remove('show');
submitBtn.disabled = true;
submitBtn.textContent = 'Authenticating...';
}
async function api(path, opts = {}) {
opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
const res = await fetch(path, opts);
const text = await res.text();
let data = {};
try { data = text ? JSON.parse(text) : {}; } catch (_) { /* keep empty */ }
return { res, data };
}
let currentUser = '';
let setupToken = '';
let enrollToken = '';
let mfaToken = '';
function finish(accessToken, username) {
localStorage.setItem('sbx_token', accessToken);
localStorage.setItem('sbx_user', username);
window.location.href = '/';
}
// ── Step 1: password (or empty for first-time setup) ─────────────
$('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const username = $('username').value.trim();
const password = $('password').value;
currentUser = username;
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = 'Authenticating...';
try {
const res = await fetch('/api/v1/auth/auth/login', {
const { res, data } = await api('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error(data.detail || 'Invalid credentials');
if (!res.ok) {
const data = await res.json();
throw new Error(data.detail || 'Invalid credentials');
if (data.setup_required) {
setupToken = data.setup_token;
hintBox.innerHTML = 'Choose a strong password: <strong>12+ chars, mix of types</strong>.';
showPanel('setupForm');
$('newpass1').focus();
} else if (data.enrollment_required) {
enrollToken = data.enrollment_token;
await startEnroll();
} else if (data.mfa_required) {
mfaToken = data.mfa_token;
hintBox.textContent = 'Enter the 6-digit code from your authenticator app (or a backup code).';
showPanel('mfaForm');
$('mfaCode').focus();
} else if (data.access_token) {
finish(data.access_token, username);
} else {
throw new Error('Unexpected server response');
}
const data = await res.json();
localStorage.setItem('sbx_token', data.access_token);
localStorage.setItem('sbx_user', username);
// Redirect to dashboard
window.location.href = '/';
} catch (err) {
errorDiv.textContent = err.message;
errorDiv.classList.add('show');
submitBtn.disabled = false;
submitBtn.textContent = 'Access System';
showError(err.message);
} finally {
btn.disabled = false; btn.textContent = 'Access System';
}
});
// Auto-focus password if username is pre-filled
if (document.getElementById('username').value) {
document.getElementById('password').focus();
// ── Step 2: set initial password ─────────────────────────────────
$('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const p1 = $('newpass1').value;
const p2 = $('newpass2').value;
if (p1 !== p2) { showError('Passwords do not match'); return; }
const btn = $('setupBtn');
btn.disabled = true; btn.textContent = 'Setting...';
try {
const { res, data } = await api('/api/v1/auth/set-password', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + setupToken },
body: JSON.stringify({ new_password: p1 }),
});
if (!res.ok) throw new Error(data.detail || 'Server refused the password');
// Re-login with the new password — the server returns a fresh response
$('password').value = p1;
hintBox.textContent = 'Password set. Logging in...';
showPanel('loginForm');
$('loginForm').requestSubmit();
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false; btn.textContent = 'Set Password';
}
});
// ── Step 3: enrol TOTP ───────────────────────────────────────────
async function startEnroll() {
const { res, data } = await api('/api/v1/auth/totp/enroll', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + enrollToken },
});
if (!res.ok) { showError(data.detail || 'Enrollment failed'); showPanel('loginForm'); return; }
$('totpSecretBox').textContent = data.secret;
$('totpUriLink').href = data.otpauth_uri;
hintBox.textContent = 'Scan the secret with Google Authenticator / Aegis / Authy.';
showPanel('enrollPanel');
$('totpCode').focus();
}
$('confirmForm').addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const code = $('totpCode').value.trim();
const btn = $('confirmBtn');
btn.disabled = true; btn.textContent = 'Verifying...';
try {
const { res, data } = await api('/api/v1/auth/totp/confirm', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + enrollToken },
body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(data.detail || 'Invalid code');
// Show backup codes ONCE before letting the user proceed.
$('backupCodes').textContent = (data.backup_codes || []).join('\n');
hintBox.textContent = data.backup_codes_note || 'Save these backup codes.';
showPanel('backupPanel');
// Stash access_token so the ack button can finish.
$('backupAck').dataset.token = data.access_token;
$('backupAck').dataset.user = currentUser;
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false; btn.textContent = 'Verify and Enable';
}
});
$('backupAck').addEventListener('click', (e) => {
e.preventDefault();
finish(e.target.dataset.token, e.target.dataset.user);
});
// ── Step 4: MFA challenge ────────────────────────────────────────
$('mfaForm').addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const code = $('mfaCode').value.trim();
const btn = $('mfaBtn');
btn.disabled = true; btn.textContent = 'Verifying...';
try {
const { res, data } = await api('/api/v1/auth/login/mfa', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + mfaToken },
body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(data.detail || 'Invalid code');
finish(data.access_token, currentUser);
} catch (err) {
showError(err.message);
} finally {
btn.disabled = false; btn.textContent = 'Verify';
}
});
// Auto-focus username (so "leave password empty" is one click)
$('username').focus();
// Load auth mode and version info (public endpoint)
async function loadPublicInfo() {
try {