mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
|
|
import { useSignal } from '@preact/signals';
|
||
|
|
import { startRegistration } from '@simplewebauthn/browser';
|
||
|
|
const methodTypeLabels = {
|
||
|
|
totp: 'Authenticator App',
|
||
|
|
passkey: 'Passkey',
|
||
|
|
email: 'Email'
|
||
|
|
};
|
||
|
|
const methodTypeDescriptions = {
|
||
|
|
totp: 'Use an authenticator app like Aegis Authenticator or Google Authenticator to generate codes.',
|
||
|
|
passkey: 'Use biometric authentication or security keys.',
|
||
|
|
email: 'Receive codes in your email.'
|
||
|
|
};
|
||
|
|
const availableMethodTypes = ['totp', 'passkey', 'email'];
|
||
|
|
export default function MultiFactorAuthSettings({
|
||
|
|
methods
|
||
|
|
}) {
|
||
|
|
const setupData = useSignal(null);
|
||
|
|
const isLoading = useSignal(false);
|
||
|
|
const error = useSignal(null);
|
||
|
|
const success = useSignal(null);
|
||
|
|
const showDisableForm = useSignal(null);
|
||
|
|
const verificationToken = useSignal('');
|
||
|
|
const disablePassword = useSignal('');
|
||
|
|
const enabledMethods = methods.filter(method => method.enabled);
|
||
|
|
const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
|
||
|
|
const setupPasskey = async () => {
|
||
|
|
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({})
|
||
|
|
});
|
||
|
|
if (!beginResponse.ok) {
|
||
|
|
throw new Error(`Failed to begin passkey registration! ${beginResponse.statusText} ${await beginResponse.text()}`);
|
||
|
|
}
|
||
|
|
const beginData = await beginResponse.json();
|
||
|
|
if (!beginData.success) {
|
||
|
|
throw new Error(beginData.error || 'Failed to begin passkey registration.');
|
||
|
|
}
|
||
|
|
const registrationResponse = await startRegistration({
|
||
|
|
optionsJSON: beginData.options
|
||
|
|
});
|
||
|
|
const completeRequestBody = {
|
||
|
|
methodId: beginData.sessionData.methodId,
|
||
|
|
challenge: beginData.sessionData.challenge,
|
||
|
|
registrationResponse
|
||
|
|
};
|
||
|
|
const completeResponse = await fetch('/api/auth/multi-factor/passkey/setup-complete', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(completeRequestBody)
|
||
|
|
});
|
||
|
|
if (!completeResponse.ok) {
|
||
|
|
throw new Error(`Failed to complete passkey registration! ${completeResponse.statusText} ${await completeResponse.text()}`);
|
||
|
|
}
|
||
|
|
const completeData = await completeResponse.json();
|
||
|
|
if (!completeData.success) {
|
||
|
|
throw new Error(completeData.error || 'Failed to complete passkey registration.');
|
||
|
|
}
|
||
|
|
setupData.value = {
|
||
|
|
methodId: beginData.sessionData.methodId,
|
||
|
|
type: 'passkey'
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const setupTOTP = async () => {
|
||
|
|
const response = await fetch('/api/auth/multi-factor/totp/setup', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({})
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to setup TOTP multi-factor authentication. ${response.statusText} ${await response.text()}`);
|
||
|
|
}
|
||
|
|
const data = await response.json();
|
||
|
|
if (!data.success || !data.data) {
|
||
|
|
throw new Error(data.error || 'Failed to setup TOTP multi-factor authentication.');
|
||
|
|
}
|
||
|
|
setupData.value = {
|
||
|
|
type: 'totp',
|
||
|
|
secret: data.data.secret,
|
||
|
|
qrCodeUrl: data.data.qrCodeUrl,
|
||
|
|
backupCodes: data.data.backupCodes,
|
||
|
|
methodId: data.data.methodId
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const setupEmail = async () => {
|
||
|
|
const response = await fetch('/api/auth/multi-factor/email/setup', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify({})
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to setup email multi-factor authentication. Please check your SMTP settings are valid and try again. ${response.statusText} ${await response.text()}`);
|
||
|
|
}
|
||
|
|
const data = await response.json();
|
||
|
|
if (!data.success || !data.data) {
|
||
|
|
throw new Error(data.error || 'Failed to setup email multi-factor authentication.');
|
||
|
|
}
|
||
|
|
setupData.value = {
|
||
|
|
type: 'email',
|
||
|
|
methodId: data.data.methodId
|
||
|
|
};
|
||
|
|
};
|
||
|
|
const setupMultiFactorAuth = async type => {
|
||
|
|
isLoading.value = true;
|
||
|
|
error.value = null;
|
||
|
|
try {
|
||
|
|
if (type === 'totp') {
|
||
|
|
await setupTOTP();
|
||
|
|
} else if (type === 'passkey') {
|
||
|
|
await setupPasskey();
|
||
|
|
} else if (type === 'email') {
|
||
|
|
await setupEmail();
|
||
|
|
}
|
||
|
|
} catch (setupError) {
|
||
|
|
error.value = setupError.message;
|
||
|
|
} finally {
|
||
|
|
isLoading.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const enableMultiFactorAuth = async () => {
|
||
|
|
if (!setupData.value) {
|
||
|
|
error.value = 'No setup data available';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (setupData.value.type !== 'passkey' && !verificationToken.value) {
|
||
|
|
error.value = 'Please enter a verification code/token';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
isLoading.value = true;
|
||
|
|
error.value = null;
|
||
|
|
try {
|
||
|
|
const requestBody = {
|
||
|
|
methodId: setupData.value.methodId,
|
||
|
|
code: setupData.value.type === 'passkey' ? 'passkey-verified' : verificationToken.value
|
||
|
|
};
|
||
|
|
const response = await fetch('/api/auth/multi-factor/enable', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(requestBody)
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to enable multi-factor authentication method. ${response.statusText} ${await response.text()}`);
|
||
|
|
}
|
||
|
|
const data = await response.json();
|
||
|
|
if (!data.success) {
|
||
|
|
throw new Error(data.error || 'Failed to enable multi-factor authentication method.');
|
||
|
|
}
|
||
|
|
success.value = 'Multi-factor authentication method has been enabled successfully! Reloading...';
|
||
|
|
setupData.value = null;
|
||
|
|
verificationToken.value = '';
|
||
|
|
setTimeout(() => {
|
||
|
|
window.location.reload();
|
||
|
|
}, 2000);
|
||
|
|
} catch (enableError) {
|
||
|
|
error.value = enableError.message;
|
||
|
|
} finally {
|
||
|
|
isLoading.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const disableMultiFactorAuth = async (methodId, disableAll = false) => {
|
||
|
|
if (!disablePassword.value) {
|
||
|
|
error.value = 'Please enter your password';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
isLoading.value = true;
|
||
|
|
error.value = null;
|
||
|
|
try {
|
||
|
|
const requestBody = {
|
||
|
|
methodId,
|
||
|
|
password: disablePassword.value,
|
||
|
|
disableAll
|
||
|
|
};
|
||
|
|
const response = await fetch('/api/auth/multi-factor/disable', {
|
||
|
|
method: 'POST',
|
||
|
|
body: JSON.stringify(requestBody)
|
||
|
|
});
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Failed to disable multi-factor authentication method. ${response.statusText} ${await response.text()}`);
|
||
|
|
}
|
||
|
|
const data = await response.json();
|
||
|
|
if (!data.success) {
|
||
|
|
throw new Error(data.error || 'Failed to disable multi-factor authentication method.');
|
||
|
|
}
|
||
|
|
success.value = 'Multi-factor authentication method has been disabled successfully! Reloading...';
|
||
|
|
showDisableForm.value = null;
|
||
|
|
disablePassword.value = '';
|
||
|
|
setTimeout(() => {
|
||
|
|
window.location.reload();
|
||
|
|
}, 2000);
|
||
|
|
} catch (disableError) {
|
||
|
|
error.value = disableError.message;
|
||
|
|
} finally {
|
||
|
|
isLoading.value = false;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
const cancelSetup = () => {
|
||
|
|
setupData.value = null;
|
||
|
|
verificationToken.value = '';
|
||
|
|
error.value = null;
|
||
|
|
};
|
||
|
|
const cancelDisable = () => {
|
||
|
|
showDisableForm.value = null;
|
||
|
|
disablePassword.value = '';
|
||
|
|
error.value = null;
|
||
|
|
};
|
||
|
|
return h("section", {
|
||
|
|
class: "mb-16"
|
||
|
|
}, h("h2", {
|
||
|
|
class: "text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96"
|
||
|
|
}, "Multi-Factor Authentication (MFA)"), h("section", {
|
||
|
|
class: "px-4 max-w-3xl mx-auto lg:min-w-96"
|
||
|
|
}, error.value ? h("section", {
|
||
|
|
class: "notification-error mb-4"
|
||
|
|
}, h("p", null, error.value)) : null, success.value ? h("section", {
|
||
|
|
class: "notification-success mb-4"
|
||
|
|
}, h("p", null, success.value)) : null, h("p", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, "Multi-factor authentication adds an extra layer of security to your account by requiring additional verification beyond your password."), availableMethodTypes.filter(type => !enabledMethods.some(method => method.type === type)).length > 0 ? h("section", {
|
||
|
|
class: "mb-6 mt-4"
|
||
|
|
}, h("h3", {
|
||
|
|
class: "text-lg font-semibold mb-4"
|
||
|
|
}, "Available Authentication Methods"), h("section", {
|
||
|
|
class: "space-y-4"
|
||
|
|
}, availableMethodTypes.filter(type => !enabledMethods.some(method => method.type === type) && setupData.value?.type !== type).map(type => h("section", {
|
||
|
|
key: type,
|
||
|
|
class: "border rounded-lg p-4"
|
||
|
|
}, h("section", {
|
||
|
|
class: "flex items-center justify-between"
|
||
|
|
}, h("section", null, h("h4", {
|
||
|
|
class: "font-medium"
|
||
|
|
}, methodTypeLabels[type]), h("p", {
|
||
|
|
class: "text-sm text-gray-400"
|
||
|
|
}, methodTypeDescriptions[type])), h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: () => setupMultiFactorAuth(type),
|
||
|
|
disabled: isLoading.value,
|
||
|
|
class: "button-secondary"
|
||
|
|
}, isLoading.value ? '...' : 'Add')))))) : null, setupData.value && setupData.value.type === 'totp' ? h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("h3", {
|
||
|
|
class: "text-lg font-semibold mb-4"
|
||
|
|
}, "Setup Authenticator App"), h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("p", {
|
||
|
|
class: "mb-4"
|
||
|
|
}, "1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.):"), h("section", {
|
||
|
|
class: "flex justify-center mb-4 max-w-sm mx-auto"
|
||
|
|
}, h("img", {
|
||
|
|
src: setupData.value.qrCodeUrl,
|
||
|
|
alt: "TOTP QR Code",
|
||
|
|
class: "border-8 border-white"
|
||
|
|
})), h("p", {
|
||
|
|
class: "text-sm text-gray-400 mb-4"
|
||
|
|
}, "Or manually enter this secret:", ' ', h("code", {
|
||
|
|
class: "bg-gray-200 px-2 py-1 rounded text-gray-900"
|
||
|
|
}, setupData.value.secret))), h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("p", {
|
||
|
|
class: "mb-4"
|
||
|
|
}, "2. Save these backup codes ", h("strong", {
|
||
|
|
class: "font-bold text-sky-500"
|
||
|
|
}, "NOW"), " in a safe place:"), h("section", {
|
||
|
|
class: "bg-gray-200 border rounded p-4 font-mono text-sm text-gray-900"
|
||
|
|
}, setupData.value.backupCodes.map((code, index) => h("section", {
|
||
|
|
key: index,
|
||
|
|
class: "mb-1"
|
||
|
|
}, code))), h("p", {
|
||
|
|
class: "text-sm text-gray-400 mt-2"
|
||
|
|
}, "These codes can be used to access your account if you lose your authenticator device.", ' ', h("strong", {
|
||
|
|
class: "font-bold text-sky-500"
|
||
|
|
}, "They won't be visible again"), ".")), h("fieldset", {
|
||
|
|
class: "block mb-6"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1"
|
||
|
|
}, "3. Enter the 6-digit code from your authenticator app:"), h("input", {
|
||
|
|
type: "text",
|
||
|
|
value: verificationToken.value,
|
||
|
|
onInput: event => verificationToken.value = event.target.value,
|
||
|
|
placeholder: "123456",
|
||
|
|
class: "mt-1 input-field",
|
||
|
|
maxLength: 6
|
||
|
|
})), h("section", {
|
||
|
|
class: "flex justify-end gap-2 mt-8 mb-4"
|
||
|
|
}, h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: cancelSetup,
|
||
|
|
disabled: isLoading.value,
|
||
|
|
class: "button-secondary"
|
||
|
|
}, "Cancel"), h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: enableMultiFactorAuth,
|
||
|
|
disabled: isLoading.value || !verificationToken.value,
|
||
|
|
class: "button"
|
||
|
|
}, isLoading.value ? 'Enabling...' : 'Enable TOTP MFA'))) : null, setupData.value && setupData.value.type === 'passkey' ? h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("h3", {
|
||
|
|
class: "text-lg font-semibold mb-4"
|
||
|
|
}, "Passkey Setup Complete"), h("p", {
|
||
|
|
class: "mb-4"
|
||
|
|
}, "Your passkey has been successfully registered! You can now enable it for multi-factor authentication."), h("section", {
|
||
|
|
class: "flex justify-end gap-2 mt-8 mb-4"
|
||
|
|
}, h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: cancelSetup,
|
||
|
|
disabled: isLoading.value,
|
||
|
|
class: "button-secondary"
|
||
|
|
}, "Cancel"), h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: enableMultiFactorAuth,
|
||
|
|
disabled: isLoading.value,
|
||
|
|
class: "button"
|
||
|
|
}, isLoading.value ? 'Enabling...' : 'Enable Passkey MFA'))) : null, setupData.value && setupData.value.type === 'email' ? h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("h3", {
|
||
|
|
class: "text-lg font-semibold mb-4"
|
||
|
|
}, "Setup Email"), h("fieldset", {
|
||
|
|
class: "block mb-6"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1"
|
||
|
|
}, "Enter the 6-digit code you received in your email:"), h("input", {
|
||
|
|
type: "text",
|
||
|
|
value: verificationToken.value,
|
||
|
|
onInput: event => verificationToken.value = event.target.value,
|
||
|
|
placeholder: "123456",
|
||
|
|
class: "mt-1 input-field",
|
||
|
|
maxLength: 6
|
||
|
|
})), h("section", {
|
||
|
|
class: "flex justify-end gap-2 mt-8 mb-4"
|
||
|
|
}, h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: cancelSetup,
|
||
|
|
disabled: isLoading.value,
|
||
|
|
class: "button-secondary"
|
||
|
|
}, "Cancel"), h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: enableMultiFactorAuth,
|
||
|
|
disabled: isLoading.value || !verificationToken.value,
|
||
|
|
class: "button"
|
||
|
|
}, isLoading.value ? 'Enabling...' : 'Enable Email MFA'))) : null, hasMultiFactorAuthEnabled && !showDisableForm.value ? h("section", null, h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("h3", {
|
||
|
|
class: "text-lg font-semibold mb-4"
|
||
|
|
}, "Active Authentication Methods"), enabledMethods.map(method => h("section", {
|
||
|
|
key: method.id,
|
||
|
|
class: "border rounded-lg p-4 mb-4"
|
||
|
|
}, h("section", {
|
||
|
|
class: "flex items-center justify-between"
|
||
|
|
}, h("section", null, h("section", {
|
||
|
|
class: `flex items-center ${method.type === 'totp' && typeof method.backupCodesCount !== 'undefined' ? 'mb-2' : ''}`
|
||
|
|
}, h("span", {
|
||
|
|
class: "inline-block w-3 h-3 bg-green-500 rounded-full mr-2"
|
||
|
|
}), h("span", {
|
||
|
|
class: "font-medium"
|
||
|
|
}, method.name)), method.type === 'totp' && typeof method.backupCodesCount !== 'undefined' ? h("p", {
|
||
|
|
class: "text-sm text-gray-600"
|
||
|
|
}, method.backupCodesCount > 0 ? `${method.backupCodesCount} backup codes remaining` : 'No backup codes remaining') : null), h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: () => showDisableForm.value = method.id,
|
||
|
|
class: "button-secondary"
|
||
|
|
}, "Disable"))))), h("section", {
|
||
|
|
class: "flex justify-end mt-8 mb-4"
|
||
|
|
}, h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: () => showDisableForm.value = 'all',
|
||
|
|
class: "button-danger"
|
||
|
|
}, "Disable All MFA"))) : null, showDisableForm.value ? h("section", {
|
||
|
|
class: "mb-6"
|
||
|
|
}, h("h3", {
|
||
|
|
class: "text-lg font-semibold mb-4"
|
||
|
|
}, showDisableForm.value === 'all' ? 'Disable All Multi-Factor Authentication' : 'Disable Authentication Method'), h("p", {
|
||
|
|
class: "mb-4"
|
||
|
|
}, showDisableForm.value === 'all' ? 'This will disable all multi-factor authentication methods and make your account less secure.' : 'This will disable this authentication method.', " Please enter your password to confirm."), h("fieldset", {
|
||
|
|
class: "block mb-4"
|
||
|
|
}, h("label", {
|
||
|
|
class: "text-slate-300 block pb-1"
|
||
|
|
}, "Password"), h("input", {
|
||
|
|
type: "password",
|
||
|
|
value: disablePassword.value,
|
||
|
|
onInput: event => disablePassword.value = event.target.value,
|
||
|
|
placeholder: "Enter your password",
|
||
|
|
class: "mt-1 input-field"
|
||
|
|
})), h("section", {
|
||
|
|
class: "flex justify-end gap-2 mt-8 mb-4"
|
||
|
|
}, h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: cancelDisable,
|
||
|
|
disabled: isLoading.value,
|
||
|
|
class: "button-secondary"
|
||
|
|
}, "Cancel"), h("button", {
|
||
|
|
type: "button",
|
||
|
|
onClick: () => disableMultiFactorAuth(showDisableForm.value === 'all' ? undefined : showDisableForm.value || undefined, showDisableForm.value === 'all'),
|
||
|
|
disabled: isLoading.value || !disablePassword.value,
|
||
|
|
class: "button-danger"
|
||
|
|
}, isLoading.value ? 'Disabling...' : 'Disable'))) : null));
|
||
|
|
}
|