import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import fp from 'fastify-plugin' import { hasPermission, permissionForRequest, type AuthUser, } from '@evofw/shared' import type { AppConfig } from '../config.js' import { createHash } from 'node:crypto' import { repos } from '@evofw/db' declare module 'fastify' { interface FastifyRequest { authUser?: AuthUser agentId?: string } } declare module '@fastify/jwt' { interface FastifyJWT { payload: { sub: string email?: string name?: string apps?: string[] permissions?: string[] is_admin?: boolean iss?: string } user: { sub: string email?: string name?: string apps?: string[] permissions?: string[] is_admin?: boolean iss?: string } } } function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex') } function isPublicPath(url: string): boolean { const path = url.split('?')[0] ?? url if (path === '/health' || path === '/ready') return true if (path === '/api/auth/config' || path === '/api/v1/auth/config') return true if (path.startsWith('/v1/agent/enroll')) return true if (path.startsWith('/v1/agent/install')) return true if (path.startsWith('/v1/agent/sync-script')) return true if (path.startsWith('/v1/agent/uninstall')) return true if (path.startsWith('/v1/agent/mikrotik')) return true return false } function isAgentPath(url: string): boolean { const path = url.split('?')[0] ?? url return ( path === '/v1/agent/policy' || path === '/v1/agent/policy.rsc' || path === '/v1/agent/apply-report' || path === '/v1/agent/heartbeat' ) } async function authPlugin( app: FastifyInstance, opts: { config: AppConfig }, ) { const { config } = opts app.get('/api/auth/config', async () => ({ required: config.authRequired, portal_url: config.authPortalUrl, })) app.get('/api/v1/auth/config', async () => ({ required: config.authRequired, portal_url: config.authPortalUrl, })) if (config.authRequired) { if (!config.jwtSecret || config.jwtSecret.length < 8) { throw new Error( 'AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true', ) } await app.register(import('@fastify/jwt'), { secret: config.jwtSecret, verify: { allowedIss: [config.authIssuer] }, }) } else { await app.register(import('@fastify/jwt'), { secret: config.jwtSecret || 'dev-secret-change-me', }) } app.decorate( 'requireAuth', async (request: FastifyRequest, reply: FastifyReply) => { if (!config.authRequired) { request.authUser = { id: 'dev', email: 'dev@local', name: 'Dev', apps: ['fw'], permissions: [ 'fw:dashboard:read', 'fw:agents:write', 'fw:lists:write', 'fw:policies:write', 'fw:stats:read', 'fw:settings:admin', 'fw:audit:read', ], isAdmin: true, } return } try { await request.jwtVerify() } catch { return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' }, }) } const payload = request.user const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [] const permissions = Array.isArray(payload.permissions) ? payload.permissions.map(String) : [] if (!apps.includes('fw') && !payload.is_admin) { return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа к приложению EvoFirewall', }, }) } request.authUser = { id: String(payload.sub), email: String(payload.email ?? ''), name: String(payload.name ?? ''), apps, permissions: payload.is_admin ? [ 'fw:dashboard:read', 'fw:agents:write', 'fw:lists:write', 'fw:policies:write', 'fw:stats:read', 'fw:settings:admin', 'fw:audit:read', ] : permissions, isAdmin: Boolean(payload.is_admin), } const required = permissionForRequest(request.method, request.url) if ( required && !request.authUser.isAdmin && !hasPermission(request.authUser.permissions, required) ) { return reply.code(403).send({ error: { code: 'FORBIDDEN', message: `Недостаточно прав: ${required}`, }, }) } }, ) app.addHook('onRequest', async (request, reply) => { if (isPublicPath(request.url)) return if (isAgentPath(request.url)) { const auth = request.headers.authorization if (!auth?.startsWith('Bearer ')) { return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Agent token required' }, }) } const token = auth.slice('Bearer '.length).trim() const agent = repos.getAgentByTokenHash(app.db, hashToken(token)) if (!agent || agent.status !== 'approved') { return reply.code(agent?.status === 'pending' ? 403 : 401).send({ error: { code: agent?.status === 'pending' ? 'FORBIDDEN' : 'UNAUTHORIZED', message: agent?.status === 'pending' ? 'Agent pending approval' : 'Invalid agent token', }, }) } request.agentId = agent.id return } }) } declare module 'fastify' { interface FastifyInstance { requireAuth: ( request: FastifyRequest, reply: FastifyReply, ) => Promise } } export default fp(authPlugin, { name: 'auth' }) export { hashToken }