From e73ae3d5ea76a2e7a36802bb142d999cdb6b4654 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 5 Jul 2026 15:05:53 +0800 Subject: [PATCH] feat: enhance two-factor authentication handling and UI improvements --- src/handlers/accounts.ts | 25 ++++++++------------- src/handlers/identity.ts | 4 ++-- src/utils/yubico-otp.ts | 21 ++++++++++++++--- webapp/src/components/AppGlobalOverlays.tsx | 7 +++++- webapp/src/components/ConfirmDialog.tsx | 2 +- webapp/src/lib/app-auth.ts | 2 +- webapp/src/styles/overlays.css | 4 ++++ 7 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index f09354d..cbb4f64 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -7,7 +7,7 @@ import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; import { hashApiKey } from '../utils/api-key'; -import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; +import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { buildAccountKeys } from '../utils/user-decryption'; import { buildProfileResponse } from '../utils/profile-response'; @@ -829,7 +829,7 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us if (!user) return errorResponse('User not found', 404); const data = []; - if (user.totpSecret) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)); + if (isTotpEnabled(user.totpSecret)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_AUTHENTICATOR, true)); if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true)); const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true)); @@ -908,7 +908,10 @@ export async function handlePutTwoFactorAuthenticator(request: Request, env: Env return errorResponse('User verification failed.', 400); } if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 400); - if (!await verifyTotpToken(key, token)) return errorResponse('Invalid token.', 400); + const matchedCounter = await findMatchingTotpCounter(key, token); + if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) { + return errorResponse('Invalid token.', 400); + } user.totpSecret = key; if (!user.totpRecoveryCode) { @@ -959,14 +962,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user const publicIds: Array = []; let credentials = await getStoredYubicoCredentials(storage, env); let apiKeyBootstrapOtpIndex: number | null = null; - const existingPublicIds = [ - user.yubikeyKey1, - user.yubikeyKey2, - user.yubikeyKey3, - user.yubikeyKey4, - user.yubikeyKey5, - ].map((value) => String(value || '').trim().toLowerCase()); - for (const [index, key] of keys.entries()) { + for (const key of keys) { const trimmed = key.trim(); if (!trimmed) { publicIds.push(null); @@ -975,9 +971,6 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user const publicId = yubiKeyPublicIdFromOtp(trimmed); if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400); if (isYubiKeyPublicId(trimmed)) { - if (existingPublicIds[index] !== publicId) { - return errorResponse('A full YubiKey OTP is required to add or replace a key.', 400); - } publicIds.push(publicId); continue; } @@ -1186,8 +1179,8 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st if (!verifiedUser) { return errorResponse('User verification failed.', 400); } - const verified = await verifyTotpToken(normalizedSecret, body.token); - if (!verified) { + const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token); + if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) { return errorResponse('Invalid TOTP token', 400); } user.totpSecret = normalizedSecret; diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 4459407..17fe05f 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -217,13 +217,13 @@ async function twoFactorRequiredResponse( webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record | null; if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN)); } - const providers2: Record> = {}; + const providers2: Record | null> = {}; for (const provider of providers) { providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY) ? { Nfc: user?.yubikeyNfc ?? false } : provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions ? webAuthnOptions - : { Email: null }; + : null; } const customResponse = { TwoFactorProviders: providers, diff --git a/src/utils/yubico-otp.ts b/src/utils/yubico-otp.ts index 3c810db..cd2e6f1 100644 --- a/src/utils/yubico-otp.ts +++ b/src/utils/yubico-otp.ts @@ -93,6 +93,16 @@ async function hmacSha1Base64(base64Key: string, message: string): Promise a.localeCompare(b)) @@ -149,7 +159,11 @@ export async function verifyYubicoOtp( otp, }); if (secretKey) { - params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); + try { + params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); + } catch { + return false; + } } for (const baseUrl of validationUrls(env)) { @@ -158,12 +172,13 @@ export async function verifyYubicoOtp( if (!response.ok) continue; const parsed = parseYubicoResponse(await response.text()); if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; - if (secretKey && parsed.h) { + if (secretKey) { + if (!parsed.h) continue; const signedParams = new URLSearchParams(); for (const [key, value] of Object.entries(parsed)) { if (key !== 'h') signedParams.set(key, value); } - if ((await hmacSha1Base64(secretKey, canonicalQuery(signedParams))) !== parsed.h) continue; + if (!constantTimeStringEquals(await hmacSha1Base64(secretKey, canonicalQuery(signedParams)), parsed.h)) continue; } return true; } catch { diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 360516f..0d33d68 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -92,7 +92,12 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { + {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')} hideCancel diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index 5c81b6c..51a1c1f 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -6,7 +6,7 @@ import { t } from '@/lib/i18n'; interface ConfirmDialogProps { open: boolean; - title: string; + title: ComponentChildren; message?: string; variant?: 'default' | 'warning'; showIcon?: boolean; diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index e30da2b..030113c 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -143,7 +143,7 @@ function readTwoFactorProviderTypes(providers: unknown): number[] { } } else if (providers && typeof providers === 'object') { for (const [key, value] of Object.entries(providers as Record)) { - if (!value) continue; + if (value === false) continue; const providerType = twoFactorProviderTypeFromValue(key); if (providerType != null) providerTypes.push(providerType); } diff --git a/webapp/src/styles/overlays.css b/webapp/src/styles/overlays.css index 2e951c9..d50f2b1 100644 --- a/webapp/src/styles/overlays.css +++ b/webapp/src/styles/overlays.css @@ -68,6 +68,10 @@ @apply my-1.5 text-3xl; } +.dialog-title-stack { + @apply flex flex-col items-center gap-1; +} + .dialog-message { @apply mb-2.5; color: #475467;