feat(auth): интегрировать SSO auth-portal
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>
This commit is contained in:
Denozordec
2026-09-04 20:38:28 +07:00
co-authored by Cursor
parent 0208aa4d7c
commit 0e9349e508
27 changed files with 1368 additions and 129 deletions
+7
View File
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
+5 -1
View File
@@ -10,10 +10,12 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,6 +23,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.1.0",
"pino-pretty": "^13.1.3",
"undici": "^8.1.0",
"zod": "^4.4.1"
@@ -29,6 +32,7 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
+46 -2
View File
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback
return v === "1" || v.toLowerCase() === "true"
}
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
AUTH_JWT_SECRET: z.string().default(""),
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
const parsed = envSchema.safeParse(process.env)
const raw = {
DATABASE_PATH: process.env.DATABASE_PATH,
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
AUTH_JWT_SECRET:
process.env.AUTH_JWT_SECRET?.trim() ||
process.env.JWT_SECRET?.trim() ||
(isProd ? "" : "dev-secret-change-me"),
AUTH_ISSUER:
process.env.AUTH_ISSUER?.trim() ||
process.env.ISSUER?.trim() ||
"https://auth.shnt.top",
AUTH_PORTAL_URL: (
process.env.AUTH_PORTAL_URL ??
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
}
const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
process.exit(1)
}
export const env = {
DATABASE_PATH: parsed.data.DATABASE_PATH,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
authIssuer: parsed.data.AUTH_ISSUER,
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
}
+97 -62
View File
@@ -1,11 +1,12 @@
import Fastify from "fastify"
import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
@@ -24,71 +25,105 @@ import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
export async function buildApp(opts?: {
logger?: boolean
startScheduler?: boolean
}): Promise<FastifyInstance> {
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
? false
: {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
},
})
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
await app.register(authPlugin)
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/api/auth/config", async () => ({
required: env.authRequired,
portal_url: env.authPortalUrl,
issuer: env.authIssuer,
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
}
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
// ── start ──────────────────────────────────────────────────────────────────────
if (opts?.startScheduler !== false) {
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
return app
}
const isMain =
process.argv[1] &&
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
if (isMain) {
try {
const app = await buildApp()
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
console.error(err)
process.exit(1)
}
}
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForRequest } from "./permissions.js"
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
assert.equal(
permissionForRequest("GET", "/api/servers"),
"mm:servers:read",
)
assert.equal(
permissionForRequest("POST", "/api/servers"),
"mm:servers:write",
)
assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
)
console.log("permissions.test.ts: ok")
+173
View File
@@ -0,0 +1,173 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: mm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin?: boolean
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
type Rule = {
methods: string[]
match: (path: string) => boolean
permission: string
}
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/system") ||
p.startsWith("/api/scheduler") ||
p.startsWith("/api/evobgp"),
permission: "mm:settings:admin",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec"),
permission: "mm:network:write",
},
]
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
if (rule.match(pathname)) return rule.permission
}
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
return null
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Smoke: AUTH_REQUIRED gate via Fastify inject.
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
*/
import assert from "node:assert/strict"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
process.env.CORS_ORIGIN = "http://localhost:3000"
process.env.DATABASE_PATH = ":memory:"
process.env.NODE_ENV = "test"
// Dynamic import after env is set
const { buildApp } = await import("../index.js")
const secret = new TextEncoder().encode("test-secret-at-least-8")
async function mint(payload: Record<string, unknown>): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local")
.setExpirationTime("1h")
.sign(secret)
}
const app = await buildApp({ logger: false, startScheduler: false })
const health = await app.inject({ method: "GET", url: "/health" })
assert.equal(health.statusCode, 200)
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
assert.equal(cfg.statusCode, 200)
assert.equal(cfg.json().required, true)
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
assert.equal(noToken.statusCode, 401)
const badApp = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["cdn"],
permissions: ["cdn:dashboard:read"],
})
const forbiddenApp = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${badApp}` },
})
assert.equal(forbiddenApp.statusCode, 403)
const okToken = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["mm"],
permissions: ["mm:dashboard:read"],
})
const ok = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${okToken}` },
})
// May be 200 or 500 if DB missing — must not be 401/403
assert.notEqual(ok.statusCode, 401)
assert.notEqual(ok.statusCode, 403)
await app.close()
console.log("auth.smoke.test.ts: ok")
+119
View File
@@ -0,0 +1,119 @@
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" })