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, {
|
await app.register(fastifyStatic, {
|
||||||
root: resolve(config.staticDir),
|
root: resolve(config.staticDir),
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
|
// Traefik перед приложением не сжимает; .gz/.br для статики готовит
|
||||||
|
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
|
||||||
|
preCompressed: true,
|
||||||
})
|
})
|
||||||
app.setNotFoundHandler((req, reply) => {
|
app.setNotFoundHandler((req, reply) => {
|
||||||
if (req.method === 'GET' && !req.url.startsWith('/api')) {
|
if (req.method === 'GET' && !req.url.startsWith('/api')) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
jwtVerify,
|
jwtVerify,
|
||||||
SignJWT,
|
SignJWT,
|
||||||
type JWK,
|
type JWK,
|
||||||
type KeyLike,
|
|
||||||
} from 'jose'
|
} from 'jose'
|
||||||
import { createHash, randomUUID } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
@@ -20,11 +19,13 @@ import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
|
|||||||
|
|
||||||
export type OidcKeyMaterial = {
|
export type OidcKeyMaterial = {
|
||||||
kid: string
|
kid: string
|
||||||
privateKey: KeyLike
|
privateKey: CryptoKey
|
||||||
publicJwk: JWK
|
publicJwk: JWK
|
||||||
}
|
}
|
||||||
|
|
||||||
let cached: OidcKeyMaterial | null = null
|
let cached: OidcKeyMaterial | null = null
|
||||||
|
// importJWK — асинхронный разбор JWK; без кэша он выполнялся на каждый /oauth/userinfo.
|
||||||
|
const importedPublicKeys = new Map<string, CryptoKey>()
|
||||||
|
|
||||||
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
|
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
|
||||||
return {
|
return {
|
||||||
@@ -115,6 +116,7 @@ export async function ensureOidcSigningKey(
|
|||||||
/** Reset cache (tests). */
|
/** Reset cache (tests). */
|
||||||
export function resetOidcKeyCache(): void {
|
export function resetOidcKeyCache(): void {
|
||||||
cached = null
|
cached = null
|
||||||
|
importedPublicKeys.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOidcDiscovery(config: AppConfig) {
|
export function buildOidcDiscovery(config: AppConfig) {
|
||||||
@@ -156,7 +158,7 @@ export async function buildJwks(
|
|||||||
): Promise<{ keys: JWK[] }> {
|
): Promise<{ keys: JWK[] }> {
|
||||||
await ensureOidcSigningKey(app)
|
await ensureOidcSigningKey(app)
|
||||||
const keys = listOidcSigningKeys(app.db)
|
const keys = listOidcSigningKeys(app.db)
|
||||||
.map((row) => {
|
.map((row): JWK | null => {
|
||||||
try {
|
try {
|
||||||
const jwk = JSON.parse(row.publicJwkJson) as JWK
|
const jwk = JSON.parse(row.publicJwkJson) as JWK
|
||||||
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
|
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
|
||||||
@@ -164,7 +166,7 @@ export async function buildJwks(
|
|||||||
return null
|
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)) {
|
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
|
||||||
keys.unshift(cached.publicJwk)
|
keys.unshift(cached.publicJwk)
|
||||||
@@ -194,7 +196,12 @@ export async function verifyOidcAccessToken(
|
|||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const key = await ensureOidcSigningKey(app)
|
const key = await ensureOidcSigningKey(app)
|
||||||
const issuer = oidcIssuerFromConfig(app.config)
|
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, {
|
const { payload } = await jwtVerify(token, publicKey, {
|
||||||
issuer,
|
issuer,
|
||||||
algorithms: ['RS256'],
|
algorithms: ['RS256'],
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { hash } from '@node-rs/argon2'
|
import { hash } from '@node-rs/argon2'
|
||||||
import {
|
import {
|
||||||
|
countWebauthnCredentials,
|
||||||
|
countWebauthnCredentialsBatch,
|
||||||
|
createOidcClient,
|
||||||
createUser,
|
createUser,
|
||||||
|
deleteOidcClient,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
deleteWebauthnCredentialsForUser,
|
||||||
|
generateOidcClientSecret,
|
||||||
getAppSwitcherConfig,
|
getAppSwitcherConfig,
|
||||||
|
getOidcClientById,
|
||||||
getUserApps,
|
getUserApps,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserPermissions,
|
getUserPermissions,
|
||||||
listActiveSessions,
|
listActiveSessions,
|
||||||
|
listOidcClients,
|
||||||
|
listUserAppsBatch,
|
||||||
|
listUserPermissionsBatch,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
parseJsonStringArray,
|
||||||
revokeAllSessionsForUser,
|
revokeAllSessionsForUser,
|
||||||
revokeSessionById,
|
revokeSessionById,
|
||||||
setAppSwitcherConfig,
|
setAppSwitcherConfig,
|
||||||
setUserAccess,
|
setUserAccess,
|
||||||
updateUser,
|
|
||||||
createOidcClient,
|
|
||||||
deleteOidcClient,
|
|
||||||
generateOidcClientSecret,
|
|
||||||
getOidcClientById,
|
|
||||||
listOidcClients,
|
|
||||||
parseJsonStringArray,
|
|
||||||
updateOidcClient,
|
updateOidcClient,
|
||||||
countWebauthnCredentials,
|
updateUser,
|
||||||
deleteWebauthnCredentialsForUser,
|
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import {
|
import {
|
||||||
APP_IDS,
|
APP_IDS,
|
||||||
@@ -46,30 +49,40 @@ import { oidcIssuerFromConfig } from '../config.js'
|
|||||||
|
|
||||||
const allowedPermissions = new Set(allPermissionKeys())
|
const allowedPermissions = new Set(allPermissionKeys())
|
||||||
|
|
||||||
function mapUser(
|
function toAdminUser(
|
||||||
db: FastifyInstance['db'],
|
|
||||||
user: NonNullable<ReturnType<typeof getUserById>>,
|
user: NonNullable<ReturnType<typeof getUserById>>,
|
||||||
|
apps: string[],
|
||||||
|
permissions: string[],
|
||||||
|
passkeyCount: number,
|
||||||
): AdminUser {
|
): 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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
is_admin: user.isAdmin,
|
is_admin: user.isAdmin,
|
||||||
disabled: user.disabled,
|
disabled: user.disabled,
|
||||||
apps,
|
apps: apps.filter((a): a is AppId => (APP_IDS as readonly string[]).includes(a)),
|
||||||
permissions,
|
permissions: normalizePermissionKeys(permissions),
|
||||||
last_login_at: user.lastLoginAt ?? null,
|
last_login_at: user.lastLoginAt ?? null,
|
||||||
last_login_ip: user.lastLoginIp ?? null,
|
last_login_ip: user.lastLoginIp ?? null,
|
||||||
passkey_count: countWebauthnCredentials(db, user.id),
|
passkey_count: passkeyCount,
|
||||||
created_at: user.createdAt,
|
created_at: user.createdAt,
|
||||||
updated_at: user.updatedAt,
|
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 {
|
function validateAccess(apps: string[], permissions: string[]): string | null {
|
||||||
for (const app of apps) {
|
for (const app of apps) {
|
||||||
if (!(APP_IDS as readonly string[]).includes(app)) {
|
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 () => {
|
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 } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
|
|||||||
@@ -160,11 +160,16 @@ function verifyPkce(
|
|||||||
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||||
await ensureOidcSigningKey(app)
|
await ensureOidcSigningKey(app)
|
||||||
|
|
||||||
app.get('/.well-known/openid-configuration', async () =>
|
app.get('/.well-known/openid-configuration', async (_request, reply) => {
|
||||||
buildOidcDiscovery(app.config),
|
// Статичный документ — 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) => {
|
app.get('/oauth/authorize', async (request, reply) => {
|
||||||
const q = request.query as Record<string, string | undefined>
|
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_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_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_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_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_action ON audit_log(action);
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
|
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 { createHash, randomUUID } from 'node:crypto'
|
||||||
import type { AppDb } from './index.js'
|
import type { AppDb } from './index.js'
|
||||||
import {
|
import {
|
||||||
@@ -51,6 +51,30 @@ export function getUserPermissions(db: AppDb, userId: string): string[] {
|
|||||||
.map((r) => r.permission)
|
.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(
|
export function createUser(
|
||||||
db: AppDb,
|
db: AppDb,
|
||||||
input: {
|
input: {
|
||||||
@@ -263,12 +287,17 @@ export function revokeSessionById(db: AppDb, sessionId: string): boolean {
|
|||||||
|
|
||||||
export function revokeAllSessionsForUser(db: AppDb, userId: string): number {
|
export function revokeAllSessionsForUser(db: AppDb, userId: string): number {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const active = listActiveSessions(db, { userId })
|
// Активная сессия = не отозвана и не истекла; один UPDATE вместо цикла.
|
||||||
for (const s of active) {
|
const result = db
|
||||||
db.update(refreshSessions)
|
.update(refreshSessions)
|
||||||
.set({ revokedAt: now })
|
.set({ revokedAt: now })
|
||||||
.where(eq(refreshSessions.id, s.id))
|
.where(
|
||||||
.run()
|
and(
|
||||||
}
|
eq(refreshSessions.userId, userId),
|
||||||
return active.length
|
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 { randomUUID } from 'node:crypto'
|
||||||
import type { AppDb } from './index.js'
|
import type { AppDb } from './index.js'
|
||||||
import { webauthnChallenges, webauthnCredentials } from './schema/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
|
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(
|
export function getWebauthnCredentialById(
|
||||||
db: AppDb,
|
db: AppDb,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user