mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
parent
10d3272ddd
commit
eaefe99f09
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user