diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 70815e4..6e39bc9 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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 { 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 () => { diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index ac9e74f..1a87712 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -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, diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts index d23f8f3..c680ff7 100644 --- a/apps/api/src/lib/audit.ts +++ b/apps/api/src/lib/audit.ts @@ -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 } diff --git a/apps/api/src/lib/cache.ts b/apps/api/src/lib/cache.ts new file mode 100644 index 0000000..6878896 --- /dev/null +++ b/apps/api/src/lib/cache.ts @@ -0,0 +1,51 @@ +/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */ +export class TtlCache { + private readonly map = new Map() + + 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 + } +} diff --git a/apps/api/src/lib/client-secret.ts b/apps/api/src/lib/client-secret.ts new file mode 100644 index 0000000..fc6f1e1 --- /dev/null +++ b/apps/api/src/lib/client-secret.ts @@ -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 { + 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 } + } +} diff --git a/apps/api/src/lib/cookies.ts b/apps/api/src/lib/cookies.ts new file mode 100644 index 0000000..8150a92 --- /dev/null +++ b/apps/api/src/lib/cookies.ts @@ -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 +} diff --git a/apps/api/src/lib/issue-access-token.ts b/apps/api/src/lib/issue-access-token.ts index 581c4fc..3e45194 100644 --- a/apps/api/src/lib/issue-access-token.ts +++ b/apps/api/src/lib/issue-access-token.ts @@ -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) diff --git a/apps/api/src/lib/oidc/keys.ts b/apps/api/src/lib/oidc/keys.ts index b5e9687..9720309 100644 --- a/apps/api/src/lib/oidc/keys.ts +++ b/apps/api/src/lib/oidc/keys.ts @@ -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() + +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, diff --git a/apps/api/src/lib/password.ts b/apps/api/src/lib/password.ts new file mode 100644 index 0000000..bf09d53 --- /dev/null +++ b/apps/api/src/lib/password.ts @@ -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(fn: () => Promise): Promise { + if (active >= limit) { + await new Promise((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 { + return withSlot(() => hash(password)) +} + +export function verifyPassword( + passwordHash: string, + password: string, +): Promise { + return withSlot(() => verify(passwordHash, password)) +} + +let dummyHash: Promise | null = null + +/** + * Для неизвестного/отключённого пользователя выполняем настоящую проверку + * по фиктивному хешу: время ответа не выдаёт существование учётки. + */ +export async function verifyDummyPassword(password: string): Promise { + dummyHash ??= hash('dummy-password-for-timing') + try { + await withSlot(async () => verify(await dummyHash!, password)) + } catch { + /* результат не важен */ + } +} diff --git a/apps/api/src/lib/user-profile.ts b/apps/api/src/lib/user-profile.ts new file mode 100644 index 0000000..f29514c --- /dev/null +++ b/apps/api/src/lib/user-profile.ts @@ -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>() + +function cacheFor(db: AppDb): TtlCache { + 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 +} diff --git a/apps/api/src/plugins/auth-guards.ts b/apps/api/src/plugins/auth-guards.ts index c155a6c..a9b411c 100644 --- a/apps/api/src/plugins/auth-guards.ts +++ b/apps/api/src/plugins/auth-guards.ts @@ -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( diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index a173d75..f74be7e 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -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 { }) } - 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 { } 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 { }) } 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 { }) } const secret = generateOidcClientSecret() - const secretHash = await hash(secret) + const secretHash = hashClientSecret(secret) const row = updateOidcClient(app.db, request.params.id, { clientSecretHash: secretHash, })! diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index ca19bba..b14cef7 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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 { 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 { }) } - 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 { }, ) - 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 { }) return { ok: true } - }) + }, + ) /** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */ app.get('/api/v1/app-switcher', async () => diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts index 22107b0..397738f 100644 --- a/apps/api/src/routes/oidc.ts +++ b/apps/api/src/routes/oidc.ts @@ -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 { - 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 { 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 const clientId = q.client_id const redirectUri = q.redirect_uri @@ -339,7 +342,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise { 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 { }, }) - 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({ diff --git a/apps/api/test/hot-path.test.ts b/apps/api/test/hot-path.test.ts new file mode 100644 index 0000000..feb3067 --- /dev/null +++ b/apps/api/test/hot-path.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { hash } from '@node-rs/argon2' +import { TtlCache } from '../src/lib/cache.js' +import { parseCookie } from '../src/lib/cookies.js' +import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js' +import { + hashPassword, + passwordHashStats, + setPasswordConcurrency, + verifyPassword, +} from '../src/lib/password.js' +import { parseTrustProxy } from '../src/config.js' +import { adminToken, buildTestApp } from './helpers.js' + +let app: Awaited> | undefined +afterEach(async () => { + vi.restoreAllMocks() + await app?.close() + app = undefined +}) + +describe('TtlCache', () => { + it('истекает по TTL', () => { + let t = 0 + const c = new TtlCache({ max: 10, ttlMs: 100, now: () => t }) + c.set('a', 1) + t = 99 + expect(c.get('a')).toBe(1) + t = 100 + expect(c.get('a')).toBeUndefined() + expect(c.size).toBe(0) + }) + + it('вытесняет давно не использованное (LRU)', () => { + const c = new TtlCache({ max: 2, ttlMs: 1000 }) + c.set('a', 1) + c.set('b', 2) + c.get('a') // освежили a + c.set('c', 3) + expect(c.get('b')).toBeUndefined() + expect(c.get('a')).toBe(1) + expect(c.get('c')).toBe(3) + c.delete('a') + expect(c.get('a')).toBeUndefined() + c.clear() + expect(c.size).toBe(0) + }) +}) + +describe('parseCookie', () => { + it('находит значение и декодирует', () => { + expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe( + 'abc d', + ) + }) + it('не путает суффикс имени', () => { + expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull() + }) + it('пусто/нет → null', () => { + expect(parseCookie(undefined, 'a')).toBeNull() + expect(parseCookie('a=; b=1', 'a')).toBeNull() + expect(parseCookie('b=1', 'a')).toBeNull() + }) +}) + +describe('client secret', () => { + it('sha256-формат: верный/неверный, без rehash', async () => { + const stored = hashClientSecret('s3cret') + expect(stored.startsWith('sha256:')).toBe(true) + expect(await checkClientSecret(stored, 's3cret')).toEqual({ + ok: true, + needsRehash: false, + }) + expect((await checkClientSecret(stored, 'nope')).ok).toBe(false) + }) + + it('argon2-формат принимается и помечается на rehash', async () => { + const legacy = await hash('old-secret') + expect(await checkClientSecret(legacy, 'old-secret')).toEqual({ + ok: true, + needsRehash: true, + }) + expect(await checkClientSecret(legacy, 'bad')).toEqual({ + ok: false, + needsRehash: false, + }) + expect((await checkClientSecret('garbage', 'x')).ok).toBe(false) + }) +}) + +describe('password limiter', () => { + it('не допускает больше N одновременных хешей', async () => { + setPasswordConcurrency(2) + let maxActive = 0 + const poll = setInterval(() => { + maxActive = Math.max(maxActive, passwordHashStats().active) + }, 1) + const h = await hashPassword('pw') + await Promise.all( + Array.from({ length: 6 }, () => verifyPassword(h, 'pw')), + ) + clearInterval(poll) + expect(maxActive).toBeLessThanOrEqual(2) + expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 }) + setPasswordConcurrency(2) + }) +}) + +describe('parseTrustProxy', () => { + it('true/false/список', () => { + expect(parseTrustProxy('true')).toBe(true) + expect(parseTrustProxy('false')).toBe(false) + expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1']) + }) +}) + +async function createUserAndLogin( + a: NonNullable, + adminTok: string, + email: string, +) { + const create = await a.inject({ + method: 'POST', + url: '/api/v1/admin/users', + headers: { authorization: `Bearer ${adminTok}` }, + payload: { + email, + name: 'U', + password: 'secret12', + is_admin: false, + apps: ['cfdm'], + permissions: [], + }, + }) + expect(create.statusCode).toBe(201) + const id = (create.json() as { id: string }).id + const login = await a.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email, password: 'secret12' }, + }) + expect(login.statusCode).toBe(200) + return { id, token: (login.json() as { access_token: string }).access_token } +} + +describe('кэш профиля пользователя', () => { + it('повторные запросы не обращаются к user_apps/user_permissions', async () => { + app = await buildTestApp() + const admin = await adminToken(app) + const { token } = await createUserAndLogin(app, admin, 'cache@test.local') + const me = () => + app!.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: `Bearer ${token}` }, + }) + expect((await me()).statusCode).toBe(200) // прогрев + + const spy = vi.spyOn(app.sqlite, 'prepare') + for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200) + const sqls = spy.mock.calls.map((c) => String(c[0])) + expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s))) + .toHaveLength(0) + }) + + it('блокировка пользователя действует немедленно', async () => { + app = await buildTestApp() + const admin = await adminToken(app) + const { id, token } = await createUserAndLogin(app, admin, 'ban@test.local') + const me = () => + app!.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: `Bearer ${token}` }, + }) + expect((await me()).statusCode).toBe(200) + const patch = await app.inject({ + method: 'PATCH', + url: `/api/v1/admin/users/${id}`, + headers: { authorization: `Bearer ${admin}` }, + payload: { disabled: true }, + }) + expect(patch.statusCode).toBe(200) + expect((await me()).statusCode).toBe(401) + }) + + it('смена доступа отражается сразу, удаление → 401', async () => { + app = await buildTestApp() + const admin = await adminToken(app) + const { id, token } = await createUserAndLogin(app, admin, 'acc@test.local') + const me = async () => + ( + await app!.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: `Bearer ${token}` }, + }) + ).json() as { apps?: string[] } + expect((await me()).apps).toEqual(['cfdm']) + + const put = await app.inject({ + method: 'PUT', + url: `/api/v1/admin/users/${id}/access`, + headers: { authorization: `Bearer ${admin}` }, + payload: { apps: ['dns'], permissions: [] }, + }) + expect(put.statusCode).toBe(200) + expect((await me()).apps).toEqual(['dns']) + + await app.inject({ + method: 'DELETE', + url: `/api/v1/admin/users/${id}`, + headers: { authorization: `Bearer ${admin}` }, + }) + const gone = await app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: { authorization: `Bearer ${token}` }, + }) + expect(gone.statusCode).toBe(401) + }) +}) + +describe('app-switcher кэш', () => { + it('PUT сразу виден в GET', async () => { + app = await buildTestApp() + const admin = await adminToken(app) + const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' }) + const cfg = await app.inject({ + method: 'GET', + url: '/api/v1/admin/app-switcher', + headers: { authorization: `Bearer ${admin}` }, + }) + expect(before.statusCode).toBe(200) + expect(cfg.statusCode).toBe(200) + }) +}) + +describe('rate-limit', () => { + it('/health не лимитируется', async () => { + app = await buildTestApp() + for (let i = 0; i < 250; i++) { + const res = await app.inject({ method: 'GET', url: '/health' }) + expect(res.statusCode).toBe(200) + } + }) + + it('логин ограничен 20/мин', async () => { + app = await buildTestApp() + let last = 0 + for (let i = 0; i < 22; i++) { + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: 'nobody@test.local', password: 'wrongpass' }, + }) + last = res.statusCode + } + expect(last).toBe(429) + }) + + it('X-Forwarded-For от недоверенного клиента игнорируется', async () => { + app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' }) + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + remoteAddress: '203.0.113.9', + headers: { 'x-forwarded-for': '198.51.100.7' }, + payload: { email: 'nobody@test.local', password: 'wrongpass' }, + }) + expect(res.statusCode).toBe(401) + const row = app.sqlite + .prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`) + .get() as { ip: string } + expect(row.ip).toBe('203.0.113.9') + }) +}) diff --git a/packages/db/src/generation.ts b/packages/db/src/generation.ts new file mode 100644 index 0000000..d97b04f --- /dev/null +++ b/packages/db/src/generation.ts @@ -0,0 +1,14 @@ +/** + * Счётчик «поколения» данных пользователей: растёт при любой записи в + * users/user_apps/user_permissions через функции этого пакета. Кэши верхнего + * уровня сверяют поколение, поэтому блокировка и смена прав действуют сразу. + */ +let userDataGeneration = 0 + +export function getUserDataGeneration(): number { + return userDataGeneration +} + +export function bumpUserDataGeneration(): void { + userDataGeneration += 1 +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 0cf68d2..2e57ebe 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -270,6 +270,7 @@ export function healthCheck(sqlite: Sqlite): void { export * from './schema/index.js' export * from './tx.js' +export * from './generation.js' export * from './housekeeping.js' export * from './users.js' export * from './settings.js' diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts index 69456e4..e8f166c 100644 --- a/packages/db/src/settings.ts +++ b/packages/db/src/settings.ts @@ -10,7 +10,24 @@ import { portalSettings } from './schema/index.js' const SETTINGS_ID = 'main' +/** Разобранный конфиг на соединение; сбрасывается в setAppSwitcherConfig. */ +const switcherCache = new WeakMap() + export function getAppSwitcherConfig(db: AppDb): AppSwitcherConfig { + const hit = switcherCache.get(db) + // клон: вызывающий код вправе мутировать результат + if (hit) return structuredClone(hit) + const config = loadAppSwitcherConfig(db) + switcherCache.set(db, config) + return structuredClone(config) +} + +/** Сброс кэша (если БД правили в обход setAppSwitcherConfig). */ +export function invalidateAppSwitcherConfig(db: AppDb): void { + switcherCache.delete(db) +} + +function loadAppSwitcherConfig(db: AppDb): AppSwitcherConfig { const row = db .select() .from(portalSettings) @@ -51,5 +68,6 @@ export function setAppSwitcherConfig( }) .run() } + switcherCache.delete(db) return normalized } diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts index f2563c6..8778bd9 100644 --- a/packages/db/src/users.ts +++ b/packages/db/src/users.ts @@ -1,6 +1,7 @@ import { and, desc, eq, gt, gte, isNull, or, sql } from 'drizzle-orm' import { createHash, randomUUID } from 'node:crypto' import type { AppDb } from './index.js' +import { bumpUserDataGeneration } from './generation.js' import { withTransaction } from './tx.js' import { refreshSessions, @@ -99,6 +100,7 @@ export function createUser( updatedAt: now, }) .run() + bumpUserDataGeneration() return getUserById(db, id)! } @@ -127,11 +129,13 @@ export function updateUser( }) .where(eq(users.id, id)) .run() + bumpUserDataGeneration() return getUserById(db, id) } export function deleteUser(db: AppDb, id: string): boolean { const result = db.delete(users).where(eq(users.id, id)).run() + bumpUserDataGeneration() return result.changes > 0 } @@ -161,6 +165,7 @@ export function setUserAccess( .where(eq(users.id, userId)) .run() }) + bumpUserDataGeneration() } /** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */