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
+665 -71

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 () =>
+22 -17
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> {
const res = await checkClientSecret(client.clientSecretHash, secret)
if (res.ok && res.needsRehash) {
// Миграция argon2 → SHA-256 при первом успешном обмене.
try {
return await verify(client.clientSecretHash, secret)
} catch {
return false
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({
+277
View File
@@ -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<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.restoreAllMocks()
await app?.close()
app = undefined
})
describe('TtlCache', () => {
it('истекает по TTL', () => {
let t = 0
const c = new TtlCache<string, number>({ 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<string, number>({ 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<typeof app>,
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, '[email protected]')
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, '[email protected]')
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, '[email protected]')
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: '[email protected]', 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: '[email protected]', 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')
})
})
+14
View File
@@ -0,0 +1,14 @@
/**
* Счётчик «поколения» данных пользователей: растёт при любой записи в
* users/user_apps/user_permissions через функции этого пакета. Кэши верхнего
* уровня сверяют поколение, поэтому блокировка и смена прав действуют сразу.
*/
let userDataGeneration = 0
export function getUserDataGeneration(): number {
return userDataGeneration
}
export function bumpUserDataGeneration(): void {
userDataGeneration += 1
}
+1
View File
@@ -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'
+18
View File
@@ -10,7 +10,24 @@ import { portalSettings } from './schema/index.js'
const SETTINGS_ID = 'main'
/** Разобранный конфиг на соединение; сбрасывается в setAppSwitcherConfig. */
const switcherCache = new WeakMap<AppDb, AppSwitcherConfig>()
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
}
+5
View File
@@ -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. */