diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5021bda..d3d639e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,8 @@ import { createDb, getAppSwitcherConfig, getUserApps, + hasAnyUser, + listAdmins, healthCheck, migrateSchema, setUserAccess, @@ -26,7 +28,11 @@ import { } from '@authportal/db' import { hashPassword, setPasswordConcurrency } from './lib/password.js' import { randomUUID } from 'node:crypto' -import { parseTrustProxy, type AppConfig } from './config.js' +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' @@ -56,9 +62,9 @@ declare module 'fastify' { } } -async function ensureBootstrapAdmin(app: FastifyInstance): Promise { - const existing = app.db.select().from(users).all() - if (existing.length === 0) { +export async function ensureBootstrapAdmin(app: FastifyInstance): Promise { + if (!hasAnyUser(app.db)) { + assertBootstrapPassword(app.config) const now = new Date().toISOString() const passwordHash = await hashPassword(app.config.adminPassword) const id = randomUUID() @@ -82,8 +88,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise { } // Ensure existing admins have app tiles if access was never set - for (const user of existing) { - if (!user.isAdmin) continue + 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()) @@ -109,7 +114,18 @@ export async function buildApp(opts: { migrateSchema(sqlite) const app = Fastify({ - logger: { level: config.logLevel }, + 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, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 8c1e879..37b33da 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -58,6 +58,33 @@ export const configSchema = z.object({ export type AppConfig = z.infer +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) || config.jwtSecret.length < 16) { + throw new Error( + 'JWT_SECRET: в production нужен уникальный секрет длиной не менее 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') @@ -67,7 +94,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const issuer = env.ISSUER ?? 'https://auth.shnt.top' const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd) - return configSchema.parse({ + const config = configSchema.parse({ databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db', jwtSecret, jwtTtlHours: env.JWT_TTL_HOURS ?? 1, @@ -99,6 +126,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal', webauthnOrigins: origins, }) + assertProductionSecrets(config) + return config } function webauthnFromIssuer( diff --git a/apps/api/test/config.test.ts b/apps/api/test/config.test.ts new file mode 100644 index 0000000..aaaf7c5 --- /dev/null +++ b/apps/api/test/config.test.ts @@ -0,0 +1,138 @@ +import { afterEach, describe, expect, it } 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', 'short-secret'])( + 'слабый JWT_SECRET "%s" отклоняется', + (secret) => { + expect(() => + loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }), + ).toThrow(/JWT_SECRET/) + }, + ) + + it('нормальный секрет в production принимается; в test слабый разрешён', () => { + expect(() => + loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }), + ).not.toThrow() + expect(() => + loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }), + ).not.toThrow() + }) + + 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 | 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') + }) +}) diff --git a/docker-compose.yml b/docker-compose.yml index 2da4970..8af57ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: - "8080:8080" environment: DATABASE_URL: sqlite:/data/app.db - JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env (min 16 chars)} JWT_TTL_HOURS: ${JWT_TTL_HOURS:-1} REFRESH_TTL_DAYS: ${REFRESH_TTL_DAYS:-14} ISSUER: ${ISSUER:-https://auth.shnt.top} @@ -15,7 +15,7 @@ services: # Опционально: PKCS8 PEM RSA. Иначе ключ генерируется и хранится в SQLite (/data). OIDC_RSA_PRIVATE_KEY: ${OIDC_RSA_PRIVATE_KEY:-} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@shnt.top} - ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env (min 8 chars)} ADMIN_NAME: ${ADMIN_NAME:-Admin} RETURN_TO_ALLOWLIST: ${RETURN_TO_ALLOWLIST:-.shnt.top,localhost,private} LOG_LEVEL: info diff --git a/packages/db/src/users.ts b/packages/db/src/users.ts index 8778bd9..463df1f 100644 --- a/packages/db/src/users.ts +++ b/packages/db/src/users.ts @@ -20,6 +20,15 @@ export function listUsers(db: AppDb): UserRow[] { return db.select().from(users).all() } +/** Есть ли хотя бы один пользователь (LIMIT 1 вместо загрузки всей таблицы). */ +export function hasAnyUser(db: AppDb): boolean { + return db.select({ id: users.id }).from(users).limit(1).get() !== undefined +} + +export function listAdmins(db: AppDb): UserRow[] { + return db.select().from(users).where(eq(users.isAdmin, true)).all() +} + export function getUserById(db: AppDb, id: string): UserRow | undefined { return db.select().from(users).where(eq(users.id, id)).get() }