From 87f35631b47ce1fbd232d6378745783fdb260155 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 23 Sep 2026 14:51:14 +0700 Subject: [PATCH] =?UTF-8?q?perf(api):=20=D0=B3=D0=BE=D1=80=D1=8F=D1=87?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D1=83=D1=82=D0=B8=20OIDC=20=D0=B8=20?= =?UTF-8?q?=D0=B1=D0=B0=D1=82=D1=87-=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - кэш importJWK: не импортировать RSA-ключ на каждый /oauth/userinfo - Cache-Control для /.well-known/openid-configuration и jwks.json - индекс refresh_sessions(token_hash) — было full scan на каждом authorize/logout - GET /admin/users: 3 SELECT на пользователя → батч-запросы (N+1) - revokeAllSessionsForUser: цикл UPDATE → один UPDATE - preCompressed статики (файлы готовит Dockerfile) --- apps/api/src/app.ts | 3 ++ apps/api/src/lib/oidc/keys.ts | 17 +++++++--- apps/api/src/routes/admin.ts | 63 ++++++++++++++++++++++++----------- apps/api/src/routes/oidc.ts | 13 +++++--- packages/db/src/index.ts | 1 + packages/db/src/users.ts | 47 +++++++++++++++++++++----- packages/db/src/webauthn.ts | 15 ++++++++- 7 files changed, 121 insertions(+), 38 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 54ddfb8..94befc3 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -143,6 +143,9 @@ export async function buildApp(opts: { await app.register(fastifyStatic, { root: resolve(config.staticDir), wildcard: false, + // Traefik перед приложением не сжимает; .gz/.br для статики готовит + // Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём. + preCompressed: true, }) app.setNotFoundHandler((req, reply) => { if (req.method === 'GET' && !req.url.startsWith('/api')) { diff --git a/apps/api/src/lib/oidc/keys.ts b/apps/api/src/lib/oidc/keys.ts index 2a9be4b..b5e9687 100644 --- a/apps/api/src/lib/oidc/keys.ts +++ b/apps/api/src/lib/oidc/keys.ts @@ -7,7 +7,6 @@ import { jwtVerify, SignJWT, type JWK, - type KeyLike, } from 'jose' import { createHash, randomUUID } from 'node:crypto' import type { FastifyInstance } from 'fastify' @@ -20,11 +19,13 @@ import { oidcIssuerFromConfig, type AppConfig } from '../../config.js' export type OidcKeyMaterial = { kid: string - privateKey: KeyLike + privateKey: CryptoKey publicJwk: JWK } let cached: OidcKeyMaterial | null = null +// importJWK — асинхронный разбор JWK; без кэша он выполнялся на каждый /oauth/userinfo. +const importedPublicKeys = new Map() function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK { return { @@ -115,6 +116,7 @@ export async function ensureOidcSigningKey( /** Reset cache (tests). */ export function resetOidcKeyCache(): void { cached = null + importedPublicKeys.clear() } export function buildOidcDiscovery(config: AppConfig) { @@ -156,7 +158,7 @@ export async function buildJwks( ): Promise<{ keys: JWK[] }> { await ensureOidcSigningKey(app) const keys = listOidcSigningKeys(app.db) - .map((row) => { + .map((row): JWK | null => { try { const jwk = JSON.parse(row.publicJwkJson) as JWK return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' } @@ -164,7 +166,7 @@ export async function buildJwks( return null } }) - .filter((k): k is JWK => k != null) + .filter((k): k is JWK => k !== null) if (cached && !keys.some((k) => k.kid === cached!.kid)) { keys.unshift(cached.publicJwk) @@ -194,7 +196,12 @@ export async function verifyOidcAccessToken( ): Promise> { const key = await ensureOidcSigningKey(app) const issuer = oidcIssuerFromConfig(app.config) - const publicKey = await importJWK(key.publicJwk, 'RS256') + let publicKey: CryptoKey | undefined = importedPublicKeys.get(key.kid) + if (!publicKey) { + // Для RS256 + RSA JWK importJWK всегда возвращает CryptoKey. + publicKey = (await importJWK(key.publicJwk, 'RS256')) as CryptoKey + importedPublicKeys.set(key.kid, publicKey) + } const { payload } = await jwtVerify(token, publicKey, { issuer, algorithms: ['RS256'], diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 7ab6318..9021293 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,29 +1,32 @@ import type { FastifyInstance } from 'fastify' import { hash } from '@node-rs/argon2' import { + countWebauthnCredentials, + countWebauthnCredentialsBatch, + createOidcClient, createUser, + deleteOidcClient, deleteUser, + deleteWebauthnCredentialsForUser, + generateOidcClientSecret, getAppSwitcherConfig, + getOidcClientById, getUserApps, getUserByEmail, getUserById, getUserPermissions, listActiveSessions, + listOidcClients, + listUserAppsBatch, + listUserPermissionsBatch, listUsers, + parseJsonStringArray, revokeAllSessionsForUser, revokeSessionById, setAppSwitcherConfig, setUserAccess, - updateUser, - createOidcClient, - deleteOidcClient, - generateOidcClientSecret, - getOidcClientById, - listOidcClients, - parseJsonStringArray, updateOidcClient, - countWebauthnCredentials, - deleteWebauthnCredentialsForUser, + updateUser, } from '@authportal/db' import { APP_IDS, @@ -46,30 +49,40 @@ import { oidcIssuerFromConfig } from '../config.js' const allowedPermissions = new Set(allPermissionKeys()) -function mapUser( - db: FastifyInstance['db'], +function toAdminUser( user: NonNullable>, + apps: string[], + permissions: string[], + passkeyCount: number, ): AdminUser { - const apps = getUserApps(db, user.id).filter((a): a is AppId => - (APP_IDS as readonly string[]).includes(a), - ) - const permissions = normalizePermissionKeys(getUserPermissions(db, user.id)) return { id: user.id, email: user.email, name: user.name, is_admin: user.isAdmin, disabled: user.disabled, - apps, - permissions, + apps: apps.filter((a): a is AppId => (APP_IDS as readonly string[]).includes(a)), + permissions: normalizePermissionKeys(permissions), last_login_at: user.lastLoginAt ?? null, last_login_ip: user.lastLoginIp ?? null, - passkey_count: countWebauthnCredentials(db, user.id), + passkey_count: passkeyCount, created_at: user.createdAt, updated_at: user.updatedAt, } } +function mapUser( + db: FastifyInstance['db'], + user: NonNullable>, +): AdminUser { + return toAdminUser( + user, + getUserApps(db, user.id), + getUserPermissions(db, user.id), + countWebauthnCredentials(db, user.id), + ) +} + function validateAccess(apps: string[], permissions: string[]): string | null { for (const app of apps) { if (!(APP_IDS as readonly string[]).includes(app)) { @@ -106,7 +119,19 @@ export async function adminRoutes(app: FastifyInstance): Promise { }) app.get('/api/v1/admin/users', async () => { - return listUsers(app.db).map((u) => mapUser(app.db, u)) + // Батч-запросы вместо 3 SELECT'ов на пользователя (N+1 при N пользователях). + const usersRows = listUsers(app.db) + const appsByUser = listUserAppsBatch(app.db) + const permsByUser = listUserPermissionsBatch(app.db) + const passkeysByUser = countWebauthnCredentialsBatch(app.db) + return usersRows.map((u) => + toAdminUser( + u, + appsByUser.get(u.id) ?? [], + permsByUser.get(u.id) ?? [], + passkeysByUser.get(u.id) ?? 0, + ), + ) }) app.get<{ Params: { id: string } }>( diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts index c816f42..22107b0 100644 --- a/apps/api/src/routes/oidc.ts +++ b/apps/api/src/routes/oidc.ts @@ -160,11 +160,16 @@ function verifyPkce( export async function oidcRoutes(app: FastifyInstance): Promise { await ensureOidcSigningKey(app) - app.get('/.well-known/openid-configuration', async () => - buildOidcDiscovery(app.config), - ) + app.get('/.well-known/openid-configuration', async (_request, reply) => { + // Статичный документ — RP не должен перечитывать его на каждый запрос. + reply.header('Cache-Control', 'public, max-age=300') + return buildOidcDiscovery(app.config) + }) - app.get('/.well-known/jwks.json', async () => buildJwks(app)) + app.get('/.well-known/jwks.json', async (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=300') + return buildJwks(app) + }) app.get('/oauth/authorize', async (request, reply) => { const q = request.query as Record diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b1e2903..5732549 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -91,6 +91,7 @@ export function migrateSchema(sqlite: Sqlite): void { CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id); CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at); CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action); CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at); diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts index 8987d9a..f7470ef 100644 --- a/packages/db/src/users.ts +++ b/packages/db/src/users.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +import { and, eq, gt, isNull } from 'drizzle-orm' import { createHash, randomUUID } from 'node:crypto' import type { AppDb } from './index.js' import { @@ -51,6 +51,30 @@ export function getUserPermissions(db: AppDb, userId: string): string[] { .map((r) => r.permission) } +/** Apps всех пользователей одним запросом — для списков без N+1. */ +export function listUserAppsBatch(db: AppDb): Map { + const rows = db.select().from(userApps).all() + const byUser = new Map() + for (const row of rows) { + const list = byUser.get(row.userId) + if (list) list.push(row.appId) + else byUser.set(row.userId, [row.appId]) + } + return byUser +} + +/** Permissions всех пользователей одним запросом — для списков без N+1. */ +export function listUserPermissionsBatch(db: AppDb): Map { + const rows = db.select().from(userPermissions).all() + const byUser = new Map() + for (const row of rows) { + const list = byUser.get(row.userId) + if (list) list.push(row.permission) + else byUser.set(row.userId, [row.permission]) + } + return byUser +} + export function createUser( db: AppDb, input: { @@ -263,12 +287,17 @@ export function revokeSessionById(db: AppDb, sessionId: string): boolean { export function revokeAllSessionsForUser(db: AppDb, userId: string): number { const now = new Date().toISOString() - const active = listActiveSessions(db, { userId }) - for (const s of active) { - db.update(refreshSessions) - .set({ revokedAt: now }) - .where(eq(refreshSessions.id, s.id)) - .run() - } - return active.length + // Активная сессия = не отозвана и не истекла; один UPDATE вместо цикла. + const result = db + .update(refreshSessions) + .set({ revokedAt: now }) + .where( + and( + eq(refreshSessions.userId, userId), + isNull(refreshSessions.revokedAt), + gt(refreshSessions.expiresAt, now), + ), + ) + .run() + return result.changes } diff --git a/packages/db/src/webauthn.ts b/packages/db/src/webauthn.ts index 4ebc99c..165032d 100644 --- a/packages/db/src/webauthn.ts +++ b/packages/db/src/webauthn.ts @@ -1,4 +1,4 @@ -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, lt, sql } from 'drizzle-orm' import { randomUUID } from 'node:crypto' import type { AppDb } from './index.js' import { webauthnChallenges, webauthnCredentials } from './schema/index.js' @@ -85,6 +85,19 @@ export function countWebauthnCredentials(db: AppDb, userId: string): number { return listWebauthnCredentials(db, userId).length } +/** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */ +export function countWebauthnCredentialsBatch(db: AppDb): Map { + const rows = db + .select({ + userId: webauthnCredentials.userId, + total: sql`count(*)`, + }) + .from(webauthnCredentials) + .groupBy(webauthnCredentials.userId) + .all() + return new Map(rows.map((r) => [r.userId, Number(r.total)])) +} + export function getWebauthnCredentialById( db: AppDb, id: string,