perf(api): горячие пути OIDC и батч-запросы
- кэш 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)
This commit is contained in:
@@ -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')) {
|
||||
|
||||
@@ -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<string, CryptoKey>()
|
||||
|
||||
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<Record<string, unknown>> {
|
||||
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'],
|
||||
|
||||
@@ -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<ReturnType<typeof getUserById>>,
|
||||
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<ReturnType<typeof getUserById>>,
|
||||
): 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<void> {
|
||||
})
|
||||
|
||||
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 } }>(
|
||||
|
||||
@@ -160,11 +160,16 @@ function verifyPkce(
|
||||
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<string, string | undefined>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string[]> {
|
||||
const rows = db.select().from(userApps).all()
|
||||
const byUser = new Map<string, string[]>()
|
||||
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<string, string[]> {
|
||||
const rows = db.select().from(userPermissions).all()
|
||||
const byUser = new Map<string, string[]>()
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, number> {
|
||||
const rows = db
|
||||
.select({
|
||||
userId: webauthnCredentials.userId,
|
||||
total: sql<number>`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,
|
||||
|
||||
Reference in New Issue
Block a user