feat: add passkey-based two-factor authentication

This commit is contained in:
shuaiplus
2026-07-05 14:51:11 +08:00
parent f63b745d05
commit c019c93726
31 changed files with 1540 additions and 116 deletions
+82 -9
View File
@@ -1,3 +1,4 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost';
import { t } from '@/lib/i18n';
@@ -22,11 +23,13 @@ interface AppGlobalOverlaysProps {
onCancelConfirm: () => void;
pendingTotpOpen: boolean;
pendingTotpProviderType?: number;
pendingTotpAvailableProviders?: number[];
totpCode: string;
rememberDevice: boolean;
onTotpCodeChange: (value: string) => void;
onRememberDeviceChange: (checked: boolean) => void;
onConfirmTotp: () => void;
onSelectTotpProvider: (providerType: number) => void;
onCancelTotp: () => void;
onUseRecoveryCode: () => void;
totpSubmitting: boolean;
@@ -38,8 +41,40 @@ interface AppGlobalOverlaysProps {
disableTotpSubmitting: boolean;
}
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_ORDER = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
const available = new Set(providerTypes || []);
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
}
function twoFactorProviderLabel(providerType: number): string {
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
return t('txt_authenticator_app');
}
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const isYubiKeyOtp = props.pendingTotpProviderType === 3;
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders]
);
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
useEffect(() => {
setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
return (
<>
<ConfirmDialog
@@ -57,10 +92,11 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<ConfirmDialog
open={props.pendingTotpOpen}
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : t('txt_two_step_verification')}
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : t('txt_password_is_already_verified')}
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? `${t('txt_two_step_verification')} ${t('txt_passkey')}` : t('txt_two_step_verification')}
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
confirmText={t('txt_verify')}
cancelText={t('txt_cancel')}
hideCancel
closeButton
showIcon={false}
confirmDisabled={props.totpSubmitting}
cancelDisabled={props.totpSubmitting}
@@ -69,16 +105,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
afterActions={(
<div className="dialog-extra">
<div className="dialog-divider" />
{alternateProviders.length > 0 && (
<div className="two-factor-method-switcher">
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.totpSubmitting}
aria-expanded={methodChooserOpen}
onClick={() => setMethodChooserOpen((open) => !open)}
>
{t('txt_select_another_verification_method')}
</button>
{methodChooserOpen && (
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
{alternateProviders.map((providerType) => (
<button
key={providerType}
type="button"
className="btn btn-secondary two-factor-method-option"
disabled={props.totpSubmitting}
onClick={() => {
setMethodChooserOpen(false);
props.onSelectTotpProvider(providerType);
}}
>
{twoFactorProviderLabel(providerType)}
</button>
))}
</div>
)}
</div>
)}
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
{t('txt_use_recovery_code')}
</button>
</div>
)}
>
<label className="field">
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
{isWebAuthn ? (
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
) : (
<label className="field">
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
</label>
)}
<label className="check-line check-line-compact">
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
<span>{t('txt_trust_this_device_for_30_days')}</span>
@@ -90,7 +162,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
title={t('txt_disable_totp')}
message={t('txt_enter_master_password_to_disable_two_step_verification')}
confirmText={t('txt_disable_totp')}
cancelText={t('txt_cancel')}
hideCancel
closeButton
danger
showIcon={false}
confirmDisabled={props.disableTotpSubmitting}
+11 -1
View File
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -56,6 +56,7 @@ export interface AppMainRoutesProps {
adminError: string;
totpEnabled: boolean;
yubikeyEnabled: boolean;
passkey2faEnabled: boolean;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[];
@@ -118,6 +119,10 @@ export interface AppMainRoutesProps {
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onDisableYubiKey: (masterPassword: string) => Promise<void>;
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
@@ -276,6 +281,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
profile={props.profile}
totpEnabled={props.totpEnabled}
yubikeyEnabled={props.yubikeyEnabled}
passkey2faEnabled={props.passkey2faEnabled}
themePreference={props.themePreference}
lockTimeoutMinutes={props.lockTimeoutMinutes}
sessionTimeoutAction={props.sessionTimeoutAction}
@@ -290,6 +296,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
onDisableYubiKey={props.onDisableYubiKey}
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
onGetRecoveryCode={props.onGetRecoveryCode}
onGetApiKey={props.onGetApiKey}
onRotateApiKey={props.onRotateApiKey}
-28
View File
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
}
export default function NotFoundPage(props: NotFoundPageProps) {
const starBoxes = [1, 2, 3, 4];
const stars = [1, 2, 3, 4, 5, 6, 7];
return (
<main className="not-found-page">
<div className="not-found-space" aria-hidden="true">
{starBoxes.map((box) => (
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
{stars.map((star) => (
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
))}
</div>
))}
</div>
<section className="not-found-shell" aria-labelledby="not-found-title">
<div className="not-found-brand">
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
</div>
<div className="not-found-astro-stage" aria-hidden="true">
<div className="not-found-astronaut">
<div className="not-found-astro-head" />
<div className="not-found-astro-arm not-found-astro-arm-left" />
<div className="not-found-astro-arm not-found-astro-arm-right" />
<div className="not-found-astro-body">
<div className="not-found-astro-panel" />
</div>
<div className="not-found-astro-leg not-found-astro-leg-left" />
<div className="not-found-astro-leg not-found-astro-leg-right" />
<div className="not-found-astro-pack" />
</div>
</div>
<div className="not-found-copy">
<div className="not-found-code">404</div>
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
+162 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, Profile, YubiKeyOtpSettings } from '@/lib/types';
import type { AccountPasskeyCredential, Profile, TwoFactorPasskeyCredential, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
@@ -10,6 +10,7 @@ interface SettingsPageProps {
profile: Profile;
totpEnabled: boolean;
yubikeyEnabled: boolean;
passkey2faEnabled: boolean;
themePreference: ThemePreference;
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout';
@@ -24,6 +25,10 @@ interface SettingsPageProps {
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
onDisableYubiKey: (masterPassword: string) => Promise<void>;
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
@@ -47,6 +52,7 @@ type MasterPasswordPromptAction =
| 'rotateApiKey'
| 'manageTotp'
| 'manageYubiKey'
| 'managePasskey2fa'
| 'createPasskey'
| 'enablePasskeyDirectUnlock'
| 'deletePasskey';
@@ -143,6 +149,12 @@ export default function SettingsPage(props: SettingsPageProps) {
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
const [yubiKeySubmitting, setYubiKeySubmitting] = useState(false);
const [twoFactorPasskeyEnabled, setTwoFactorPasskeyEnabled] = useState(props.passkey2faEnabled);
const [twoFactorPasskeys, setTwoFactorPasskeys] = useState<TwoFactorPasskeyCredential[]>([]);
const [twoFactorPasskeyDialogOpen, setTwoFactorPasskeyDialogOpen] = useState(false);
const [twoFactorPasskeyMasterPassword, setTwoFactorPasskeyMasterPassword] = useState('');
const [twoFactorPasskeyName, setTwoFactorPasskeyName] = useState(t('txt_passkey'));
const [twoFactorPasskeySubmitting, setTwoFactorPasskeySubmitting] = useState(false);
const [twoFactorStatusRefreshing, setTwoFactorStatusRefreshing] = useState(false);
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
const [totpManagePassword, setTotpManagePassword] = useState('');
@@ -172,6 +184,10 @@ export default function SettingsPage(props: SettingsPageProps) {
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
useEffect(() => {
setTwoFactorPasskeyEnabled(props.passkey2faEnabled);
}, [props.passkey2faEnabled]);
useEffect(() => {
void refreshAccountPasskeys();
}, [props.profile.id]);
@@ -250,6 +266,12 @@ export default function SettingsPage(props: SettingsPageProps) {
applyYubiKeySettings(settings);
setYubiKeyConfigOpen(false);
setYubiKeyDialogOpen(true);
} else if (masterPasswordPrompt === 'managePasskey2fa') {
const settings = await props.onGetTwoFactorPasskeySettings(masterPassword);
setTwoFactorPasskeyMasterPassword(masterPassword);
applyTwoFactorPasskeySettings(settings);
setTwoFactorPasskeyName(t('txt_passkey'));
setTwoFactorPasskeyDialogOpen(true);
} else if (masterPasswordPrompt === 'createPasskey') {
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
setCreatePasskeyMasterPassword(masterPassword);
@@ -284,6 +306,8 @@ export default function SettingsPage(props: SettingsPageProps) {
? t('txt_totp')
: masterPasswordPrompt === 'manageYubiKey'
? 'YubiKey'
: masterPasswordPrompt === 'managePasskey2fa'
? t('txt_two_step_passkeys')
: masterPasswordPrompt === 'createPasskey'
? t('txt_add_account_passkey')
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
@@ -327,7 +351,6 @@ export default function SettingsPage(props: SettingsPageProps) {
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
setYubiKeyNfc(false);
setYubiKeyEnabled(false);
setYubiKeyYubicoConfigured(false);
setYubiKeyYubicoClientId('');
setYubiKeyYubicoSecretKey('');
@@ -402,6 +425,60 @@ export default function SettingsPage(props: SettingsPageProps) {
}
}
function applyTwoFactorPasskeySettings(settings: TwoFactorPasskeySettings): void {
setTwoFactorPasskeyEnabled(settings.enabled);
setTwoFactorPasskeys(settings.keys);
}
function closeTwoFactorPasskeyDialog(): void {
if (twoFactorPasskeySubmitting) return;
setTwoFactorPasskeyDialogOpen(false);
setTwoFactorPasskeyMasterPassword('');
setTwoFactorPasskeyName(t('txt_passkey'));
}
async function createTwoFactorPasskeyDialog(): Promise<void> {
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword) return;
setTwoFactorPasskeySubmitting(true);
try {
const settings = await props.onCreateTwoFactorPasskey(twoFactorPasskeyName, twoFactorPasskeyMasterPassword);
applyTwoFactorPasskeySettings(settings);
setTwoFactorPasskeyName(t('txt_passkey'));
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_passkey_setup_failed'));
} finally {
setTwoFactorPasskeySubmitting(false);
}
}
async function deleteTwoFactorPasskeyDialog(id: number): Promise<void> {
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword || twoFactorPasskeys.length < 2) return;
setTwoFactorPasskeySubmitting(true);
try {
applyTwoFactorPasskeySettings(await props.onDeleteTwoFactorPasskey(id, twoFactorPasskeyMasterPassword));
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
} finally {
setTwoFactorPasskeySubmitting(false);
}
}
async function disableTwoFactorPasskeysDialog(): Promise<void> {
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword || !twoFactorPasskeyEnabled) return;
setTwoFactorPasskeySubmitting(true);
try {
await props.onDisableTwoFactorPasskeys(twoFactorPasskeyMasterPassword);
applyTwoFactorPasskeySettings({ enabled: false, keys: [] });
setTwoFactorPasskeyDialogOpen(false);
setTwoFactorPasskeyMasterPassword('');
setTwoFactorPasskeyName(t('txt_passkey'));
} catch (error) {
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_disable_passkey_two_step_failed'));
} finally {
setTwoFactorPasskeySubmitting(false);
}
}
async function refreshTwoFactorStatus(): Promise<void> {
if (twoFactorStatusRefreshing) return;
setTwoFactorStatusRefreshing(true);
@@ -726,10 +803,13 @@ export default function SettingsPage(props: SettingsPageProps) {
<KeyRound size={28} />
</div>
<div className="two-step-provider-copy">
<strong>{t('txt_passkeys')}</strong>
<div className="two-step-provider-title">
<strong>{t('txt_passkeys')}</strong>
{twoFactorPasskeyEnabled && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
</div>
<span>{t('txt_passkey_provider_help')}</span>
</div>
<button type="button" className="btn btn-secondary" disabled>
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('managePasskey2fa')}>
{t('txt_manage')}
</button>
</div>
@@ -1028,13 +1108,90 @@ export default function SettingsPage(props: SettingsPageProps) {
)}
</div>
</ConfirmDialog>
<ConfirmDialog
open={twoFactorPasskeyDialogOpen}
title={t('txt_two_step_passkeys')}
message={t('txt_two_step_passkeys_help')}
hideConfirm
hideCancel
closeButton
cancelDisabled={twoFactorPasskeySubmitting}
onConfirm={() => {}}
onCancel={closeTwoFactorPasskeyDialog}
>
<div className="settings-vertical-fields">
<div className="field">
<label htmlFor="two-factor-passkey-name">{t('txt_passkey_name')}</label>
<div className="two-factor-passkey-register-row">
<input
id="two-factor-passkey-name"
className="input"
maxLength={128}
value={twoFactorPasskeyName}
placeholder={t('txt_two_step_passkey_name_placeholder')}
onInput={(e) => setTwoFactorPasskeyName((e.currentTarget as HTMLInputElement).value)}
/>
<button
type="button"
className="btn btn-primary"
disabled={twoFactorPasskeySubmitting}
onClick={() => void createTwoFactorPasskeyDialog()}
>
<KeyRound size={14} className="btn-icon" />
{t('txt_register')}
</button>
</div>
</div>
<div className="two-factor-passkey-list-block">
<div className="settings-list-label">{t('txt_key_list')}</div>
{twoFactorPasskeys.length > 0 ? (
<div className="account-passkey-list">
{twoFactorPasskeys.map((credential, index) => (
<div key={credential.id} className="account-passkey-row two-factor-passkey-row">
<span className="account-passkey-index">{index + 1}</span>
<div className="account-passkey-main">
<strong>{credential.name || t('txt_dash')}</strong>
</div>
<button
type="button"
className="btn btn-danger small"
disabled={twoFactorPasskeySubmitting || twoFactorPasskeys.length < 2}
title={twoFactorPasskeys.length < 2 ? t('txt_remove_last_passkey_hint') : t('txt_delete')}
onClick={() => void deleteTwoFactorPasskeyDialog(credential.id)}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_delete')}
</button>
</div>
))}
</div>
) : (
<p className="muted-inline settings-field-note">{t('txt_no_two_step_passkeys')}</p>
)}
</div>
<div className="actions two-factor-passkey-danger-actions">
{twoFactorPasskeyEnabled && (
<button
type="button"
className="btn btn-danger"
disabled={twoFactorPasskeySubmitting}
onClick={() => void disableTwoFactorPasskeysDialog()}
>
{t('txt_disable_all_keys')}
</button>
)}
</div>
</div>
</ConfirmDialog>
<ConfirmDialog
open={recoveryCodeDialogOpen}
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
message={t('txt_your_two_step_recovery_code')}
hideConfirm
hideCancel
closeButton
cancelText={t('txt_close')}
onConfirm={() => {}}
onCancel={() => setRecoveryCodeDialogOpen(false)}
afterActions={(