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();
+18
View File
@@ -78,9 +78,13 @@ import { handleGetDomains, handleUpdateDomains } from './handlers/domains';
import {
handleCreateAccountPasskeyCredential,
handleDeleteAccountPasskeyCredential,
handleDeleteTwoFactorWebAuthn,
handleGetAccountPasskeyAttestationOptions,
handleGetAccountPasskeyCredentials,
handleGetAccountPasskeyUpdateAssertionOptions,
handleGetTwoFactorWebAuthn,
handleGetTwoFactorWebAuthnChallenge,
handlePutTwoFactorWebAuthn,
handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys';
import {
@@ -149,6 +153,14 @@ export async function handleAuthenticatedRoute(
return handleGetTwoFactorYubiKey(request, env, userId);
}
if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
}
if (path === '/api/two-factor/get-webauthn-challenge' && method === 'POST') {
return handleGetTwoFactorWebAuthnChallenge(request, env, userId, currentUser);
}
if (path === '/api/two-factor/authenticator') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorAuthenticator(request, env, userId);
if (method === 'DELETE') return handleDisableTwoFactorProvider(request, env, userId);
@@ -161,6 +173,12 @@ export async function handleAuthenticatedRoute(
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/webauthn') {
if (method === 'PUT' || method === 'POST') return handlePutTwoFactorWebAuthn(request, env, userId, currentUser);
if (method === 'DELETE') return handleDeleteTwoFactorWebAuthn(request, env, userId, currentUser);
return errorResponse('Method not allowed', 405);
}
if ((path === '/api/two-factor/yubikey/config' || path === '/api/two-factor/yubi-key/config') && (method === 'PUT' || method === 'POST')) {
return handlePutTwoFactorYubiKeyConfig(request, env, userId);
}
+20 -13
View File
@@ -7,6 +7,7 @@ let accountPasskeySchemaReady = false;
const ACCOUNT_PASSKEY_CREDENTIAL_COLUMN_DEFS = [
{ name: 'id', sql: 'id TEXT' },
{ name: 'user_id', sql: "user_id TEXT NOT NULL DEFAULT ''" },
{ name: 'purpose', sql: "purpose TEXT NOT NULL DEFAULT 'login'" },
{ name: 'name', sql: "name TEXT NOT NULL DEFAULT 'Account passkey'" },
{ name: 'public_key', sql: "public_key TEXT NOT NULL DEFAULT ''" },
{ name: 'credential_id', sql: "credential_id TEXT NOT NULL DEFAULT ''" },
@@ -42,7 +43,7 @@ async function ensureAccountPasskeySchema(db: D1Database): Promise<void> {
await db
.prepare(
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
"id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT 'login', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, " +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)'
@@ -100,6 +101,7 @@ function parseTransports(value: string | null): string[] | null {
function mapCredentialRow(row: {
id: string;
user_id: string;
purpose?: string | null;
name: string;
public_key: string;
credential_id: string;
@@ -117,6 +119,7 @@ function mapCredentialRow(row: {
return {
id: row.id,
userId: row.user_id,
purpose: row.purpose === 'twoFactor' ? 'twoFactor' : 'login',
name: row.name,
publicKey: row.public_key,
credentialId: row.credential_id,
@@ -160,16 +163,17 @@ export async function saveAccountPasskeyCredential(
await safeBind(
db.prepare(
'INSERT INTO webauthn_credentials(' +
'id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, ' +
'encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
') VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' +
'name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'purpose=excluded.purpose, name=excluded.name, public_key=excluded.public_key, credential_id=excluded.credential_id, counter=excluded.counter, ' +
'type=excluded.type, aa_guid=excluded.aa_guid, transports=excluded.transports, encrypted_user_key=excluded.encrypted_user_key, ' +
'encrypted_public_key=excluded.encrypted_public_key, encrypted_private_key=excluded.encrypted_private_key, supports_prf=excluded.supports_prf, updated_at=excluded.updated_at'
),
credential.id,
credential.userId,
credential.purpose,
credential.name,
credential.publicKey,
credential.credentialId,
@@ -188,12 +192,13 @@ export async function saveAccountPasskeyCredential(
export async function listAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
await ensureAccountPasskeySchema(db);
const rows = await db
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at ASC')
.bind(userId)
.prepare('SELECT * FROM webauthn_credentials WHERE user_id = ? AND purpose = ? ORDER BY created_at ASC')
.bind(userId, purpose)
.all<any>();
return (rows.results || []).map(mapCredentialRow);
}
@@ -225,12 +230,13 @@ export async function getAccountPasskeyCredentialByCredentialId(
export async function countAccountPasskeyCredentialsByUserId(
db: D1Database,
userId: string
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
await ensureAccountPasskeySchema(db);
const row = await db
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?')
.bind(userId)
.prepare('SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ? AND purpose = ?')
.bind(userId, purpose)
.first<{ count: number }>();
return Number(row?.count || 0);
}
@@ -272,12 +278,13 @@ export async function updateAccountPasskeyEncryption(
export async function deleteAccountPasskeyCredential(
db: D1Database,
userId: string,
id: string
id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> {
await ensureAccountPasskeySchema(db);
const result = await db
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ?')
.bind(userId, id)
.prepare('DELETE FROM webauthn_credentials WHERE user_id = ? AND id = ? AND purpose = ?')
.bind(userId, id, purpose)
.run();
return Number(result.meta.changes || 0) > 0;
}
+2 -1
View File
@@ -140,10 +140,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, purpose TEXT NOT NULL DEFAULT \'login\', name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'ALTER TABLE webauthn_credentials ADD COLUMN purpose TEXT NOT NULL DEFAULT \'login\'',
'CREATE UNIQUE INDEX IF NOT EXISTS idx_webauthn_credentials_credential_id ON webauthn_credentials(credential_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)',
'CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user_updated ON webauthn_credentials(user_id, updated_at)',
+17 -7
View File
@@ -161,7 +161,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-07-03-yubikey-otp';
const STORAGE_SCHEMA_VERSION = '2026-07-05-passkey-2fa';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage.
@@ -398,8 +398,11 @@ export class StorageService {
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
}
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
async getAccountPasskeyCredentialsByUserId(
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<AccountPasskeyCredential[]> {
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
}
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
@@ -410,8 +413,11 @@ export class StorageService {
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
}
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
async countAccountPasskeyCredentialsByUserId(
userId: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<number> {
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId, purpose);
}
async updateAccountPasskeyCounter(
@@ -442,8 +448,12 @@ export class StorageService {
);
}
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
async deleteAccountPasskeyCredential(
userId: string,
id: string,
purpose: AccountPasskeyCredential['purpose'] = 'login'
): Promise<boolean> {
return deleteStoredAccountPasskeyCredential(this.db, userId, id, purpose);
}
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
+7 -1
View File
@@ -252,6 +252,7 @@ export type AccountPasskeyPrfStatus = 0 | 1 | 2;
export interface AccountPasskeyCredential {
id: string;
userId: string;
purpose: 'login' | 'twoFactor';
name: string;
publicKey: string;
credentialId: string;
@@ -267,7 +268,12 @@ export interface AccountPasskeyCredential {
updatedAt: string;
}
export type AccountPasskeyChallengeScope = 'Authentication' | 'CreateCredential' | 'UpdateKeySet';
export type AccountPasskeyChallengeScope =
| 'Authentication'
| 'CreateCredential'
| 'UpdateKeySet'
| 'TwoFactorAuthentication'
| 'TwoFactorCreate';
export interface AccountPasskeyChallenge {
challengeHash: string;
+3 -1
View File
@@ -59,7 +59,9 @@ export async function sha256Base64Url(value: string): Promise<string> {
}
export function accountPasskeyTokenTtlMs(scope: AccountPasskeyChallengeScope): number {
return scope === 'CreateCredential' ? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS : ACCOUNT_PASSKEY_TOKEN_TTL_MS;
return scope === 'CreateCredential' || scope === 'TwoFactorCreate'
? ACCOUNT_PASSKEY_CREATE_TOKEN_TTL_MS
: ACCOUNT_PASSKEY_TOKEN_TTL_MS;
}
export async function createAccountPasskeyToken(