OTP security hardened

This commit is contained in:
2026-08-26 18:24:02 +02:00
parent c3c3c8e1a6
commit 580c2a4257
13 changed files with 227 additions and 13 deletions
+26
View File
@@ -152,6 +152,11 @@ function renderUsers(users) {
privilegeLabel.append(privilegeCheckbox, document.createTextNode(' Administrator'));
row.append(label, privilegeLabel);
if (!isCurrentUser) {
const otpButton = document.createElement('button');
otpButton.type = 'button';
otpButton.textContent = 'Reset OTP';
otpButton.addEventListener('click', () => resetUserOtp(user, otpButton));
row.append(otpButton);
const button = document.createElement('button');
button.type = 'button';
button.className = 'danger-button';
@@ -163,6 +168,27 @@ function renderUsers(users) {
}));
}
async function resetUserOtp(user, button) {
if (!window.confirm(`Disable OTP for ${user.username}?`)) return;
button.disabled = true;
const status = document.querySelector('#user-status');
try {
const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/otp/reset`, {
method: 'POST',
headers: authHeaders(),
});
if (!response.ok) {
throw new Error(await responseError(response, `Request failed (${response.status})`));
}
status.textContent = `OTP disabled for ${user.username}.`;
status.style.color = '#94e2d5';
} catch (error) {
status.textContent = `Could not reset OTP for ${user.username}: ${error.message}`;
status.style.color = '#f38ba8';
button.disabled = false;
}
}
async function loadUsers() {
const response = await fetch('/api/admin/users', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load users');
+23 -1
View File
@@ -12,11 +12,13 @@ const mastodonConnectButton = document.querySelector('#mastodon-connect');
const otpSetupButton = document.querySelector('#otp-setup');
const otpEnableButton = document.querySelector('#otp-enable');
const otpDisableButton = document.querySelector('#otp-disable');
const otpRecoverButton = document.querySelector('#otp-recover');
const otpProvisioning = document.querySelector('#otp-provisioning');
const otpDisabled = document.querySelector('#otp-disabled');
const otpEnabled = document.querySelector('#otp-enabled');
const otpSecret = document.querySelector('#otp-secret');
const otpUri = document.querySelector('#otp-uri');
const otpRecoveryCodes = document.querySelector('#otp-recovery-codes');
const otpStatus = document.querySelector('#otp-status');
const emailAddressList = document.querySelector('#email-address-list');
const additionalEmailForm = document.querySelector('#additional-email-form');
@@ -131,6 +133,7 @@ otpSetupButton.addEventListener('click', async () => {
}
otpSecret.textContent = result.secret;
otpUri.href = result.otpauth_url;
otpRecoveryCodes.textContent = result.recovery_codes.join('\n');
otpProvisioning.classList.remove('hidden');
setOtpStatus('Enter a code from your authenticator app to confirm setup.');
});
@@ -153,8 +156,9 @@ otpEnableButton.addEventListener('click', async () => {
otpDisableButton.addEventListener('click', async () => {
const code = document.querySelector('#otp-disable-code').value.trim();
const currentPassword = document.querySelector('#otp-current-password').value;
const response = await fetch('/api/user/otp', {
method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'disable', code}),
method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'disable', code, current_password: currentPassword}),
});
const result = await response.json();
if (!response.ok) {
@@ -167,6 +171,24 @@ otpDisableButton.addEventListener('click', async () => {
setOtpStatus('One-time password disabled.');
});
otpRecoverButton.addEventListener('click', async () => {
const currentPassword = document.querySelector('#otp-current-password').value;
const recoveryCode = document.querySelector('#otp-recovery-code').value.trim();
const response = await fetch('/api/user/otp/recover', {
method: 'POST', headers: authHeaders(true), body: JSON.stringify({current_password: currentPassword, recovery_code: recoveryCode}),
});
const result = await response.json();
if (!response.ok) {
setOtpStatus(result.detail || 'Could not recover one-time password access.', true);
return;
}
otpDisabled.classList.remove('hidden');
otpEnabled.classList.add('hidden');
document.querySelector('#otp-current-password').value = '';
document.querySelector('#otp-recovery-code').value = '';
setOtpStatus('One-time password access recovered.');
});
async function loadProfile() {
const response = await fetch('/api/user/me', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load profile');
+6
View File
@@ -108,13 +108,19 @@
<label>Verification code <input id="otp-setup-code" inputmode="numeric"
autocomplete="one-time-code" /></label>
<button id="otp-enable" type="button">Enable one-time password</button>
<p>Save these recovery codes in a secure place. They are shown only once:</p>
<code id="otp-recovery-codes"></code>
</div>
</div>
<div id="otp-enabled" class="hidden">
<p>One-time password is enabled.</p>
<label>Current password <input id="otp-current-password" type="password" autocomplete="current-password" /></label>
<label>Verification code <input id="otp-disable-code" inputmode="numeric"
autocomplete="one-time-code" /></label>
<button id="otp-disable" type="button">Disable one-time password</button>
<p>Lost access to your authenticator? Use a saved recovery code.</p>
<label>Recovery code <input id="otp-recovery-code" type="text" autocomplete="one-time-code" /></label>
<button id="otp-recover" type="button">Recover and disable one-time password</button>
</div>
<p id="otp-status" class="status" role="status"></p>
</section>