mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
feat: enhance two-factor authentication handling and UI improvements
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+17
-2
@@ -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) {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
title: ComponentChildren;
|
||||
message?: string;
|
||||
variant?: 'default' | 'warning';
|
||||
showIcon?: boolean;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user