feat(auth): добавить вход по passkey
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s

Альтернатива паролю на портале; SSO приложений без изменений.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-19 10:24:46 +07:00
co-authored by Cursor
parent f76b89c1df
commit 46dc2f714c
26 changed files with 1820 additions and 72 deletions
+31
View File
@@ -182,6 +182,36 @@ export function migrateSchema(sqlite: Sqlite): void {
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_client ON oidc_auth_codes(client_id);
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_user ON oidc_auth_codes(user_id);
`)
sqlite.exec(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
device_type TEXT,
backed_up INTEGER NOT NULL DEFAULT 0,
transports_json TEXT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
purpose TEXT NOT NULL,
challenge TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires
ON webauthn_challenges(expires_at);
`)
}
export function healthCheck(sqlite: Sqlite): void {
@@ -193,3 +223,4 @@ export * from './users.js'
export * from './settings.js'
export * from './audit-log.js'
export * from './oidc.js'
export * from './webauthn.js'
+25
View File
@@ -101,3 +101,28 @@ export const oidcSigningKeys = sqliteTable('oidc_signing_keys', {
active: integer('active', { mode: 'boolean' }).notNull().default(true),
createdAt: text('created_at').notNull(),
})
export const webauthnCredentials = sqliteTable('webauthn_credentials', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
credentialId: text('credential_id').notNull().unique(),
publicKey: text('public_key').notNull(),
counter: integer('counter').notNull().default(0),
deviceType: text('device_type'),
backedUp: integer('backed_up', { mode: 'boolean' }).notNull().default(false),
transportsJson: text('transports_json'),
name: text('name').notNull(),
createdAt: text('created_at').notNull(),
lastUsedAt: text('last_used_at'),
})
export const webauthnChallenges = sqliteTable('webauthn_challenges', {
id: text('id').primaryKey(),
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
purpose: text('purpose').notNull(),
challenge: text('challenge').notNull(),
expiresAt: text('expires_at').notNull(),
createdAt: text('created_at').notNull(),
})
+216
View File
@@ -0,0 +1,216 @@
import { and, eq, lt } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import type { AppDb } from './index.js'
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
export type WebauthnCredentialRow = typeof webauthnCredentials.$inferSelect
export type WebauthnChallengeRow = typeof webauthnChallenges.$inferSelect
export type WebauthnChallengePurpose = 'register' | 'authenticate'
const CHALLENGE_TTL_MS = 5 * 60 * 1000
export function purgeExpiredWebauthnChallenges(db: AppDb): void {
const now = new Date().toISOString()
db.delete(webauthnChallenges)
.where(lt(webauthnChallenges.expiresAt, now))
.run()
}
export function createWebauthnChallenge(
db: AppDb,
input: {
purpose: WebauthnChallengePurpose
challenge: string
userId?: string | null
},
): WebauthnChallengeRow {
purgeExpiredWebauthnChallenges(db)
const now = new Date()
const id = randomUUID()
db.insert(webauthnChallenges)
.values({
id,
userId: input.userId ?? null,
purpose: input.purpose,
challenge: input.challenge,
expiresAt: new Date(now.getTime() + CHALLENGE_TTL_MS).toISOString(),
createdAt: now.toISOString(),
})
.run()
return getWebauthnChallengeById(db, id)!
}
export function getWebauthnChallengeById(
db: AppDb,
id: string,
): WebauthnChallengeRow | undefined {
return db
.select()
.from(webauthnChallenges)
.where(eq(webauthnChallenges.id, id))
.get()
}
export function consumeWebauthnChallenge(
db: AppDb,
id: string,
purpose: WebauthnChallengePurpose,
userId?: string | null,
): WebauthnChallengeRow | undefined {
const row = getWebauthnChallengeById(db, id)
if (!row) return undefined
if (row.purpose !== purpose) return undefined
if (row.expiresAt < new Date().toISOString()) {
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
return undefined
}
if (userId != null && row.userId && row.userId !== userId) return undefined
db.delete(webauthnChallenges).where(eq(webauthnChallenges.id, id)).run()
return row
}
export function listWebauthnCredentials(
db: AppDb,
userId: string,
): WebauthnCredentialRow[] {
return db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId))
.all()
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
}
export function countWebauthnCredentials(db: AppDb, userId: string): number {
return listWebauthnCredentials(db, userId).length
}
export function getWebauthnCredentialById(
db: AppDb,
id: string,
): WebauthnCredentialRow | undefined {
return db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.id, id))
.get()
}
export function getWebauthnCredentialByCredentialId(
db: AppDb,
credentialId: string,
): WebauthnCredentialRow | undefined {
return db
.select()
.from(webauthnCredentials)
.where(eq(webauthnCredentials.credentialId, credentialId))
.get()
}
export function createWebauthnCredential(
db: AppDb,
input: {
userId: string
credentialId: string
publicKey: string
counter: number
deviceType?: string | null
backedUp?: boolean
transports?: string[]
name: string
},
): WebauthnCredentialRow {
const id = randomUUID()
const now = new Date().toISOString()
db.insert(webauthnCredentials)
.values({
id,
userId: input.userId,
credentialId: input.credentialId,
publicKey: input.publicKey,
counter: input.counter,
deviceType: input.deviceType ?? null,
backedUp: input.backedUp ?? false,
transportsJson: input.transports
? JSON.stringify(input.transports)
: null,
name: input.name,
createdAt: now,
lastUsedAt: null,
})
.run()
return getWebauthnCredentialById(db, id)!
}
export function updateWebauthnCredentialName(
db: AppDb,
id: string,
userId: string,
name: string,
): WebauthnCredentialRow | undefined {
const existing = getWebauthnCredentialById(db, id)
if (!existing || existing.userId !== userId) return undefined
db.update(webauthnCredentials)
.set({ name })
.where(
and(
eq(webauthnCredentials.id, id),
eq(webauthnCredentials.userId, userId),
),
)
.run()
return getWebauthnCredentialById(db, id)
}
export function touchWebauthnCredential(
db: AppDb,
id: string,
counter: number,
): void {
db.update(webauthnCredentials)
.set({
counter,
lastUsedAt: new Date().toISOString(),
})
.where(eq(webauthnCredentials.id, id))
.run()
}
export function deleteWebauthnCredential(
db: AppDb,
id: string,
userId: string,
): boolean {
const result = db
.delete(webauthnCredentials)
.where(
and(
eq(webauthnCredentials.id, id),
eq(webauthnCredentials.userId, userId),
),
)
.run()
return result.changes > 0
}
export function deleteWebauthnCredentialsForUser(
db: AppDb,
userId: string,
): number {
const result = db
.delete(webauthnCredentials)
.where(eq(webauthnCredentials.userId, userId))
.run()
return result.changes
}
export function parseTransportsJson(raw: string | null): string[] {
if (!raw) return []
try {
const v = JSON.parse(raw) as unknown
if (!Array.isArray(v)) return []
return v.filter((x): x is string => typeof x === 'string')
} catch {
return []
}
}