perf(api): кэш профиля и конфигурации, лимитер argon2, SHA-256 для client secret, trust proxy

This commit is contained in:
Denozordec committed 2026-10-23 16:54:05 +07:00
1 parent 107eddba9e
commit 30d331c633
19 files changed
+666 -72

No files matched your search

+11 -5
View File
@@ -24,9 +24,9 @@ import {
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hash } from '@node-rs/argon2'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto'
import type { AppConfig } from './config.js'
import { parseTrustProxy, type AppConfig } from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
@@ -52,7 +52,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
const existing = app.db.select().from(users).all()
if (existing.length === 0) {
const now = new Date().toISOString()
const passwordHash = await hash(app.config.adminPassword)
const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
@@ -96,12 +96,13 @@ export async function buildApp(opts: {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
trustProxy: parseTrustProxy(config.trustProxy),
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
keepAliveTimeout: 65_000,
connectionTimeout: 0,
@@ -143,7 +144,12 @@ export async function buildApp(opts: {
await app.register(formbody)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
await app.register(rateLimit, {
max: 200,
timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready',
})
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
+17
View File
@@ -5,6 +5,17 @@ const boolFromEnv = (v: string | undefined, fallback: boolean) => {
return v === '1' || v.toLowerCase() === 'true'
}
/** Loopback + приватные сети (Docker/Traefik). */
export const DEFAULT_TRUST_PROXY =
'127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7'
export function parseTrustProxy(raw: string): boolean | string[] {
const v = raw.trim()
if (v === 'true') return true
if (v === 'false' || v === '') return false
return v.split(',').map((s) => s.trim()).filter(Boolean)
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
@@ -22,6 +33,10 @@ export const configSchema = z.object({
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
argon2Concurrency: z.coerce.number().int().positive().default(2),
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */
trustProxy: z.string().default(DEFAULT_TRUST_PROXY),
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
sessionRetentionDays: z.coerce.number().positive().default(7),
/** Максимум на graceful shutdown, затем принудительный выход. */
@@ -62,6 +77,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
+1 -4
View File
@@ -5,10 +5,7 @@ import {
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
const forwarded = request.headers['x-forwarded-for']
if (typeof forwarded === 'string' && forwarded.trim()) {
return forwarded.split(',')[0]?.trim() ?? null
}
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
return request.ip ?? null
}
+51
View File
@@ -0,0 +1,51 @@
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
export class TtlCache<K, V> {
private readonly map = new Map<K, { value: V; expires: number }>()
constructor(
private readonly opts: {
max: number
ttlMs: number
now?: () => number
},
) {}
private now(): number {
return (this.opts.now ?? Date.now)()
}
get(key: K): V | undefined {
const hit = this.map.get(key)
if (!hit) return undefined
if (hit.expires <= this.now()) {
this.map.delete(key)
return undefined
}
// освежаем позицию для LRU
this.map.delete(key)
this.map.set(key, hit)
return hit.value
}
set(key: K, value: V): void {
this.map.delete(key)
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
while (this.map.size > this.opts.max) {
const oldest = this.map.keys().next()
if (oldest.done) break
this.map.delete(oldest.value)
}
}
delete(key: K): void {
this.map.delete(key)
}
clear(): void {
this.map.clear()
}
get size(): number {
return this.map.size
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { verifyPassword } from './password.js'
const PREFIX = 'sha256:'
/**
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
*/
export function hashClientSecret(secret: string): string {
return PREFIX + createHash('sha256').update(secret).digest('hex')
}
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
export async function checkClientSecret(
stored: string,
secret: string,
): Promise<ClientSecretCheck> {
if (stored.startsWith(PREFIX)) {
const a = Buffer.from(stored)
const b = Buffer.from(hashClientSecret(secret))
return {
ok: a.length === b.length && timingSafeEqual(a, b),
needsRehash: false,
}
}
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
try {
const ok = await verifyPassword(stored, secret)
return { ok, needsRehash: ok }
} catch {
return { ok: false, needsRehash: false }
}
}
+27
View File
@@ -0,0 +1,27 @@
/** Значение cookie без построения RegExp на каждый запрос. */
export function parseCookie(
header: string | undefined,
name: string,
): string | null {
if (!header) return null
let from = 0
while (from < header.length) {
let end = header.indexOf(';', from)
if (end === -1) end = header.length
const eq = header.indexOf('=', from)
if (eq !== -1 && eq < end) {
const key = header.slice(from, eq).trim()
if (key === name) {
const raw = header.slice(eq + 1, end).trim()
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
from = end + 1
}
return null
}
+8 -5
View File
@@ -12,17 +12,20 @@ import {
type UserRow,
} from '@authportal/db'
import { toMe } from '../plugins/auth-guards.js'
import { getUserProfile } from './user-profile.js'
/** Mint access JWT + Me from current DB state (login / reissue). */
export function issueAccessToken(
app: FastifyInstance,
user: UserRow,
): LoginResponse {
const apps = getUserApps(app.db, user.id)
let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id))
if (user.isAdmin) {
permissions = allPermissionKeys()
}
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
const profile = getUserProfile(app.db, user.id)
const apps = profile?.apps ?? getUserApps(app.db, user.id)
const permissions = user.isAdmin
? allPermissionKeys()
: (profile?.permissions ??
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
const switcher = getAppSwitcherConfig(app.db)
const tenants = tenantsClaimForUser(switcher, apps)
const me = toMe(user, apps, permissions)
+17
View File
@@ -117,6 +117,7 @@ export async function ensureOidcSigningKey(
export function resetOidcKeyCache(): void {
cached = null
importedPublicKeys.clear()
jwksCache = new WeakMap()
}
export function buildOidcDiscovery(config: AppConfig) {
@@ -157,6 +158,17 @@ export async function buildJwks(
app: FastifyInstance,
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const hit = jwksCache.get(app.db)
if (hit && hit.kid === cached?.kid) return hit.jwks
const jwks = computeJwks(app)
jwksCache.set(app.db, { kid: cached?.kid ?? '', jwks })
return jwks
}
/** Мемоизация JWKS: набор ключей меняется только при выпуске нового (меняется kid). */
let jwksCache = new WeakMap<object, { kid: string; jwks: { keys: JWK[] } }>()
function computeJwks(app: FastifyInstance): { keys: JWK[] } {
const keys = listOidcSigningKeys(app.db)
.map((row): JWK | null => {
try {
@@ -174,6 +186,11 @@ export async function buildJwks(
return { keys }
}
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
+58
View File
@@ -0,0 +1,58 @@
import { hash, verify } from '@node-rs/argon2'
/**
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
*/
let limit = 2
let active = 0
const waiters: Array<() => void> = []
export function setPasswordConcurrency(n: number): void {
limit = Math.max(1, Math.floor(n))
}
export function passwordHashStats(): { active: number; waiting: number } {
return { active, waiting: waiters.length }
}
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve))
} else {
active += 1
}
try {
return await fn()
} finally {
const next = waiters.shift()
if (next) next() // слот передаётся ожидающему без изменения active
else active -= 1
}
}
export function hashPassword(password: string): Promise<string> {
return withSlot(() => hash(password))
}
export function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
return withSlot(() => verify(passwordHash, password))
}
let dummyHash: Promise<string> | null = null
/**
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
* по фиктивному хешу: время ответа не выдаёт существование учётки.
*/
export async function verifyDummyPassword(password: string): Promise<void> {
dummyHash ??= hash('dummy-password-for-timing')
try {
await withSlot(async () => verify(await dummyHash!, password))
} catch {
/* результат не важен */
}
}
+70
View File
@@ -0,0 +1,70 @@
import {
getUserApps,
getUserById,
getUserDataGeneration,
getUserPermissions,
type AppDb,
type UserRow,
} from '@authportal/db'
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { TtlCache } from './cache.js'
export type UserProfile = {
row: UserRow
/** Сырые app_id из БД. */
apps: string[]
/** Нормализованные права; у админа — полный каталог. */
permissions: string[]
}
type Entry = { profile: UserProfile; generation: number }
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
const PROFILE_TTL_MS = 10_000
const PROFILE_MAX = 500
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
function cacheFor(db: AppDb): TtlCache<string, Entry> {
let c = caches.get(db)
if (!c) {
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
caches.set(db, c)
}
return c
}
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
const row = getUserById(db, id)
if (!row) return undefined
return profileFromRow(db, row)
}
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
const apps = getUserApps(db, row.id)
const permissions = row.isAdmin
? allPermissionKeys()
: normalizePermissionKeys(getUserPermissions(db, row.id))
return { row, apps, permissions }
}
/**
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
* запрос → 0 пока данные пользователей не менялись.
*/
export function getUserProfile(
db: AppDb,
id: string,
): UserProfile | undefined {
const cache = cacheFor(db)
const generation = getUserDataGeneration()
const hit = cache.get(id)
if (hit && hit.generation === generation) return hit.profile
const profile = loadUserProfile(db, id)
if (!profile) {
cache.delete(id)
return undefined
}
cache.set(id, { profile, generation })
return profile
}
+14 -27
View File
@@ -1,12 +1,8 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import {
getUserApps,
getUserById,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import type { UserRow } from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS, allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
export type AuthUser = {
id: string
@@ -67,24 +63,15 @@ export function toMe(user: UserRow, apps: string[], permissions: string[]): MeRe
}
}
export function loadAuthUser(
request: FastifyRequest,
user: UserRow,
): AuthUser {
const apps = getUserApps(request.server.db, user.id)
let permissions = normalizePermissionKeys(
getUserPermissions(request.server.db, user.id),
)
if (user.isAdmin) {
permissions = allPermissionKeys()
}
export function authUserFromProfile(profile: UserProfile): AuthUser {
const { row } = profile
return {
id: user.id,
email: user.email,
name: user.name,
isAdmin: user.isAdmin,
apps: asAppIds(apps),
permissions,
id: row.id,
email: row.email,
name: row.name,
isAdmin: row.isAdmin,
apps: asAppIds(profile.apps),
permissions: profile.permissions,
}
}
@@ -101,14 +88,14 @@ export async function requireAuth(
}
const sub = request.user.sub
const row = getUserById(request.server.db, sub)
if (!row || row.disabled) {
const profile = getUserProfile(request.server.db, sub)
if (!profile || profile.row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = loadAuthUser(request, row)
request.authUser = authUserFromProfile(profile)
}
export async function requireAdmin(
+6 -5
View File
@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
import { hashPassword } from '../lib/password.js'
import { hashClientSecret } from '../lib/client-secret.js'
import {
countWebauthnCredentials,
countWebauthnCredentialsBatch,
@@ -169,7 +170,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const passwordHash = await hash(data.password)
const passwordHash = await hashPassword(data.password)
const user = withTransaction(app.db, () => {
const created = createUser(app.db, {
email: data.email,
@@ -222,7 +223,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
}
const passwordHash = parsed.data.password
? await hash(parsed.data.password)
? await hashPassword(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
@@ -467,7 +468,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const secretHash = hashClientSecret(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
@@ -549,7 +550,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
}
const secret = generateOidcClientSecret()
const secretHash = await hash(secret)
const secretHash = hashClientSecret(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
+13 -8
View File
@@ -1,5 +1,4 @@
import type { FastifyInstance } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
@@ -14,6 +13,8 @@ import {
searchDirectoryUsers,
revokeRefreshSession,
} from '@authportal/db'
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
import { parseCookie } from '../lib/cookies.js'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
@@ -44,6 +45,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
await verifyDummyPassword(password)
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
@@ -63,7 +65,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
}
const ok = await verify(user.passwordHash, password)
const ok = await verifyPassword(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
@@ -163,11 +165,13 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
},
)
app.post('/api/v1/auth/logout', async (request, reply) => {
const cookie = request.headers.cookie ?? ''
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
if (match?.[1]) {
revokeRefreshSession(app.db, match[1])
app.post(
'/api/v1/auth/logout',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (request, reply) => {
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (refresh) {
revokeRefreshSession(app.db, refresh)
}
reply.header(
'Set-Cookie',
@@ -205,7 +209,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
})
return { ok: true }
})
},
)
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
app.get('/api/v1/app-switcher', async () =>
+23 -18
View File
@@ -1,7 +1,7 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { verify } from '@node-rs/argon2'
import {
updateOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
@@ -19,6 +19,8 @@ import {
} from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
import { parseCookie } from '../lib/cookies.js'
import {
buildJwks,
buildOidcDiscovery,
@@ -33,15 +35,6 @@ const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function cookieValue(
cookieHeader: string | undefined,
name: string,
): string | null {
if (!cookieHeader) return null
const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`))
return match?.[1] ? decodeURIComponent(match[1]) : null
}
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
@@ -60,14 +53,22 @@ function parseBasicAuth(
}
async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow,
secret: string,
): Promise<boolean> {
try {
return await verify(client.clientSecretHash, secret)
} catch {
return false
const res = await checkClientSecret(client.clientSecretHash, secret)
if (res.ok && res.needsRehash) {
// Миграция argon2 → SHA-256 при первом успешном обмене.
try {
updateOidcClient(app.db, client.id, {
clientSecretHash: hashClientSecret(secret),
})
} catch (err) {
app.log.warn({ err }, 'client secret rehash failed')
}
}
return res.ok
}
function redirectError(
@@ -94,7 +95,7 @@ function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE)
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
@@ -171,7 +172,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
return buildJwks(app)
})
app.get('/oauth/authorize', async (request, reply) => {
app.get('/oauth/authorize', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const q = request.query as Record<string, string | undefined>
const clientId = q.client_id
const redirectUri = q.redirect_uri
@@ -339,7 +342,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(client, clientSecret)
const secretOk = await authenticateClient(app, client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
@@ -420,7 +423,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
},
})
app.get('/oauth/userinfo', async (request, reply) => {
app.get('/oauth/userinfo', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({