From 0e9349e508cf7a2e8ebd90e0b940ca47d7a49568 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 4 Sep 2026 20:38:28 +0700 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20SSO=20auth-p?= =?UTF-8?q?ortal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose. Co-authored-by: Cursor --- .gitea/workflows/docker.yml | 1 + Dockerfile.frontend | 2 + README.md | 2 + app/(main)/layout.tsx | 37 ++-- app/access-denied/page.tsx | 19 ++ app/auth/callback/page.tsx | 98 +++++++++ backend/.env.example | 7 + backend/package.json | 6 +- backend/src/config.ts | 48 ++++- backend/src/index.ts | 159 +++++++++------ backend/src/lib/permissions.test.ts | 25 +++ backend/src/lib/permissions.ts | 173 ++++++++++++++++ backend/src/plugins/auth.smoke.test.ts | 72 +++++++ backend/src/plugins/auth.ts | 119 +++++++++++ components/auth-guard.tsx | 51 +++++ components/nav-user.tsx | 45 ++++- deploy/docker-compose.cdn-mm.yml | 4 + deploy/docker-compose.traefik-cdn.yml | 4 + deploy/docker-compose.traefik.yml | 4 + deploy/env.cdn-mm.example | 6 +- deploy/env.traefik.example | 6 + deploy/run-beside-cdn-traefik.sh | 4 + docs/integrate-auth-portal.md | 60 ++++++ hooks/use-app-switcher.ts | 65 ++++-- lib/auth.ts | 264 +++++++++++++++++++++++++ package-lock.json | 190 ++++++++++++++++-- shared/api/http-client.ts | 26 ++- 27 files changed, 1368 insertions(+), 129 deletions(-) create mode 100644 app/access-denied/page.tsx create mode 100644 app/auth/callback/page.tsx create mode 100644 backend/src/lib/permissions.test.ts create mode 100644 backend/src/lib/permissions.ts create mode 100644 backend/src/plugins/auth.smoke.test.ts create mode 100644 backend/src/plugins/auth.ts create mode 100644 components/auth-guard.tsx create mode 100644 docs/integrate-auth-portal.md create mode 100644 lib/auth.ts diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 104e75b..2dbbaa2 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -177,6 +177,7 @@ jobs: NEXT_PUBLIC_BACKEND_URL=same-origin NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live NEXT_PUBLIC_ALLOW_MOCK_DATA=false + NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }} NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }} tags: | diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 4de37b8..9869b16 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false ARG NEXT_PUBLIC_APP_VERSION=dev ARG NEXT_PUBLIC_RELEASE_URL= +ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL +ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL COPY packages/contracts packages/contracts COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./ COPY app app diff --git a/README.md b/README.md index a4d080f..7e5c9b5 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,8 @@ sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-t DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`. +SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`). + Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня). ### Прод-контейнеры diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index d6e09ba..adcc72c 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -1,5 +1,6 @@ import type { CSSProperties, ReactNode } from "react" import { AppSidebar } from "@/components/app-sidebar" +import { AuthGuard } from "@/components/auth-guard" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { CommandPalette } from "@/components/command-palette" import { ReleaseNotesModal } from "@/components/release-notes-modal" @@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS = export default function MainLayout({ children }: { children: ReactNode }) { return ( - - - - - К содержимому - - - - {children} - - - - - - + + + + + + К содержимому + + + + {children} + + + + + + + ) } diff --git a/app/access-denied/page.tsx b/app/access-denied/page.tsx new file mode 100644 index 0000000..9f84d83 --- /dev/null +++ b/app/access-denied/page.tsx @@ -0,0 +1,19 @@ +"use client" + +export default function AccessDeniedPage() { + return ( +
+

Нет доступа

+

+ У вашей учётной записи нет приложения MikrotikManager (`mm`) или + необходимых прав. Обратитесь к администратору auth-portal. +

+ + Войти снова + +
+ ) +} diff --git a/app/auth/callback/page.tsx b/app/auth/callback/page.tsx new file mode 100644 index 0000000..2e339e1 --- /dev/null +++ b/app/auth/callback/page.tsx @@ -0,0 +1,98 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { + clearPortalHandoffFlag, + clearToken, + ensureAuthConfig, + firstAllowedPath, + getClaims, + getToken, + parseHashToken, + redirectToPortalLogin, + redirectToPortalLoginInteractive, + setToken, +} from "@/lib/auth" + +export default function AuthCallbackPage() { + const router = useRouter() + const [message, setMessage] = useState("Перенаправление на Auth Portal…") + + useEffect(() => { + let cancelled = false + + void (async () => { + await ensureAuthConfig() + if (cancelled) return + + const params = new URLSearchParams(window.location.search) + const error = params.get("error") + if (error === "sso_loop" || error === "jwt_rejected") { + redirectToPortalLoginInteractive() + return + } + + const { accessToken } = parseHashToken(window.location.hash) + if (accessToken) { + setToken(accessToken) + clearPortalHandoffFlag() + const claims = getClaims() + if (!claims) { + clearToken() + redirectToPortalLoginInteractive() + return + } + if (!claims.apps.includes("mm")) { + setMessage("Нет доступа к приложению") + router.replace("/access-denied") + return + } + + try { + const res = await fetch("/api/auth/config", { + headers: { Authorization: `Bearer ${accessToken}` }, + }) + if (res.status === 401) { + clearToken() + redirectToPortalLoginInteractive() + return + } + } catch { + /* ignore network — proceed */ + } + + const next = firstAllowedPath() + if (next === "/access-denied") { + router.replace("/access-denied") + return + } + router.replace(next) + return + } + + if (getToken() && getClaims()) { + clearPortalHandoffFlag() + if (!getClaims()!.apps.includes("mm")) { + router.replace("/access-denied") + return + } + router.replace(firstAllowedPath()) + return + } + + const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`) + if (!ok) redirectToPortalLoginInteractive() + })() + + return () => { + cancelled = true + } + }, [router]) + + return ( +
+ {message} +
+ ) +} diff --git a/backend/.env.example b/backend/.env.example index 0208d8a..7a4baa9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json index ea6f30a..915ed09 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } diff --git a/backend/src/config.ts b/backend/src/config.ts index 0d51755..dc0fac3 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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, +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 79e174a..db12c5d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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 { + 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) + } } diff --git a/backend/src/lib/permissions.test.ts b/backend/src/lib/permissions.test.ts new file mode 100644 index 0000000..c1bfb4a --- /dev/null +++ b/backend/src/lib/permissions.test.ts @@ -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") diff --git a/backend/src/lib/permissions.ts b/backend/src/lib/permissions.ts new file mode 100644 index 0000000..9d6bb6a --- /dev/null +++ b/backend/src/lib/permissions.ts @@ -0,0 +1,173 @@ +/** + * Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission). + * Format: mm:
: + */ + +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 +} diff --git a/backend/src/plugins/auth.smoke.test.ts b/backend/src/plugins/auth.smoke.test.ts new file mode 100644 index 0000000..b73d208 --- /dev/null +++ b/backend/src/plugins/auth.smoke.test.ts @@ -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): Promise { + 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") diff --git a/backend/src/plugins/auth.ts b/backend/src/plugins/auth.ts new file mode 100644 index 0000000..c4bd639 --- /dev/null +++ b/backend/src/plugins/auth.ts @@ -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 { + 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" }) diff --git a/components/auth-guard.tsx b/components/auth-guard.tsx new file mode 100644 index 0000000..f728982 --- /dev/null +++ b/components/auth-guard.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useEffect, useState, type ReactNode } from "react" +import { + ensureAuthConfig, + getClaims, + getToken, + isAuthEnabled, + redirectToPortalLogin, + redirectToPortalLoginInteractive, +} from "@/lib/auth" + +export function AuthGuard({ children }: { children: ReactNode }) { + const [ready, setReady] = useState(false) + + useEffect(() => { + let cancelled = false + void (async () => { + await ensureAuthConfig() + if (cancelled) return + if (!isAuthEnabled()) { + setReady(true) + return + } + const claims = getClaims() + if (!getToken() || !claims) { + const ok = redirectToPortalLogin() + if (!ok) redirectToPortalLoginInteractive() + return + } + if (!claims.apps.includes("mm")) { + window.location.assign("/access-denied") + return + } + setReady(true) + })() + return () => { + cancelled = true + } + }, []) + + if (!ready) { + return ( +
+ Проверка сессии… +
+ ) + } + + return children +} diff --git a/components/nav-user.tsx b/components/nav-user.tsx index 9bad623..c18dc27 100644 --- a/components/nav-user.tsx +++ b/components/nav-user.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { useTheme } from "@/components/theme-provider" import { ChevronsUpDownIcon, + LogOutIcon, MonitorIcon, MoonIcon, PaletteIcon, @@ -13,6 +14,12 @@ import { } from "lucide-react" import { cn } from "@/lib/utils" +import { + ensureAuthConfig, + getClaims, + isAuthEnabled, + redirectToPortalLogout, +} from "@/lib/auth" import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { @@ -96,11 +103,37 @@ function ThemeSegmentedToggle() { ) } +function initials(name: string, email: string): string { + const base = (name || email || "?").trim() + const parts = base.split(/\s+/).filter(Boolean) + if (parts.length >= 2) { + return (parts[0]![0]! + parts[1]![0]!).toUpperCase() + } + return base.slice(0, 2).toUpperCase() +} + export function NavUser() { const { isMobile } = useSidebar() - const name = "Оператор" - const email = "локальный доступ" - const fallback = "ОП" + const [name, setName] = useState("Оператор") + const [email, setEmail] = useState("локальный доступ") + const [showLogout, setShowLogout] = useState(false) + + useEffect(() => { + void ensureAuthConfig().then(() => { + const claims = getClaims() + if (claims) { + setName(claims.name || claims.email || "Пользователь") + setEmail(claims.email || "") + setShowLogout(isAuthEnabled()) + } else if (isAuthEnabled()) { + setName("Сессия") + setEmail("требуется вход") + setShowLogout(true) + } + }) + }, []) + + const fallback = initials(name, email) return ( @@ -155,6 +188,12 @@ export function NavUser() { + {showLogout ? ( + redirectToPortalLogout()}> + + Выйти + + ) : null} diff --git a/deploy/docker-compose.cdn-mm.yml b/deploy/docker-compose.cdn-mm.yml index 425abc0..54585b5 100644 --- a/deploy/docker-compose.cdn-mm.yml +++ b/deploy/docker-compose.cdn-mm.yml @@ -130,6 +130,10 @@ services: PORT: "8000" DATABASE_PATH: /app/data/mikrotik.db CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env} + AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} + AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} volumes: - ./data/mm:/app/data networks: diff --git a/deploy/docker-compose.traefik-cdn.yml b/deploy/docker-compose.traefik-cdn.yml index 1dccd40..1ca1407 100644 --- a/deploy/docker-compose.traefik-cdn.yml +++ b/deploy/docker-compose.traefik-cdn.yml @@ -29,6 +29,10 @@ services: PORT: "8000" DATABASE_PATH: /app/data/mikrotik.db CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env} + AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} + AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} volumes: - ./data:/app/data networks: diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml index ead2267..6e44daa 100644 --- a/deploy/docker-compose.traefik.yml +++ b/deploy/docker-compose.traefik.yml @@ -67,6 +67,10 @@ services: PORT: "8000" DATABASE_PATH: /app/data/mikrotik.db CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env} + AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} + AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} volumes: - ./data:/app/data networks: diff --git a/deploy/env.cdn-mm.example b/deploy/env.cdn-mm.example index 19ba98c..2863a56 100644 --- a/deploy/env.cdn-mm.example +++ b/deploy/env.cdn-mm.example @@ -29,7 +29,7 @@ CLOUDFLARE_API_TOKEN= LOG_LEVEL=info NODE_ENV=production -# Portal SSO (prod default AUTH_REQUIRED=true) +# Portal SSO — used by CDN Manager and MikrotikManager backend AUTH_REQUIRED=true # Same HS256 secret as auth-portal JWT_SECRET (required) AUTH_JWT_SECRET= @@ -37,10 +37,10 @@ AUTH_JWT_SECRET= JWT_SECRET= AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=https://auth.shnt.top -# Shared with auth-portal AUDIT_INGEST_SECRET (optional) +# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager) AUTH_AUDIT_INGEST_SECRET= -# Legacy local admin — only when AUTH_REQUIRED=false +# Legacy local admin (CDN) — only when AUTH_REQUIRED=false ADMIN_USERNAME=admin ADMIN_PASSWORD_HASH= diff --git a/deploy/env.traefik.example b/deploy/env.traefik.example index 857fe3f..fff87f9 100644 --- a/deploy/env.traefik.example +++ b/deploy/env.traefik.example @@ -9,6 +9,12 @@ MM_DOMAIN=mm.shnt.top # Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://). CORS_ORIGIN=https://mm.shnt.top +# --- Portal SSO (MM backend) --- +AUTH_REQUIRED=true +AUTH_JWT_SECRET= +AUTH_ISSUER=https://auth.shnt.top +AUTH_PORTAL_URL=https://auth.shnt.top + # --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) --- # Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script). CF_DNS_API_TOKEN= diff --git a/deploy/run-beside-cdn-traefik.sh b/deploy/run-beside-cdn-traefik.sh index ec6e26b..7e7a9cb 100644 --- a/deploy/run-beside-cdn-traefik.sh +++ b/deploy/run-beside-cdn-traefik.sh @@ -109,6 +109,10 @@ docker run -d \ -e PORT=8000 \ -e DATABASE_PATH=/app/data/mikrotik.db \ -e "CORS_ORIGIN=${CORS_ORIGIN}" \ + -e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \ + -e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \ + -e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \ + -e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \ -v "${MM_ROOT}/data:/app/data" \ --label mmapp.updater.managed=true \ --label mmapp.updater.target=backend \ diff --git a/docs/integrate-auth-portal.md b/docs/integrate-auth-portal.md new file mode 100644 index 0000000..47dc6f1 --- /dev/null +++ b/docs/integrate-auth-portal.md @@ -0,0 +1,60 @@ +# Интеграция auth-portal ↔ MikrotikManager + +App id: **`mm`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-mikrotikmanager.md`](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-mikrotikmanager.md). + +## Flow + +``` +Browser → MikrotikManager UI (нет token) + → redirect AUTH_PORTAL_URL/?return_to=…/auth/callback + → login + → redirect return_to#access_token=… + → /auth/callback сохраняет token (localStorage: mmapp_token) + → API Authorization: Bearer +``` + +## Permissions + +| Permission | UI / API | +|------------|----------| +| `mm:dashboard:read` | `/dashboard`, sidebar-counts | +| `mm:servers:read` / `write` | `/servers` | +| `mm:filters:read` / `write` | filters / GRE | +| `mm:bgp:read` / `write` | BGP | +| `mm:uptime:read` / `write` | `/uptime` | +| `mm:traffic:read` / `write` | `/traffic` | +| `mm:alerts:read` / `write` | `/alerts` | +| `mm:backups:read` / `write` | `/backups` | +| `mm:certificates:read` / `write` | certificates | +| `mm:network:read` / `write` | network / OSPF | +| `mm:settings:admin` | `/settings`, system DB, scheduler | + +## Env + +```env +# backend +AUTH_REQUIRED=true +AUTH_JWT_SECRET=<тот же JWT_SECRET портала> +AUTH_ISSUER=https://auth.shnt.top +AUTH_PORTAL_URL=https://auth.shnt.top +CORS_ORIGIN=https://mm.shnt.top +``` + +```env +# frontend build (Docker) +NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top +``` + +UI читает `GET /api/auth/config` (через Next rewrite) для `required` / `portal_url`. + +В portal Admin → Apps выдайте app `mm` и нужные `mm:*`. URL в App Switcher: `https://mm.shnt.top`. + +## Checklist + +1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER` +2. Origin MM в `RETURN_TO_ALLOWLIST` портала +3. Пользователю выдан app `mm` +4. `AUTH_REQUIRED=true` на backend +5. Logout → `{AUTH_PORTAL_URL}/logout` + +Prod: [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) или standalone Traefik compose. diff --git a/hooks/use-app-switcher.ts b/hooks/use-app-switcher.ts index 52c95c4..6e0af5b 100644 --- a/hooks/use-app-switcher.ts +++ b/hooks/use-app-switcher.ts @@ -8,6 +8,21 @@ import { parseAppSwitcherConfig, type AppSwitcherConfig, } from "@/lib/app-switcher-config" +import { + ensureAuthConfig, + getAuthConfigSync, + getClaims, +} from "@/lib/auth" + +function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig { + const claims = getClaims() + if (!claims?.apps?.length) return config + const allowed = new Set(claims.apps) + const apps = config.apps.filter( + (a) => a.id === "mm" || allowed.has(a.id), + ) + return { ...config, apps: apps.length > 0 ? apps : config.apps } +} export function useAppSwitcherConfig(): { config: AppSwitcherConfig @@ -16,31 +31,43 @@ export function useAppSwitcherConfig(): { const [config, setConfig] = useState(() => mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG), ) - const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl())) + const [isLoading, setIsLoading] = useState(true) useEffect(() => { - const base = authPortalBaseUrl() - if (!base) { - setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)) - setIsLoading(false) - return - } - let cancelled = false - setIsLoading(true) - fetch(`${base}/api/v1/app-switcher`) - .then((res) => (res.ok ? res.json() : Promise.reject())) - .then((raw: unknown) => { + + void (async () => { + await ensureAuthConfig() + if (cancelled) return + + const portal = + getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null + if (!portal) { + setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))) + setIsLoading(false) + return + } + + setIsLoading(true) + try { + const res = await fetch(`${portal}/api/v1/app-switcher`) + if (!res.ok) throw new Error("switcher fetch failed") + const raw: unknown = await res.json() if (cancelled) return const parsed = parseAppSwitcherConfig(raw) - setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)) - }) - .catch(() => { - if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)) - }) - .finally(() => { + setConfig( + filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)), + ) + } catch { + if (!cancelled) { + setConfig( + filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)), + ) + } + } finally { if (!cancelled) setIsLoading(false) - }) + } + })() return () => { cancelled = true diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..be34622 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,264 @@ +/** Portal JWT storage + claims helpers for MikrotikManager. */ + +const TOKEN_KEY = "mmapp_token" +const HANDOFF_KEY = "mmapp_auth_401_handoff" +const HANDOFF_AT_KEY = "mmapp_portal_handoff_at" +const HANDOFF_COOLDOWN_MS = 12_000 + +export type AccessClaims = { + sub: string + email: string + name: string + apps: string[] + permissions: string[] + is_admin?: boolean + iss?: string + exp?: number +} + +export type RuntimeAuthConfig = { + required: boolean + portalUrl: string +} + +let runtimeConfig: RuntimeAuthConfig | null = null +let runtimeConfigPromise: Promise | null = null + +function envPortalUrl(): string { + return ( + process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175" + ).replace(/\/$/, "") +} + +function envAuthEnabled(): boolean { + const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase() + return v === "true" || v === "1" +} + +/** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */ +export async function ensureAuthConfig(): Promise { + if (runtimeConfig) return runtimeConfig + if (runtimeConfigPromise) return runtimeConfigPromise + + runtimeConfigPromise = (async () => { + try { + const res = await fetch("/api/auth/config") + if (res.ok) { + const data = (await res.json()) as { + required?: boolean + portal_url?: string + } + runtimeConfig = { + required: Boolean(data.required) || envAuthEnabled(), + portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""), + } + return runtimeConfig + } + } catch { + /* use env defaults */ + } + runtimeConfig = { + required: envAuthEnabled(), + portalUrl: envPortalUrl(), + } + return runtimeConfig + })().finally(() => { + runtimeConfigPromise = null + }) + + return runtimeConfigPromise +} + +export function getAuthConfigSync(): RuntimeAuthConfig | null { + return runtimeConfig +} + +export function getToken(): string | null { + if (typeof window === "undefined") return null + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY) +} + +export function isAuthEnabled(): boolean { + if (runtimeConfig) return runtimeConfig.required + return envAuthEnabled() +} + +export function authPortalUrl(): string { + if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl + return envPortalUrl() +} + +export function isPortalHandoffCoolingDown(): boolean { + if (typeof window === "undefined") return false + const raw = sessionStorage.getItem(HANDOFF_AT_KEY) + if (!raw) return false + const at = Number(raw) + if (!Number.isFinite(at)) return false + return Date.now() - at < HANDOFF_COOLDOWN_MS +} + +export function markPortalHandoff(): void { + sessionStorage.setItem(HANDOFF_KEY, "1") + sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now())) +} + +export function clearPortalHandoffFlag(): void { + sessionStorage.removeItem(HANDOFF_KEY) +} + +export function resetPortalHandoff(): void { + sessionStorage.removeItem(HANDOFF_KEY) + sessionStorage.removeItem(HANDOFF_AT_KEY) +} + +export function redirectToPortalLogin(returnTo?: string): boolean { + if (isPortalHandoffCoolingDown()) { + clearToken() + return false + } + markPortalHandoff() + const callback = returnTo ?? `${window.location.origin}/auth/callback` + const url = new URL(authPortalUrl()) + url.searchParams.set("return_to", callback) + window.location.assign(url.toString()) + return true +} + +export function redirectToPortalLoginInteractive(): void { + clearToken() + resetPortalHandoff() + window.location.assign(authPortalUrl()) +} + +export function redirectToPortalLogout(): void { + clearToken() + resetPortalHandoff() + window.location.assign(`${authPortalUrl()}/logout`) +} + +export function parseHashToken(hash: string): { + accessToken: string | null + expiresAt: string | null +} { + const raw = hash.startsWith("#") ? hash.slice(1) : hash + const params = new URLSearchParams(raw) + return { + accessToken: params.get("access_token"), + expiresAt: params.get("expires_at"), + } +} + +export function decodeClaims(token: string): AccessClaims | null { + try { + const parts = token.split(".") + if (parts.length < 2) return null + const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/")) + const payload = JSON.parse(json) as Record + return { + sub: String(payload.sub ?? ""), + email: String(payload.email ?? ""), + name: String(payload.name ?? ""), + apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [], + permissions: Array.isArray(payload.permissions) + ? payload.permissions.map(String) + : [], + is_admin: Boolean(payload.is_admin), + iss: payload.iss ? String(payload.iss) : undefined, + exp: typeof payload.exp === "number" ? payload.exp : undefined, + } + } catch { + return null + } +} + +export function getClaims(): AccessClaims | null { + const token = getToken() + if (!token) return null + const claims = decodeClaims(token) + if (!claims) return null + if (claims.exp && claims.exp * 1000 < Date.now()) { + clearToken() + resetPortalHandoff() + return null + } + return claims +} + +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 +} + +export function can(required: string): boolean { + if (!isAuthEnabled()) return true + const claims = getClaims() + if (!claims) return false + if (!claims.apps.includes("mm")) return false + return hasPermission(claims.permissions, required) +} + +export function permissionForPath(pathname: string): string | null { + if (pathname === "/" || pathname.startsWith("/dashboard")) { + return "mm:dashboard:read" + } + if (pathname.startsWith("/servers")) return "mm:servers:read" + if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) { + return "mm:filters:read" + } + if (pathname.startsWith("/bgp")) return "mm:bgp:read" + if (pathname.startsWith("/uptime")) return "mm:uptime:read" + if (pathname.startsWith("/traffic")) return "mm:traffic:read" + if (pathname.startsWith("/alerts")) return "mm:alerts:read" + if (pathname.startsWith("/backups")) return "mm:backups:read" + if (pathname.startsWith("/certificates")) return "mm:certificates:read" + if ( + pathname.startsWith("/network") || + pathname.startsWith("/ospf") || + pathname.startsWith("/route-optimizer") + ) { + return "mm:network:read" + } + if (pathname.startsWith("/settings")) return "mm:settings:admin" + return "mm:dashboard:read" +} + +export function firstAllowedPath(): string { + const candidates = [ + "/dashboard", + "/servers", + "/filters", + "/uptime", + "/traffic", + "/alerts", + "/backups", + "/settings", + ] + for (const path of candidates) { + const perm = permissionForPath(path) + if (!perm || can(perm)) return path + } + return "/access-denied" +} diff --git a/package-lock.json b/package-lock.json index fdd72ab..65ca490 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "version": "1.0.0", "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", @@ -56,6 +57,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" @@ -64,6 +66,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" } @@ -2018,6 +2021,45 @@ ], "license": "MIT" }, + "node_modules/@fastify/jwt": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz", + "integrity": "sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.2.4", + "fastify-plugin": "^6.0.0", + "steed": "^1.1.3" + } + }, + "node_modules/@fastify/jwt/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/merge-json-schemas": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", @@ -2814,6 +2856,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@mmapp/contracts": { "resolved": "packages/contracts", "link": true @@ -5077,6 +5128,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", @@ -5275,6 +5338,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -6281,6 +6350,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/eciesjs": { "version": "0.4.18", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", @@ -7256,6 +7334,22 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/fast-jwt": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.3.tgz", + "integrity": "sha512-pQDXx7IHeZT4jSmpE9o80RrBqfrG4fPrl8anazSM5vErIdK1iCc13z/EWX+H0j7liWSRnwTpHswIKMeLYGAckw==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0", + "safe-regex2": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -7318,6 +7412,18 @@ "fast-string-width": "^3.0.2" } }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fastify": { "version": "5.8.5", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", @@ -7379,6 +7485,16 @@ "node": ">=10" } }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7388,6 +7504,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -8839,9 +8965,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.11.tgz", + "integrity": "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -9596,6 +9722,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -9624,6 +9756,15 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mnemonist": { + "version": "0.40.4", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz", + "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10103,6 +10244,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -11792,6 +11939,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -13458,6 +13618,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -13634,21 +13803,6 @@ "dependencies": { "zod": "^4.4.1" } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz", - "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } } } } diff --git a/shared/api/http-client.ts b/shared/api/http-client.ts index 90a45a5..48b0a25 100644 --- a/shared/api/http-client.ts +++ b/shared/api/http-client.ts @@ -1,4 +1,10 @@ import { configuredBackendUrl } from "@/lib/backend-url" +import { + getToken, + isAuthEnabled, + redirectToPortalLogin, + redirectToPortalLoginInteractive, +} from "@/lib/auth" export class ApiClientError extends Error { constructor( @@ -28,14 +34,26 @@ export async function requestJson( init?: RequestInit, ): Promise { const hasBody = init?.body != null + const headers = new Headers(init?.headers) + if (hasBody && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json") + } + const token = typeof window !== "undefined" ? getToken() : null + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`) + } + const res = await fetch(resolveRequestUrl(baseUrl, path), { ...init, - headers: { - ...(hasBody ? { "Content-Type": "application/json" } : {}), - ...(init?.headers ?? {}), - }, + headers, }) + if (res.status === 401 && typeof window !== "undefined" && isAuthEnabled()) { + const ok = redirectToPortalLogin() + if (!ok) redirectToPortalLoginInteractive() + throw new ApiClientError("Unauthorized", 401) + } + if (res.status === 204) return undefined as T const payload = await res.json().catch(() => undefined)