Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m52s
Docker images / frontend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose. Co-authored-by: Cursor <cursoragent@cursor.com>
120 lines
2.9 KiB
TypeScript
120 lines
2.9 KiB
TypeScript
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
|
|
import fp from "fastify-plugin"
|
|
import { env } from "../config.js"
|
|
import {
|
|
hasPermission,
|
|
permissionForRequest,
|
|
type AuthUser,
|
|
} from "../lib/permissions.js"
|
|
|
|
declare module "fastify" {
|
|
interface FastifyRequest {
|
|
authUser?: AuthUser
|
|
}
|
|
}
|
|
|
|
declare module "@fastify/jwt" {
|
|
interface FastifyJWT {
|
|
payload: {
|
|
sub: string
|
|
email?: string
|
|
name?: string
|
|
apps?: string[]
|
|
permissions?: string[]
|
|
is_admin?: boolean
|
|
iss?: string
|
|
exp?: number
|
|
}
|
|
user: {
|
|
sub: string
|
|
email?: string
|
|
name?: string
|
|
apps?: string[]
|
|
permissions?: string[]
|
|
is_admin?: boolean
|
|
iss?: string
|
|
exp?: number
|
|
}
|
|
}
|
|
}
|
|
|
|
async function authPlugin(app: FastifyInstance) {
|
|
if (env.authRequired && env.jwtSecret.length < 8) {
|
|
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
|
|
}
|
|
|
|
await app.register(import("@fastify/jwt"), {
|
|
secret: env.jwtSecret,
|
|
...(env.authRequired
|
|
? {
|
|
verify: {
|
|
allowedIss: [env.authIssuer],
|
|
},
|
|
}
|
|
: {}),
|
|
})
|
|
|
|
if (env.authRequired) {
|
|
app.log.info(
|
|
{ issuer: env.authIssuer, portal: env.authPortalUrl },
|
|
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
|
)
|
|
} else {
|
|
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Protect /api/* when AUTH_REQUIRED=true.
|
|
* Public: /health, /api/auth/config
|
|
*/
|
|
export async function requireAuth(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
): Promise<void> {
|
|
if (!env.authRequired) return
|
|
|
|
const pathname = (request.url.split("?")[0] ?? request.url)
|
|
if (pathname === "/api/auth/config") return
|
|
|
|
const authHeader = request.headers.authorization ?? ""
|
|
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
|
|
if (!token) {
|
|
return reply.code(401).send({ error: "Unauthorized" })
|
|
}
|
|
|
|
try {
|
|
await request.jwtVerify()
|
|
} catch {
|
|
return reply.code(401).send({ error: "Unauthorized" })
|
|
}
|
|
|
|
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("mm")) {
|
|
return reply
|
|
.code(403)
|
|
.send({ error: "Нет доступа к приложению MikrotikManager" })
|
|
}
|
|
|
|
request.authUser = {
|
|
id: String(payload.sub),
|
|
email: String(payload.email ?? ""),
|
|
name: String(payload.name ?? ""),
|
|
apps,
|
|
permissions,
|
|
isAdmin: Boolean(payload.is_admin),
|
|
}
|
|
|
|
const required = permissionForRequest(request.method, pathname)
|
|
if (required && !hasPermission(permissions, required)) {
|
|
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
|
|
}
|
|
}
|
|
|
|
export default fp(authPlugin, { name: "auth" })
|