mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-07 07:30:12 +00:00
feat: add passkey-based two-factor authentication
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user