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
+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> {