267 lines
8.7 KiB
TypeScript
267 lines
8.7 KiB
TypeScript
import type { FastifyError, FastifyInstance } from 'fastify'
|
|
import Fastify from 'fastify'
|
|
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 { 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']
|
|
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)
|
|
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),
|
|
)
|
|
await app.register(formbody)
|
|
await app.register(cors, {
|
|
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
|
|
credentials: true,
|
|
})
|
|
await app.register(helmet, { contentSecurityPolicy: false })
|
|
await app.register(rateLimit, {
|
|
max: 200,
|
|
timeWindow: '1 minute',
|
|
// пробы Docker/Traefik не должны упираться в лимит
|
|
allowList: (req) => req.url === '/health' || req.url === '/ready',
|
|
})
|
|
await app.register(fjwt, { secret: config.jwtSecret })
|
|
|
|
// 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
|
|
}
|