Add TOTP-based Multi-Factor Authentication (MFA) for local users

Global MFA toggle in Security settings, QR code setup on first login,
6-digit TOTP verification on subsequent logins. Azure AD users exempt.
Admins can reset user MFA. TOTP secrets encrypted at rest with Fernet.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 23:14:06 +01:00
parent 647630ff19
commit 3d28f13054
13 changed files with 615 additions and 62 deletions

View File

@@ -40,6 +40,43 @@
<i class="bi bi-microsoft me-2"></i><span data-i18n="login.signInWithMicrosoft">Sign in with Microsoft</span>
</button>
</div>
<!-- MFA: TOTP Verify (existing setup) -->
<div id="mfa-verify-section" class="d-none">
<div id="mfa-verify-error" class="alert alert-danger d-none"></div>
<p class="text-muted text-center mb-3" data-i18n="mfa.enterCode">Enter your 6-digit authenticator code</p>
<form id="mfa-verify-form">
<input type="text" class="form-control form-control-lg text-center" id="mfa-code"
maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required autofocus>
<button type="submit" class="btn btn-primary w-100 mt-3">
<span class="spinner-border spinner-border-sm d-none me-1" id="mfa-verify-spinner"></span>
<span data-i18n="mfa.verify">Verify</span>
</button>
</form>
<a href="#" id="mfa-back-to-login" class="d-block text-center mt-2 small" data-i18n="mfa.backToLogin">Back to login</a>
</div>
<!-- MFA: TOTP Setup (first time) -->
<div id="mfa-setup-section" class="d-none">
<div id="mfa-setup-error" class="alert alert-danger d-none"></div>
<p class="text-muted text-center mb-2" data-i18n="mfa.scanQrCode">Scan this QR code with your authenticator app</p>
<div class="text-center mb-3">
<img id="mfa-qr-code" class="img-fluid rounded" style="max-width:200px" alt="TOTP QR Code">
</div>
<p class="text-muted small text-center mb-3">
<span data-i18n="mfa.orEnterManually">Or enter this key manually:</span><br>
<code id="mfa-secret-manual" class="user-select-all"></code>
</p>
<form id="mfa-setup-form">
<input type="text" class="form-control form-control-lg text-center" id="mfa-setup-code"
maxlength="6" pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code" required>
<button type="submit" class="btn btn-success w-100 mt-3">
<span class="spinner-border spinner-border-sm d-none me-1" id="mfa-setup-spinner"></span>
<span data-i18n="mfa.verifyAndActivate">Verify & Activate</span>
</button>
</form>
<a href="#" id="mfa-setup-back-to-login" class="d-block text-center mt-2 small" data-i18n="mfa.backToLogin">Back to login</a>
</div>
</div>
</div>
</div>
@@ -457,12 +494,13 @@
<th data-i18n="settings.thRole">Role</th>
<th data-i18n="settings.thAuth">Auth</th>
<th data-i18n="settings.thLanguage">Language</th>
<th>MFA</th>
<th data-i18n="settings.thStatus">Status</th>
<th data-i18n="settings.thActions">Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
<tr><td colspan="8" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
<tr><td colspan="9" class="text-center text-muted py-4" data-i18n="common.loading">Loading...</td></tr>
</tbody>
</table>
</div>
@@ -509,6 +547,28 @@
<!-- Security -->
<div class="tab-pane fade" id="settings-security">
<!-- MFA Settings -->
<div class="card shadow-sm mb-4" id="mfa-settings-card">
<div class="card-body">
<h5 class="mb-3" data-i18n="mfa.title">Multi-Factor Authentication (MFA)</h5>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="cfg-mfa-enabled">
<label class="form-check-label" for="cfg-mfa-enabled" data-i18n="mfa.enableMfa">Enable MFA for all local users</label>
</div>
<p class="text-muted small" data-i18n="mfa.mfaDescription">When enabled, local users must verify with a TOTP authenticator app after entering their password. Azure AD users are not affected.</p>
<button class="btn btn-primary btn-sm" id="save-mfa-settings" onclick="saveMfaSettings()">
<i class="bi bi-save me-1"></i><span data-i18n="mfa.saveMfaSettings">Save MFA Settings</span>
</button>
<hr class="my-3" id="mfa-own-status-divider">
<h6 data-i18n="mfa.yourTotpStatus">Your TOTP Status</h6>
<div id="mfa-own-status" class="mb-2"></div>
<button class="btn btn-outline-danger btn-sm d-none" id="mfa-disable-own" onclick="disableOwnTotp()">
<i class="bi bi-shield-x me-1"></i><span data-i18n="mfa.disableMyTotp">Disable my TOTP</span>
</button>
</div>
</div>
<!-- Change Password -->
<div class="card shadow-sm">
<div class="card-body">
<h5 class="mb-3" data-i18n="settings.securityTitle">Change Admin Password</h5>

View File

@@ -36,7 +36,7 @@ async function api(method, path, body = null) {
console.error(`API network error: ${method} ${path}`, networkErr);
throw new Error(t('errors.networkError'));
}
if (resp.status === 401) {
if (resp.status === 401 && !path.startsWith('/auth/mfa/') && path !== '/auth/login') {
logout();
throw new Error(t('errors.sessionExpired'));
}
@@ -98,6 +98,8 @@ async function initApp() {
function showLoginPage() {
document.getElementById('login-page').classList.remove('d-none');
document.getElementById('app-page').classList.add('d-none');
// Reset MFA sections when going back to login
resetLoginForm();
}
function showAppPage() {
@@ -211,6 +213,9 @@ async function handleAzureCallback() {
}
}
// Track MFA token between login steps
let pendingMfaToken = null;
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('login-error');
@@ -223,16 +228,26 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value,
});
authToken = data.access_token;
localStorage.setItem('authToken', authToken);
currentUser = data.user;
document.getElementById('nav-username').textContent = currentUser.username;
// Apply user's language preference
if (currentUser.default_language) {
await setLanguage(currentUser.default_language);
// Check if MFA is required
if (data.mfa_required) {
pendingMfaToken = data.mfa_token;
document.getElementById('login-form').classList.add('d-none');
document.getElementById('azure-login-divider').classList.add('d-none');
if (data.totp_setup_needed) {
// First-time TOTP setup — get QR code
await startMfaSetup();
} else {
// Existing TOTP — show verify form
document.getElementById('mfa-verify-section').classList.remove('d-none');
document.getElementById('mfa-code').focus();
}
return;
}
showAppPage();
loadDashboard();
// Normal login (no MFA)
completeLogin(data);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
@@ -241,6 +256,107 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
}
});
async function completeLogin(data) {
authToken = data.access_token;
localStorage.setItem('authToken', authToken);
currentUser = data.user;
document.getElementById('nav-username').textContent = currentUser.username;
if (currentUser.default_language) {
await setLanguage(currentUser.default_language);
}
pendingMfaToken = null;
showAppPage();
loadDashboard();
}
function resetLoginForm() {
pendingMfaToken = null;
document.getElementById('login-form').classList.remove('d-none');
document.getElementById('mfa-verify-section').classList.add('d-none');
document.getElementById('mfa-setup-section').classList.add('d-none');
document.getElementById('login-error').classList.add('d-none');
document.getElementById('login-password').value = '';
// Re-check azure config visibility
if (azureConfig.azure_enabled) {
document.getElementById('azure-login-divider').classList.remove('d-none');
}
}
// MFA Verify form (existing TOTP)
document.getElementById('mfa-verify-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('mfa-verify-error');
const spinner = document.getElementById('mfa-verify-spinner');
errorEl.classList.add('d-none');
spinner.classList.remove('d-none');
try {
const data = await api('POST', '/auth/mfa/verify', {
mfa_token: pendingMfaToken,
totp_code: document.getElementById('mfa-code').value,
});
completeLogin(data);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
document.getElementById('mfa-code').value = '';
document.getElementById('mfa-code').focus();
} finally {
spinner.classList.add('d-none');
}
});
// MFA Setup — get QR code from server
async function startMfaSetup() {
try {
const data = await api('POST', '/auth/mfa/setup', {
mfa_token: pendingMfaToken,
});
document.getElementById('mfa-qr-code').src = data.qr_code;
document.getElementById('mfa-secret-manual').textContent = data.secret;
document.getElementById('mfa-setup-section').classList.remove('d-none');
document.getElementById('mfa-setup-code').focus();
} catch (err) {
document.getElementById('login-error').textContent = err.message;
document.getElementById('login-error').classList.remove('d-none');
resetLoginForm();
}
}
// MFA Setup Complete form (first-time TOTP)
document.getElementById('mfa-setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorEl = document.getElementById('mfa-setup-error');
const spinner = document.getElementById('mfa-setup-spinner');
errorEl.classList.add('d-none');
spinner.classList.remove('d-none');
try {
const data = await api('POST', '/auth/mfa/setup/complete', {
mfa_token: pendingMfaToken,
totp_code: document.getElementById('mfa-setup-code').value,
});
completeLogin(data);
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
document.getElementById('mfa-setup-code').value = '';
document.getElementById('mfa-setup-code').focus();
} finally {
spinner.classList.add('d-none');
}
});
// Back-to-login links
document.getElementById('mfa-back-to-login').addEventListener('click', (e) => {
e.preventDefault();
resetLoginForm();
});
document.getElementById('mfa-setup-back-to-login').addEventListener('click', (e) => {
e.preventDefault();
resetLoginForm();
});
function logout() {
// Use fetch directly (not api()) to avoid 401 → logout → 401 infinite loop
if (authToken) {
@@ -711,6 +827,10 @@ async function loadSettings() {
document.getElementById('cfg-default-language').value = cfg.default_language || 'en';
updateLogoPreview(cfg.branding_logo_path);
// MFA tab (Security)
document.getElementById('cfg-mfa-enabled').checked = cfg.mfa_enabled || false;
loadMfaStatus();
// Azure AD tab
document.getElementById('cfg-azure-enabled').checked = cfg.azure_enabled || false;
document.getElementById('cfg-azure-tenant').value = cfg.azure_tenant_id || '';
@@ -905,11 +1025,14 @@ async function loadUsers() {
const users = await api('GET', '/users');
const tbody = document.getElementById('users-table-body');
if (!users || users.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="9" class="text-center text-muted py-4">${t('settings.noUsersFound') || t('common.loading')}</td></tr>`;
return;
}
tbody.innerHTML = users.map(u => {
const langDisplay = u.default_language ? u.default_language.toUpperCase() : `<span class="text-muted">${t('settings.systemDefault')}</span>`;
const mfaDisplay = u.totp_enabled
? `<span class="badge bg-success">${t('mfa.totpActive')}</span>`
: `<span class="text-muted">&mdash;</span>`;
return `<tr>
<td>${u.id}</td>
<td><strong>${esc(u.username)}</strong></td>
@@ -917,6 +1040,7 @@ async function loadUsers() {
<td><span class="badge bg-info">${esc(u.role || 'admin')}</span></td>
<td><span class="badge bg-${u.auth_provider === 'azure' ? 'primary' : 'secondary'}">${esc(u.auth_provider || 'local')}</span></td>
<td>${langDisplay}</td>
<td>${mfaDisplay}</td>
<td>${u.is_active ? `<span class="badge bg-success">${t('common.active')}</span>` : `<span class="badge bg-danger">${t('common.disabled')}</span>`}</td>
<td>
<div class="btn-group btn-group-sm">
@@ -925,13 +1049,14 @@ async function loadUsers() {
: `<button class="btn btn-outline-success" title="${t('common.enable')}" onclick="toggleUserActive(${u.id}, true)"><i class="bi bi-play-circle"></i></button>`
}
${u.auth_provider === 'local' ? `<button class="btn btn-outline-info" title="${t('common.resetPassword')}" onclick="resetUserPassword(${u.id}, '${esc(u.username)}')"><i class="bi bi-key"></i></button>` : ''}
${u.totp_enabled ? `<button class="btn btn-outline-secondary" title="${t('mfa.resetMfa')}" onclick="resetUserMfa(${u.id}, '${esc(u.username)}')"><i class="bi bi-shield-x"></i></button>` : ''}
<button class="btn btn-outline-danger" title="${t('common.delete')}" onclick="deleteUser(${u.id}, '${esc(u.username)}')"><i class="bi bi-trash"></i></button>
</div>
</td>
</tr>`;
}).join('');
} catch (err) {
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="8" class="text-danger">${err.message}</td></tr>`;
document.getElementById('users-table-body').innerHTML = `<tr><td colspan="9" class="text-danger">${err.message}</td></tr>`;
}
}
@@ -1019,6 +1144,61 @@ document.getElementById('settings-azure-form').addEventListener('submit', async
}
});
// ---------------------------------------------------------------------------
// MFA Settings
// ---------------------------------------------------------------------------
async function saveMfaSettings() {
try {
await api('PUT', '/settings/system', {
mfa_enabled: document.getElementById('cfg-mfa-enabled').checked,
});
showSettingsAlert('success', t('mfa.mfaSaved'));
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
}
async function loadMfaStatus() {
try {
const data = await api('GET', '/auth/mfa/status');
document.getElementById('cfg-mfa-enabled').checked = data.mfa_enabled_global;
const statusEl = document.getElementById('mfa-own-status');
const disableBtn = document.getElementById('mfa-disable-own');
if (data.totp_enabled_user) {
statusEl.innerHTML = `<span class="badge bg-success">${t('mfa.totpActive')}</span>`;
disableBtn.classList.remove('d-none');
} else {
statusEl.innerHTML = `<span class="badge bg-warning text-dark">${t('mfa.totpNotSetUp')}</span>`;
disableBtn.classList.add('d-none');
}
} catch (err) {
console.error('Failed to load MFA status:', err);
}
}
async function disableOwnTotp() {
try {
await api('POST', '/auth/mfa/disable');
showSettingsAlert('success', t('mfa.mfaDisabled'));
loadMfaStatus();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
}
async function resetUserMfa(id, username) {
if (!confirm(t('mfa.confirmResetMfa', { username }))) return;
try {
await api('POST', `/users/${id}/reset-mfa`);
showSettingsAlert('success', t('mfa.mfaResetSuccess', { username }));
loadUsers();
} catch (err) {
showSettingsAlert('danger', t('errors.failed', { error: err.message }));
}
}
function togglePasswordVisibility(inputId) {
const input = document.getElementById(inputId);
if (!input) return;

View File

@@ -225,6 +225,29 @@
"cancel": "Abbrechen",
"createUser": "Benutzer erstellen"
},
"mfa": {
"title": "Multi-Faktor-Authentifizierung (MFA)",
"enableMfa": "MFA fuer alle lokalen Benutzer aktivieren",
"mfaDescription": "Wenn aktiviert, muessen lokale Benutzer sich nach der Passworteingabe mit einer TOTP-Authenticator-App verifizieren. Azure AD Benutzer sind nicht betroffen.",
"saveMfaSettings": "MFA Einstellungen speichern",
"yourTotpStatus": "Ihr TOTP Status",
"totpActive": "Aktiv",
"totpNotSetUp": "Nicht eingerichtet",
"disableMyTotp": "Mein TOTP deaktivieren",
"enterCode": "Geben Sie Ihren 6-stelligen Authenticator-Code ein",
"verify": "Verifizieren",
"backToLogin": "Zurueck zum Login",
"scanQrCode": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App",
"orEnterManually": "Oder geben Sie diesen Schluessel manuell ein:",
"verifyAndActivate": "Verifizieren & Aktivieren",
"resetMfa": "MFA zuruecksetzen",
"confirmResetMfa": "MFA fuer '{username}' zuruecksetzen? Der Benutzer muss seinen Authenticator beim naechsten Login neu einrichten.",
"mfaResetSuccess": "MFA fuer '{username}' zurueckgesetzt.",
"mfaDisabled": "Ihr TOTP wurde deaktiviert.",
"mfaSaved": "MFA Einstellungen gespeichert.",
"invalidCode": "Ungueltiger Code. Bitte versuchen Sie es erneut.",
"codeExpired": "Verifizierung abgelaufen. Bitte melden Sie sich erneut an."
},
"common": {
"loading": "Laden...",
"back": "Zurueck",
@@ -244,7 +267,7 @@
"disabled": "Deaktiviert"
},
"errors": {
"networkError": "Netzwerkfehler \u2014 Server nicht erreichbar.",
"networkError": "Netzwerkfehler Server nicht erreichbar.",
"sessionExpired": "Sitzung abgelaufen.",
"requestFailed": "Anfrage fehlgeschlagen.",
"serverError": "Serverfehler (HTTP {status}).",

View File

@@ -225,6 +225,29 @@
"cancel": "Cancel",
"createUser": "Create User"
},
"mfa": {
"title": "Multi-Factor Authentication (MFA)",
"enableMfa": "Enable MFA for all local users",
"mfaDescription": "When enabled, local users must verify with a TOTP authenticator app after entering their password. Azure AD users are not affected.",
"saveMfaSettings": "Save MFA Settings",
"yourTotpStatus": "Your TOTP Status",
"totpActive": "Active",
"totpNotSetUp": "Not set up",
"disableMyTotp": "Disable my TOTP",
"enterCode": "Enter your 6-digit authenticator code",
"verify": "Verify",
"backToLogin": "Back to login",
"scanQrCode": "Scan this QR code with your authenticator app",
"orEnterManually": "Or enter this key manually:",
"verifyAndActivate": "Verify & Activate",
"resetMfa": "Reset MFA",
"confirmResetMfa": "Reset MFA for '{username}'? They will need to set up their authenticator again on next login.",
"mfaResetSuccess": "MFA reset for '{username}'.",
"mfaDisabled": "Your TOTP has been disabled.",
"mfaSaved": "MFA settings saved.",
"invalidCode": "Invalid code. Please try again.",
"codeExpired": "Verification expired. Please log in again."
},
"common": {
"loading": "Loading...",
"back": "Back",