perf(api): кэш профиля и конфигурации, лимитер argon2, SHA-256 для client secret, trust proxy
This commit is contained in:
1 parent
107eddba9e
commit
30d331c633
19 files changed
+666
-72
No files matched your search
+11
-5
@@ -24,9 +24,9 @@ import {
|
|||||||
type AppDb,
|
type AppDb,
|
||||||
type Sqlite,
|
type Sqlite,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import { hash } from '@node-rs/argon2'
|
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
|
||||||
import { randomUUID } from 'node:crypto'
|
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 { authRoutes } from './routes/auth.js'
|
||||||
import { adminRoutes } from './routes/admin.js'
|
import { adminRoutes } from './routes/admin.js'
|
||||||
import { auditAdminRoutes } from './routes/audit.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()
|
const existing = app.db.select().from(users).all()
|
||||||
if (existing.length === 0) {
|
if (existing.length === 0) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const passwordHash = await hash(app.config.adminPassword)
|
const passwordHash = await hashPassword(app.config.adminPassword)
|
||||||
const id = randomUUID()
|
const id = randomUUID()
|
||||||
app.db
|
app.db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
@@ -96,12 +96,13 @@ export async function buildApp(opts: {
|
|||||||
mkdirSync(dirname(resolve(path)), { recursive: true })
|
mkdirSync(dirname(resolve(path)), { recursive: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPasswordConcurrency(config.argon2Concurrency)
|
||||||
const { db, sqlite } = createDb(dbUrl)
|
const { db, sqlite } = createDb(dbUrl)
|
||||||
migrateSchema(sqlite)
|
migrateSchema(sqlite)
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: config.logLevel },
|
logger: { level: config.logLevel },
|
||||||
trustProxy: true,
|
trustProxy: parseTrustProxy(config.trustProxy),
|
||||||
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
||||||
keepAliveTimeout: 65_000,
|
keepAliveTimeout: 65_000,
|
||||||
connectionTimeout: 0,
|
connectionTimeout: 0,
|
||||||
@@ -143,7 +144,12 @@ export async function buildApp(opts: {
|
|||||||
await app.register(formbody)
|
await app.register(formbody)
|
||||||
await app.register(cors, { origin: true, credentials: true })
|
await app.register(cors, { origin: true, credentials: true })
|
||||||
await app.register(helmet, { contentSecurityPolicy: false })
|
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 })
|
await app.register(fjwt, { secret: config.jwtSecret })
|
||||||
|
|
||||||
app.get('/health', async () => {
|
app.get('/health', async () => {
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ const boolFromEnv = (v: string | undefined, fallback: boolean) => {
|
|||||||
return v === '1' || v.toLowerCase() === 'true'
|
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({
|
export const configSchema = z.object({
|
||||||
databaseUrl: z.string().default('sqlite:data/app.db'),
|
databaseUrl: z.string().default('sqlite:data/app.db'),
|
||||||
jwtSecret: z.string().min(8),
|
jwtSecret: z.string().min(8),
|
||||||
@@ -22,6 +33,10 @@ export const configSchema = z.object({
|
|||||||
serverPort: z.coerce.number().int().positive().default(8080),
|
serverPort: z.coerce.number().int().positive().default(8080),
|
||||||
staticDir: z.string().optional(),
|
staticDir: z.string().optional(),
|
||||||
logLevel: z.string().default('info'),
|
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),
|
sessionRetentionDays: z.coerce.number().positive().default(7),
|
||||||
/** Максимум на graceful shutdown, затем принудительный выход. */
|
/** Максимум на graceful shutdown, затем принудительный выход. */
|
||||||
@@ -62,6 +77,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
logLevel: env.LOG_LEVEL ?? 'info',
|
logLevel: env.LOG_LEVEL ?? 'info',
|
||||||
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
|
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
|
||||||
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
|
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
|
||||||
|
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
|
||||||
|
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
|
||||||
isProd:
|
isProd:
|
||||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||||
isProd,
|
isProd,
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import {
|
|||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
|
|
||||||
export function clientIp(request: FastifyRequest): string | null {
|
export function clientIp(request: FastifyRequest): string | null {
|
||||||
const forwarded = request.headers['x-forwarded-for']
|
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
|
||||||
if (typeof forwarded === 'string' && forwarded.trim()) {
|
|
||||||
return forwarded.split(',')[0]?.trim() ?? null
|
|
||||||
}
|
|
||||||
return request.ip ?? null
|
return request.ip ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -12,17 +12,20 @@ import {
|
|||||||
type UserRow,
|
type UserRow,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import { toMe } from '../plugins/auth-guards.js'
|
import { toMe } from '../plugins/auth-guards.js'
|
||||||
|
import { getUserProfile } from './user-profile.js'
|
||||||
|
|
||||||
/** Mint access JWT + Me from current DB state (login / reissue). */
|
/** Mint access JWT + Me from current DB state (login / reissue). */
|
||||||
export function issueAccessToken(
|
export function issueAccessToken(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
user: UserRow,
|
user: UserRow,
|
||||||
): LoginResponse {
|
): LoginResponse {
|
||||||
const apps = getUserApps(app.db, user.id)
|
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
|
||||||
let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id))
|
const profile = getUserProfile(app.db, user.id)
|
||||||
if (user.isAdmin) {
|
const apps = profile?.apps ?? getUserApps(app.db, user.id)
|
||||||
permissions = allPermissionKeys()
|
const permissions = user.isAdmin
|
||||||
}
|
? allPermissionKeys()
|
||||||
|
: (profile?.permissions ??
|
||||||
|
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
|
||||||
const switcher = getAppSwitcherConfig(app.db)
|
const switcher = getAppSwitcherConfig(app.db)
|
||||||
const tenants = tenantsClaimForUser(switcher, apps)
|
const tenants = tenantsClaimForUser(switcher, apps)
|
||||||
const me = toMe(user, apps, permissions)
|
const me = toMe(user, apps, permissions)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export async function ensureOidcSigningKey(
|
|||||||
export function resetOidcKeyCache(): void {
|
export function resetOidcKeyCache(): void {
|
||||||
cached = null
|
cached = null
|
||||||
importedPublicKeys.clear()
|
importedPublicKeys.clear()
|
||||||
|
jwksCache = new WeakMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOidcDiscovery(config: AppConfig) {
|
export function buildOidcDiscovery(config: AppConfig) {
|
||||||
@@ -157,6 +158,17 @@ export async function buildJwks(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
): Promise<{ keys: JWK[] }> {
|
): Promise<{ keys: JWK[] }> {
|
||||||
await ensureOidcSigningKey(app)
|
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)
|
const keys = listOidcSigningKeys(app.db)
|
||||||
.map((row): JWK | null => {
|
.map((row): JWK | null => {
|
||||||
try {
|
try {
|
||||||
@@ -174,6 +186,11 @@ export async function buildJwks(
|
|||||||
return { keys }
|
return { keys }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
|
||||||
|
export function invalidateJwks(app: FastifyInstance): void {
|
||||||
|
jwksCache.delete(app.db)
|
||||||
|
}
|
||||||
|
|
||||||
export async function signOidcJwt(
|
export async function signOidcJwt(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
claims: Record<string, unknown>,
|
claims: Record<string, unknown>,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
/* результат не важен */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||||
import {
|
import type { UserRow } from '@authportal/db'
|
||||||
getUserApps,
|
|
||||||
getUserById,
|
|
||||||
getUserPermissions,
|
|
||||||
type UserRow,
|
|
||||||
} from '@authportal/db'
|
|
||||||
import type { AppId, MeResponse } from '@authportal/shared'
|
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 = {
|
export type AuthUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -67,24 +63,15 @@ export function toMe(user: UserRow, apps: string[], permissions: string[]): MeRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadAuthUser(
|
export function authUserFromProfile(profile: UserProfile): AuthUser {
|
||||||
request: FastifyRequest,
|
const { row } = profile
|
||||||
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()
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: row.id,
|
||||||
email: user.email,
|
email: row.email,
|
||||||
name: user.name,
|
name: row.name,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: row.isAdmin,
|
||||||
apps: asAppIds(apps),
|
apps: asAppIds(profile.apps),
|
||||||
permissions,
|
permissions: profile.permissions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +88,14 @@ export async function requireAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sub = request.user.sub
|
const sub = request.user.sub
|
||||||
const row = getUserById(request.server.db, sub)
|
const profile = getUserProfile(request.server.db, sub)
|
||||||
if (!row || row.disabled) {
|
if (!profile || profile.row.disabled) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
request.authUser = loadAuthUser(request, row)
|
request.authUser = authUserFromProfile(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAdmin(
|
export async function requireAdmin(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
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 {
|
import {
|
||||||
countWebauthnCredentials,
|
countWebauthnCredentials,
|
||||||
countWebauthnCredentialsBatch,
|
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 user = withTransaction(app.db, () => {
|
||||||
const created = createUser(app.db, {
|
const created = createUser(app.db, {
|
||||||
email: data.email,
|
email: data.email,
|
||||||
@@ -222,7 +223,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = parsed.data.password
|
const passwordHash = parsed.data.password
|
||||||
? await hash(parsed.data.password)
|
? await hashPassword(parsed.data.password)
|
||||||
: undefined
|
: undefined
|
||||||
const updated = updateUser(app.db, request.params.id, {
|
const updated = updateUser(app.db, request.params.id, {
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
@@ -467,7 +468,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const secret = generateOidcClientSecret()
|
const secret = generateOidcClientSecret()
|
||||||
const secretHash = await hash(secret)
|
const secretHash = hashClientSecret(secret)
|
||||||
const row = createOidcClient(app.db, {
|
const row = createOidcClient(app.db, {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
clientSecretHash: secretHash,
|
clientSecretHash: secretHash,
|
||||||
@@ -549,7 +550,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const secret = generateOidcClientSecret()
|
const secret = generateOidcClientSecret()
|
||||||
const secretHash = await hash(secret)
|
const secretHash = hashClientSecret(secret)
|
||||||
const row = updateOidcClient(app.db, request.params.id, {
|
const row = updateOidcClient(app.db, request.params.id, {
|
||||||
clientSecretHash: secretHash,
|
clientSecretHash: secretHash,
|
||||||
})!
|
})!
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { verify } from '@node-rs/argon2'
|
|
||||||
import {
|
import {
|
||||||
PERMISSION_CATALOG,
|
PERMISSION_CATALOG,
|
||||||
appsMetaFromSwitcher,
|
appsMetaFromSwitcher,
|
||||||
@@ -14,6 +13,8 @@ import {
|
|||||||
searchDirectoryUsers,
|
searchDirectoryUsers,
|
||||||
revokeRefreshSession,
|
revokeRefreshSession,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
|
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
|
||||||
|
import { parseCookie } from '../lib/cookies.js'
|
||||||
import { requireAuth } from '../plugins/auth-guards.js'
|
import { requireAuth } from '../plugins/auth-guards.js'
|
||||||
import { issueAccessToken } from '../lib/issue-access-token.js'
|
import { issueAccessToken } from '../lib/issue-access-token.js'
|
||||||
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.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 targetApp = targetAppFromReturnTo(returnTo)
|
||||||
const user = getUserByEmail(app.db, email)
|
const user = getUserByEmail(app.db, email)
|
||||||
if (!user || user.disabled) {
|
if (!user || user.disabled) {
|
||||||
|
await verifyDummyPassword(password)
|
||||||
safeAudit(app, {
|
safeAudit(app, {
|
||||||
action: 'auth.login_failed',
|
action: 'auth.login_failed',
|
||||||
severity: 'warning',
|
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) {
|
if (!ok) {
|
||||||
safeAudit(app, {
|
safeAudit(app, {
|
||||||
action: 'auth.login_failed',
|
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) => {
|
app.post(
|
||||||
const cookie = request.headers.cookie ?? ''
|
'/api/v1/auth/logout',
|
||||||
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
|
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
|
||||||
if (match?.[1]) {
|
async (request, reply) => {
|
||||||
revokeRefreshSession(app.db, match[1])
|
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
|
||||||
|
if (refresh) {
|
||||||
|
revokeRefreshSession(app.db, refresh)
|
||||||
}
|
}
|
||||||
reply.header(
|
reply.header(
|
||||||
'Set-Cookie',
|
'Set-Cookie',
|
||||||
@@ -205,7 +209,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
||||||
app.get('/api/v1/app-switcher', async () =>
|
app.get('/api/v1/app-switcher', async () =>
|
||||||
|
|||||||
+23
-18
@@ -1,7 +1,7 @@
|
|||||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||||
import { verify } from '@node-rs/argon2'
|
|
||||||
import {
|
import {
|
||||||
|
updateOidcClient,
|
||||||
createOidcAuthCode,
|
createOidcAuthCode,
|
||||||
consumeOidcAuthCode,
|
consumeOidcAuthCode,
|
||||||
getOidcClientByClientId,
|
getOidcClientByClientId,
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import { oidcIssuerFromConfig } from '../config.js'
|
import { oidcIssuerFromConfig } from '../config.js'
|
||||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||||
|
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
|
||||||
|
import { parseCookie } from '../lib/cookies.js'
|
||||||
import {
|
import {
|
||||||
buildJwks,
|
buildJwks,
|
||||||
buildOidcDiscovery,
|
buildOidcDiscovery,
|
||||||
@@ -33,15 +35,6 @@ const CODE_TTL_MS = 5 * 60 * 1000
|
|||||||
const ACCESS_TTL_SEC = 3600
|
const ACCESS_TTL_SEC = 3600
|
||||||
const ID_TOKEN_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(
|
function parseBasicAuth(
|
||||||
header: string | undefined,
|
header: string | undefined,
|
||||||
): { clientId: string; clientSecret: string } | null {
|
): { clientId: string; clientSecret: string } | null {
|
||||||
@@ -60,14 +53,22 @@ function parseBasicAuth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authenticateClient(
|
async function authenticateClient(
|
||||||
|
app: FastifyInstance,
|
||||||
client: OidcClientRow,
|
client: OidcClientRow,
|
||||||
secret: string,
|
secret: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
const res = await checkClientSecret(client.clientSecretHash, secret)
|
||||||
return await verify(client.clientSecretHash, secret)
|
if (res.ok && res.needsRehash) {
|
||||||
} catch {
|
// Миграция argon2 → SHA-256 при первом успешном обмене.
|
||||||
return false
|
try {
|
||||||
|
updateOidcClient(app.db, client.id, {
|
||||||
|
clientSecretHash: hashClientSecret(secret),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'client secret rehash failed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return res.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectError(
|
function redirectError(
|
||||||
@@ -94,7 +95,7 @@ function resolveSessionUser(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
): UserRow | undefined {
|
): UserRow | undefined {
|
||||||
const raw = cookieValue(request.headers.cookie, REFRESH_COOKIE)
|
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
|
||||||
if (!raw) return undefined
|
if (!raw) return undefined
|
||||||
return getUserByRefreshToken(app.db, raw)
|
return getUserByRefreshToken(app.db, raw)
|
||||||
}
|
}
|
||||||
@@ -171,7 +172,9 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return buildJwks(app)
|
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 q = request.query as Record<string, string | undefined>
|
||||||
const clientId = q.client_id
|
const clientId = q.client_id
|
||||||
const redirectUri = q.redirect_uri
|
const redirectUri = q.redirect_uri
|
||||||
@@ -339,7 +342,7 @@ export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
error_description: 'Unknown or disabled client',
|
error_description: 'Unknown or disabled client',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const secretOk = await authenticateClient(client, clientSecret)
|
const secretOk = await authenticateClient(app, client, clientSecret)
|
||||||
if (!secretOk) {
|
if (!secretOk) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
error: 'invalid_client',
|
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
|
const auth = request.headers.authorization
|
||||||
if (!auth?.startsWith('Bearer ')) {
|
if (!auth?.startsWith('Bearer ')) {
|
||||||
return reply.status(401).send({
|
return reply.status(401).send({
|
||||||
|
|||||||
@@ -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, '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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Счётчик «поколения» данных пользователей: растёт при любой записи в
|
||||||
|
* users/user_apps/user_permissions через функции этого пакета. Кэши верхнего
|
||||||
|
* уровня сверяют поколение, поэтому блокировка и смена прав действуют сразу.
|
||||||
|
*/
|
||||||
|
let userDataGeneration = 0
|
||||||
|
|
||||||
|
export function getUserDataGeneration(): number {
|
||||||
|
return userDataGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bumpUserDataGeneration(): void {
|
||||||
|
userDataGeneration += 1
|
||||||
|
}
|
||||||
@@ -270,6 +270,7 @@ export function healthCheck(sqlite: Sqlite): void {
|
|||||||
|
|
||||||
export * from './schema/index.js'
|
export * from './schema/index.js'
|
||||||
export * from './tx.js'
|
export * from './tx.js'
|
||||||
|
export * from './generation.js'
|
||||||
export * from './housekeeping.js'
|
export * from './housekeeping.js'
|
||||||
export * from './users.js'
|
export * from './users.js'
|
||||||
export * from './settings.js'
|
export * from './settings.js'
|
||||||
|
|||||||
@@ -10,7 +10,24 @@ import { portalSettings } from './schema/index.js'
|
|||||||
|
|
||||||
const SETTINGS_ID = 'main'
|
const SETTINGS_ID = 'main'
|
||||||
|
|
||||||
|
/** Разобранный конфиг на соединение; сбрасывается в setAppSwitcherConfig. */
|
||||||
|
const switcherCache = new WeakMap<AppDb, AppSwitcherConfig>()
|
||||||
|
|
||||||
export function getAppSwitcherConfig(db: 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
|
const row = db
|
||||||
.select()
|
.select()
|
||||||
.from(portalSettings)
|
.from(portalSettings)
|
||||||
@@ -51,5 +68,6 @@ export function setAppSwitcherConfig(
|
|||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
switcherCache.delete(db)
|
||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, desc, eq, gt, gte, isNull, or, sql } from 'drizzle-orm'
|
import { and, desc, eq, gt, gte, isNull, or, sql } 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 { bumpUserDataGeneration } from './generation.js'
|
||||||
import { withTransaction } from './tx.js'
|
import { withTransaction } from './tx.js'
|
||||||
import {
|
import {
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
@@ -99,6 +100,7 @@ export function createUser(
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
.run()
|
.run()
|
||||||
|
bumpUserDataGeneration()
|
||||||
return getUserById(db, id)!
|
return getUserById(db, id)!
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +129,13 @@ export function updateUser(
|
|||||||
})
|
})
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id))
|
||||||
.run()
|
.run()
|
||||||
|
bumpUserDataGeneration()
|
||||||
return getUserById(db, id)
|
return getUserById(db, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteUser(db: AppDb, id: string): boolean {
|
export function deleteUser(db: AppDb, id: string): boolean {
|
||||||
const result = db.delete(users).where(eq(users.id, id)).run()
|
const result = db.delete(users).where(eq(users.id, id)).run()
|
||||||
|
bumpUserDataGeneration()
|
||||||
return result.changes > 0
|
return result.changes > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +165,7 @@ export function setUserAccess(
|
|||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
.run()
|
.run()
|
||||||
})
|
})
|
||||||
|
bumpUserDataGeneration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */
|
/** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */
|
||||||
|
|||||||
Reference in new issue
Block a user