feat: enhance two-factor authentication handling and UI improvements

This commit is contained in:
shuaiplus
2026-07-05 15:05:53 +08:00
parent c019c93726
commit e73ae3d5ea
7 changed files with 41 additions and 24 deletions
+9 -16
View File
@@ -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<string | null> = [];
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;
+2 -2
View File
@@ -217,13 +217,13 @@ async function twoFactorRequiredResponse(
webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN));
}
const providers2: Record<string, Record<string, unknown>> = {};
const providers2: Record<string, Record<string, unknown> | 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,
+18 -3
View File
@@ -93,6 +93,16 @@ async function hmacSha1Base64(base64Key: string, message: string): Promise<strin
return bytesToBase64(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message))));
}
function constantTimeStringEquals(a: string, b: string): boolean {
const aBytes = new TextEncoder().encode(a);
const bBytes = new TextEncoder().encode(b);
let diff = aBytes.length ^ bBytes.length;
for (let index = 0; index < aBytes.length && index < bBytes.length; index += 1) {
diff |= aBytes[index] ^ bBytes[index];
}
return diff === 0;
}
function canonicalQuery(params: URLSearchParams): string {
return Array.from(params.entries())
.sort(([a], [b]) => 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 {
+6 -1
View File
@@ -92,7 +92,12 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<ConfirmDialog
open={props.pendingTotpOpen}
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? `${t('txt_two_step_verification')} ${t('txt_passkey')}` : t('txt_two_step_verification')}
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? (
<span className="dialog-title-stack">
<span>{t('txt_two_step_verification')}</span>
<span>{t('txt_passkey')}</span>
</span>
) : 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
+1 -1
View File
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
interface ConfirmDialogProps {
open: boolean;
title: string;
title: ComponentChildren;
message?: string;
variant?: 'default' | 'warning';
showIcon?: boolean;
+1 -1
View File
@@ -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<string, unknown>)) {
if (!value) continue;
if (value === false) continue;
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) providerTypes.push(providerType);
}
+4
View File
@@ -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;