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 { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { hashApiKey } from '../utils/api-key'; 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 { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { buildAccountKeys } from '../utils/user-decryption'; import { buildAccountKeys } from '../utils/user-decryption';
import { buildProfileResponse } from '../utils/profile-response'; 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); if (!user) return errorResponse('User not found', 404);
const data = []; 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)); if (isYubiKeyEnabled(user)) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_YUBIKEY, true));
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor'); const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (webAuthnCredentials.length > 0) data.push(twoFactorProviderResponse(TWO_FACTOR_PROVIDER_WEBAUTHN, true)); 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); return errorResponse('User verification failed.', 400);
} }
if (!isTotpEnabled(key)) return errorResponse('Invalid TOTP secret', 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; user.totpSecret = key;
if (!user.totpRecoveryCode) { if (!user.totpRecoveryCode) {
@@ -959,14 +962,7 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
const publicIds: Array<string | null> = []; const publicIds: Array<string | null> = [];
let credentials = await getStoredYubicoCredentials(storage, env); let credentials = await getStoredYubicoCredentials(storage, env);
let apiKeyBootstrapOtpIndex: number | null = null; let apiKeyBootstrapOtpIndex: number | null = null;
const existingPublicIds = [ for (const key of keys) {
user.yubikeyKey1,
user.yubikeyKey2,
user.yubikeyKey3,
user.yubikeyKey4,
user.yubikeyKey5,
].map((value) => String(value || '').trim().toLowerCase());
for (const [index, key] of keys.entries()) {
const trimmed = key.trim(); const trimmed = key.trim();
if (!trimmed) { if (!trimmed) {
publicIds.push(null); publicIds.push(null);
@@ -975,9 +971,6 @@ export async function handlePutTwoFactorYubiKey(request: Request, env: Env, user
const publicId = yubiKeyPublicIdFromOtp(trimmed); const publicId = yubiKeyPublicIdFromOtp(trimmed);
if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400); if (!publicId) return errorResponse('Invalid YubiKey OTP.', 400);
if (isYubiKeyPublicId(trimmed)) { 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); publicIds.push(publicId);
continue; continue;
} }
@@ -1186,8 +1179,8 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (!verifiedUser) { if (!verifiedUser) {
return errorResponse('User verification failed.', 400); return errorResponse('User verification failed.', 400);
} }
const verified = await verifyTotpToken(normalizedSecret, body.token); const matchedCounter = await findMatchingTotpCounter(normalizedSecret, body.token);
if (!verified) { if (matchedCounter == null || !await storage.consumeTotpLoginCounter(user.id, matchedCounter)) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
} }
user.totpSecret = normalizedSecret; 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; webAuthnOptions = await buildTwoFactorPasskeyAssertionOptions(request, env, storage, user) as Record<string, unknown> | null;
if (webAuthnOptions) providers.push(String(TWO_FACTOR_PROVIDER_WEBAUTHN)); 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) { for (const provider of providers) {
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY) providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
? { Nfc: user?.yubikeyNfc ?? false } ? { Nfc: user?.yubikeyNfc ?? false }
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions : provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
? webAuthnOptions ? webAuthnOptions
: { Email: null }; : null;
} }
const customResponse = { const customResponse = {
TwoFactorProviders: providers, TwoFactorProviders: providers,
+17 -2
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)))); 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 { function canonicalQuery(params: URLSearchParams): string {
return Array.from(params.entries()) return Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
@@ -149,7 +159,11 @@ export async function verifyYubicoOtp(
otp, otp,
}); });
if (secretKey) { if (secretKey) {
try {
params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params))); params.set('h', await hmacSha1Base64(secretKey, canonicalQuery(params)));
} catch {
return false;
}
} }
for (const baseUrl of validationUrls(env)) { for (const baseUrl of validationUrls(env)) {
@@ -158,12 +172,13 @@ export async function verifyYubicoOtp(
if (!response.ok) continue; if (!response.ok) continue;
const parsed = parseYubicoResponse(await response.text()); const parsed = parseYubicoResponse(await response.text());
if (parsed.otp !== otp || parsed.nonce !== nonce || parsed.status !== 'OK') continue; 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(); const signedParams = new URLSearchParams();
for (const [key, value] of Object.entries(parsed)) { for (const [key, value] of Object.entries(parsed)) {
if (key !== 'h') signedParams.set(key, value); 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; return true;
} catch { } catch {
+6 -1
View File
@@ -92,7 +92,12 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} 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')} 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')} confirmText={t('txt_verify')}
hideCancel hideCancel
+1 -1
View File
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
interface ConfirmDialogProps { interface ConfirmDialogProps {
open: boolean; open: boolean;
title: string; title: ComponentChildren;
message?: string; message?: string;
variant?: 'default' | 'warning'; variant?: 'default' | 'warning';
showIcon?: boolean; showIcon?: boolean;
+1 -1
View File
@@ -143,7 +143,7 @@ function readTwoFactorProviderTypes(providers: unknown): number[] {
} }
} else if (providers && typeof providers === 'object') { } else if (providers && typeof providers === 'object') {
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) { for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
if (!value) continue; if (value === false) continue;
const providerType = twoFactorProviderTypeFromValue(key); const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) providerTypes.push(providerType); if (providerType != null) providerTypes.push(providerType);
} }
+4
View File
@@ -68,6 +68,10 @@
@apply my-1.5 text-3xl; @apply my-1.5 text-3xl;
} }
.dialog-title-stack {
@apply flex flex-col items-center gap-1;
}
.dialog-message { .dialog-message {
@apply mb-2.5; @apply mb-2.5;
color: #475467; color: #475467;