fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist', 'data']),
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@authportal/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@authportal/db": "workspace:*",
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@fastify/compress": "^8.3.1",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/formbody": "^9.0.0",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@simplewebauthn/server": "^13.3.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"fastify": "^5.4.0",
|
||||
"jose": "^6.2.8",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"eslint": "catalog:",
|
||||
"globals": "catalog:",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { FastifyError, FastifyInstance } from 'fastify'
|
||||
import Fastify from 'fastify'
|
||||
import compress from '@fastify/compress'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import formbody from '@fastify/formbody'
|
||||
import fjwt from '@fastify/jwt'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import {
|
||||
APP_IDS,
|
||||
allPermissionKeys,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
closeDb,
|
||||
createDb,
|
||||
getAppSwitcherConfig,
|
||||
getUserApps,
|
||||
hasAnyUser,
|
||||
listAdmins,
|
||||
healthCheck,
|
||||
migrateSchema,
|
||||
setUserAccess,
|
||||
users,
|
||||
type AppDb,
|
||||
type Sqlite,
|
||||
} from '@authportal/db'
|
||||
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
assertBootstrapPassword,
|
||||
parseTrustProxy,
|
||||
type AppConfig,
|
||||
} from './config.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { adminRoutes } from './routes/admin.js'
|
||||
import { auditAdminRoutes } from './routes/audit.js'
|
||||
import { auditIngestRoutes } from './routes/ingest-audit.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { webauthnRoutes } from './routes/webauthn.js'
|
||||
import { startAuditRetentionJob } from './services/audit-retention.js'
|
||||
import { startHousekeepingJob } from './services/housekeeping.js'
|
||||
import { configurePortalRevocationSender } from './services/portal-revocations.js'
|
||||
import { startBackupJob } from './services/backup.js'
|
||||
import {
|
||||
ensureOidcSigningKey,
|
||||
isOidcKeyReady,
|
||||
resetOidcKeyCache,
|
||||
} from './lib/oidc/keys.js'
|
||||
import { createOriginChecker } from './lib/cors.js'
|
||||
|
||||
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
|
||||
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
|
||||
function boolFlag(v: string): boolean {
|
||||
return v === '1' || v.toLowerCase() === 'true'
|
||||
}
|
||||
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
|
||||
const INDEX_HTML_RE = /[\\/]index\.html$/
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
config: AppConfig
|
||||
db: AppDb
|
||||
sqlite: Sqlite
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
|
||||
if (!hasAnyUser(app.db)) {
|
||||
assertBootstrapPassword(app.config)
|
||||
const now = new Date().toISOString()
|
||||
const passwordHash = await hashPassword(app.config.adminPassword)
|
||||
const id = randomUUID()
|
||||
app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id,
|
||||
email: app.config.adminEmail.toLowerCase(),
|
||||
name: app.config.adminName,
|
||||
passwordHash,
|
||||
isAdmin: true,
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys())
|
||||
app.log.info(`bootstrap admin created: ${app.config.adminEmail}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure existing admins have app tiles if access was never set
|
||||
for (const user of listAdmins(app.db)) {
|
||||
const apps = getUserApps(app.db, user.id)
|
||||
if (apps.length === 0) {
|
||||
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
|
||||
app.log.info(`granted full catalog access to admin ${user.email}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildApp(opts: {
|
||||
config: AppConfig
|
||||
databaseUrl?: string
|
||||
}): Promise<FastifyInstance> {
|
||||
const config = opts.config
|
||||
const dbUrl = opts.databaseUrl ?? config.databaseUrl
|
||||
|
||||
const path = dbUrl.replace(/^sqlite:/, '')
|
||||
if (path !== ':memory:') {
|
||||
mkdirSync(dirname(resolve(path)), { recursive: true })
|
||||
}
|
||||
|
||||
setPasswordConcurrency(config.argon2Concurrency)
|
||||
const { db, sqlite } = createDb(dbUrl)
|
||||
migrateSchema(sqlite)
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: config.logLevel,
|
||||
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'res.headers["set-cookie"]',
|
||||
],
|
||||
censor: '[redacted]',
|
||||
},
|
||||
},
|
||||
trustProxy: parseTrustProxy(config.trustProxy),
|
||||
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
|
||||
keepAliveTimeout: 65_000,
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 30_000,
|
||||
bodyLimit: 1_048_576,
|
||||
})
|
||||
|
||||
app.decorate('config', config)
|
||||
app.decorate('db', db)
|
||||
app.decorate('sqlite', sqlite)
|
||||
// Configure before bootstrap/access writes; failed opt-in must close this owned handle.
|
||||
try { configurePortalRevocationSender(app) } catch (error) { closeDb(sqlite); throw error }
|
||||
app.addHook('onClose', async () => {
|
||||
closeDb(sqlite)
|
||||
})
|
||||
|
||||
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
|
||||
const status =
|
||||
err.name === 'ZodError'
|
||||
? 400
|
||||
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
|
||||
? (err as FastifyError).statusCode!
|
||||
: 500
|
||||
if (status >= 500) {
|
||||
req.log.error(err)
|
||||
return reply.status(500).send({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
}
|
||||
const code =
|
||||
status === 429
|
||||
? 'RATE_LIMITED'
|
||||
: status === 400
|
||||
? 'BAD_REQUEST'
|
||||
: ((err as FastifyError).code ?? 'ERROR')
|
||||
return reply.status(status).send({ error: { code, message: err.message } })
|
||||
})
|
||||
|
||||
|
||||
const originAllowed = createOriginChecker(config, () =>
|
||||
getAppSwitcherConfig(db).apps.map((a) => a.url),
|
||||
)
|
||||
if (boolFlag(config.enableCompress)) {
|
||||
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
|
||||
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
|
||||
}
|
||||
await app.register(formbody)
|
||||
await app.register(cors, {
|
||||
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
|
||||
credentials: true,
|
||||
})
|
||||
await app.register(helmet, {
|
||||
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
|
||||
contentSecurityPolicy: {
|
||||
useDefaults: false,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'blob:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
},
|
||||
},
|
||||
})
|
||||
await app.register(rateLimit, {
|
||||
max: config.rateLimitMax,
|
||||
timeWindow: '1 minute',
|
||||
// пробы Docker/Traefik не должны упираться в лимит
|
||||
allowList: (req) => req.url === '/health' || req.url === '/ready',
|
||||
})
|
||||
await app.register(fjwt, { secret: config.jwtSecret })
|
||||
|
||||
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
|
||||
// контейнер из-за временной блокировки.
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
// readiness: БД отвечает и ключ подписи OIDC загружен.
|
||||
app.get('/ready', async (_request, reply) => {
|
||||
try {
|
||||
healthCheck(sqlite)
|
||||
} catch {
|
||||
return reply.status(503).send({ ok: false, reason: 'db' })
|
||||
}
|
||||
if (!isOidcKeyReady()) {
|
||||
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
|
||||
|
||||
resetOidcKeyCache()
|
||||
await ensureOidcSigningKey(app)
|
||||
await ensureBootstrapAdmin(app)
|
||||
await app.register(authRoutes)
|
||||
await app.register(adminRoutes)
|
||||
await app.register(auditAdminRoutes)
|
||||
await app.register(auditIngestRoutes)
|
||||
await app.register(oidcRoutes)
|
||||
await app.register(webauthnRoutes)
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
const stopRetention = startAuditRetentionJob(app)
|
||||
const stopHousekeeping = startHousekeepingJob(app)
|
||||
const stopBackup = startBackupJob(app)
|
||||
app.addHook('onClose', async () => {
|
||||
stopRetention()
|
||||
stopHousekeeping()
|
||||
stopBackup()
|
||||
})
|
||||
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
|
||||
app.log.warn(
|
||||
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
|
||||
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.staticDir && existsSync(config.staticDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: resolve(config.staticDir),
|
||||
wildcard: false,
|
||||
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
|
||||
preCompressed: true,
|
||||
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
|
||||
cacheControl: false,
|
||||
setHeaders(res, filePath) {
|
||||
if (HASHED_ASSET_RE.test(filePath)) {
|
||||
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
} else if (INDEX_HTML_RE.test(filePath)) {
|
||||
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=0')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (
|
||||
spa &&
|
||||
req.method === 'GET' &&
|
||||
!API_PREFIXES.some((p) => req.url.startsWith(p))
|
||||
) {
|
||||
return reply.sendFile('index.html')
|
||||
}
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
})
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
|
||||
if (v === undefined || v === '') return fallback
|
||||
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),
|
||||
jwtTtlHours: z.coerce.number().positive().default(1),
|
||||
refreshTtlDays: z.coerce.number().positive().default(14),
|
||||
issuer: z.string().url().default('https://auth.shnt.top'),
|
||||
/** OIDC issuer URL; defaults to `issuer`. */
|
||||
oidcIssuer: z.string().url().optional(),
|
||||
/** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */
|
||||
oidcRsaPrivateKey: z.string().optional(),
|
||||
adminEmail: z.string().email().default('admin@shnt.top'),
|
||||
adminPassword: z.string().default('admin'),
|
||||
adminName: z.string().default('Admin'),
|
||||
returnToAllowlist: z.string().default('.shnt.top,localhost,private'),
|
||||
serverPort: z.coerce.number().int().positive().default(8080),
|
||||
staticDir: z.string().optional(),
|
||||
logLevel: z.string().default('info'),
|
||||
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
|
||||
backupDir: z.string().optional(),
|
||||
/** Сколько последних копий хранить. */
|
||||
backupKeep: z.coerce.number().int().positive().default(7),
|
||||
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
|
||||
backupIntervalHours: z.coerce.number().min(0).default(0),
|
||||
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
|
||||
enableCompress: z.string().default('false'),
|
||||
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
|
||||
rateLimitMax: z.coerce.number().int().positive().default(200),
|
||||
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
|
||||
corsOrigins: z.string().default(''),
|
||||
/** Одновременных 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, затем принудительный выход. */
|
||||
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
|
||||
isProd: z.boolean(),
|
||||
auditIngestSecret: z.string().min(8).optional(),
|
||||
portalRevocationsEnabled: z.boolean().default(false),
|
||||
portalRevocationsTarget: z.string().optional(),
|
||||
portalRevocationsSecret: z.string().optional(),
|
||||
webauthnRpID: z.string().min(1),
|
||||
webauthnRpName: z.string().min(1).default('Auth Portal'),
|
||||
webauthnOrigins: z.array(z.string().url()).min(1),
|
||||
})
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>
|
||||
|
||||
const WEAK_JWT_SECRETS = new Set([
|
||||
'change-me-in-production',
|
||||
'dev-secret-change-me',
|
||||
'secret',
|
||||
'changeme',
|
||||
])
|
||||
|
||||
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
|
||||
function assertProductionSecrets(config: AppConfig): void {
|
||||
if (!config.isProd) return
|
||||
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
|
||||
throw new Error(
|
||||
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
|
||||
)
|
||||
}
|
||||
if (config.jwtSecret.length < 16) {
|
||||
// Не роняем старые установки с коротким, но уникальным секретом.
|
||||
process.emitWarning(
|
||||
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
|
||||
export function assertBootstrapPassword(config: AppConfig): void {
|
||||
if (!config.isProd) return
|
||||
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
|
||||
throw new Error(
|
||||
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const isProd = env.NODE_ENV === 'production'
|
||||
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
|
||||
const auditIngestSecret =
|
||||
env.AUDIT_INGEST_SECRET ??
|
||||
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
|
||||
|
||||
const config = configSchema.parse({
|
||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||
jwtSecret,
|
||||
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
|
||||
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
|
||||
issuer,
|
||||
oidcIssuer: env.OIDC_ISSUER || issuer,
|
||||
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
|
||||
adminEmail: env.ADMIN_EMAIL ?? 'admin@shnt.top',
|
||||
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
|
||||
adminName: env.ADMIN_NAME ?? 'Admin',
|
||||
returnToAllowlist:
|
||||
env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private',
|
||||
serverPort: env.SERVER_PORT ?? 8080,
|
||||
staticDir: env.STATIC_DIR || undefined,
|
||||
logLevel: env.LOG_LEVEL ?? 'info',
|
||||
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
|
||||
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
|
||||
backupDir: env.BACKUP_DIR || undefined,
|
||||
backupKeep: env.BACKUP_KEEP ?? 7,
|
||||
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
|
||||
enableCompress: env.ENABLE_COMPRESS ?? 'false',
|
||||
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
|
||||
corsOrigins: env.CORS_ORIGINS ?? '',
|
||||
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
|
||||
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
|
||||
isProd:
|
||||
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
|
||||
isProd,
|
||||
auditIngestSecret,
|
||||
portalRevocationsEnabled: boolFromEnv(env.MCP_REVOCATIONS_ENABLED, false),
|
||||
portalRevocationsTarget: env.MCP_REVOCATIONS_TARGET || undefined,
|
||||
portalRevocationsSecret: env.MCP_REVOCATIONS_SECRET || undefined,
|
||||
webauthnRpID: rpID,
|
||||
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
|
||||
webauthnOrigins: origins,
|
||||
})
|
||||
assertProductionSecrets(config)
|
||||
return config
|
||||
}
|
||||
|
||||
function webauthnFromIssuer(
|
||||
issuer: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
isProd: boolean,
|
||||
): { rpID: string; origins: string[] } {
|
||||
let issuerUrl: URL
|
||||
try {
|
||||
issuerUrl = new URL(issuer)
|
||||
} catch {
|
||||
issuerUrl = new URL('https://auth.shnt.top')
|
||||
}
|
||||
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
|
||||
const origins = new Set<string>()
|
||||
origins.add(issuerUrl.origin)
|
||||
const extra = env.WEBAUTHN_ORIGINS ?? ''
|
||||
for (const raw of extra.split(',')) {
|
||||
const value = raw.trim().replace(/\/$/, '')
|
||||
if (!value) continue
|
||||
try {
|
||||
origins.add(new URL(value).origin)
|
||||
} catch {
|
||||
/* skip invalid */
|
||||
}
|
||||
}
|
||||
if (!isProd) {
|
||||
origins.add('http://localhost:5173')
|
||||
origins.add('http://localhost:8080')
|
||||
}
|
||||
return { rpID, origins: [...origins] }
|
||||
}
|
||||
|
||||
export function oidcIssuerFromConfig(config: AppConfig): string {
|
||||
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
appendAudit,
|
||||
type AppendAuditInput,
|
||||
} from '@authportal/db'
|
||||
|
||||
export function clientIp(request: FastifyRequest): string | null {
|
||||
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
|
||||
return request.ip ?? null
|
||||
}
|
||||
|
||||
export function safeAudit(
|
||||
app: FastifyInstance,
|
||||
input: AppendAuditInput,
|
||||
): void {
|
||||
try {
|
||||
appendAudit(app.db, input)
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'audit_log append failed')
|
||||
}
|
||||
}
|
||||
|
||||
export function actorFromRequest(request: FastifyRequest): Pick<
|
||||
AppendAuditInput,
|
||||
'actorUserId' | 'actorEmail' | 'actorName'
|
||||
> {
|
||||
const u = request.authUser
|
||||
if (!u) {
|
||||
return {
|
||||
actorUserId: null,
|
||||
actorEmail: null,
|
||||
actorName: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
actorUserId: u.id,
|
||||
actorEmail: u.email,
|
||||
actorName: u.name,
|
||||
}
|
||||
}
|
||||
@@ -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,64 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
createRefreshSession,
|
||||
touchLastLogin,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import { issueAccessToken } from './issue-access-token.js'
|
||||
import { safeAudit } from './audit.js'
|
||||
import { targetAppFromReturnTo } from './target-app.js'
|
||||
|
||||
export const REFRESH_COOKIE = 'refresh_token'
|
||||
|
||||
export function completeLogin(
|
||||
app: FastifyInstance,
|
||||
reply: FastifyReply,
|
||||
user: UserRow,
|
||||
opts: {
|
||||
method: 'password' | 'passkey'
|
||||
returnTo?: string
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
},
|
||||
) {
|
||||
const body = issueAccessToken(app, user)
|
||||
const refreshRaw = randomBytes(32).toString('hex')
|
||||
const refreshExpires = new Date(
|
||||
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
|
||||
ip: opts.ip,
|
||||
userAgent: opts.userAgent,
|
||||
})
|
||||
touchLastLogin(app.db, user.id, opts.ip)
|
||||
|
||||
reply.header(
|
||||
'Set-Cookie',
|
||||
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
|
||||
)
|
||||
|
||||
const targetApp = targetAppFromReturnTo(opts.returnTo)
|
||||
const isPasskey = opts.method === 'passkey'
|
||||
safeAudit(app, {
|
||||
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: isPasskey
|
||||
? `Вход (passkey): ${user.email}`
|
||||
: `Вход: ${user.email}`,
|
||||
details: {
|
||||
method: opts.method,
|
||||
user_agent: opts.userAgent,
|
||||
return_to: opts.returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip: opts.ip,
|
||||
})
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isReturnToAllowed } from '@authportal/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { oidcIssuerFromConfig } from '../config.js'
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
|
||||
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
|
||||
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
|
||||
* разрешить всё (только осознанно).
|
||||
*/
|
||||
export function createOriginChecker(
|
||||
config: AppConfig,
|
||||
switcherUrls: () => string[],
|
||||
): (origin: string) => boolean {
|
||||
const explicit = new Set<string>()
|
||||
let allowAll = false
|
||||
for (const raw of config.corsOrigins.split(',')) {
|
||||
const v = raw.trim()
|
||||
if (!v) continue
|
||||
if (v === '*') allowAll = true
|
||||
else {
|
||||
const o = originOf(v)
|
||||
if (o) explicit.add(o)
|
||||
}
|
||||
}
|
||||
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
|
||||
const o = originOf(u)
|
||||
if (o) explicit.add(o)
|
||||
}
|
||||
return (origin: string) => {
|
||||
if (allowAll) return true
|
||||
const o = originOf(origin)
|
||||
if (!o) return false
|
||||
if (explicit.has(o)) return true
|
||||
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
|
||||
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
|
||||
for (const u of switcherUrls()) {
|
||||
if (originOf(u) === o) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
allPermissionKeys,
|
||||
normalizePermissionKeys,
|
||||
tenantsClaimForUser,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
getAppSwitcherConfig,
|
||||
getUserApps,
|
||||
getUserPermissions,
|
||||
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 {
|
||||
// Профиль из кэша (инвалидируется записями в 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)
|
||||
const expiresAt = new Date(
|
||||
Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000,
|
||||
)
|
||||
const accessToken = app.jwt.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
apps,
|
||||
permissions,
|
||||
tenants,
|
||||
bgp_tenant_id: tenants.bgp,
|
||||
is_admin: user.isAdmin,
|
||||
iss: app.config.issuer,
|
||||
},
|
||||
{ expiresIn: `${app.config.jwtTtlHours}h` },
|
||||
)
|
||||
return {
|
||||
access_token: accessToken,
|
||||
expires_at: expiresAt.toISOString(),
|
||||
token_type: 'Bearer',
|
||||
user: me,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
exportJWK,
|
||||
exportPKCS8,
|
||||
generateKeyPair,
|
||||
importJWK,
|
||||
importPKCS8,
|
||||
jwtVerify,
|
||||
SignJWT,
|
||||
type JWK,
|
||||
} from 'jose'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getActiveOidcSigningKey,
|
||||
insertOidcSigningKey,
|
||||
listOidcSigningKeys,
|
||||
} from '@authportal/db'
|
||||
import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
|
||||
|
||||
export type OidcKeyMaterial = {
|
||||
kid: string
|
||||
privateKey: CryptoKey
|
||||
publicJwk: JWK
|
||||
}
|
||||
|
||||
let cached: OidcKeyMaterial | null = null
|
||||
// importJWK — асинхронный разбор JWK; без кэша он выполнялся на каждый /oauth/userinfo.
|
||||
const importedPublicKeys = new Map<string, CryptoKey>()
|
||||
|
||||
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
|
||||
return {
|
||||
kty: jwk.kty,
|
||||
n: jwk.n,
|
||||
e: jwk.e,
|
||||
alg: 'RS256',
|
||||
use: 'sig',
|
||||
kid,
|
||||
}
|
||||
}
|
||||
|
||||
async function materialFromPem(
|
||||
kid: string,
|
||||
privatePem: string,
|
||||
publicJwkJson?: string,
|
||||
): Promise<OidcKeyMaterial> {
|
||||
// jose defaults extractable=false for private keys → exportJWK throws
|
||||
const privateKey = await importPKCS8(privatePem, 'RS256', {
|
||||
extractable: true,
|
||||
})
|
||||
|
||||
if (publicJwkJson) {
|
||||
try {
|
||||
const stored = JSON.parse(publicJwkJson) as JWK
|
||||
return {
|
||||
kid,
|
||||
privateKey,
|
||||
publicJwk: publicJwkFromPrivateExport({ ...stored, kid }, kid),
|
||||
}
|
||||
} catch {
|
||||
// fall through to derive from private key
|
||||
}
|
||||
}
|
||||
|
||||
const full = await exportJWK(privateKey)
|
||||
return {
|
||||
kid,
|
||||
privateKey,
|
||||
publicJwk: publicJwkFromPrivateExport(full, kid),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an active RSA signing key exists (env PEM or DB / generate).
|
||||
*/
|
||||
export async function ensureOidcSigningKey(
|
||||
app: FastifyInstance,
|
||||
): Promise<OidcKeyMaterial> {
|
||||
if (cached) return cached
|
||||
|
||||
const envPem = app.config.oidcRsaPrivateKey?.trim()
|
||||
if (envPem) {
|
||||
const kid = createHash('sha256').update(envPem).digest('hex').slice(0, 16)
|
||||
cached = await materialFromPem(kid, envPem)
|
||||
return cached
|
||||
}
|
||||
|
||||
const existing = getActiveOidcSigningKey(app.db)
|
||||
if (existing) {
|
||||
cached = await materialFromPem(
|
||||
existing.kid,
|
||||
existing.privatePem,
|
||||
existing.publicJwkJson,
|
||||
)
|
||||
return cached
|
||||
}
|
||||
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256', {
|
||||
extractable: true,
|
||||
})
|
||||
const kid = randomUUID().replace(/-/g, '').slice(0, 16)
|
||||
const privatePem = await exportPKCS8(privateKey)
|
||||
const publicJwk = await exportJWK(publicKey)
|
||||
publicJwk.alg = 'RS256'
|
||||
publicJwk.use = 'sig'
|
||||
publicJwk.kid = kid
|
||||
insertOidcSigningKey(app.db, {
|
||||
kid,
|
||||
privatePem,
|
||||
publicJwkJson: JSON.stringify(publicJwk),
|
||||
})
|
||||
cached = { kid, privateKey, publicJwk }
|
||||
app.log.info({ kid }, 'OIDC RS256 signing key generated')
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Ключ подписи загружен — приложение готово выдавать токены. */
|
||||
export function isOidcKeyReady(): boolean {
|
||||
return cached !== null
|
||||
}
|
||||
|
||||
/** Reset cache (tests). */
|
||||
export function resetOidcKeyCache(): void {
|
||||
cached = null
|
||||
importedPublicKeys.clear()
|
||||
jwksCache = new WeakMap()
|
||||
}
|
||||
|
||||
export function buildOidcDiscovery(config: AppConfig) {
|
||||
const issuer = oidcIssuerFromConfig(config)
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/oauth/authorize`,
|
||||
token_endpoint: `${issuer}/oauth/token`,
|
||||
userinfo_endpoint: `${issuer}/oauth/userinfo`,
|
||||
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
scopes_supported: ['openid', 'profile', 'email', 'groups'],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
'client_secret_post',
|
||||
'client_secret_basic',
|
||||
],
|
||||
claims_supported: [
|
||||
'sub',
|
||||
'iss',
|
||||
'aud',
|
||||
'exp',
|
||||
'iat',
|
||||
'email',
|
||||
'email_verified',
|
||||
'name',
|
||||
'preferred_username',
|
||||
'groups',
|
||||
'roles',
|
||||
],
|
||||
grant_types_supported: ['authorization_code'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const jwk = JSON.parse(row.publicJwkJson) as JWK
|
||||
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((k): k is JWK => k !== null)
|
||||
|
||||
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
|
||||
keys.unshift(cached.publicJwk)
|
||||
}
|
||||
return { keys }
|
||||
}
|
||||
|
||||
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
|
||||
export function invalidateJwks(app: FastifyInstance): void {
|
||||
jwksCache.delete(app.db)
|
||||
}
|
||||
|
||||
export async function signOidcJwt(
|
||||
app: FastifyInstance,
|
||||
claims: Record<string, unknown>,
|
||||
expiresInSeconds: number,
|
||||
): Promise<string> {
|
||||
const key = await ensureOidcSigningKey(app)
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
|
||||
.setIssuer(issuer)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${expiresInSeconds}s`)
|
||||
.sign(key.privateKey)
|
||||
}
|
||||
|
||||
/** Verify OIDC access token (RS256) and return payload. */
|
||||
export async function verifyOidcAccessToken(
|
||||
app: FastifyInstance,
|
||||
token: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const key = await ensureOidcSigningKey(app)
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
let publicKey: CryptoKey | undefined = importedPublicKeys.get(key.kid)
|
||||
if (!publicKey) {
|
||||
// Для RS256 + RSA JWK importJWK всегда возвращает CryptoKey.
|
||||
publicKey = (await importJWK(key.publicJwk, 'RS256')) as CryptoKey
|
||||
importedPublicKeys.set(key.kid, publicKey)
|
||||
}
|
||||
const { payload } = await jwtVerify(token, publicKey, {
|
||||
issuer,
|
||||
algorithms: ['RS256'],
|
||||
})
|
||||
return payload as 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,67 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
|
||||
export interface ShutdownOptions {
|
||||
timeoutMs: number
|
||||
exit?: (code: number) => void
|
||||
signals?: NodeJS.Signals[]
|
||||
proc?: Pick<NodeJS.Process, 'on' | 'off'>
|
||||
}
|
||||
|
||||
/**
|
||||
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
|
||||
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
|
||||
* Возвращает функцию отписки и ручной триггер (для тестов).
|
||||
*/
|
||||
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
|
||||
const exit = opts.exit ?? ((code: number) => process.exit(code))
|
||||
const proc = opts.proc ?? process
|
||||
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
|
||||
let closing: Promise<void> | null = null
|
||||
|
||||
const shutdown = (reason: string, code = 0): Promise<void> => {
|
||||
if (closing) {
|
||||
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
|
||||
exit(1)
|
||||
return closing
|
||||
}
|
||||
app.log.info(`shutdown: ${reason}`)
|
||||
const timer = setTimeout(() => {
|
||||
app.log.error('shutdown: timeout, forcing exit')
|
||||
exit(1)
|
||||
}, opts.timeoutMs)
|
||||
timer.unref()
|
||||
closing = app
|
||||
.close()
|
||||
.then(() => {
|
||||
clearTimeout(timer)
|
||||
exit(code)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
clearTimeout(timer)
|
||||
app.log.error(err, 'shutdown: close failed')
|
||||
exit(1)
|
||||
})
|
||||
return closing
|
||||
}
|
||||
|
||||
const handlers = signals.map((sig) => {
|
||||
const h = () => void shutdown(sig)
|
||||
proc.on(sig, h)
|
||||
return [sig, h] as const
|
||||
})
|
||||
const onFatal = (err: unknown) => {
|
||||
app.log.error(err, 'fatal error')
|
||||
void shutdown('fatal', 1)
|
||||
}
|
||||
proc.on('unhandledRejection', onFatal)
|
||||
proc.on('uncaughtException', onFatal)
|
||||
|
||||
return {
|
||||
shutdown,
|
||||
dispose() {
|
||||
for (const [sig, h] of handlers) proc.off(sig, h)
|
||||
proc.off('unhandledRejection', onFatal)
|
||||
proc.off('uncaughtException', onFatal)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { AuditSourceApp } from '@authportal/shared'
|
||||
|
||||
/** Resolve SSO target app from return_to URL host/path (and OIDC authorize unwrap). */
|
||||
export function targetAppFromReturnTo(
|
||||
returnTo: string | undefined | null,
|
||||
): AuditSourceApp {
|
||||
if (!returnTo) return 'portal'
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(returnTo)
|
||||
} catch {
|
||||
return 'portal'
|
||||
}
|
||||
const host = u.hostname.toLowerCase()
|
||||
const path = u.pathname.toLowerCase()
|
||||
const search = u.search
|
||||
|
||||
// Portal OIDC authorize as return_to → map via client's redirect_uri
|
||||
if (path === '/oauth/authorize' || path.endsWith('/oauth/authorize')) {
|
||||
try {
|
||||
const redirectUri = new URLSearchParams(search).get('redirect_uri')
|
||||
if (redirectUri) return targetAppFromReturnTo(redirectUri)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const hay = `${host} ${path}`
|
||||
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
|
||||
if (
|
||||
/\bcfdm\b/.test(hay) ||
|
||||
host.includes('cfdm') ||
|
||||
host.includes('domain')
|
||||
) {
|
||||
return 'cfdm'
|
||||
}
|
||||
if (/\bbgp\b/.test(hay) || host.includes('bgp')) return 'bgp'
|
||||
if (
|
||||
/\bfw\b/.test(hay) ||
|
||||
host.includes('firewall') ||
|
||||
host.includes('evofw')
|
||||
) {
|
||||
return 'fw'
|
||||
}
|
||||
if (
|
||||
/\bdns\b/.test(hay) ||
|
||||
host.includes('technitium') ||
|
||||
path.includes('/sso/')
|
||||
) {
|
||||
return 'dns'
|
||||
}
|
||||
if (
|
||||
/\bcdn\b/.test(hay) ||
|
||||
host.includes('cdnmanager') ||
|
||||
host.includes('cdn-manager')
|
||||
) {
|
||||
return 'cdn'
|
||||
}
|
||||
if (
|
||||
/\bmm\b/.test(hay) ||
|
||||
host.includes('mikrotik') ||
|
||||
host.includes('mmapp')
|
||||
) {
|
||||
return 'mm'
|
||||
}
|
||||
return 'portal'
|
||||
}
|
||||
|
||||
export function clientUserAgent(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): string | null {
|
||||
const ua = headers['user-agent']
|
||||
if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512)
|
||||
return null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
|
||||
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
|
||||
|
||||
export function webauthnRelyingParty(app: FastifyInstance): {
|
||||
rpID: string
|
||||
rpName: string
|
||||
origins: string[]
|
||||
} {
|
||||
return {
|
||||
rpID: app.config.webauthnRpID,
|
||||
rpName: app.config.webauthnRpName,
|
||||
origins: app.config.webauthnOrigins,
|
||||
}
|
||||
}
|
||||
|
||||
export function userIdToBytes(userId: string): Uint8Array<ArrayBuffer> {
|
||||
// Копия через конструктор даёт Uint8Array<ArrayBuffer>, которого требует SimpleWebAuthn v13.
|
||||
return new Uint8Array(isoUint8Array.fromUTF8String(userId))
|
||||
}
|
||||
|
||||
export function encodePublicKey(publicKey: Uint8Array<ArrayBuffer>): string {
|
||||
return isoBase64URL.fromBuffer(publicKey)
|
||||
}
|
||||
|
||||
export function decodePublicKey(stored: string): Uint8Array<ArrayBuffer> {
|
||||
return new Uint8Array(isoBase64URL.toBuffer(stored))
|
||||
}
|
||||
|
||||
export function asTransports(
|
||||
values: string[],
|
||||
): AuthenticatorTransportFuture[] | undefined {
|
||||
if (values.length === 0) return undefined
|
||||
return values as AuthenticatorTransportFuture[]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import type { UserRow } from '@authportal/db'
|
||||
import type { AppId, MeResponse } from '@authportal/shared'
|
||||
import { APP_IDS } from '@authportal/shared'
|
||||
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
isAdmin: boolean
|
||||
apps: AppId[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
tenants?: Record<string, string>
|
||||
bgp_tenant_id?: string
|
||||
is_admin?: boolean
|
||||
iss: string
|
||||
}
|
||||
user: {
|
||||
sub: string
|
||||
email: string
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
tenants?: Record<string, string>
|
||||
bgp_tenant_id?: string
|
||||
is_admin?: boolean
|
||||
iss: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authUser?: AuthUser
|
||||
}
|
||||
}
|
||||
|
||||
function asAppIds(apps: string[]): AppId[] {
|
||||
return apps.filter((a): a is AppId =>
|
||||
(APP_IDS as readonly string[]).includes(a),
|
||||
)
|
||||
}
|
||||
|
||||
export function toMe(user: UserRow, apps: string[], permissions: string[]): MeResponse {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
is_admin: user.isAdmin,
|
||||
apps: asAppIds(apps),
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
|
||||
export function authUserFromProfile(profile: UserProfile): AuthUser {
|
||||
const { row } = profile
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
isAdmin: row.isAdmin,
|
||||
apps: asAppIds(profile.apps),
|
||||
permissions: profile.permissions,
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
|
||||
const sub = request.user.sub
|
||||
const profile = getUserProfile(request.server.db, sub)
|
||||
if (!profile || profile.row.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
|
||||
request.authUser = authUserFromProfile(profile)
|
||||
}
|
||||
|
||||
export async function requireAdmin(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
await requireAuth(request, reply)
|
||||
if (reply.sent) return
|
||||
if (!request.authUser?.isAdmin) {
|
||||
return reply.status(403).send({
|
||||
error: { code: 'FORBIDDEN', message: 'Только для администраторов' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { hashPassword } from '../lib/password.js'
|
||||
import { hashClientSecret } from '../lib/client-secret.js'
|
||||
import {
|
||||
countWebauthnCredentials,
|
||||
countWebauthnCredentialsBatch,
|
||||
createOidcClient,
|
||||
createUser,
|
||||
deleteOidcClient,
|
||||
deleteUser,
|
||||
deleteWebauthnCredentialsForUser,
|
||||
generateOidcClientSecret,
|
||||
getAppSwitcherConfig,
|
||||
getOidcClientById,
|
||||
getUserApps,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
getUserPermissions,
|
||||
listActiveSessions,
|
||||
listOidcClients,
|
||||
listUserAppsBatch,
|
||||
listUserPermissionsBatch,
|
||||
listUsers,
|
||||
parseJsonStringArray,
|
||||
revokeAllSessionsForUser,
|
||||
revokeSessionById,
|
||||
setAppSwitcherConfig,
|
||||
setUserAccess,
|
||||
updateOidcClient,
|
||||
updateUser,
|
||||
withTransaction,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
APP_IDS,
|
||||
OIDC_SCOPES,
|
||||
allPermissionKeys,
|
||||
appSwitcherConfigSchema,
|
||||
createOidcClientRequestSchema,
|
||||
createUserRequestSchema,
|
||||
normalizePermissionKeys,
|
||||
patchOidcClientRequestSchema,
|
||||
patchUserRequestSchema,
|
||||
putUserAccessRequestSchema,
|
||||
type AdminUser,
|
||||
type AppId,
|
||||
type OidcClientPublic,
|
||||
} from '@authportal/shared'
|
||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { oidcIssuerFromConfig } from '../config.js'
|
||||
|
||||
const allowedPermissions = new Set(allPermissionKeys())
|
||||
|
||||
function toAdminUser(
|
||||
user: NonNullable<ReturnType<typeof getUserById>>,
|
||||
apps: string[],
|
||||
permissions: string[],
|
||||
passkeyCount: number,
|
||||
): AdminUser {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
is_admin: user.isAdmin,
|
||||
disabled: user.disabled,
|
||||
apps: apps.filter((a): a is AppId => (APP_IDS as readonly string[]).includes(a)),
|
||||
permissions: normalizePermissionKeys(permissions),
|
||||
last_login_at: user.lastLoginAt ?? null,
|
||||
last_login_ip: user.lastLoginIp ?? null,
|
||||
passkey_count: passkeyCount,
|
||||
created_at: user.createdAt,
|
||||
updated_at: user.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function mapUser(
|
||||
db: FastifyInstance['db'],
|
||||
user: NonNullable<ReturnType<typeof getUserById>>,
|
||||
): AdminUser {
|
||||
return toAdminUser(
|
||||
user,
|
||||
getUserApps(db, user.id),
|
||||
getUserPermissions(db, user.id),
|
||||
countWebauthnCredentials(db, user.id),
|
||||
)
|
||||
}
|
||||
|
||||
function validateAccess(apps: string[], permissions: string[]): string | null {
|
||||
for (const app of apps) {
|
||||
if (!(APP_IDS as readonly string[]).includes(app)) {
|
||||
return `Неизвестное приложение: ${app}`
|
||||
}
|
||||
}
|
||||
for (const p of permissions) {
|
||||
if (!allowedPermissions.has(p)) {
|
||||
return `Неизвестное право: ${p}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mapOidcClient(
|
||||
row: NonNullable<ReturnType<typeof getOidcClientById>>,
|
||||
): OidcClientPublic {
|
||||
return {
|
||||
id: row.id,
|
||||
client_id: row.clientId,
|
||||
name: row.name,
|
||||
redirect_uris: parseJsonStringArray(row.redirectUrisJson),
|
||||
scopes: parseJsonStringArray(row.scopesJson),
|
||||
enabled: row.enabled,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (!request.url.startsWith('/api/v1/admin')) return
|
||||
await requireAdmin(request, reply)
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/users', async () => {
|
||||
// Батч-запросы вместо 3 SELECT'ов на пользователя (N+1 при N пользователях).
|
||||
const usersRows = listUsers(app.db)
|
||||
const appsByUser = listUserAppsBatch(app.db)
|
||||
const permsByUser = listUserPermissionsBatch(app.db)
|
||||
const passkeysByUser = countWebauthnCredentialsBatch(app.db)
|
||||
return usersRows.map((u) =>
|
||||
toAdminUser(
|
||||
u,
|
||||
appsByUser.get(u.id) ?? [],
|
||||
permsByUser.get(u.id) ?? [],
|
||||
passkeysByUser.get(u.id) ?? 0,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
const user = getUserById(app.db, request.params.id)
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
return mapUser(app.db, user)
|
||||
},
|
||||
)
|
||||
|
||||
app.post('/api/v1/admin/users', async (request, reply) => {
|
||||
const parsed = createUserRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const data = parsed.data
|
||||
const permissions = normalizePermissionKeys(data.permissions)
|
||||
if (getUserByEmail(app.db, data.email)) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Email уже занят' },
|
||||
})
|
||||
}
|
||||
const accessError = validateAccess(data.apps, permissions)
|
||||
if (accessError) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: accessError },
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(data.password)
|
||||
const user = withTransaction(app.db, () => {
|
||||
const created = createUser(app.db, {
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
passwordHash,
|
||||
isAdmin: data.is_admin,
|
||||
})
|
||||
setUserAccess(app.db, created.id, data.apps, permissions)
|
||||
return created
|
||||
})
|
||||
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
|
||||
safeAudit(app, {
|
||||
action: 'user.create',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
summary: `Создан пользователь ${mapped.email}`,
|
||||
details: {
|
||||
is_admin: mapped.is_admin,
|
||||
apps: mapped.apps,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return reply.status(201).send(mapped)
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
const parsed = patchUserRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
if (parsed.data.email && parsed.data.email !== existing.email) {
|
||||
const clash = getUserByEmail(app.db, parsed.data.email)
|
||||
if (clash) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Email уже занят' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = parsed.data.password
|
||||
? await hashPassword(parsed.data.password)
|
||||
: undefined
|
||||
const updated = updateUser(app.db, request.params.id, {
|
||||
email: parsed.data.email,
|
||||
name: parsed.data.name,
|
||||
passwordHash,
|
||||
isAdmin: parsed.data.is_admin,
|
||||
disabled: parsed.data.disabled,
|
||||
})
|
||||
const mapped = mapUser(app.db, updated!)
|
||||
safeAudit(app, {
|
||||
action: 'user.update',
|
||||
severity: parsed.data.disabled ? 'warning' : 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: mapped.id,
|
||||
summary: `Изменён пользователь ${mapped.email}`,
|
||||
details: {
|
||||
...parsed.data,
|
||||
password: parsed.data.password ? '[changed]' : undefined,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/passkeys',
|
||||
async (request, reply) => {
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
|
||||
safeAudit(app, {
|
||||
action: 'admin.passkey_reset',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
|
||||
details: { removed },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true, removed, user: mapUser(app.db, existing) }
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id',
|
||||
async (request, reply) => {
|
||||
if (request.authUser?.id === request.params.id) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Нельзя удалить себя',
|
||||
},
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const ok = deleteUser(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
safeAudit(app, {
|
||||
action: 'user.delete',
|
||||
severity: 'critical',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: existing.id,
|
||||
summary: `Удалён пользователь ${existing.email}`,
|
||||
details: { email: existing.email, name: existing.name },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return reply.status(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
app.put<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/access',
|
||||
async (request, reply) => {
|
||||
const parsed = putUserAccessRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const existing = getUserById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const permissions = normalizePermissionKeys(parsed.data.permissions)
|
||||
const accessError = validateAccess(parsed.data.apps, permissions)
|
||||
if (accessError) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: accessError },
|
||||
})
|
||||
}
|
||||
setUserAccess(
|
||||
app.db,
|
||||
request.params.id,
|
||||
parsed.data.apps,
|
||||
permissions,
|
||||
)
|
||||
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
|
||||
safeAudit(app, {
|
||||
action: 'user.access_update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: mapped.id,
|
||||
summary: `Обновлены права: ${mapped.email}`,
|
||||
details: {
|
||||
apps: mapped.apps,
|
||||
permissions_count: mapped.permissions.length,
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/admin/app-switcher', async () =>
|
||||
getAppSwitcherConfig(app.db),
|
||||
)
|
||||
|
||||
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
|
||||
const parsed = appSwitcherConfigSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const result = setAppSwitcherConfig(app.db, parsed.data)
|
||||
safeAudit(app, {
|
||||
action: 'app_switcher.update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: 'app_switcher',
|
||||
summary: 'Обновлены ссылки App Switcher',
|
||||
details: {
|
||||
menuLabel: result.menuLabel,
|
||||
apps: result.apps.map((a) => a.id),
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/sessions', async (request) => {
|
||||
const userId = (request.query as { user_id?: string }).user_id
|
||||
return listActiveSessions(app.db, { userId }).map((s) => ({
|
||||
id: s.id,
|
||||
user_id: s.userId,
|
||||
email: s.email,
|
||||
name: s.name,
|
||||
ip: s.ip,
|
||||
user_agent: s.userAgent,
|
||||
created_at: s.createdAt,
|
||||
expires_at: s.expiresAt,
|
||||
}))
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/sessions/:id',
|
||||
async (request, reply) => {
|
||||
const ok = revokeSessionById(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
|
||||
})
|
||||
}
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'session',
|
||||
targetId: request.params.id,
|
||||
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
|
||||
details: { source: 'admin_revoke' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/users/:id/sessions/revoke-all',
|
||||
async (request, reply) => {
|
||||
const user = getUserById(app.db, request.params.id)
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
|
||||
})
|
||||
}
|
||||
const revoked = revokeAllSessionsForUser(app.db, user.id)
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
|
||||
details: { revoked, source: 'admin_revoke_all' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { revoked }
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/admin/oidc/meta', async () => {
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
return {
|
||||
issuer,
|
||||
discovery_url: `${issuer}/.well-known/openid-configuration`,
|
||||
jwks_url: `${issuer}/.well-known/jwks.json`,
|
||||
scopes: [...OIDC_SCOPES],
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/oidc/clients', async () =>
|
||||
listOidcClients(app.db).map(mapOidcClient),
|
||||
)
|
||||
|
||||
app.post('/api/v1/admin/oidc/clients', async (request, reply) => {
|
||||
const parsed = createOidcClientRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const secret = generateOidcClientSecret()
|
||||
const secretHash = hashClientSecret(secret)
|
||||
const row = createOidcClient(app.db, {
|
||||
name: parsed.data.name,
|
||||
clientSecretHash: secretHash,
|
||||
redirectUris: parsed.data.redirect_uris,
|
||||
scopes: parsed.data.scopes,
|
||||
enabled: parsed.data.enabled,
|
||||
})
|
||||
const mapped = mapOidcClient(row)
|
||||
safeAudit(app, {
|
||||
action: 'app_switcher.update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: row.id,
|
||||
summary: `OIDC client создан: ${row.name}`,
|
||||
details: { client_id: row.clientId },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ...mapped, client_secret: secret }
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id',
|
||||
async (request, reply) => {
|
||||
const row = getOidcClientById(app.db, request.params.id)
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
return mapOidcClient(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id',
|
||||
async (request, reply) => {
|
||||
const parsed = patchOidcClientRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const row = updateOidcClient(app.db, request.params.id, {
|
||||
name: parsed.data.name,
|
||||
redirectUris: parsed.data.redirect_uris,
|
||||
scopes: parsed.data.scopes,
|
||||
enabled: parsed.data.enabled,
|
||||
})
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
return mapOidcClient(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id',
|
||||
async (request, reply) => {
|
||||
const ok = deleteOidcClient(app.db, request.params.id)
|
||||
if (!ok) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/v1/admin/oidc/clients/:id/rotate-secret',
|
||||
async (request, reply) => {
|
||||
const existing = getOidcClientById(app.db, request.params.id)
|
||||
if (!existing) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
|
||||
})
|
||||
}
|
||||
const secret = generateOidcClientSecret()
|
||||
const secretHash = hashClientSecret(secret)
|
||||
const row = updateOidcClient(app.db, request.params.id, {
|
||||
clientSecretHash: secretHash,
|
||||
})!
|
||||
return { ...mapOidcClient(row), client_secret: secret }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getAuditRetentionDays,
|
||||
listAudit,
|
||||
purgeAuditOlderThan,
|
||||
setAuditRetentionDays,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
auditListQuerySchema,
|
||||
putAuditSettingsSchema,
|
||||
} from '@authportal/shared'
|
||||
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { requireAdmin } from '../plugins/auth-guards.js'
|
||||
|
||||
export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
if (!request.url.startsWith('/api/v1/admin/audit')) return
|
||||
await requireAdmin(request, reply)
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/audit', async (request, reply) => {
|
||||
const parsed = auditListQuerySchema.safeParse(request.query)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
|
||||
})
|
||||
}
|
||||
const q = parsed.data
|
||||
return listAudit(app.db, {
|
||||
action: q.action,
|
||||
severity: q.severity,
|
||||
userId: q.user_id,
|
||||
actorEmail: q.actor_email,
|
||||
sourceApp: q.source_app,
|
||||
kind: q.kind,
|
||||
limit: q.limit,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/v1/admin/audit/settings', async () => ({
|
||||
retention_days: getAuditRetentionDays(app.db),
|
||||
}))
|
||||
|
||||
app.put('/api/v1/admin/audit/settings', async (request, reply) => {
|
||||
const parsed = putAuditSettingsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'retention_days: от 7 до 3650',
|
||||
},
|
||||
})
|
||||
}
|
||||
const prev = getAuditRetentionDays(app.db)
|
||||
const days = setAuditRetentionDays(app.db, parsed.data.retention_days)
|
||||
safeAudit(app, {
|
||||
action: 'audit.settings_update',
|
||||
severity: 'info',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'settings',
|
||||
targetId: 'audit_retention',
|
||||
summary: `Срок хранения журнала: ${prev} → ${days} дн.`,
|
||||
details: { previous: prev, retention_days: days },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { retention_days: days }
|
||||
})
|
||||
|
||||
app.post('/api/v1/admin/audit/purge', async (request) => {
|
||||
const days = getAuditRetentionDays(app.db)
|
||||
const deleted = purgeAuditOlderThan(app.db, days)
|
||||
safeAudit(app, {
|
||||
action: 'audit.purge',
|
||||
severity: 'warning',
|
||||
...actorFromRequest(request),
|
||||
targetType: 'system',
|
||||
summary: `Ручная очистка: удалено ${deleted} записей старше ${days} дн.`,
|
||||
details: { deleted, retention_days: days, source: 'manual' },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { deleted, retention_days: days }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
appsMetaFromSwitcher,
|
||||
loginRequestSchema,
|
||||
publicAppSwitcherConfig,
|
||||
ssoAccessRequestSchema,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
getAppSwitcherConfig,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
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'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
|
||||
app.get('/api/v1/auth/config', async (_request, reply) => {
|
||||
reply.header('Cache-Control', 'public, max-age=60')
|
||||
return {
|
||||
return_to_allowlist: app.config.returnToAllowlist,
|
||||
issuer: app.config.issuer,
|
||||
webauthn: true,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/v1/auth/login', {
|
||||
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
|
||||
handler: async (request, reply) => {
|
||||
const parsed = loginRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
|
||||
const { email, password, return_to: returnTo } = parsed.data
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
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',
|
||||
actorEmail: email.toLowerCase(),
|
||||
targetType: 'session',
|
||||
summary: `Неудачный вход: ${email}`,
|
||||
details: {
|
||||
reason: !user ? 'unknown_user' : 'disabled',
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||
})
|
||||
}
|
||||
|
||||
const ok = await verifyPassword(user.passwordHash, password)
|
||||
if (!ok) {
|
||||
safeAudit(app, {
|
||||
action: 'auth.login_failed',
|
||||
severity: 'warning',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `Неудачный вход: ${user.email}`,
|
||||
details: {
|
||||
reason: 'bad_password',
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetApp,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
|
||||
})
|
||||
}
|
||||
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'password',
|
||||
returnTo,
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Re-mint access JWT from DB (apps/permissions/is_admin/tenants).
|
||||
* Fixes stale localStorage JWT after role changes or account switch.
|
||||
*/
|
||||
app.post(
|
||||
'/api/v1/auth/reissue',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
return issueAccessToken(app, user)
|
||||
},
|
||||
)
|
||||
|
||||
/** Record SSO handoff to a connected app (already authenticated). */
|
||||
app.post(
|
||||
'/api/v1/auth/sso-access',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = ssoAccessRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const returnTo = parsed.data.return_to
|
||||
const targetApp = targetAppFromReturnTo(returnTo)
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
safeAudit(app, {
|
||||
action: 'auth.sso_handoff',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `SSO: ${user.email} → ${targetApp}`,
|
||||
details: {
|
||||
return_to: returnTo,
|
||||
target_app: targetApp,
|
||||
user_agent: userAgent,
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return { ok: true, target_app: targetApp }
|
||||
},
|
||||
)
|
||||
|
||||
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',
|
||||
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
|
||||
)
|
||||
|
||||
// Best-effort actor from JWT if present
|
||||
let actorUserId: string | null = null
|
||||
let actorEmail: string | null = null
|
||||
let actorName: string | null = null
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
const sub = request.user.sub
|
||||
const row = getUserById(app.db, sub)
|
||||
if (row) {
|
||||
actorUserId = row.id
|
||||
actorEmail = row.email
|
||||
actorName = row.name
|
||||
}
|
||||
} catch {
|
||||
/* anonymous logout */
|
||||
}
|
||||
|
||||
safeAudit(app, {
|
||||
action: 'auth.logout',
|
||||
severity: 'info',
|
||||
actorUserId,
|
||||
actorEmail,
|
||||
actorName,
|
||||
targetType: 'session',
|
||||
targetId: actorUserId,
|
||||
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
|
||||
details: { user_agent: clientUserAgent(request.headers) },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
||||
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
|
||||
app.get('/api/v1/app-switcher', async () =>
|
||||
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/auth/me',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const auth = request.authUser!
|
||||
return {
|
||||
id: auth.id,
|
||||
email: auth.email,
|
||||
name: auth.name,
|
||||
is_admin: auth.isAdmin,
|
||||
apps: auth.apps,
|
||||
permissions: auth.permissions,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/directory/users',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const q = String(
|
||||
(request.query as { q?: string }).q ?? '',
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return searchDirectoryUsers(app.db, q, 50)
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
return {
|
||||
apps: appsMetaFromSwitcher(switcher),
|
||||
permissions: PERMISSION_CATALOG,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { appendAuditBatch } from '@authportal/db'
|
||||
import {
|
||||
ingestAuditRequestSchema,
|
||||
type AuditSourceApp,
|
||||
} from '@authportal/shared'
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
|
||||
function secretsEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a)
|
||||
const bb = Buffer.from(b)
|
||||
if (ba.length !== bb.length) return false
|
||||
return timingSafeEqual(ba, bb)
|
||||
}
|
||||
|
||||
export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
'/api/v1/ingest/audit',
|
||||
{
|
||||
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const secret = app.config.auditIngestSecret
|
||||
if (!secret) {
|
||||
return reply.status(503).send({
|
||||
error: {
|
||||
code: 'UNAVAILABLE',
|
||||
message: 'Audit ingest не настроен',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const auth = request.headers.authorization ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
|
||||
if (!token || !secretsEqual(token, secret)) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный ingest secret' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = ingestAuditRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
|
||||
const { accepted, duplicates, failed } = appendAuditBatch(
|
||||
app.db,
|
||||
parsed.data.events.map((event) => ({
|
||||
eventId: event.event_id,
|
||||
sourceApp: event.source_app as AuditSourceApp,
|
||||
action: event.action,
|
||||
severity: event.severity,
|
||||
actorUserId: event.actor_user_id,
|
||||
actorEmail: event.actor_email,
|
||||
actorName: event.actor_name,
|
||||
targetType: event.target_type,
|
||||
targetId: event.target_id,
|
||||
summary: event.summary,
|
||||
details: event.details,
|
||||
ip: event.ip,
|
||||
createdAt: event.created_at,
|
||||
})),
|
||||
)
|
||||
if (failed > 0) {
|
||||
app.log.warn({ failed }, 'ingest append failed for some events')
|
||||
}
|
||||
|
||||
return { accepted, duplicates }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import {
|
||||
updateOidcClient,
|
||||
createOidcAuthCode,
|
||||
consumeOidcAuthCode,
|
||||
getOidcClientByClientId,
|
||||
getUserById,
|
||||
getUserByRefreshToken,
|
||||
getUserApps,
|
||||
getUserPermissions,
|
||||
parseJsonStringArray,
|
||||
type OidcClientRow,
|
||||
type UserRow,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
oidcGroupsForUser,
|
||||
normalizePermissionKeys,
|
||||
} 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,
|
||||
ensureOidcSigningKey,
|
||||
signOidcJwt,
|
||||
verifyOidcAccessToken,
|
||||
} from '../lib/oidc/keys.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token'
|
||||
const CODE_TTL_MS = 5 * 60 * 1000
|
||||
const ACCESS_TTL_SEC = 3600
|
||||
const ID_TOKEN_TTL_SEC = 3600
|
||||
|
||||
function parseBasicAuth(
|
||||
header: string | undefined,
|
||||
): { clientId: string; clientSecret: string } | null {
|
||||
if (!header?.startsWith('Basic ')) return null
|
||||
try {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
|
||||
const idx = decoded.indexOf(':')
|
||||
if (idx < 0) return null
|
||||
return {
|
||||
clientId: decoded.slice(0, idx),
|
||||
clientSecret: decoded.slice(idx + 1),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
updateOidcClient(app.db, client.id, {
|
||||
clientSecretHash: hashClientSecret(secret),
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'client secret rehash failed')
|
||||
}
|
||||
}
|
||||
return res.ok
|
||||
}
|
||||
|
||||
function redirectError(
|
||||
reply: FastifyReply,
|
||||
redirectUri: string | undefined,
|
||||
error: string,
|
||||
description: string,
|
||||
state?: string | null,
|
||||
) {
|
||||
if (!redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error,
|
||||
error_description: description,
|
||||
})
|
||||
}
|
||||
const url = new URL(redirectUri)
|
||||
url.searchParams.set('error', error)
|
||||
url.searchParams.set('error_description', description)
|
||||
if (state) url.searchParams.set('state', state)
|
||||
return reply.redirect(url.toString())
|
||||
}
|
||||
|
||||
function resolveSessionUser(
|
||||
app: FastifyInstance,
|
||||
request: FastifyRequest,
|
||||
): UserRow | undefined {
|
||||
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
|
||||
if (!raw) return undefined
|
||||
return getUserByRefreshToken(app.db, raw)
|
||||
}
|
||||
|
||||
function buildUserInfoClaims(
|
||||
app: FastifyInstance,
|
||||
user: UserRow,
|
||||
scope: string,
|
||||
) {
|
||||
const scopes = new Set(scope.split(/\s+/).filter(Boolean))
|
||||
const permissions = normalizePermissionKeys(
|
||||
getUserPermissions(app.db, user.id),
|
||||
)
|
||||
const groups = oidcGroupsForUser({
|
||||
isAdmin: user.isAdmin,
|
||||
permissions,
|
||||
})
|
||||
|
||||
const claims: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
}
|
||||
if (scopes.has('email') || scopes.has('profile') || scopes.has('openid')) {
|
||||
claims.email = user.email
|
||||
claims.email_verified = true
|
||||
}
|
||||
if (scopes.has('profile') || scopes.has('openid')) {
|
||||
claims.name = user.name
|
||||
claims.preferred_username = user.email
|
||||
}
|
||||
if (scopes.has('groups')) {
|
||||
claims.groups = groups
|
||||
claims.roles = groups
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
function safeEqualStr(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a)
|
||||
const bb = Buffer.from(b)
|
||||
if (ba.length !== bb.length) return false
|
||||
return timingSafeEqual(ba, bb)
|
||||
}
|
||||
|
||||
function verifyPkce(
|
||||
codeVerifier: string | undefined,
|
||||
challenge: string | null,
|
||||
method: string | null,
|
||||
): boolean {
|
||||
if (!challenge) return true
|
||||
if (!codeVerifier) return false
|
||||
if (method === 'plain' || !method) {
|
||||
return safeEqualStr(codeVerifier, challenge)
|
||||
}
|
||||
if (method === 'S256') {
|
||||
const hash = createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url')
|
||||
return hash === challenge
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
|
||||
await ensureOidcSigningKey(app)
|
||||
|
||||
app.get('/.well-known/openid-configuration', async (_request, reply) => {
|
||||
// Статичный документ — RP не должен перечитывать его на каждый запрос.
|
||||
reply.header('Cache-Control', 'public, max-age=300')
|
||||
return buildOidcDiscovery(app.config)
|
||||
})
|
||||
|
||||
app.get('/.well-known/jwks.json', async (_request, reply) => {
|
||||
reply.header('Cache-Control', 'public, max-age=300')
|
||||
return buildJwks(app)
|
||||
})
|
||||
|
||||
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
|
||||
const responseType = q.response_type
|
||||
const scope = q.scope ?? 'openid'
|
||||
const state = q.state
|
||||
const nonce = q.nonce
|
||||
const codeChallenge = q.code_challenge
|
||||
const codeChallengeMethod = q.code_challenge_method
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_request',
|
||||
error_description: 'client_id and redirect_uri are required',
|
||||
})
|
||||
}
|
||||
if (responseType !== 'code') {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'unsupported_response_type',
|
||||
'Only response_type=code is supported',
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
const client = getOidcClientByClientId(app.db, clientId)
|
||||
if (!client || !client.enabled) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Unknown or disabled client',
|
||||
})
|
||||
}
|
||||
const allowedRedirects = parseJsonStringArray(client.redirectUrisJson)
|
||||
if (!allowedRedirects.includes(redirectUri)) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_request',
|
||||
error_description: 'redirect_uri is not registered',
|
||||
})
|
||||
}
|
||||
|
||||
const requestedScopes = scope.split(/\s+/).filter(Boolean)
|
||||
const clientScopes = new Set(parseJsonStringArray(client.scopesJson))
|
||||
if (!requestedScopes.includes('openid')) {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'invalid_scope',
|
||||
'openid scope is required',
|
||||
state,
|
||||
)
|
||||
}
|
||||
for (const s of requestedScopes) {
|
||||
if (!clientScopes.has(s)) {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'invalid_scope',
|
||||
`Scope not allowed: ${s}`,
|
||||
state,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const user = resolveSessionUser(app, request)
|
||||
if (!user) {
|
||||
const issuer = oidcIssuerFromConfig(app.config)
|
||||
const authorizeUrl = new URL(`${issuer}/oauth/authorize`)
|
||||
for (const [k, v] of Object.entries(q)) {
|
||||
if (v != null) authorizeUrl.searchParams.set(k, v)
|
||||
}
|
||||
const login = new URL(`${issuer}/`)
|
||||
login.searchParams.set('return_to', authorizeUrl.toString())
|
||||
return reply.redirect(login.toString())
|
||||
}
|
||||
|
||||
const apps = getUserApps(app.db, user.id)
|
||||
// Soft check: prefer dns app assignment for Technitium-like clients
|
||||
if (!user.isAdmin && apps.length === 0) {
|
||||
return redirectError(
|
||||
reply,
|
||||
redirectUri,
|
||||
'access_denied',
|
||||
'User has no application access',
|
||||
state,
|
||||
)
|
||||
}
|
||||
|
||||
const rawCode = randomBytes(32).toString('base64url')
|
||||
createOidcAuthCode(app.db, {
|
||||
rawCode,
|
||||
clientId: client.clientId,
|
||||
userId: user.id,
|
||||
redirectUri,
|
||||
scope: requestedScopes.join(' '),
|
||||
nonce: nonce ?? null,
|
||||
codeChallenge: codeChallenge ?? null,
|
||||
codeChallengeMethod: codeChallengeMethod ?? null,
|
||||
expiresAt: new Date(Date.now() + CODE_TTL_MS),
|
||||
})
|
||||
|
||||
const targetApp = targetAppFromReturnTo(redirectUri)
|
||||
safeAudit(app, {
|
||||
action: 'auth.sso_handoff',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'session',
|
||||
targetId: user.id,
|
||||
summary: `SSO OIDC: ${user.email} → ${targetApp}`,
|
||||
details: {
|
||||
return_to: redirectUri,
|
||||
target_app: targetApp,
|
||||
user_agent: clientUserAgent(request.headers),
|
||||
oidc_client_id: client.clientId,
|
||||
oidc_client_name: client.name,
|
||||
auth_mode: 'oidc',
|
||||
},
|
||||
ip: clientIp(request),
|
||||
})
|
||||
|
||||
const dest = new URL(redirectUri)
|
||||
dest.searchParams.set('code', rawCode)
|
||||
if (state) dest.searchParams.set('state', state)
|
||||
return reply.redirect(dest.toString())
|
||||
})
|
||||
|
||||
app.post('/oauth/token', {
|
||||
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
|
||||
handler: async (request, reply) => {
|
||||
const raw = request.body
|
||||
const body: Record<string, string | undefined> =
|
||||
typeof raw === 'string'
|
||||
? Object.fromEntries(new URLSearchParams(raw))
|
||||
: raw && typeof raw === 'object'
|
||||
? (raw as Record<string, string | undefined>)
|
||||
: {}
|
||||
const basic = parseBasicAuth(request.headers.authorization)
|
||||
const clientId = body.client_id ?? basic?.clientId
|
||||
const clientSecret = body.client_secret ?? basic?.clientSecret
|
||||
const grantType = body.grant_type
|
||||
const code = body.code
|
||||
const redirectUri = body.redirect_uri
|
||||
const codeVerifier = body.code_verifier
|
||||
|
||||
if (grantType !== 'authorization_code') {
|
||||
return reply.status(400).send({
|
||||
error: 'unsupported_grant_type',
|
||||
error_description: 'Only authorization_code is supported',
|
||||
})
|
||||
}
|
||||
if (!clientId || !clientSecret || !code || !redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_request',
|
||||
error_description:
|
||||
'client_id, client_secret, code, and redirect_uri are required',
|
||||
})
|
||||
}
|
||||
|
||||
const client = getOidcClientByClientId(app.db, clientId)
|
||||
if (!client || !client.enabled) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Unknown or disabled client',
|
||||
})
|
||||
}
|
||||
const secretOk = await authenticateClient(app, client, clientSecret)
|
||||
if (!secretOk) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Invalid client credentials',
|
||||
})
|
||||
}
|
||||
|
||||
const authCode = consumeOidcAuthCode(app.db, code)
|
||||
if (!authCode) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Invalid or expired authorization code',
|
||||
})
|
||||
}
|
||||
if (authCode.clientId !== client.clientId) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'Code was not issued to this client',
|
||||
})
|
||||
}
|
||||
if (authCode.redirectUri !== redirectUri) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'redirect_uri mismatch',
|
||||
})
|
||||
}
|
||||
if (
|
||||
!verifyPkce(
|
||||
codeVerifier,
|
||||
authCode.codeChallenge,
|
||||
authCode.codeChallengeMethod,
|
||||
)
|
||||
) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'PKCE verification failed',
|
||||
})
|
||||
}
|
||||
|
||||
const user = getUserById(app.db, authCode.userId)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(400).send({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'User unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
const info = buildUserInfoClaims(app, user, authCode.scope)
|
||||
const idClaims: Record<string, unknown> = {
|
||||
...info,
|
||||
aud: client.clientId,
|
||||
}
|
||||
if (authCode.nonce) idClaims.nonce = authCode.nonce
|
||||
|
||||
const accessClaims: Record<string, unknown> = {
|
||||
sub: user.id,
|
||||
aud: client.clientId,
|
||||
scope: authCode.scope,
|
||||
token_use: 'access',
|
||||
}
|
||||
if (info.groups) {
|
||||
accessClaims.groups = info.groups
|
||||
accessClaims.roles = info.roles
|
||||
}
|
||||
|
||||
const [idToken, accessToken] = await Promise.all([
|
||||
signOidcJwt(app, idClaims, ID_TOKEN_TTL_SEC),
|
||||
signOidcJwt(app, accessClaims, ACCESS_TTL_SEC),
|
||||
])
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TTL_SEC,
|
||||
id_token: idToken,
|
||||
scope: authCode.scope,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Bearer token required',
|
||||
})
|
||||
}
|
||||
const token = auth.slice(7)
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = await verifyOidcAccessToken(app, token)
|
||||
} catch {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Token invalid or expired',
|
||||
})
|
||||
}
|
||||
const sub = typeof payload.sub === 'string' ? payload.sub : null
|
||||
if (!sub) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Missing sub',
|
||||
})
|
||||
}
|
||||
const user = getUserById(app.db, sub)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: 'invalid_token',
|
||||
error_description: 'User unavailable',
|
||||
})
|
||||
}
|
||||
const scope =
|
||||
typeof payload.scope === 'string'
|
||||
? payload.scope
|
||||
: 'openid profile email groups'
|
||||
return buildUserInfoClaims(app, user, scope)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server'
|
||||
import {
|
||||
consumeWebauthnChallenge,
|
||||
createWebauthnChallenge,
|
||||
createWebauthnCredential,
|
||||
deleteWebauthnCredential,
|
||||
getUserById,
|
||||
getWebauthnCredentialByCredentialId,
|
||||
getWebauthnCredentialById,
|
||||
listWebauthnCredentials,
|
||||
parseTransportsJson,
|
||||
updateWebauthnCredentialName,
|
||||
touchWebauthnCredential,
|
||||
type WebauthnCredentialRow,
|
||||
} from '@authportal/db'
|
||||
import {
|
||||
patchPasskeyRequestSchema,
|
||||
webauthnVerifyRequestSchema,
|
||||
type PasskeyCredential,
|
||||
} from '@authportal/shared'
|
||||
import { requireAuth } from '../plugins/auth-guards.js'
|
||||
import { completeLogin } from '../lib/complete-login.js'
|
||||
import { clientIp, safeAudit } from '../lib/audit.js'
|
||||
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
|
||||
import {
|
||||
asTransports,
|
||||
decodePublicKey,
|
||||
encodePublicKey,
|
||||
userIdToBytes,
|
||||
webauthnRelyingParty,
|
||||
} from '../lib/webauthn.js'
|
||||
|
||||
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
|
||||
|
||||
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
created_at: row.createdAt,
|
||||
last_used_at: row.lastUsedAt ?? null,
|
||||
device_type: row.deviceType ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function defaultPasskeyName(userAgent: string | null): string {
|
||||
const date = new Date().toLocaleDateString('ru-RU')
|
||||
if (!userAgent) return `Passkey ${date}`
|
||||
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
|
||||
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
|
||||
if (/Android/i.test(userAgent)) return `Android ${date}`
|
||||
return `Passkey ${date}`
|
||||
}
|
||||
|
||||
function asRegistrationResponse(
|
||||
raw: unknown,
|
||||
): RegistrationResponseJSON | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||
return null
|
||||
}
|
||||
return raw as RegistrationResponseJSON
|
||||
}
|
||||
|
||||
function asAuthenticationResponse(
|
||||
raw: unknown,
|
||||
): AuthenticationResponseJSON | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
|
||||
return null
|
||||
}
|
||||
return raw as AuthenticationResponseJSON
|
||||
}
|
||||
|
||||
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post(
|
||||
'/api/v1/webauthn/register/options',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: LOGIN_RATE },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const rp = webauthnRelyingParty(app)
|
||||
const existing = listWebauthnCredentials(app.db, user.id)
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: rp.rpName,
|
||||
rpID: rp.rpID,
|
||||
userName: user.email,
|
||||
userDisplayName: user.name,
|
||||
userID: userIdToBytes(user.id),
|
||||
attestationType: 'none',
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
excludeCredentials: existing.map((cred) => ({
|
||||
id: cred.credentialId,
|
||||
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||
})),
|
||||
})
|
||||
const row = createWebauthnChallenge(app.db, {
|
||||
purpose: 'register',
|
||||
challenge: options.challenge,
|
||||
userId: user.id,
|
||||
})
|
||||
return { challenge_id: row.id, options }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/register',
|
||||
{
|
||||
onRequest: requireAuth,
|
||||
config: { rateLimit: LOGIN_RATE },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const user = getUserById(app.db, auth.id)
|
||||
if (!user || user.disabled) {
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
|
||||
})
|
||||
}
|
||||
const response = asRegistrationResponse(parsed.data.response)
|
||||
if (!response) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
|
||||
})
|
||||
}
|
||||
const challenge = consumeWebauthnChallenge(
|
||||
app.db,
|
||||
parsed.data.challenge_id,
|
||||
'register',
|
||||
user.id,
|
||||
)
|
||||
if (!challenge) {
|
||||
return reply.status(400).send({
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Срок действия challenge истёк, повторите регистрацию',
|
||||
},
|
||||
})
|
||||
}
|
||||
const rp = webauthnRelyingParty(app)
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: true,
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webauthn register verify failed')
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
|
||||
})
|
||||
}
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
|
||||
})
|
||||
}
|
||||
const info = verification.registrationInfo
|
||||
const duplicate = getWebauthnCredentialByCredentialId(
|
||||
app.db,
|
||||
info.credential.id,
|
||||
)
|
||||
if (duplicate) {
|
||||
return reply.status(409).send({
|
||||
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
|
||||
})
|
||||
}
|
||||
const name =
|
||||
parsed.data.name?.trim() ||
|
||||
defaultPasskeyName(clientUserAgent(request.headers))
|
||||
const row = createWebauthnCredential(app.db, {
|
||||
userId: user.id,
|
||||
credentialId: info.credential.id,
|
||||
publicKey: encodePublicKey(info.credential.publicKey),
|
||||
counter: info.credential.counter,
|
||||
deviceType: info.credentialDeviceType,
|
||||
backedUp: info.credentialBackedUp,
|
||||
transports: info.credential.transports,
|
||||
name,
|
||||
})
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_register',
|
||||
severity: 'info',
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
actorName: user.name,
|
||||
targetType: 'credential',
|
||||
targetId: row.id,
|
||||
summary: `Passkey добавлен: ${user.email}`,
|
||||
details: { name: row.name, device_type: row.deviceType },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return toPasskeyDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/webauthn/credentials',
|
||||
{ onRequest: requireAuth },
|
||||
async (request) => {
|
||||
const auth = request.authUser!
|
||||
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string } }>(
|
||||
'/api/v1/webauthn/credentials/:id',
|
||||
{ onRequest: requireAuth },
|
||||
async (request, reply) => {
|
||||
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const auth = request.authUser!
|
||||
const row = updateWebauthnCredentialName(
|
||||
app.db,
|
||||
request.params.id,
|
||||
auth.id,
|
||||
parsed.data.name.trim(),
|
||||
)
|
||||
if (!row) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||
})
|
||||
}
|
||||
return toPasskeyDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
'/api/v1/webauthn/credentials/:id',
|
||||
{ onRequest: requireAuth },
|
||||
async (request, reply) => {
|
||||
const auth = request.authUser!
|
||||
const existing = getWebauthnCredentialById(app.db, request.params.id)
|
||||
if (!existing || existing.userId !== auth.id) {
|
||||
return reply.status(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
|
||||
})
|
||||
}
|
||||
deleteWebauthnCredential(app.db, request.params.id, auth.id)
|
||||
const user = getUserById(app.db, auth.id)
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_delete',
|
||||
severity: 'warning',
|
||||
actorUserId: auth.id,
|
||||
actorEmail: auth.email,
|
||||
actorName: auth.name,
|
||||
targetType: 'credential',
|
||||
targetId: request.params.id,
|
||||
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
|
||||
details: { name: existing.name },
|
||||
ip: clientIp(request),
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/login/options',
|
||||
{ config: { rateLimit: LOGIN_RATE } },
|
||||
async () => {
|
||||
const rp = webauthnRelyingParty(app)
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: rp.rpID,
|
||||
userVerification: 'preferred',
|
||||
})
|
||||
const row = createWebauthnChallenge(app.db, {
|
||||
purpose: 'authenticate',
|
||||
challenge: options.challenge,
|
||||
})
|
||||
return { challenge_id: row.id, options }
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/v1/webauthn/login',
|
||||
{ config: { rateLimit: LOGIN_RATE } },
|
||||
async (request, reply) => {
|
||||
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({
|
||||
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
|
||||
})
|
||||
}
|
||||
const ip = clientIp(request)
|
||||
const userAgent = clientUserAgent(request.headers)
|
||||
const returnTo = parsed.data.return_to
|
||||
const fail = (reason: string) => {
|
||||
safeAudit(app, {
|
||||
action: 'auth.passkey_login_failed',
|
||||
severity: 'warning',
|
||||
targetType: 'session',
|
||||
summary: 'Неудачный вход по passkey',
|
||||
details: {
|
||||
reason,
|
||||
user_agent: userAgent,
|
||||
return_to: returnTo ?? null,
|
||||
target_app: targetAppFromReturnTo(returnTo),
|
||||
},
|
||||
ip,
|
||||
})
|
||||
return reply.status(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
|
||||
})
|
||||
}
|
||||
|
||||
const response = asAuthenticationResponse(parsed.data.response)
|
||||
if (!response) return fail('bad_response')
|
||||
|
||||
const challenge = consumeWebauthnChallenge(
|
||||
app.db,
|
||||
parsed.data.challenge_id,
|
||||
'authenticate',
|
||||
)
|
||||
if (!challenge) return fail('expired_challenge')
|
||||
|
||||
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
|
||||
if (!cred) return fail('unknown_credential')
|
||||
|
||||
const user = getUserById(app.db, cred.userId)
|
||||
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
|
||||
|
||||
const rp = webauthnRelyingParty(app)
|
||||
let verification
|
||||
try {
|
||||
verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: rp.origins,
|
||||
expectedRPID: rp.rpID,
|
||||
requireUserVerification: true,
|
||||
credential: {
|
||||
id: cred.credentialId,
|
||||
publicKey: decodePublicKey(cred.publicKey),
|
||||
counter: cred.counter,
|
||||
transports: asTransports(parseTransportsJson(cred.transportsJson)),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'webauthn login verify failed')
|
||||
return fail('verify_error')
|
||||
}
|
||||
if (!verification.verified) return fail('not_verified')
|
||||
|
||||
touchWebauthnCredential(
|
||||
app.db,
|
||||
cred.id,
|
||||
verification.authenticationInfo.newCounter,
|
||||
)
|
||||
return completeLogin(app, reply, user, {
|
||||
method: 'passkey',
|
||||
returnTo,
|
||||
ip,
|
||||
userAgent,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildApp } from './app.js'
|
||||
import { loadConfig } from './config.js'
|
||||
import { registerShutdown } from './lib/shutdown.js'
|
||||
|
||||
for (const path of [
|
||||
resolve(import.meta.dirname, '../../../.env'),
|
||||
'.env',
|
||||
'../.env',
|
||||
]) {
|
||||
if (!existsSync(path)) continue
|
||||
const content = readFileSync(path, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
|
||||
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
|
||||
// (для cron / `docker exec auth-portal node dist/server.js backup`).
|
||||
if (process.argv[2] === 'backup') {
|
||||
const { createDb, closeDb } = await import('@authportal/db')
|
||||
const { runBackup } = await import('./services/backup.js')
|
||||
const { sqlite } = createDb(config.databaseUrl)
|
||||
try {
|
||||
const res = await runBackup(sqlite, config)
|
||||
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
|
||||
closeDb(sqlite)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
console.error('backup failed:', err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const app = await buildApp({ config })
|
||||
|
||||
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||
app.log.info(`auth-portal listening on ${config.serverPort}`)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import {
|
||||
getAuditRetentionDays,
|
||||
purgeAuditOlderThan,
|
||||
} from '@authportal/db'
|
||||
import { safeAudit } from '../lib/audit.js'
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
export function startAuditRetentionJob(app: FastifyInstance): () => void {
|
||||
const run = () => {
|
||||
try {
|
||||
const days = getAuditRetentionDays(app.db)
|
||||
const deleted = purgeAuditOlderThan(app.db, days)
|
||||
if (deleted > 0) {
|
||||
safeAudit(app, {
|
||||
action: 'audit.purge',
|
||||
severity: 'info',
|
||||
targetType: 'system',
|
||||
summary: `Автоочистка: удалено ${deleted} записей старше ${days} дн.`,
|
||||
details: { deleted, retention_days: days, source: 'scheduler' },
|
||||
})
|
||||
app.log.info(
|
||||
{ deleted, retention_days: days },
|
||||
'audit_log retention purge',
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'audit_log retention job failed')
|
||||
}
|
||||
}
|
||||
|
||||
// Defer first run slightly so bootstrap finishes
|
||||
const initial = setTimeout(run, 15_000)
|
||||
const interval = setInterval(run, HOUR_MS)
|
||||
|
||||
return () => {
|
||||
clearTimeout(initial)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
|
||||
if (config.backupDir) return resolve(config.backupDir)
|
||||
const path = resolveDatabasePath(databaseUrl)
|
||||
return join(dirname(resolve(path)), 'backups')
|
||||
}
|
||||
|
||||
export async function runBackup(
|
||||
sqlite: Sqlite,
|
||||
config: AppConfig,
|
||||
databaseUrl = config.databaseUrl,
|
||||
) {
|
||||
return backupDatabase(sqlite, {
|
||||
dir: backupDirFor(config, databaseUrl),
|
||||
keep: config.backupKeep,
|
||||
})
|
||||
}
|
||||
|
||||
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
|
||||
export function startBackupJob(app: FastifyInstance): () => void {
|
||||
const hours = app.config.backupIntervalHours
|
||||
if (hours <= 0 || app.sqlite.memory) return () => {}
|
||||
let running = false
|
||||
const run = async () => {
|
||||
if (running) return
|
||||
running = true
|
||||
try {
|
||||
const res = await runBackup(app.sqlite, app.config)
|
||||
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
|
||||
} catch (err) {
|
||||
app.log.error({ err }, 'db backup failed')
|
||||
} finally {
|
||||
running = false
|
||||
}
|
||||
}
|
||||
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
|
||||
interval.unref()
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { runHousekeeping } from '@authportal/db'
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
|
||||
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
|
||||
export function startHousekeepingJob(app: FastifyInstance): () => void {
|
||||
const run = () => {
|
||||
try {
|
||||
const res = runHousekeeping(app.db, {
|
||||
retentionDays: app.config.sessionRetentionDays,
|
||||
})
|
||||
if (res.sessions + res.authCodes + res.challenges > 0) {
|
||||
app.log.info(res, 'housekeeping purge')
|
||||
}
|
||||
} catch (err) {
|
||||
app.log.warn({ err }, 'housekeeping job failed')
|
||||
}
|
||||
}
|
||||
const initial = setTimeout(run, 30_000)
|
||||
const interval = setInterval(run, HOUR_MS)
|
||||
initial.unref()
|
||||
interval.unref()
|
||||
return () => {
|
||||
clearTimeout(initial)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
import https from 'node:https'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { completePortalRevocation, enablePortalRevocations, nextPortalRevocation } from '@authportal/db'
|
||||
|
||||
export function portalRevocationTarget(raw: string): URL {
|
||||
if (raw.length > 512) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
|
||||
let url: URL
|
||||
try { url = new URL(raw) } catch { throw new Error('PORTAL_REVOCATION_TARGET_INVALID') }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|
||||
|| url.pathname !== '/_internal/mcp/portal/revocations' || url.href !== raw) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
|
||||
return url
|
||||
}
|
||||
/** One fixed operator-selected HTTPS destination. Core https never follows redirects. */
|
||||
export function sendPortalRevocation(target: URL, secret: string, body: string, signal: AbortSignal): Promise<boolean> {
|
||||
if (Buffer.byteLength(body) > 4096) return Promise.resolve(false)
|
||||
const timestamp = String(Math.floor(Date.now() / 1000))
|
||||
const signature = 'sha256=' + createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex')
|
||||
return new Promise(resolve => {
|
||||
let settled = false
|
||||
let outcome = false
|
||||
const finish = (accepted: boolean) => {
|
||||
if (!settled) { settled = true; outcome = accepted; clearTimeout(deadline); request.destroy() }
|
||||
}
|
||||
const request = https.request(target, { method: 'POST', agent: false, rejectUnauthorized: true, signal, headers: {
|
||||
'content-type': 'application/json', 'content-length': Buffer.byteLength(body),
|
||||
'x-mm-timestamp': timestamp, 'x-mm-signature': signature,
|
||||
} }, response => {
|
||||
const chunks: Buffer[] = []; let size = 0
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > 4096) { response.destroy(); request.destroy(); finish(false) }
|
||||
else chunks.push(chunk)
|
||||
})
|
||||
response.once('error', () => finish(false))
|
||||
response.once('aborted', () => finish(false))
|
||||
response.once('end', () => {
|
||||
let accepted = false
|
||||
try {
|
||||
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
accepted = response.statusCode === 200 && !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
&& Object.keys(value).sort().join(',') === 'duplicate,ok' && (value as {ok?: unknown}).ok === true
|
||||
&& typeof (value as {duplicate?: unknown}).duplicate === 'boolean'
|
||||
} catch { /* No raw transport/response/error material is logged. */ }
|
||||
finish(accepted)
|
||||
})
|
||||
})
|
||||
const deadline = setTimeout(() => { request.destroy(); finish(false) }, 3000)
|
||||
request.once('error', () => finish(false))
|
||||
request.once('close', () => { clearTimeout(deadline); resolve(outcome) })
|
||||
request.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
export function configurePortalRevocationSender(app: FastifyInstance): void {
|
||||
if (!app.config.portalRevocationsEnabled) return
|
||||
const target = portalRevocationTarget(app.config.portalRevocationsTarget ?? '')
|
||||
const secret = app.config.portalRevocationsSecret
|
||||
if (!secret || secret.length < 32 || Buffer.byteLength(secret) > 1024) throw new Error('PORTAL_REVOCATION_SECRET_REQUIRED')
|
||||
enablePortalRevocations(app.db, target.href)
|
||||
let stopped = false, running: Promise<void> | undefined
|
||||
const abort = new AbortController()
|
||||
const run = async () => {
|
||||
// Bounded work per tick, single in-flight request; rows survive all failures/restarts.
|
||||
for (let i = 0; i < 4 && !stopped; i++) {
|
||||
const event = nextPortalRevocation(app.db)
|
||||
if (!event) return
|
||||
const delivered = await sendPortalRevocation(target, secret, event.body, abort.signal)
|
||||
completePortalRevocation(app.db, event, delivered)
|
||||
if (!delivered) return
|
||||
}
|
||||
}
|
||||
const tick = () => {
|
||||
if (stopped || running) return
|
||||
running = run().catch(() => { app.log.error({event: 'portal.revocation.delivery-unavailable'}, 'Revocation delivery unavailable') })
|
||||
.finally(() => { running = undefined })
|
||||
}
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
app.addHook('onReady', async () => { interval = setInterval(tick, 1000); interval.unref(); tick() })
|
||||
// preClose executes before onClose closes SQLite. Abort then drain exact in-flight work.
|
||||
app.addHook('preClose', async () => { stopped = true; clearInterval(interval); abort.abort(); await running })
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { hash } from '@node-rs/argon2'
|
||||
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
type App = Awaited<ReturnType<typeof buildTestApp>>
|
||||
let app: App | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
|
||||
|
||||
async function login(a: App, email = 'admin@test.local', password = 'adminpass') {
|
||||
const res = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email, password },
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
describe('auth', () => {
|
||||
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
expect((await login(app, 'admin@test.local', 'wrong')).statusCode).toBe(401)
|
||||
expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401)
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
email: 'off@test.local',
|
||||
name: 'Off',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
const id = (created.json() as { id: string }).id
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: auth(token),
|
||||
payload: { disabled: true },
|
||||
})
|
||||
expect((await login(app, 'off@test.local', 'secret12')).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('me / reissue / catalog / logout по cookie', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await login(app)
|
||||
const token = (res.json() as { access_token: string }).access_token
|
||||
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect((me.json() as { email: string }).email).toBe('admin@test.local')
|
||||
|
||||
const reissue = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/reissue',
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(reissue.statusCode).toBe(200)
|
||||
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
|
||||
|
||||
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
|
||||
expect(catalog.statusCode).toBe(200)
|
||||
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
|
||||
|
||||
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
|
||||
expect(noAuth.statusCode).toBe(401)
|
||||
|
||||
const logout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/logout',
|
||||
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
|
||||
})
|
||||
expect(logout.statusCode).toBe(200)
|
||||
const revoked = app.sqlite
|
||||
.prepare('SELECT revoked_at FROM refresh_sessions')
|
||||
.get() as { revoked_at: string | null }
|
||||
expect(revoked.revoked_at).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin users', () => {
|
||||
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const payload = {
|
||||
email: 'dup@test.local',
|
||||
name: 'Dup',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
}
|
||||
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||
expect(first.statusCode).toBe(201)
|
||||
const id = (first.json() as { id: string }).id
|
||||
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
|
||||
expect(dup.statusCode).toBe(409)
|
||||
const bad = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: { email: 'nope' },
|
||||
})
|
||||
expect(bad.statusCode).toBe(400)
|
||||
|
||||
const patched = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/users/${id}`,
|
||||
headers: auth(token),
|
||||
payload: { name: 'Renamed', password: 'newsecret12' },
|
||||
})
|
||||
expect(patched.statusCode).toBe(200)
|
||||
expect((patched.json() as { name: string }).name).toBe('Renamed')
|
||||
expect((await login(app, 'dup@test.local', 'newsecret12')).statusCode).toBe(200)
|
||||
expect((await login(app, 'dup@test.local', 'secret12')).statusCode).toBe(401)
|
||||
|
||||
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||
expect(del.statusCode).toBe(204)
|
||||
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
|
||||
expect(gone.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
|
||||
const myId = (me.json() as { id: string }).id
|
||||
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
|
||||
expect(self.statusCode).toBe(400)
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
email: 'plain@test.local',
|
||||
name: 'Plain',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
const userTok = ((await login(app, 'plain@test.local', 'secret12')).json() as { access_token: string }).access_token
|
||||
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
|
||||
expect(forbidden.statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OIDC клиенты и обмен кода', () => {
|
||||
async function setup(a: App) {
|
||||
const token = await adminToken(a)
|
||||
const created = await a.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: auth(token),
|
||||
payload: {
|
||||
name: 'RP',
|
||||
redirect_uris: ['https://rp.test.local/cb'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
expect(created.statusCode).toBe(200)
|
||||
const client = created.json() as { id: string; client_id: string; client_secret: string }
|
||||
const loginRes = await login(a)
|
||||
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
|
||||
return { token, client, refresh }
|
||||
}
|
||||
|
||||
async function authorizeCode(
|
||||
a: App,
|
||||
refresh: string,
|
||||
clientId: string,
|
||||
extra: Record<string, string> = {},
|
||||
): Promise<string> {
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh },
|
||||
query: {
|
||||
client_id: clientId,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile',
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
return new URL(res.headers.location as string).searchParams.get('code')!
|
||||
}
|
||||
|
||||
const exchange = (a: App, body: Record<string, string>) =>
|
||||
a.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
payload: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
...body,
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client, refresh } = await setup(app)
|
||||
const stored = app.sqlite
|
||||
.prepare('SELECT client_secret_hash h FROM oidc_clients')
|
||||
.get() as { h: string }
|
||||
expect(stored.h.startsWith('sha256:')).toBe(true)
|
||||
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
const ok = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
|
||||
|
||||
const replay = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
})
|
||||
expect(replay.statusCode).toBe(400)
|
||||
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
|
||||
})
|
||||
|
||||
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client, refresh } = await setup(app)
|
||||
const verifier = 'v'.repeat(50)
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url')
|
||||
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
|
||||
const creds = { client_id: client.client_id, client_secret: client.client_secret }
|
||||
|
||||
const good = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||
code_verifier: verifier,
|
||||
})
|
||||
expect(good.statusCode).toBe(200)
|
||||
|
||||
const wrong = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||
code_verifier: 'x'.repeat(50),
|
||||
})
|
||||
expect(wrong.statusCode).toBe(400)
|
||||
|
||||
const missing = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, pkce),
|
||||
})
|
||||
expect(missing.statusCode).toBe(400)
|
||||
|
||||
const plain = await exchange(app, {
|
||||
...creds,
|
||||
code: await authorizeCode(app, refresh, client.client_id, {
|
||||
code_challenge: 'plain-challenge',
|
||||
code_challenge_method: 'plain',
|
||||
}),
|
||||
code_verifier: 'plain-challenge',
|
||||
})
|
||||
expect(plain.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client, refresh } = await setup(app)
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
|
||||
const badSecret = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: 'nope',
|
||||
code,
|
||||
})
|
||||
expect(badSecret.statusCode).toBe(401)
|
||||
|
||||
const badRedirect = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://evil.test/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
},
|
||||
})
|
||||
expect(badRedirect.statusCode).toBe(400)
|
||||
|
||||
const badScope = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid admin',
|
||||
},
|
||||
})
|
||||
expect(badScope.statusCode).toBe(302)
|
||||
expect(badScope.headers.location).toContain('error=invalid_scope')
|
||||
|
||||
const ok = await exchange(app, {
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
code,
|
||||
})
|
||||
const accessToken = (ok.json() as { access_token: string }).access_token
|
||||
const info = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/userinfo',
|
||||
headers: auth(accessToken),
|
||||
})
|
||||
expect(info.statusCode).toBe(200)
|
||||
expect((info.json() as { email: string }).email).toBe('admin@test.local')
|
||||
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
|
||||
expect(noToken.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('без сессии authorize редиректит на логин', async () => {
|
||||
app = await buildTestApp()
|
||||
const { client } = await setup(app)
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://rp.test.local/cb',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
},
|
||||
})
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers.location).toContain('return_to=')
|
||||
})
|
||||
|
||||
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
|
||||
app = await buildTestApp()
|
||||
const { refresh } = await setup(app)
|
||||
const secret = generateOidcClientSecret()
|
||||
const legacy = createOidcClient(app.db, {
|
||||
name: 'Legacy',
|
||||
redirectUris: ['https://rp.test.local/cb'],
|
||||
scopes: ['openid', 'profile'],
|
||||
enabled: true,
|
||||
clientSecretHash: await hash(secret),
|
||||
})
|
||||
const code = await authorizeCode(app, refresh, legacy.clientId)
|
||||
const res = await exchange(app, {
|
||||
client_id: legacy.clientId,
|
||||
client_secret: secret,
|
||||
code,
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const row = app.sqlite
|
||||
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
|
||||
.get(legacy.clientId) as { h: string }
|
||||
expect(row.h.startsWith('sha256:')).toBe(true)
|
||||
|
||||
// после миграции секрет всё ещё работает
|
||||
const code2 = await authorizeCode(app, refresh, legacy.clientId)
|
||||
const again = await exchange(app, {
|
||||
client_id: legacy.clientId,
|
||||
client_secret: secret,
|
||||
code: code2,
|
||||
})
|
||||
expect(again.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
|
||||
app = await buildTestApp()
|
||||
const { token, client, refresh } = await setup(app)
|
||||
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
payload: { name: 'Renamed RP' },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
|
||||
|
||||
const rotate = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(rotate.statusCode).toBe(200)
|
||||
const fresh = (rotate.json() as { client_secret: string }).client_secret
|
||||
expect(fresh).not.toBe(client.client_secret)
|
||||
|
||||
const code = await authorizeCode(app, refresh, client.client_id)
|
||||
expect(
|
||||
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
|
||||
).toBe(401)
|
||||
expect(
|
||||
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const del = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(del.statusCode).toBe(200)
|
||||
const gone = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/admin/oidc/clients/${client.id}`,
|
||||
headers: auth(token),
|
||||
})
|
||||
expect(gone.statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
describe('app-switcher API', () => {
|
||||
it('GET /api/v1/app-switcher is public and returns defaults', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
|
||||
expect(body.menuLabel).toBeTruthy()
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cdn',
|
||||
'cfdm',
|
||||
'dns',
|
||||
'fw',
|
||||
'mm',
|
||||
'vps',
|
||||
])
|
||||
expect(body.apps.find((a) => a.id === 'cdn')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'mm')).toMatchObject({
|
||||
authMode: 'jwt',
|
||||
})
|
||||
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
|
||||
authMode: 'oidc',
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
payload: { menuLabel: 'Apps', apps: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const getBefore = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const before = getBefore.json() as {
|
||||
menuLabel: string
|
||||
apps: {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
const updated = {
|
||||
menuLabel: 'Сервисы',
|
||||
apps: before.apps.map((a) =>
|
||||
a.id === 'cfdm'
|
||||
? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' }
|
||||
: a,
|
||||
),
|
||||
}
|
||||
|
||||
const put = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/app-switcher',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: updated,
|
||||
})
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' })
|
||||
|
||||
const getAfter = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-switcher',
|
||||
})
|
||||
const after = getAfter.json() as typeof before
|
||||
expect(after.menuLabel).toBe('Сервисы')
|
||||
expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe(
|
||||
'https://cfdm.example.test',
|
||||
)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
describe('audit log API', () => {
|
||||
it('ingests external events with secret and dedupes by event_id', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
payload: { events: [] },
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const eventId = 'evt-test-1'
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: {
|
||||
events: [
|
||||
{
|
||||
event_id: eventId,
|
||||
source_app: 'vps',
|
||||
action: 'vps.vps.create',
|
||||
summary: 'Создан VPS',
|
||||
actor_email: 'ops@test.local',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
|
||||
|
||||
const dup = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: {
|
||||
events: [
|
||||
{
|
||||
event_id: eventId,
|
||||
source_app: 'vps',
|
||||
action: 'vps.vps.create',
|
||||
summary: 'Создан VPS',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?source_app=vps',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { source_app: string; action: string }[]
|
||||
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login and lists for admin', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string }[]
|
||||
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
|
||||
|
||||
const denied = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('updates retention and purges old rows', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
appendAudit(app.db, {
|
||||
action: 'user.create',
|
||||
summary: 'old event',
|
||||
})
|
||||
// Backdate the last inserted row
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
|
||||
)
|
||||
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
|
||||
|
||||
const settings = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/admin/audit/settings',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { retention_days: 30 },
|
||||
})
|
||||
expect(settings.statusCode).toBe(200)
|
||||
expect(settings.json()).toEqual({ retention_days: 30 })
|
||||
|
||||
const before = listAudit(app.db, { limit: 500 })
|
||||
expect(before.some((e) => e.summary === 'old event')).toBe(true)
|
||||
|
||||
const deleted = purgeAuditOlderThan(app.db, 30)
|
||||
expect(deleted).toBeGreaterThanOrEqual(1)
|
||||
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
|
||||
false,
|
||||
)
|
||||
|
||||
const purge = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/audit/purge',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(purge.statusCode).toBe(200)
|
||||
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records user.create from admin mutation', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
email: 'new@test.local',
|
||||
name: 'New User',
|
||||
password: 'secret12',
|
||||
is_admin: false,
|
||||
apps: ['cfdm'],
|
||||
permissions: [],
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(201)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?action=user.create',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
const entries = list.json() as { action: string; summary: string }[]
|
||||
expect(entries.length).toBeGreaterThanOrEqual(1)
|
||||
expect(entries[0]?.summary).toContain('new@test.local')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records login with IP, UA and last_login_ip', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: {
|
||||
'x-forwarded-for': '203.0.113.10',
|
||||
'user-agent': 'VitestBrowser/1.0',
|
||||
},
|
||||
payload: {
|
||||
email: 'admin@test.local',
|
||||
password: 'adminpass',
|
||||
return_to: 'https://vps.example.test/dashboard',
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; last_login_ip: string | null }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.last_login_ip).toBe('203.0.113.10')
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
ip: string | null
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const loginEvt = entries.find((e) => e.action === 'auth.login')
|
||||
expect(loginEvt?.ip).toBe('203.0.113.10')
|
||||
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
|
||||
expect(loginEvt?.details?.target_app).toBe('vps')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records failed login with email and IP', async () => {
|
||||
const app = await buildTestApp()
|
||||
const fail = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
headers: { 'x-forwarded-for': '198.51.100.7' },
|
||||
payload: { email: 'nobody@test.local', password: 'wrong' },
|
||||
})
|
||||
expect(fail.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const entries = list.json() as {
|
||||
action: string
|
||||
actor_email: string | null
|
||||
ip: string | null
|
||||
}[]
|
||||
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
|
||||
expect(entries[0]?.actor_email).toBe('nobody@test.local')
|
||||
expect(entries[0]?.ip).toBe('198.51.100.7')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records sso-access and separates kind=changes', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sso = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/sso-access',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { return_to: 'https://bgp.example.test/' },
|
||||
})
|
||||
expect(sso.statusCode).toBe(200)
|
||||
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
|
||||
|
||||
const logins = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const loginEntries = logins.json() as { action: string }[]
|
||||
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
|
||||
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
|
||||
|
||||
const changes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=changes',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const changeEntries = changes.json() as { action: string }[]
|
||||
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists and revokes refresh sessions', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
|
||||
const sessions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(sessions.statusCode).toBe(200)
|
||||
const rows = sessions.json() as { id: string; user_id: string }[]
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1)
|
||||
const sessionId = rows[0]!.id
|
||||
|
||||
const revoke = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/sessions/${sessionId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(revoke.statusCode).toBe(200)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/sessions',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
|
||||
).toBe(false)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listBackups } from '@authportal/db'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { adminToken } from './helpers.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const d of dirs.splice(0)) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* Windows может держать файл БД до GC — временный каталог не критичен */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
|
||||
return loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
|
||||
NODE_ENV: 'test',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
describe('backup', () => {
|
||||
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
|
||||
const cfg = env('/data/x')
|
||||
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
|
||||
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
|
||||
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
|
||||
})
|
||||
|
||||
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
|
||||
dirs.push(dir)
|
||||
const config = env(dir, { BACKUP_KEEP: '2' })
|
||||
const app = await buildApp({ config })
|
||||
const res = await runBackup(app.sqlite, config)
|
||||
expect(listBackups(backupDirFor(config))).toHaveLength(1)
|
||||
await app.close()
|
||||
|
||||
const restored = await buildApp({
|
||||
config: env(dir),
|
||||
databaseUrl: `sqlite:${res.file}`,
|
||||
})
|
||||
const token = await adminToken(restored as never)
|
||||
expect(token).toBeTruthy()
|
||||
await restored.close()
|
||||
})
|
||||
|
||||
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
|
||||
vi.useFakeTimers()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
|
||||
dirs.push(dir)
|
||||
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
|
||||
const app = await buildApp({ config })
|
||||
|
||||
const base = vi.getTimerCount()
|
||||
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
|
||||
expect(vi.getTimerCount()).toBe(base)
|
||||
off()
|
||||
|
||||
const stop = startBackupJob(app)
|
||||
expect(vi.getTimerCount()).toBe(base + 1)
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
|
||||
vi.useRealTimers()
|
||||
await vi.waitFor(() =>
|
||||
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
|
||||
)
|
||||
stop()
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Writable } from 'node:stream'
|
||||
import Fastify from 'fastify'
|
||||
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
|
||||
import { buildApp } from '../src/app.js'
|
||||
|
||||
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('значения по умолчанию для новых параметров', () => {
|
||||
const c = loadConfig({ ...base, NODE_ENV: 'test' })
|
||||
expect(c.shutdownTimeoutMs).toBe(8000)
|
||||
expect(c.sessionRetentionDays).toBe(7)
|
||||
expect(c.argon2Concurrency).toBe(2)
|
||||
expect(c.backupKeep).toBe(7)
|
||||
expect(c.backupIntervalHours).toBe(0)
|
||||
expect(c.corsOrigins).toBe('')
|
||||
expect(c.trustProxy).toContain('10.0.0.0/8')
|
||||
})
|
||||
|
||||
it('читает переопределения из env', () => {
|
||||
const c = loadConfig({
|
||||
...base,
|
||||
NODE_ENV: 'test',
|
||||
SHUTDOWN_TIMEOUT_MS: '1500',
|
||||
SESSION_RETENTION_DAYS: '30',
|
||||
ARGON2_CONCURRENCY: '4',
|
||||
BACKUP_KEEP: '3',
|
||||
BACKUP_INTERVAL_HOURS: '12',
|
||||
CORS_ORIGINS: 'https://a.example',
|
||||
TRUST_PROXY: 'false',
|
||||
})
|
||||
expect(c).toMatchObject({
|
||||
shutdownTimeoutMs: 1500,
|
||||
sessionRetentionDays: 30,
|
||||
argon2Concurrency: 4,
|
||||
backupKeep: 3,
|
||||
backupIntervalHours: 12,
|
||||
corsOrigins: 'https://a.example',
|
||||
trustProxy: 'false',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['SHUTDOWN_TIMEOUT_MS', '0'],
|
||||
['ARGON2_CONCURRENCY', '-1'],
|
||||
['BACKUP_KEEP', 'abc'],
|
||||
['BACKUP_INTERVAL_HOURS', '-5'],
|
||||
])('невалидный %s=%s → ошибка', (key, value) => {
|
||||
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('production guard', () => {
|
||||
it.each(['change-me-in-production', 'dev-secret-change-me'])(
|
||||
'известный дефолт JWT_SECRET "%s" отклоняется',
|
||||
(secret) => {
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
|
||||
).toThrow(/JWT_SECRET/)
|
||||
},
|
||||
)
|
||||
|
||||
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
|
||||
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
|
||||
).not.toThrow()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
|
||||
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
|
||||
).not.toThrow()
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
expect(() =>
|
||||
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
|
||||
).not.toThrow()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('пароль первого админа: слабый запрещён только в production', () => {
|
||||
const prod = (pw?: string) =>
|
||||
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
|
||||
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
|
||||
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
|
||||
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
|
||||
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
|
||||
expect(() =>
|
||||
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('buildApp в production без нормального пароля падает при создании админа', async () => {
|
||||
await expect(
|
||||
buildApp({
|
||||
config: loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
...base,
|
||||
ADMIN_PASSWORD: 'admin',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
}),
|
||||
databaseUrl: 'sqlite::memory:',
|
||||
}),
|
||||
).rejects.toThrow(/ADMIN_PASSWORD/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redact в логах', () => {
|
||||
let app: ReturnType<typeof Fastify> | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
it('authorization и cookie не попадают в лог', async () => {
|
||||
const lines: string[] = []
|
||||
const stream = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
lines.push(String(chunk))
|
||||
cb()
|
||||
},
|
||||
})
|
||||
// Тот же набор путей, что и в buildApp.
|
||||
app = Fastify({
|
||||
logger: {
|
||||
level: 'info',
|
||||
stream,
|
||||
redact: {
|
||||
paths: ['req.headers.authorization', 'req.headers.cookie'],
|
||||
censor: '[redacted]',
|
||||
},
|
||||
serializers: {
|
||||
req: (req) => ({ method: req.method, headers: req.headers }),
|
||||
},
|
||||
},
|
||||
})
|
||||
app.get('/x', async () => ({ ok: true }))
|
||||
await app.inject({
|
||||
method: 'GET',
|
||||
url: '/x',
|
||||
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
|
||||
})
|
||||
const joined = lines.join('')
|
||||
expect(joined).toContain('[redacted]')
|
||||
expect(joined).not.toContain('SECRET')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
import { startHousekeepingJob } from '../src/services/housekeeping.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('directory/users', () => {
|
||||
it('отдаёт только id/email/name и ограничивает 50', async () => {
|
||||
app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
for (let i = 0; i < 55; i++) {
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
|
||||
VALUES (?,?,?,?,0,0,'t','t')`,
|
||||
)
|
||||
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
|
||||
}
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/directory/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json() as Array<Record<string, unknown>>
|
||||
expect(body).toHaveLength(50)
|
||||
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
|
||||
expect(res.body).not.toContain('hash')
|
||||
|
||||
const q = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/directory/users?q=U54',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect((q.json() as unknown[]).length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ingest audit', () => {
|
||||
const ev = (i: number) => ({
|
||||
event_id: `ev-${i}`,
|
||||
source_app: 'dns',
|
||||
action: 'dns.test',
|
||||
severity: 'info',
|
||||
summary: 'x',
|
||||
})
|
||||
|
||||
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
|
||||
app = await buildTestApp()
|
||||
const post = (events: unknown[]) =>
|
||||
app!.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/ingest/audit',
|
||||
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
|
||||
payload: { events },
|
||||
})
|
||||
const first = await post([ev(1), ev(2), ev(3)])
|
||||
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
|
||||
const second = await post([ev(3), ev(4)])
|
||||
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('housekeeping job', () => {
|
||||
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
|
||||
vi.useFakeTimers()
|
||||
app = await buildTestApp()
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
|
||||
VALUES ('uh','h@t.io','H','x',0,0,'t','t')`,
|
||||
)
|
||||
.run()
|
||||
app.sqlite
|
||||
.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
|
||||
)
|
||||
.run()
|
||||
const stop = startHousekeepingJob(app)
|
||||
await vi.advanceTimersByTimeAsync(31_000)
|
||||
const left = app.sqlite
|
||||
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
|
||||
.get()
|
||||
expect(left).toEqual({ c: 0 })
|
||||
stop()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../../..')
|
||||
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
|
||||
|
||||
const dockerfile = read('deploy/docker/authportal/Dockerfile')
|
||||
const composes = {
|
||||
'docker-compose.yml': read('docker-compose.yml'),
|
||||
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
|
||||
}
|
||||
|
||||
describe('Dockerfile', () => {
|
||||
it('graceful stop и ограничение ресурсов Node', () => {
|
||||
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
|
||||
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
|
||||
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
|
||||
})
|
||||
|
||||
it('healthcheck без запуска node', () => {
|
||||
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
|
||||
expect(hc).toContain('wget')
|
||||
expect(hc).not.toContain('"node"')
|
||||
})
|
||||
|
||||
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
|
||||
expect(dockerfile).toMatch(/^USER node$/m)
|
||||
const runtime = dockerfile.split('AS runtime')[1]!
|
||||
expect(runtime).not.toMatch(/apk add/)
|
||||
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
|
||||
})
|
||||
|
||||
it('статика сжимается только brotli', () => {
|
||||
expect(dockerfile).toContain('brotli')
|
||||
expect(dockerfile).not.toMatch(/gzip -k/)
|
||||
})
|
||||
|
||||
it('preview-маршруты дизайна вырезаются до сборки web', () => {
|
||||
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
|
||||
const build = dockerfile.indexOf('pnpm turbo build')
|
||||
expect(rm).toBeGreaterThan(0)
|
||||
expect(rm).toBeLessThan(build)
|
||||
})
|
||||
|
||||
it('базовый образ закреплён по digest', () => {
|
||||
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
|
||||
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
|
||||
})
|
||||
|
||||
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
|
||||
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
|
||||
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
|
||||
})
|
||||
|
||||
it('нативные модули проверяются при сборке', () => {
|
||||
expect(dockerfile).toContain('native modules ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe.each(Object.entries(composes))('%s', (_name, text) => {
|
||||
it('init, grace period и лимиты ресурсов', () => {
|
||||
expect(text).toMatch(/init: true/)
|
||||
expect(text).toMatch(/stop_grace_period: 15s/)
|
||||
expect(text).toMatch(/mem_limit: 256m/)
|
||||
expect(text).toMatch(/pids_limit: \d+/)
|
||||
expect(text).toMatch(/read_only: true/)
|
||||
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
|
||||
expect(text).toMatch(/no-new-privileges:true/)
|
||||
})
|
||||
|
||||
it('healthcheck через wget', () => {
|
||||
expect(text).toMatch(/"wget", "-q"/)
|
||||
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('traefik', () => {
|
||||
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
|
||||
const t = composes['deploy/docker-compose.traefik.yml']
|
||||
expect(t).toMatch(/condition: service_healthy/)
|
||||
expect(t).toContain('traefik", "healthcheck", "--ping')
|
||||
expect(t).toContain('--ping=true')
|
||||
})
|
||||
|
||||
it('включено сжатие ответов', () => {
|
||||
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
|
||||
'middlewares.auth-portal-compress.compress=true',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
describe('error handler', () => {
|
||||
it('необработанная ошибка → 500 без деталей', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__boom', async () => {
|
||||
throw new Error('secret internals')
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__boom' })
|
||||
expect(res.statusCode).toBe(500)
|
||||
expect(res.json()).toEqual({
|
||||
error: { code: 'INTERNAL', message: 'Internal server error' },
|
||||
})
|
||||
expect(res.body).not.toContain('secret internals')
|
||||
})
|
||||
|
||||
it('ZodError → 400 BAD_REQUEST', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
|
||||
const res = await app.inject({ method: 'GET', url: '/__zod' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
it('ошибка с statusCode 4xx сохраняет статус', async () => {
|
||||
app = await buildTestApp()
|
||||
app.get('/__teapot', async () => {
|
||||
throw Object.assign(new Error('nope'), { statusCode: 418 })
|
||||
})
|
||||
const res = await app.inject({ method: 'GET', url: '/__teapot' })
|
||||
expect(res.statusCode).toBe(418)
|
||||
})
|
||||
})
|
||||
|
||||
describe('not found', () => {
|
||||
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
|
||||
'%s → 404 JSON',
|
||||
async (url) => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
},
|
||||
)
|
||||
|
||||
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.body).toContain('spa')
|
||||
for (const url of ['/oauth/nope', '/.well-known/nope']) {
|
||||
const res = await app.inject({ method: 'GET', url })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json().error.code).toBe('NOT_FOUND')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect } from 'vitest'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
||||
|
||||
/** Фиксированный тестовый RSA-ключ: importPKCS8 (~мс) вместо generateKeyPair
|
||||
* (~100–300 мс) при построении приложения в каждом тесте. */
|
||||
const TEST_OIDC_RSA_PRIVATE_KEY = [
|
||||
'-----BEGIN PRIVATE KEY-----',
|
||||
'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCt/+Vwi6txs4ET',
|
||||
'/OczfcOX0mVCOfxT3qGejj1xX19A4gKeGGjQ5LSBDGzcztncYMd0J/H1ozUWn0NU',
|
||||
'ctolNRSTFchAYj1MoC2Pacfhe445nLR4bOssdADO9bwhHmWy8WQFyYc/lqs/xPSd',
|
||||
'haZFwDRBRRfh9uxzzUPR8FXCovHEJG5HiLuvmALL1W7nkDg+L+vyLYCNje482hS+',
|
||||
'ZcW3IH17gQpXgpqgXPaGHfZT1teIiYwhelaqPsApMQroOisKYGoQi1z/G/mdvaOF',
|
||||
'/jravyBqhr5Hm03lFOMBvMptGrXsjopy+BVlqBVcvdwdyFfz94dayhnCmfEIvK7W',
|
||||
'ynYGtoLnAgMBAAECggEAKqld04DjOb8FzGVLOKRCwyQ6YIkAFX42CT6d0QDNxYZE',
|
||||
'lwesF1+j5CosPN1YrEBsVs3+dLgd6ZyF8Rwx4UKI92E1VpffqLxzP0Dz2ZSmM/j4',
|
||||
'hLhL1vvgQN4eUM53p+6olASr7QrflQSQ51hgAqUUCtbKesREiEYRNVWkaVLzTSAr',
|
||||
'KrwWAF81hzEms0KuHw6/EgThBkWYGAMzZjECFa173yBHklt1ikdB1AOmieL9PZbB',
|
||||
'anjIequkUE2yoTJM/wOd2GN7sy61PmnMApwl1ke6k4VhrJeQhfahck0D2ynDvWqX',
|
||||
'fnn1Wr/BRmSi38RNEOySWkrCVjM0XSjO3wEuHFFyuQKBgQDwgOgzgL1W5b13LKXo',
|
||||
'8F/AJNRmi4vpM1lftMRalx5NU5ugw1iQ2zJzLG3Tl1/nvyt8tGFkXB7Dw9M9H0as',
|
||||
'jjk7D8ZzL1a7COWQx7wya1l8vMxwv40ERvoWypwpuVo12SvRnHWgGwsbqW4wxYIG',
|
||||
'9HsdvbxglFiVcSq+b6FDdg2nyQKBgQC5NgKatFPPed/DFb6MCkW+5Wnw2HzdKHxD',
|
||||
'fEkAJeb3xX+9k7OifRHppurwaDV4jUPJ5W1Whly77cg+xmEYAbUdPS1kgfYJaDn1',
|
||||
'mPN9dI7RP15v4X46/+5v5EZYAKcdUdMzr9gJ2iQVpoDi5ojp+LJknPLBuo7XTfp3',
|
||||
'kj9lifONLwKBgHa3SudRXyTr1rBjC6qGLN2N0IKl03RTcoR5mIsXGVAWOzYJ90fZ',
|
||||
'y3dqsPPHPwIILZErAQAnMNB1c2oybBJ1Se7LhKwK7Ab7B0aGMXG557xY/ZmSFCvO',
|
||||
'VVi2vosiAVOfVa/sSWu0rbED226br+l6+OLfa4NI9skdyXmjZLkGJwtxAoGBALDp',
|
||||
'LkOnzAZ6W4p00rXheg2GBVPQ9jQp38h2hyRlD+HL/OCQnR2wadPNZMyOPcUQ6peH',
|
||||
'VpE3dozr7EJKK6RKvT1YmmtO2DzUGcjGeI51L5Yf+YzUz0Z8AL1QRwD4Xqha6GqO',
|
||||
'pMWEYMNtb99MZatXqs8AO8Fg+eAe1Agy4euTgdhxAoGBAPAgrLUl7uJMdCsrHFEG',
|
||||
'K8CH3BZwz2ytSj6429IdX4OCzkkmdsWnjx5/FK2hc+fXMluhybaIziSntgs55vzZ',
|
||||
'1hd/Xw1T2FSTS6BuWegxVtjO6BQjbVXrxOumA0PPmFYaGMPvvBQrbZy8OSeuaKD/',
|
||||
'a9jKZApDKnT8vmYvmyMMtsmE',
|
||||
'-----END PRIVATE KEY-----',
|
||||
].join('\n')
|
||||
|
||||
export async function buildTestApp(env: NodeJS.ProcessEnv = {}) {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
|
||||
OIDC_RSA_PRIVATE_KEY: TEST_OIDC_RSA_PRIVATE_KEY,
|
||||
...env,
|
||||
})
|
||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
}
|
||||
|
||||
export async function adminToken(
|
||||
app: Awaited<ReturnType<typeof buildTestApp>>,
|
||||
): Promise<string> {
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
return (login.json() as { access_token: string }).access_token
|
||||
}
|
||||
@@ -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,232 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createOriginChecker } from '../src/lib/cors.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
|
||||
afterEach(async () => {
|
||||
await app?.close()
|
||||
app = undefined
|
||||
})
|
||||
|
||||
function staticDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
|
||||
mkdirSync(join(dir, 'assets'))
|
||||
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
|
||||
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('статика', () => {
|
||||
it('hashed-ассеты immutable, index.html no-cache', async () => {
|
||||
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
|
||||
expect(asset.statusCode).toBe(200)
|
||||
expect(asset.headers['cache-control']).toBe(
|
||||
'public, max-age=31536000, immutable',
|
||||
)
|
||||
const index = await app.inject({ method: 'GET', url: '/index.html' })
|
||||
expect(index.headers['cache-control']).toBe('no-cache')
|
||||
const spa = await app.inject({ method: 'GET', url: '/users/42' })
|
||||
expect(spa.statusCode).toBe(200)
|
||||
expect(spa.headers['cache-control']).toBe('no-cache')
|
||||
})
|
||||
|
||||
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
|
||||
app = await buildTestApp({ STATIC_DIR: staticDir() })
|
||||
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.body).not.toContain('<html>')
|
||||
})
|
||||
|
||||
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
|
||||
const dir = staticDir()
|
||||
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
|
||||
app = await buildTestApp({ STATIC_DIR: dir })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/assets/app-abc123.js',
|
||||
headers: { 'accept-encoding': 'br' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBe('br')
|
||||
expect(res.body).toBe('BR-BYTES')
|
||||
})
|
||||
})
|
||||
|
||||
describe('кэш-заголовки API', () => {
|
||||
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
|
||||
app = await buildTestApp()
|
||||
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
|
||||
expect(sw.headers['cache-control']).toBeUndefined()
|
||||
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
|
||||
})
|
||||
})
|
||||
|
||||
describe('health / ready', () => {
|
||||
it('/health не трогает БД и живёт после закрытия БД', async () => {
|
||||
app = await buildTestApp()
|
||||
app.sqlite.close()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('/ready 200 в норме и 503 при недоступной БД', async () => {
|
||||
app = await buildTestApp()
|
||||
const ok = await app.inject({ method: 'GET', url: '/ready' })
|
||||
expect(ok.statusCode).toBe(200)
|
||||
app.sqlite.close()
|
||||
const bad = await app.inject({ method: 'GET', url: '/ready' })
|
||||
expect(bad.statusCode).toBe(503)
|
||||
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('CORS', () => {
|
||||
const origin = (url: string) => ({ origin: url })
|
||||
|
||||
it('разрешает issuer/allowlist, блокирует чужие', async () => {
|
||||
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
|
||||
const allowed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://auth.test.local'),
|
||||
})
|
||||
expect(allowed.headers['access-control-allow-origin']).toBe(
|
||||
'https://auth.test.local',
|
||||
)
|
||||
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
|
||||
|
||||
const foreign = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://evil.example'),
|
||||
})
|
||||
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
|
||||
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/config',
|
||||
headers: origin('https://extra.example'),
|
||||
})
|
||||
expect(res.headers['access-control-allow-origin']).toBe(
|
||||
'https://extra.example',
|
||||
)
|
||||
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(none.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
|
||||
const list =
|
||||
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
|
||||
const check = createOriginChecker(
|
||||
loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ISSUER: 'https://auth.shnt.top',
|
||||
RETURN_TO_ALLOWLIST: list,
|
||||
}),
|
||||
() => [],
|
||||
)
|
||||
for (const o of [
|
||||
'https://vps.shnt.top',
|
||||
'https://dns.shnt.top',
|
||||
'https://shnt.top',
|
||||
'https://anything.shnt.top',
|
||||
]) {
|
||||
expect(check(o)).toBe(true)
|
||||
}
|
||||
expect(check('https://evil.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('private в allowlist пускает LAN-хосты', () => {
|
||||
const check = createOriginChecker(
|
||||
loadConfig({
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
RETURN_TO_ALLOWLIST: '.shnt.top,private',
|
||||
}),
|
||||
() => [],
|
||||
)
|
||||
expect(check('http://192.168.1.10:8080')).toBe(true)
|
||||
expect(check('http://10.0.0.5')).toBe(true)
|
||||
expect(check('https://8.8.8.8')).toBe(false)
|
||||
})
|
||||
|
||||
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
|
||||
const base = {
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
|
||||
}
|
||||
const check = createOriginChecker(loadConfig(base), () => [
|
||||
'https://dns.custom.io/path',
|
||||
])
|
||||
expect(check('https://cfdm.shnt.top')).toBe(true)
|
||||
expect(check('http://localhost:5173')).toBe(true)
|
||||
expect(check('https://dns.custom.io')).toBe(true)
|
||||
expect(check('https://evilshnt.top')).toBe(false)
|
||||
expect(check('not a url')).toBe(false)
|
||||
const all = createOriginChecker(
|
||||
loadConfig({ ...base, CORS_ORIGINS: '*' }),
|
||||
() => [],
|
||||
)
|
||||
expect(all('https://anything.example')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
|
||||
async function withBigRoute(env: NodeJS.ProcessEnv) {
|
||||
app = await buildTestApp(env)
|
||||
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
|
||||
app.get('/__small', async () => ({ ok: true }))
|
||||
return app
|
||||
}
|
||||
|
||||
it('по умолчанию выключено', async () => {
|
||||
const a = await withBigRoute({})
|
||||
const res = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(res.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
|
||||
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
|
||||
const big = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__big',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(big.headers['content-encoding']).toBe('gzip')
|
||||
expect(big.rawPayload.length).toBeLessThan(500)
|
||||
const small = await a.inject({
|
||||
method: 'GET',
|
||||
url: '/__small',
|
||||
headers: { 'accept-encoding': 'gzip' },
|
||||
})
|
||||
expect(small.headers['content-encoding']).toBeUndefined()
|
||||
const none = await a.inject({ method: 'GET', url: '/__big' })
|
||||
expect(none.headers['content-encoding']).toBeUndefined()
|
||||
})
|
||||
});
|
||||
|
||||
describe('CSP', () => {
|
||||
it('отдаётся жёсткая политика без внешних источников', async () => {
|
||||
app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/health' })
|
||||
const csp = String(res.headers['content-security-policy'])
|
||||
expect(csp).toContain("default-src 'self'")
|
||||
expect(csp).toContain("script-src 'self'")
|
||||
expect(csp).toContain("frame-ancestors 'none'")
|
||||
expect(csp).toContain("object-src 'none'")
|
||||
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
|
||||
expect(csp).not.toContain('http:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { jwtVerify, createLocalJWKSet } from 'jose'
|
||||
import { buildApp } from '../src/app.js'
|
||||
import { loadConfig } from '../src/config.js'
|
||||
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
describe('OIDC IdP', () => {
|
||||
it('serves discovery and JWKS', async () => {
|
||||
const app = await buildTestApp()
|
||||
const discovery = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/openid-configuration',
|
||||
})
|
||||
expect(discovery.statusCode).toBe(200)
|
||||
const meta = discovery.json() as {
|
||||
issuer: string
|
||||
authorization_endpoint: string
|
||||
jwks_uri: string
|
||||
}
|
||||
expect(meta.issuer).toBe('https://auth.test.local')
|
||||
expect(meta.authorization_endpoint).toContain('/oauth/authorize')
|
||||
expect(meta.jwks_uri).toContain('/.well-known/jwks.json')
|
||||
|
||||
const jwks = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks.statusCode).toBe(200)
|
||||
const keys = jwks.json() as { keys: { kid: string; kty: string }[] }
|
||||
expect(keys.keys.length).toBeGreaterThan(0)
|
||||
expect(keys.keys[0]?.kty).toBe('RSA')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('records auth.sso_handoff with target_app dns on authorize', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
||||
expect(refresh?.value).toBeTruthy()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Technitium',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
const client = created.json() as { client_id: string }
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/authorize',
|
||||
cookies: { refresh_token: refresh!.value },
|
||||
query: {
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email groups',
|
||||
state: 'xyz',
|
||||
},
|
||||
})
|
||||
expect(authorize.statusCode).toBe(302)
|
||||
|
||||
const audit = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/audit?kind=logins&limit=50',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(audit.statusCode).toBe(200)
|
||||
const entries = audit.json() as {
|
||||
action: string
|
||||
details: Record<string, unknown> | null
|
||||
}[]
|
||||
const handoff = entries.find(
|
||||
(e) =>
|
||||
e.action === 'auth.sso_handoff' &&
|
||||
e.details?.target_app === 'dns' &&
|
||||
e.details?.auth_mode === 'oidc',
|
||||
)
|
||||
expect(handoff).toBeTruthy()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
|
||||
const dbPath = `sqlite:${join(dir, 'app.db')}`
|
||||
try {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: 'admin@test.local',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: dbPath,
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
const first = await buildApp({ config, databaseUrl: dbPath })
|
||||
const jwks1 = await first.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks1.statusCode).toBe(200)
|
||||
const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid
|
||||
expect(kid).toBeTruthy()
|
||||
await first.close()
|
||||
|
||||
resetOidcKeyCache()
|
||||
const second = await buildApp({ config, databaseUrl: dbPath })
|
||||
const jwks2 = await second.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
expect(jwks2.statusCode).toBe(200)
|
||||
expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe(
|
||||
kid,
|
||||
)
|
||||
await second.close()
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Windows may keep better-sqlite3 handle briefly
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('authorization code flow issues id_token with groups', async () => {
|
||||
const app = await buildTestApp()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
expect(login.statusCode).toBe(200)
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
|
||||
expect(refresh?.value).toBeTruthy()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'Technitium',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
expect(created.statusCode).toBe(200)
|
||||
const client = created.json() as {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid profile email groups',
|
||||
state: 'xyz',
|
||||
nonce: 'n1',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh!.value },
|
||||
})
|
||||
expect(authorize.statusCode).toBe(302)
|
||||
const location = authorize.headers.location!
|
||||
expect(location).toContain('https://dns.test.local/sso/callback')
|
||||
const code = new URL(location).searchParams.get('code')
|
||||
expect(code).toBeTruthy()
|
||||
|
||||
const tokenRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
payload: {
|
||||
grant_type: 'authorization_code',
|
||||
code: code!,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
client_id: client.client_id,
|
||||
client_secret: client.client_secret,
|
||||
},
|
||||
})
|
||||
expect(tokenRes.statusCode).toBe(200)
|
||||
const tokens = tokenRes.json() as {
|
||||
access_token: string
|
||||
id_token: string
|
||||
token_type: string
|
||||
}
|
||||
expect(tokens.token_type).toBe('Bearer')
|
||||
|
||||
const jwksRes = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/.well-known/jwks.json',
|
||||
})
|
||||
const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] })
|
||||
const { payload } = await jwtVerify(tokens.id_token, jwks, {
|
||||
issuer: 'https://auth.test.local',
|
||||
audience: client.client_id,
|
||||
})
|
||||
expect(payload.sub).toBeTruthy()
|
||||
expect(payload.email).toBe('admin@test.local')
|
||||
expect(payload.nonce).toBe('n1')
|
||||
const groups = payload.groups as string[]
|
||||
expect(groups).toContain('technitium_admins')
|
||||
expect(groups).toContain('technitium_dns_admins')
|
||||
|
||||
const userinfo = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/oauth/userinfo',
|
||||
headers: { authorization: `Bearer ${tokens.access_token}` },
|
||||
})
|
||||
expect(userinfo.statusCode).toBe(200)
|
||||
const info = userinfo.json() as { groups: string[]; email: string }
|
||||
expect(info.email).toBe('admin@test.local')
|
||||
expect(info.groups).toContain('technitium_admins')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects invalid client secret and redirect_uri mismatch', async () => {
|
||||
const app = await buildTestApp()
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { email: 'admin@test.local', password: 'adminpass' },
|
||||
})
|
||||
const token = (login.json() as { access_token: string }).access_token
|
||||
const refresh = login.cookies.find((c) => c.name === 'refresh_token')!
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/admin/oidc/clients',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
name: 'DNS',
|
||||
redirect_uris: ['https://dns.test.local/sso/callback'],
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
const client = created.json() as {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
const badRedirect = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://evil.test/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh.value },
|
||||
})
|
||||
expect(badRedirect.statusCode).toBe(400)
|
||||
|
||||
const authorize = await app.inject({
|
||||
method: 'GET',
|
||||
url:
|
||||
'/oauth/authorize?' +
|
||||
new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
}).toString(),
|
||||
cookies: { refresh_token: refresh.value },
|
||||
})
|
||||
const code = new URL(authorize.headers.location!).searchParams.get('code')!
|
||||
|
||||
const badSecret = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
payload: {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: 'https://dns.test.local/sso/callback',
|
||||
client_id: client.client_id,
|
||||
client_secret: 'wrong-secret',
|
||||
},
|
||||
})
|
||||
expect(badSecret.statusCode).toBe(401)
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createDb, closeDb, migrateSchema } from '@authportal/db'
|
||||
import { registerShutdown } from '../src/lib/shutdown.js'
|
||||
import { buildTestApp } from './helpers.js'
|
||||
|
||||
function fakeApp(close: () => Promise<void>) {
|
||||
return {
|
||||
close,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
} as unknown as Parameters<typeof registerShutdown>[0]
|
||||
}
|
||||
|
||||
describe('registerShutdown', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('закрывает приложение один раз и выходит с 0', async () => {
|
||||
const close = vi.fn(async () => {})
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
|
||||
let release!: () => void
|
||||
const close = vi.fn(
|
||||
() => new Promise<void>((r) => (release = r)),
|
||||
)
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(close), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('SIGTERM')
|
||||
proc.emit('SIGINT')
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
release()
|
||||
})
|
||||
|
||||
it('по таймауту принудительно выходит с кодом 1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(() => new Promise<void>(() => {})),
|
||||
{ timeoutMs: 500, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.advanceTimersByTimeAsync(501)
|
||||
expect(exit).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('ошибка close → выход с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(
|
||||
fakeApp(async () => {
|
||||
throw new Error('boom')
|
||||
}),
|
||||
{ timeoutMs: 1000, exit, proc: proc as never },
|
||||
)
|
||||
proc.emit('SIGTERM')
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('unhandledRejection запускает shutdown с кодом 1', async () => {
|
||||
const exit = vi.fn()
|
||||
const proc = new EventEmitter()
|
||||
registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit,
|
||||
proc: proc as never,
|
||||
})
|
||||
proc.emit('unhandledRejection', new Error('x'))
|
||||
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
|
||||
})
|
||||
|
||||
it('dispose снимает обработчики', () => {
|
||||
const proc = new EventEmitter()
|
||||
const h = registerShutdown(fakeApp(async () => {}), {
|
||||
timeoutMs: 1000,
|
||||
exit: vi.fn(),
|
||||
proc: proc as never,
|
||||
})
|
||||
h.dispose()
|
||||
expect(proc.listenerCount('SIGTERM')).toBe(0)
|
||||
expect(proc.listenerCount('unhandledRejection')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeDb', () => {
|
||||
it('идемпотентно закрывает БД', () => {
|
||||
const { sqlite } = createDb('sqlite::memory:')
|
||||
migrateSchema(sqlite)
|
||||
closeDb(sqlite)
|
||||
expect(sqlite.open).toBe(false)
|
||||
expect(() => closeDb(sqlite)).not.toThrow()
|
||||
expect(() => sqlite.prepare('SELECT 1')).toThrow()
|
||||
})
|
||||
|
||||
it('app.close() закрывает БД приложения', async () => {
|
||||
const app = await buildTestApp()
|
||||
await app.ready()
|
||||
expect(app.sqlite.open).toBe(true)
|
||||
await app.close()
|
||||
expect(app.sqlite.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { targetAppFromReturnTo } from '../src/lib/target-app.js'
|
||||
|
||||
describe('targetAppFromReturnTo', () => {
|
||||
it('maps dns host and /sso/ path', () => {
|
||||
expect(targetAppFromReturnTo('https://dns.shnt.top/sso/callback')).toBe(
|
||||
'dns',
|
||||
)
|
||||
})
|
||||
|
||||
it('unwraps portal /oauth/authorize return_to via redirect_uri', () => {
|
||||
const returnTo =
|
||||
'https://auth.shnt.top/oauth/authorize?client_id=abc&redirect_uri=' +
|
||||
encodeURIComponent('https://dns.shnt.top/sso/callback') +
|
||||
'&response_type=code&scope=openid'
|
||||
expect(targetAppFromReturnTo(returnTo)).toBe('dns')
|
||||
})
|
||||
|
||||
it('keeps portal for bare issuer authorize without redirect_uri', () => {
|
||||
expect(
|
||||
targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'),
|
||||
).toBe('portal')
|
||||
})
|
||||
|
||||
it('maps cdn host', () => {
|
||||
expect(targetAppFromReturnTo('https://cdn.shnt.top/auth/callback')).toBe(
|
||||
'cdn',
|
||||
)
|
||||
})
|
||||
|
||||
it('maps mm host', () => {
|
||||
expect(targetAppFromReturnTo('https://mm.shnt.top/auth/callback')).toBe(
|
||||
'mm',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createWebauthnCredential } from '@authportal/db'
|
||||
import { adminToken, buildTestApp } from './helpers.js'
|
||||
|
||||
describe('webauthn / passkeys', () => {
|
||||
it('exposes webauthn flag on auth config', async () => {
|
||||
const app = await buildTestApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ webauthn: true })
|
||||
expect(app.config.webauthnRpID).toBe('auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
|
||||
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('requires JWT for register options', async () => {
|
||||
const app = await buildTestApp()
|
||||
const denied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
})
|
||||
expect(denied.statusCode).toBe(401)
|
||||
|
||||
const token = await adminToken(app)
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/register/options',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(ok.statusCode).toBe(200)
|
||||
const body = ok.json() as {
|
||||
challenge_id: string
|
||||
options: { challenge: string; rp: { id: string } }
|
||||
}
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
expect(body.options.challenge).toBeTruthy()
|
||||
expect(body.options.rp.id).toBe('auth.test.local')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('allows public login options and rejects a bogus assertion', async () => {
|
||||
const app = await buildTestApp()
|
||||
const options = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login/options',
|
||||
})
|
||||
expect(options.statusCode).toBe(200)
|
||||
const body = options.json() as { challenge_id: string; options: unknown }
|
||||
expect(body.challenge_id).toBeTruthy()
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/webauthn/login',
|
||||
payload: {
|
||||
challenge_id: body.challenge_id,
|
||||
response: { id: 'not-a-credential', type: 'public-key' },
|
||||
},
|
||||
})
|
||||
expect(login.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lists, deletes own credentials and reports passkey_count', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'dGVzdC1jcmVkLWlk',
|
||||
publicKey: 'dGVzdC1wdWJrZXk',
|
||||
counter: 0,
|
||||
name: 'Test key',
|
||||
})
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.statusCode).toBe(200)
|
||||
const creds = listed.json() as { id: string; name: string }[]
|
||||
expect(creds).toHaveLength(1)
|
||||
expect(creds[0]?.name).toBe('Test key')
|
||||
|
||||
const users = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/admin/users',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const admin = (
|
||||
users.json() as { email: string; passkey_count: number }[]
|
||||
).find((u) => u.email === 'admin@test.local')
|
||||
expect(admin?.passkey_count).toBe(1)
|
||||
|
||||
const renamed = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { name: 'Laptop' },
|
||||
})
|
||||
expect(renamed.statusCode).toBe(200)
|
||||
expect((renamed.json() as { name: string }).name).toBe('Laptop')
|
||||
|
||||
const deleted = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(deleted.statusCode).toBe(200)
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(empty.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lets admin reset a user passkeys', async () => {
|
||||
const app = await buildTestApp()
|
||||
const token = await adminToken(app)
|
||||
const me = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
const userId = (me.json() as { id: string }).id
|
||||
createWebauthnCredential(app.db, {
|
||||
userId,
|
||||
credentialId: 'cmVzZXQta2V5',
|
||||
publicKey: 'cHVia2V5',
|
||||
counter: 1,
|
||||
name: 'To reset',
|
||||
})
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/admin/users/${userId}/passkeys`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
|
||||
|
||||
const listed = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/webauthn/credentials',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(listed.json()).toEqual([])
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
// Нативные модули остаются внешними (ставятся в образ отдельно),
|
||||
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
|
||||
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/server.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node22',
|
||||
external: NATIVE,
|
||||
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
|
||||
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
|
||||
banner: {
|
||||
js: [
|
||||
"import { createRequire as __cr } from 'node:module';",
|
||||
"import { fileURLToPath as __fu } from 'node:url';",
|
||||
"import { dirname as __dn } from 'node:path';",
|
||||
'const require = __cr(import.meta.url);',
|
||||
'const __filename = __fu(import.meta.url);',
|
||||
'const __dirname = __dn(__filename);',
|
||||
].join('\n'),
|
||||
},
|
||||
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
|
||||
splitting: false,
|
||||
clean: true,
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
include: ['src/**/*.ts'],
|
||||
reporter: ['text-summary', 'lcov'],
|
||||
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
|
||||
thresholds: { lines: 75 },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user