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
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:
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -400,11 +400,42 @@ export const adminUserSchema = z.object({
|
||||
permissions: z.array(z.string()),
|
||||
last_login_at: z.string().nullable(),
|
||||
last_login_ip: z.string().nullable(),
|
||||
passkey_count: z.number().int().nonnegative().default(0),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||
|
||||
export const passkeyCredentialSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
created_at: z.string(),
|
||||
last_used_at: z.string().nullable(),
|
||||
device_type: z.string().nullable(),
|
||||
})
|
||||
export type PasskeyCredential = z.infer<typeof passkeyCredentialSchema>
|
||||
|
||||
export const webauthnOptionsResponseSchema = z.object({
|
||||
challenge_id: z.string(),
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
export type WebauthnOptionsResponse = z.infer<
|
||||
typeof webauthnOptionsResponseSchema
|
||||
>
|
||||
|
||||
export const webauthnVerifyRequestSchema = z.object({
|
||||
challenge_id: z.string().min(1),
|
||||
response: z.unknown(),
|
||||
return_to: z.string().url().optional(),
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
})
|
||||
export type WebauthnVerifyRequest = z.infer<typeof webauthnVerifyRequestSchema>
|
||||
|
||||
export const patchPasskeyRequestSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
})
|
||||
export type PatchPasskeyRequest = z.infer<typeof patchPasskeyRequestSchema>
|
||||
|
||||
export const adminSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user