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 { 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+18
-3
@@ -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) {
|
||||||
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)) {
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user