feat: add passkey-based two-factor authentication

This commit is contained in:
shuaiplus
2026-07-05 14:51:11 +08:00
parent f63b745d05
commit c019c93726
31 changed files with 1540 additions and 116 deletions
+306 -1
View File
@@ -9,7 +9,7 @@ import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { errorResponse, identityErrorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid';
import { bytesToBase64Url } from '../utils/passkey';
import { bytesToBase64Url, parseClientDataJSON } from '../utils/passkey';
import {
accountPasskeyCredentialToResponse,
accountPasskeyPrfStatus,
@@ -29,8 +29,10 @@ import {
verifyAccountPasskeyToken,
} from '../utils/account-passkeys';
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import { createRecoveryCode } from '../utils/recovery-code';
const MAX_ACCOUNT_PASSKEYS = 5;
const MAX_TWO_FACTOR_PASSKEYS = 5;
function parseBodyObject(body: unknown): Record<string, any> {
return body && typeof body === 'object' ? body as Record<string, any> : {};
@@ -81,6 +83,43 @@ function hasCompletePrfKeySet(body: Record<string, any>): boolean {
return !!(body.encryptedUserKey && body.encryptedPublicKey && body.encryptedPrivateKey);
}
function twoFactorWebAuthnResponse(credentials: AccountPasskeyCredential[]): Record<string, unknown> {
return {
Enabled: credentials.length > 0,
enabled: credentials.length > 0,
Keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
keys: credentials.map((credential, index) => ({
Id: index + 1,
id: index + 1,
Name: credential.name,
name: credential.name,
Migrated: false,
migrated: false,
})),
Object: 'twoFactorWebAuthn',
object: 'twoFactorWebAuthn',
};
}
function readRegistrationChallenge(response: ReturnType<typeof normalizeRegistrationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readAuthenticationChallenge(response: ReturnType<typeof normalizeAuthenticationResponse>): string | null {
if (!response) return null;
const clientData = parseClientDataJSON(response.response.clientDataJSON);
return String(clientData?.challenge || '').trim() || null;
}
function readPrfKeySet(body: Record<string, any>): {
encryptedUserKey: string | null;
encryptedPublicKey: string | null;
@@ -176,6 +215,9 @@ export async function assertAccountPasskeyCredential(
if (payload.userId && credential.userId !== payload.userId) {
throw new Error('Passkey does not belong to this user');
}
if (credential.purpose !== 'login') {
throw new Error('Passkey is not registered for login');
}
const userHandleUserId = userHandleToUserId(response.response.userHandle);
const resolvedUserId = payload.userId || userHandleUserId || credential.userId;
@@ -225,6 +267,268 @@ export async function handleGetAccountPasskeyCredentials(request: Request, env:
});
}
export async function buildTwoFactorPasskeyAssertionOptions(
request: Request,
env: Env,
storage: StorageService,
user: User
): Promise<Record<string, unknown> | null> {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (!credentials.length) return null;
const { rpId } = getAccountPasskeyRpConfig(request, env);
const options = await generateAuthenticationOptions({
rpID: rpId,
allowCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
userVerification: 'discouraged',
timeout: 60000,
});
await saveChallenge(storage, 'TwoFactorAuthentication', options.challenge, user.id);
return options as unknown as Record<string, unknown>;
}
export async function assertTwoFactorPasskeyCredential(
request: Request,
env: Env,
storage: StorageService,
user: User,
deviceResponse: unknown
): Promise<AccountPasskeyCredential> {
const response = normalizeAuthenticationResponse(deviceResponse);
if (!response) {
throw new Error('Invalid passkey assertion response');
}
const credential = await storage.getAccountPasskeyCredentialByCredentialId(response.rawId);
if (!credential || credential.userId !== user.id || credential.purpose !== 'twoFactor') {
throw new Error('Passkey is not registered for two-step login');
}
const challenge = readAuthenticationChallenge(response);
if (!challenge) {
throw new Error('Passkey assertion challenge is missing');
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorAuthentication',
user.id,
Date.now()
);
if (!consumed) {
throw new Error('Passkey challenge has expired or was already used');
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
credential: toSimpleWebAuthnCredential(credential),
requireUserVerification: false,
});
if (!verification.verified) {
throw new Error('Passkey assertion could not be verified');
}
await storage.updateAccountPasskeyCounter(
credential.userId,
credential.credentialId,
verification.authenticationInfo.newCounter,
new Date().toISOString()
);
credential.counter = verification.authenticationInfo.newCounter;
return credential;
}
export async function handleGetTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleGetTwoFactorWebAuthnChallenge(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const { rpId, rpName } = getAccountPasskeyRpConfig(request, env);
const options = await generateRegistrationOptions({
rpID: rpId,
rpName,
userID: Uint8Array.from(userIdToWebAuthnUserId(user.id)),
userName: user.email,
userDisplayName: user.name || user.email,
attestationType: 'none',
timeout: 60000,
excludeCredentials: credentials.map((credential) => ({
id: credential.credentialId,
transports: (credential.transports || undefined) as any,
})),
authenticatorSelection: {
residentKey: 'discouraged',
requireResidentKey: false,
userVerification: 'discouraged',
},
});
await saveChallenge(storage, 'TwoFactorCreate', options.challenge, userId);
return jsonResponse(options);
}
export async function handlePutTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const storage = new StorageService(env.DB);
const currentCount = await storage.countAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (currentCount >= MAX_TWO_FACTOR_PASSKEYS) {
return errorResponse('Maximum WebAuthn credential count reached.', 400);
}
const registrationResponse = normalizeRegistrationResponse(body.deviceResponse);
if (!registrationResponse) {
return errorResponse('Invalid passkey registration response', 400);
}
const challenge = readRegistrationChallenge(registrationResponse);
if (!challenge) {
return errorResponse('Passkey challenge is missing', 400);
}
const consumed = await storage.consumeAccountPasskeyChallenge(
await sha256Base64Url(challenge),
'TwoFactorCreate',
userId,
Date.now()
);
if (!consumed) {
return errorResponse('Passkey challenge has expired or was already used', 400);
}
const { origins, rpId } = getAccountPasskeyRpConfig(request, env);
let verification: Awaited<ReturnType<typeof verifyRegistrationResponse>>;
try {
verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: challenge,
expectedOrigin: origins,
expectedRPID: rpId,
requireUserPresence: true,
requireUserVerification: false,
});
} catch {
return errorResponse('Passkey registration could not be verified', 400);
}
if (!verification.verified) {
return errorResponse('Passkey registration could not be verified', 400);
}
const existing = await storage.getAccountPasskeyCredentialByCredentialId(verification.registrationInfo.credential.id);
if (existing) {
return errorResponse('Passkey is already registered', 409);
}
const now = new Date().toISOString();
const transports = normalizeTransports(registrationResponse.response.transports);
await storage.saveAccountPasskeyCredential({
id: generateUUID(),
userId,
purpose: 'twoFactor',
name: normalizeAccountPasskeyName(body.name || `Passkey ${currentCount + 1}`),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
counter: verification.registrationInfo.credential.counter,
type: verification.registrationInfo.credentialType || 'public-key',
aaGuid: verification.registrationInfo.aaguid || null,
transports,
encryptedUserKey: null,
encryptedPublicKey: null,
encryptedPrivateKey: null,
supportsPrf: false,
createdAt: now,
updatedAt: now,
});
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = now;
await storage.saveUser(user);
}
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.enable',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: null,
metadata: auditRequestMetadata(request),
});
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
return jsonResponse(twoFactorWebAuthnResponse(credentials));
}
export async function handleDeleteTwoFactorWebAuthn(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
if (!(await verifyUserSecret(env, user, body))) {
return errorResponse('User verification failed.', 400);
}
const requestedId = Number(body.id ?? body.Id);
if (!Number.isInteger(requestedId) || requestedId <= 0) {
return errorResponse('Invalid key id', 400);
}
const storage = new StorageService(env.DB);
const credentials = await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor');
if (credentials.length < 2) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const credential = credentials[requestedId - 1];
if (!credential) {
return errorResponse('Unable to delete WebAuthn credential.', 400);
}
const deleted = await storage.deleteAccountPasskeyCredential(userId, credential.id, 'twoFactor');
if (!deleted) return errorResponse('Unable to delete WebAuthn credential.', 400);
await storage.deleteRefreshTokensByUserId(userId);
AuthService.invalidateUserCache(userId);
await safeWriteAuditEvent(env, {
actorUserId: userId,
action: 'account.webauthn_2fa.delete',
category: 'security',
level: 'security',
targetType: 'accountPasskey',
targetId: credential.id,
metadata: auditRequestMetadata(request),
});
return jsonResponse(twoFactorWebAuthnResponse(await storage.getAccountPasskeyCredentialsByUserId(userId, 'twoFactor')));
}
export async function handleGetAccountPasskeyAttestationOptions(request: Request, env: Env, userId: string, user: User): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
@@ -380,6 +684,7 @@ export async function handleCreateAccountPasskeyCredential(request: Request, env
const credential: AccountPasskeyCredential = {
id: generateUUID(),
userId,
purpose: 'login',
name: normalizeAccountPasskeyName(body.name),
publicKey: bytesToBase64Url(verification.registrationInfo.credential.publicKey),
credentialId: verification.registrationInfo.credential.id,
+19 -3
View File
@@ -15,6 +15,7 @@ import { isYubiKeyEnabled, isYubiKeyPublicId, requestYubicoApiCredentials, verif
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TOTP_USER_VERIFICATION_TOKEN_TTL_MS = 10 * 60 * 1000;
const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
@@ -830,6 +831,8 @@ export async function handleGetTwoFactorProviders(request: Request, env: Env, us
const data = [];
if (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));
return jsonResponse({
Data: data,
@@ -1089,7 +1092,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
const typeRaw = body.type ?? body.Type ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
const type = typeof typeRaw === 'number' ? typeRaw : Number.parseInt(String(typeRaw), 10);
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY].includes(type)) {
if (![TWO_FACTOR_PROVIDER_AUTHENTICATOR, TWO_FACTOR_PROVIDER_YUBIKEY, TWO_FACTOR_PROVIDER_WEBAUTHN].includes(type)) {
return errorResponse('Two-factor provider is not supported by this server.', 400);
}
@@ -1107,13 +1110,18 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
user.totpSecret = null;
} else {
} else if (type === TWO_FACTOR_PROVIDER_YUBIKEY) {
user.yubikeyKey1 = null;
user.yubikeyKey2 = null;
user.yubikeyKey3 = null;
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
} else {
const credentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of credentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
}
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
@@ -1121,7 +1129,11 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
AuthService.invalidateUserCache(user.id);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR ? 'account.totp.disable' : 'account.yubikey.disable',
action: type === TWO_FACTOR_PROVIDER_AUTHENTICATOR
? 'account.totp.disable'
: type === TWO_FACTOR_PROVIDER_YUBIKEY
? 'account.yubikey.disable'
: 'account.webauthn_2fa.disable',
category: 'security',
level: 'security',
targetType: 'user',
@@ -1329,6 +1341,10 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
const webAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
for (const credential of webAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
+40 -5
View File
@@ -18,7 +18,9 @@ import {
import { auditRequestMetadata, safeWriteAuditEvent } from '../services/audit-events';
import {
assertAccountPasskeyCredential,
assertTwoFactorPasskeyCredential,
buildAccountPasskeyTokenUserDecryptionOption,
buildTwoFactorPasskeyAssertionOptions,
} from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
@@ -29,6 +31,7 @@ const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
const YUBICO_CLIENT_ID_CONFIG_KEY = 'globalSettings__yubico__clientId';
@@ -196,18 +199,31 @@ function masterPasswordPolicyResponse(): TokenResponse['MasterPasswordPolicy'] {
};
}
function twoFactorRequiredResponse(user?: User, message: string = 'Two factor required.'): Response {
async function twoFactorRequiredResponse(
request: Request,
env: Env,
storage: StorageService,
user?: User,
message: string = 'Two factor required.'
): Promise<Response> {
// Match Bitwarden Identity: TwoFactorProviders2 lists enabled 2FA providers only.
// Clients expose recovery-code entry points themselves; Android 2026.4 fails to
// parse the challenge if an unknown recovery provider key such as "8" is included.
const providers: string[] = [];
let webAuthnOptions: Record<string, unknown> | null = null;
if (!user || resolveTotpSecret(user.totpSecret)) providers.push(String(TWO_FACTOR_PROVIDER_AUTHENTICATOR));
if (user && isYubiKeyEnabled(user)) providers.push(String(TWO_FACTOR_PROVIDER_YUBIKEY));
if (user) {
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>> = {};
for (const provider of providers) {
providers2[provider] = provider === String(TWO_FACTOR_PROVIDER_YUBIKEY)
? { Nfc: user?.yubikeyNfc ?? false }
: { Email: null };
: provider === String(TWO_FACTOR_PROVIDER_WEBAUTHN) && webAuthnOptions
? webAuthnOptions
: { Email: null };
}
const customResponse = {
TwoFactorProviders: providers,
@@ -393,7 +409,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
const effectiveYubiKeyPublicIds = userYubiKeyPublicIds(user);
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0) {
const effectiveWebAuthnCredentials = await storage.getAccountPasskeyCredentialsByUserId(user.id, 'twoFactor');
if (effectiveTotpSecret || effectiveYubiKeyPublicIds.length > 0 || effectiveWebAuthnCredentials.length > 0) {
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
@@ -403,7 +420,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse(user, 'Two factor required.');
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
}
let passedByRememberToken = false;
@@ -418,7 +435,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) {
return twoFactorRequiredResponse(user, 'Two factor required.');
return await twoFactorRequiredResponse(request, env, storage, user, 'Two factor required.');
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
if (!effectiveTotpSecret) {
@@ -441,6 +458,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
if (!credentials || !await verifyYubicoOtp(env, normalizedTwoFactorToken, credentials)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_WEBAUTHN)) {
if (!effectiveWebAuthnCredentials.length) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
let deviceResponse: unknown;
try {
deviceResponse = JSON.parse(normalizedTwoFactorToken);
} catch {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
try {
await assertTwoFactorPasskeyCredential(request, env, storage, user, deviceResponse);
} catch {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE) ||
@@ -456,6 +488,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
user.yubikeyKey4 = null;
user.yubikeyKey5 = null;
user.yubikeyNfc = false;
for (const credential of effectiveWebAuthnCredentials) {
await storage.deleteAccountPasskeyCredential(user.id, credential.id, 'twoFactor');
}
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();