Files
EvoFirewall/apps/api/src/plugins/auth.ts
T
Denozordec 38f7a8296e
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m45s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
feat(api): add uninstall script and enhance install script functionality
- Introduced an uninstall script for agents, allowing users to easily remove the agent with a single command.
- Updated `install.sh` to quote configuration values for safety, ensuring compatibility with names containing spaces.
- Enhanced the installation process to include a warning if the uninstall script cannot be downloaded.
- Updated documentation to reflect the new uninstall functionality and changes in configuration file handling.
2026-07-21 22:53:28 +07:00

222 lines
5.9 KiB
TypeScript

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<void | FastifyReply>
}
}
export default fp(authPlugin, { name: 'auth' })
export { hashToken }