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
+22
View File
@@ -340,6 +340,28 @@ export async function createAccountPasskeyCredential(
};
}
export async function createTwoFactorPasskeyCredential(options: unknown): Promise<Record<string, unknown>> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const credential = await navigator.credentials.create({ publicKey: cloneCreationOptions(options) });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
return attestationRequest(credential);
}
export async function assertTwoFactorPasskey(options: unknown): Promise<string> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const credential = await navigator.credentials.get({ publicKey: cloneRequestOptions(options) });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_invalid_passkey_assertion_response'));
}
return JSON.stringify(assertionRequest(credential));
}
function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim();
const [type, payload] = text.split('.');
+96 -1
View File
@@ -7,6 +7,7 @@ import type {
SessionState,
TokenError,
TokenSuccess,
TwoFactorPasskeySettings,
YubiKeyOtpSettings,
} from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
@@ -756,6 +757,99 @@ export async function disableYubiKeyOtp(
}
}
function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings {
const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : [];
return {
enabled: !!(raw?.enabled ?? raw?.Enabled),
keys: keys
.map((item: any) => ({
id: Number(item?.id ?? item?.Id),
name: String(item?.name || item?.Name || ''),
migrated: !!(item?.migrated ?? item?.Migrated),
}))
.filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0),
};
}
export async function getTwoFactorPasskeySettings(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/get-webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function getTwoFactorPasskeyChallenge(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<unknown> {
const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
}
return parseJson<unknown>(resp);
}
export async function saveTwoFactorPasskey(
authedFetch: AuthedFetch,
payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown }
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/webauthn', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function deleteTwoFactorPasskey(
authedFetch: AuthedFetch,
payload: { id: number; masterPasswordHash: string }
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/webauthn', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function disableTwoFactorPasskeys(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch('/api/two-factor/disable', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 7, masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed')));
}
}
export async function verifyMasterPassword(
authedFetch: AuthedFetch,
masterPasswordHash: string
@@ -913,7 +1007,7 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
return stamp;
}
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean }> {
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> {
const resp = await authedFetch('/api/two-factor');
if (!resp.ok) throw new Error('Failed to load two-factor status');
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
@@ -926,6 +1020,7 @@ export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Prom
return {
totpEnabled: enabledTypes.has(0),
yubikeyEnabled: enabledTypes.has(3),
passkeyEnabled: enabledTypes.has(7),
};
}
+107 -20
View File
@@ -35,6 +35,9 @@ export interface PendingTotp {
masterKey: Uint8Array;
kdfIterations: number;
providerType: number;
providerData?: unknown;
availableProviders: number[];
providerDataByType: Record<number, unknown>;
}
export interface PendingPasskeyPassword {
@@ -73,27 +76,94 @@ export interface CompletedLogin {
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
}
function resolvePendingTwoFactorProvider(providers: unknown): number {
type TwoFactorTokenError = {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
CustomResponse?: {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
};
error_description?: string;
error?: string;
};
function readTwoFactorProviders(error: TwoFactorTokenError): unknown {
return error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders ?? error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
}
function readTwoFactorProviderData(error: TwoFactorTokenError, providerType: number): unknown {
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (!providers2 || typeof providers2 !== 'object') return undefined;
const record = providers2 as Record<string, unknown>;
return record[String(providerType)] ?? (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN ? record.WebAuthn : undefined);
}
function twoFactorProviderTypeFromValue(value: unknown): number | null {
const raw = value && typeof value === 'object'
? (value as Record<string, unknown>).Type ?? (value as Record<string, unknown>).type
: value;
const text = String(raw ?? '').trim();
if (!text) return null;
const normalized = text.toLowerCase();
const numeric = Number(text);
const provider = Number.isFinite(numeric)
? numeric
: normalized === 'webauthn'
? TWO_FACTOR_PROVIDER_WEBAUTHN
: normalized === 'yubikey' || normalized === 'yubikeyotp'
? TWO_FACTOR_PROVIDER_YUBIKEY
: normalized === 'authenticator' || normalized === 'totp'
? TWO_FACTOR_PROVIDER_AUTHENTICATOR
: Number.NaN;
return SUPPORTED_TWO_FACTOR_PROVIDERS.includes(provider as any) ? provider : null;
}
function sortTwoFactorProviders(providerTypes: number[]): number[] {
const unique = new Set(providerTypes);
return SUPPORTED_TWO_FACTOR_PROVIDERS.filter((provider) => unique.has(provider));
}
function readTwoFactorProviderTypes(providers: unknown): number[] {
const providerTypes: number[] = [];
if (Array.isArray(providers)) {
if (providers.some((provider: any) => Number(
provider && typeof provider === 'object' ? provider.Type ?? provider.type : provider
) === TWO_FACTOR_PROVIDER_YUBIKEY)) {
return TWO_FACTOR_PROVIDER_YUBIKEY;
for (const provider of providers) {
const providerType = twoFactorProviderTypeFromValue(provider);
if (providerType != null) providerTypes.push(providerType);
}
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
if (providers && typeof providers === 'object') {
const record = providers as Record<string, unknown>;
if (record[String(TWO_FACTOR_PROVIDER_YUBIKEY)] || record.YubiKey || record.Yubikey) {
return TWO_FACTOR_PROVIDER_YUBIKEY;
} else if (providers && typeof providers === 'object') {
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
if (!value) continue;
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) providerTypes.push(providerType);
}
}
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
return sortTwoFactorProviders(providerTypes);
}
function readTwoFactorProviderDataMap(error: TwoFactorTokenError): Record<number, unknown> {
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (!providers2 || typeof providers2 !== 'object') return {};
const out: Record<number, unknown> = {};
for (const [key, value] of Object.entries(providers2 as Record<string, unknown>)) {
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) out[providerType] = value;
}
return out;
}
function resolvePendingTwoFactorProvider(providers: unknown): number {
return readTwoFactorProviderTypes(providers)[0] ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
export type PasswordLoginResult =
@@ -438,8 +508,12 @@ export async function performPasswordLogin(
};
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
const tokenError = token as TwoFactorTokenError;
const providers = readTwoFactorProviders(tokenError);
if (providers) {
const providerType = resolvePendingTwoFactorProvider(providers);
const availableProviders = readTwoFactorProviderTypes(providers);
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
return {
kind: 'totp',
pendingTotp: {
@@ -447,7 +521,10 @@ export async function performPasswordLogin(
passwordHash: derived.hash,
masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations,
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
providerType,
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
availableProviders: availableProviders.length ? availableProviders : [providerType],
providerDataByType,
},
};
}
@@ -528,7 +605,10 @@ export async function performTotpLogin(
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
}
const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
const fallback = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN
? t('txt_passkey_verification_failed')
: t('txt_totp_verify_failed');
throw new Error(translateServerError(tokenError.error_description || tokenError.error, fallback));
}
export async function performRecoverTwoFactorLogin(
@@ -608,7 +688,7 @@ export async function performUnlock(
return unlockOffline();
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
let token: TokenSuccess | TwoFactorTokenError;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
@@ -630,8 +710,12 @@ export async function performUnlock(
};
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
const tokenError = token as TwoFactorTokenError;
const providers = readTwoFactorProviders(tokenError);
if (providers) {
const providerType = resolvePendingTwoFactorProvider(providers);
const availableProviders = readTwoFactorProviderTypes(providers);
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
return {
kind: 'totp',
pendingTotp: {
@@ -639,7 +723,10 @@ export async function performUnlock(
passwordHash: derived.hash,
masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations,
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
providerType,
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
availableProviders: availableProviders.length ? availableProviders : [providerType],
providerDataByType,
},
};
}
+11
View File
@@ -907,6 +907,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
adminLoading: false,
adminError: '',
totpEnabled: true,
passkey2faEnabled: false,
authorizedDevices: state.authorizedDevices,
authorizedDevicesLoading: false,
authorizedDevicesError: '',
@@ -1060,6 +1061,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onSavePasswordHint: readonly,
onEnableTotp: readonly,
onOpenDisableTotp: readonlyVoid,
onGetTwoFactorPasskeySettings: async () => ({ enabled: false, keys: [] }),
onCreateTwoFactorPasskey: async () => {
await readonly();
return { enabled: false, keys: [] };
},
onDeleteTwoFactorPasskey: async () => {
await readonly();
return { enabled: false, keys: [] };
},
onDisableTwoFactorPasskeys: readonly,
onGetRecoveryCode: readonlyString,
onGetApiKey: readonlyString,
onRotateApiKey: readonlyString,
+17
View File
@@ -806,6 +806,23 @@ const en: Record<string, string> = {
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
"txt_passkey": "Passkey",
"txt_passkeys": "Passkeys",
"txt_register": "Register",
"txt_key_list": "Key list",
"txt_select_another_verification_method": "Select another verification method",
"txt_select_two_step_login_method": "Select two-step login method",
"txt_two_step_passkeys": "Passkey two-step login",
"txt_two_step_passkeys_help": "Manage passkeys used only for two-step login.",
"txt_two_step_passkey_name_placeholder": "Security key",
"txt_add_two_step_passkey": "Add passkey",
"txt_two_step_passkey_added": "Passkey two-step login updated",
"txt_two_step_passkey_removed": "Passkey removed",
"txt_two_step_passkeys_disabled": "Passkey two-step login disabled",
"txt_disable_passkey_two_step_failed": "Failed to disable passkey two-step login",
"txt_use_passkey_to_complete_two_step_verification": "Use your passkey to complete two-step verification.",
"txt_touch_your_passkey_when_prompted": "Continue and approve the browser passkey prompt.",
"txt_no_two_step_passkeys": "No two-step passkeys",
"txt_remove_last_passkey_hint": "Disable passkey two-step login to remove the last key.",
"txt_passkey_setup_failed": "Passkey setup failed",
"txt_passkey_created_at_value": "Created on {value}",
"txt_account_passkey": "Account passkey",
"txt_account_passkeys": "Account passkeys",
+17
View File
@@ -806,6 +806,23 @@ const es: Record<string, string> = {
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
"txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso",
"txt_register": "Registrar",
"txt_key_list": "Lista de claves",
"txt_select_another_verification_method": "Seleccionar otro método de verificación",
"txt_select_two_step_login_method": "Seleccionar método de inicio de sesión en dos pasos",
"txt_two_step_passkeys": "Inicio de sesión en dos pasos con clave de acceso",
"txt_two_step_passkeys_help": "Administra claves de acceso usadas solo para el inicio de sesión en dos pasos.",
"txt_two_step_passkey_name_placeholder": "Llave de seguridad",
"txt_add_two_step_passkey": "Agregar clave de acceso",
"txt_two_step_passkey_added": "Inicio de sesión en dos pasos con clave de acceso actualizado",
"txt_two_step_passkey_removed": "Clave de acceso eliminada",
"txt_two_step_passkeys_disabled": "Inicio de sesión en dos pasos con clave de acceso desactivado",
"txt_disable_passkey_two_step_failed": "No se pudo desactivar el inicio de sesión en dos pasos con clave de acceso",
"txt_use_passkey_to_complete_two_step_verification": "Usa tu clave de acceso para completar la verificación en dos pasos.",
"txt_touch_your_passkey_when_prompted": "Continúa y aprueba la solicitud de clave de acceso del navegador.",
"txt_no_two_step_passkeys": "No hay claves de acceso en dos pasos",
"txt_remove_last_passkey_hint": "Desactiva el inicio de sesión en dos pasos con clave de acceso para eliminar la última clave.",
"txt_passkey_setup_failed": "Error al configurar la clave de acceso",
"txt_passkey_created_at_value": "Creado el {value}",
"txt_account_passkey": "Clave de acceso de cuenta",
"txt_account_passkeys": "Claves de acceso de cuenta",
+17
View File
@@ -806,6 +806,23 @@ const ru: Record<string, string> = {
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
"txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа",
"txt_register": "Зарегистрировать",
"txt_key_list": "Список ключей",
"txt_select_another_verification_method": "Выбрать другой способ проверки",
"txt_select_two_step_login_method": "Выберите способ двухэтапного входа",
"txt_two_step_passkeys": "Двухэтапный вход с ключом доступа",
"txt_two_step_passkeys_help": "Управление ключами доступа, которые используются только для двухэтапного входа.",
"txt_two_step_passkey_name_placeholder": "Ключ безопасности",
"txt_add_two_step_passkey": "Добавить ключ доступа",
"txt_two_step_passkey_added": "Двухэтапный вход с ключом доступа обновлен",
"txt_two_step_passkey_removed": "Ключ доступа удален",
"txt_two_step_passkeys_disabled": "Двухэтапный вход с ключом доступа отключен",
"txt_disable_passkey_two_step_failed": "Не удалось отключить двухэтапный вход с ключом доступа",
"txt_use_passkey_to_complete_two_step_verification": "Используйте ключ доступа, чтобы завершить двухэтапную проверку.",
"txt_touch_your_passkey_when_prompted": "Продолжите и подтвердите запрос ключа доступа в браузере.",
"txt_no_two_step_passkeys": "Нет ключей доступа для двухэтапного входа",
"txt_remove_last_passkey_hint": "Отключите двухэтапный вход с ключом доступа, чтобы удалить последний ключ.",
"txt_passkey_setup_failed": "Не удалось настроить ключ доступа",
"txt_passkey_created_at_value": "Создано {value}",
"txt_account_passkey": "Ключ доступа аккаунта",
"txt_account_passkeys": "Ключи доступа аккаунта",
+17
View File
@@ -806,6 +806,23 @@ const zhCN: Record<string, string> = {
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
"txt_passkey": "通行密钥",
"txt_passkeys": "通行密钥",
"txt_register": "注册",
"txt_key_list": "密钥列表",
"txt_select_another_verification_method": "选择其他验证方式",
"txt_select_two_step_login_method": "选择验证方式",
"txt_two_step_passkeys": "通行密钥二步登录",
"txt_two_step_passkeys_help": "管理仅用于二步登录的通行密钥。",
"txt_two_step_passkey_name_placeholder": "安全密钥",
"txt_add_two_step_passkey": "添加通行密钥",
"txt_two_step_passkey_added": "通行密钥二步登录已更新",
"txt_two_step_passkey_removed": "通行密钥已移除",
"txt_two_step_passkeys_disabled": "通行密钥二步登录已禁用",
"txt_disable_passkey_two_step_failed": "禁用通行密钥二步登录失败",
"txt_use_passkey_to_complete_two_step_verification": "使用通行密钥完成二步验证。",
"txt_touch_your_passkey_when_prompted": "继续并在浏览器提示中批准通行密钥验证。",
"txt_no_two_step_passkeys": "暂无二步登录通行密钥",
"txt_remove_last_passkey_hint": "请禁用通行密钥二步登录来移除最后一把密钥。",
"txt_passkey_setup_failed": "通行密钥设置失败",
"txt_passkey_created_at_value": "创建于 {value}",
"txt_account_passkey": "账号通行密钥",
"txt_account_passkeys": "账号通行密钥",
+17
View File
@@ -806,6 +806,23 @@ const zhTW: Record<string, string> = {
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
"txt_passkey": "通行密鑰",
"txt_passkeys": "通行密鑰",
"txt_register": "註冊",
"txt_key_list": "密鑰列表",
"txt_select_another_verification_method": "選擇其他驗證方式",
"txt_select_two_step_login_method": "選擇驗證方式",
"txt_two_step_passkeys": "通行密鑰兩步登入",
"txt_two_step_passkeys_help": "管理僅用於兩步登入的通行密鑰。",
"txt_two_step_passkey_name_placeholder": "安全密鑰",
"txt_add_two_step_passkey": "新增通行密鑰",
"txt_two_step_passkey_added": "通行密鑰兩步登入已更新",
"txt_two_step_passkey_removed": "通行密鑰已移除",
"txt_two_step_passkeys_disabled": "通行密鑰兩步登入已停用",
"txt_disable_passkey_two_step_failed": "停用通行密鑰兩步登入失敗",
"txt_use_passkey_to_complete_two_step_verification": "使用通行密鑰完成兩步驗證。",
"txt_touch_your_passkey_when_prompted": "繼續並在瀏覽器提示中批准通行密鑰驗證。",
"txt_no_two_step_passkeys": "暫無兩步登入通行密鑰",
"txt_remove_last_passkey_hint": "請停用通行密鑰兩步登入來移除最後一把密鑰。",
"txt_passkey_setup_failed": "通行密鑰設置失敗",
"txt_passkey_created_at_value": "創建於 {value}",
"txt_account_passkey": "賬號通行密鑰",
"txt_account_passkeys": "賬號通行密鑰",
+11
View File
@@ -350,6 +350,17 @@ export interface AccountPasskeyCredential {
revisionDate?: string;
}
export interface TwoFactorPasskeyCredential {
id: number;
name: string;
migrated?: boolean;
}
export interface TwoFactorPasskeySettings {
enabled: boolean;
keys: TwoFactorPasskeyCredential[];
}
export interface AuthRequest {
id: string;
publicKey: string;