fix(api): защита секретов в production, redact логов, bootstrap без полного скана users
This commit is contained in:
1 parent
8cba7a5c41
commit
22dd6f9937
5 files changed
+202
-10
No files matched your search
+23
-7
@@ -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<void> {
|
||||
const existing = app.db.select().from(users).all()
|
||||
if (existing.length === 0) {
|
||||
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()
|
||||
@@ -82,8 +88,7 @@ async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
+30
-1
@@ -58,6 +58,33 @@ export const configSchema = z.object({
|
||||
|
||||
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) || 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(
|
||||
|
||||
Reference in new issue
Block a user