179 lines
7.7 KiB
TypeScript
179 lines
7.7 KiB
TypeScript
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(),
|
||
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,
|
||
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(/\/$/, '')
|
||
}
|