feat(auth): implement portal SSO and local admin authentication
Added support for portal SSO with JWT authentication and local admin login. Updated environment configuration to include AUTH_REQUIRED, AUTH_JWT_SECRET, AUTH_ISSUER, and AUTH_PORTAL_URL. Enhanced the auth plugin to handle JWT verification based on the new configuration. Introduced new routes for authentication and updated the API client to manage token handling and redirects. Improved user experience by integrating authentication checks across various routes and components.
This commit is contained in:
+24
-1
@@ -15,13 +15,28 @@ export interface AppConfig {
|
||||
healthDownFailures: number;
|
||||
healthLatencyWarnMs: number;
|
||||
logLevel: string;
|
||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||
authRequired: boolean;
|
||||
authIssuer: string;
|
||||
authPortalUrl: string;
|
||||
}
|
||||
|
||||
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||
if (v === undefined || v === "") return fallback;
|
||||
return v === "1" || v.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const jwtSecret =
|
||||
process.env.AUTH_JWT_SECRET ??
|
||||
process.env.JWT_SECRET ??
|
||||
(isProd ? "" : "dev-secret-change-me");
|
||||
|
||||
return {
|
||||
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
||||
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
||||
jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me",
|
||||
jwtSecret: jwtSecret || "dev-secret-change-me",
|
||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
||||
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
||||
adminPasswordHash:
|
||||
@@ -38,5 +53,13 @@ export function loadConfig(): AppConfig {
|
||||
healthLatencyWarnMs:
|
||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer:
|
||||
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
||||
authPortalUrl: (
|
||||
process.env.AUTH_PORTAL_URL ??
|
||||
process.env.VITE_AUTH_PORTAL_URL ??
|
||||
"http://localhost:5175"
|
||||
).replace(/\/$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ export class AppError extends Error {
|
||||
return new AppError("UNAUTHORIZED", "unauthorized", 401);
|
||||
}
|
||||
|
||||
static forbidden() {
|
||||
return new AppError("FORBIDDEN", "forbidden", 403);
|
||||
static forbidden(message = "forbidden") {
|
||||
return new AppError("FORBIDDEN", message, 403);
|
||||
}
|
||||
|
||||
static conflict(message: string) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||
* Format: cfdm:<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"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/domains") ||
|
||||
p.startsWith("/api/v1/domain-monitors") ||
|
||||
p === "/api/v1/domain-monitors",
|
||||
permission: "cfdm:domains:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/domains") ||
|
||||
p.startsWith("/api/v1/domain-monitors"),
|
||||
permission: "cfdm:domains:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
||||
permission: "cfdm:dns:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
||||
permission: "cfdm:dns:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) => p.startsWith("/api/v1/certificates"),
|
||||
permission: "cfdm:certificates:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) => p.startsWith("/api/v1/certificates"),
|
||||
permission: "cfdm:certificates:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/groups") ||
|
||||
p.startsWith("/api/v1/service-groups"),
|
||||
permission: "cfdm:groups:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/groups") ||
|
||||
p.startsWith("/api/v1/service-groups"),
|
||||
permission: "cfdm:groups:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/services") ||
|
||||
p.startsWith("/api/v1/service-bindings"),
|
||||
permission: "cfdm:services:read",
|
||||
},
|
||||
{
|
||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/services") ||
|
||||
p.startsWith("/api/v1/service-bindings"),
|
||||
permission: "cfdm:services:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET", "POST"],
|
||||
match: (p) => p.startsWith("/api/v1/sync"),
|
||||
permission: "cfdm:domains:write",
|
||||
},
|
||||
{
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
match: (p) =>
|
||||
p.startsWith("/api/v1/settings") ||
|
||||
p.startsWith("/api/v1/notifications") ||
|
||||
p.startsWith("/api/v1/health-check") ||
|
||||
p.startsWith("/api/v1/health-checks"),
|
||||
permission: "cfdm:settings:admin",
|
||||
},
|
||||
];
|
||||
|
||||
/** 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;
|
||||
}
|
||||
// Default: any authenticated cfdm user for unmatched /api/v1/*
|
||||
if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read";
|
||||
return null;
|
||||
}
|
||||
@@ -1,28 +1,126 @@
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import fp from "fastify-plugin";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { AppError } from "../errors.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,
|
||||
opts: { config: AppConfig },
|
||||
) {
|
||||
const { config } = opts;
|
||||
|
||||
if (config.authRequired && (!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: opts.config.jwtSecret,
|
||||
secret: config.jwtSecret,
|
||||
...(config.authRequired
|
||||
? {
|
||||
verify: {
|
||||
allowedIss: [config.authIssuer],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
if (config.authRequired) {
|
||||
app.log.info(
|
||||
{ issuer: config.authIssuer, portal: config.authPortalUrl },
|
||||
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||
);
|
||||
} else {
|
||||
app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth");
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(request: FastifyRequest): Promise<void> {
|
||||
/**
|
||||
* Protect /api/v1 routes.
|
||||
* - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).
|
||||
* - AUTH_REQUIRED=true: portal JWT with apps.includes('cfdm') + permissions.
|
||||
*/
|
||||
export async function requireAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const config = request.server.config;
|
||||
const authHeader = request.headers.authorization ?? "";
|
||||
const token = authHeader.startsWith("Bearer ")
|
||||
? authHeader.slice(7)
|
||||
: "";
|
||||
if (!token) throw AppError.unauthorized();
|
||||
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
throw AppError.unauthorized();
|
||||
}
|
||||
|
||||
if (!config.authRequired) {
|
||||
return;
|
||||
}
|
||||
|
||||
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("cfdm")) {
|
||||
throw AppError.forbidden("Нет доступа к приложению Cloudflare Domain Manager");
|
||||
}
|
||||
|
||||
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, request.url);
|
||||
if (required && !hasPermission(permissions, required)) {
|
||||
throw AppError.forbidden(`Недостаточно прав: ${required}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(authPlugin, { name: "auth" });
|
||||
|
||||
@@ -32,6 +32,14 @@ export async function healthRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.get("/auth/config", async (request) => {
|
||||
const { config } = request.server;
|
||||
return {
|
||||
required: config.authRequired,
|
||||
portal_url: config.authPortalUrl,
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/settings/app-switcher", async (request) => {
|
||||
return getAppSwitcher(request.server.db);
|
||||
});
|
||||
@@ -42,6 +50,14 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post("/auth/login", async (request, reply) => {
|
||||
if (request.server.config.authRequired) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
message: "Локальный вход отключён — используйте auth-portal",
|
||||
},
|
||||
});
|
||||
}
|
||||
const body = loginSchema.parse(request.body);
|
||||
const result = await authService.login(
|
||||
request.server.config,
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { hasPermission, permissionForRequest } from "../src/lib/permissions.js";
|
||||
|
||||
describe("permissions helpers", () => {
|
||||
it("hasPermission respects admin ⊃ write ⊃ read", () => {
|
||||
expect(hasPermission(["cfdm:domains:write"], "cfdm:domains:read")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasPermission(["cfdm:domains:admin"], "cfdm:domains:write")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(hasPermission(["cfdm:domains:read"], "cfdm:domains:write")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("permissionForRequest maps domains and settings", () => {
|
||||
expect(permissionForRequest("GET", "/api/v1/domains")).toBe(
|
||||
"cfdm:domains:read",
|
||||
);
|
||||
expect(permissionForRequest("POST", "/api/v1/domains")).toBe(
|
||||
"cfdm:domains:write",
|
||||
);
|
||||
expect(permissionForRequest("GET", "/api/v1/settings")).toBe(
|
||||
"cfdm:settings:admin",
|
||||
);
|
||||
expect(permissionForRequest("POST", "/api/v1/sync/foo")).toBe(
|
||||
"cfdm:domains:write",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth plugin (AUTH_REQUIRED)", () => {
|
||||
const secret = "test-secret-at-least-8";
|
||||
const issuer = "https://auth.shnt.top";
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.AUTH_REQUIRED = "true";
|
||||
process.env.AUTH_JWT_SECRET = secret;
|
||||
process.env.AUTH_ISSUER = issuer;
|
||||
process.env.AUTH_PORTAL_URL = "http://localhost:5175";
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.AUTH_REQUIRED;
|
||||
delete process.env.AUTH_JWT_SECRET;
|
||||
delete process.env.AUTH_ISSUER;
|
||||
delete process.env.AUTH_PORTAL_URL;
|
||||
});
|
||||
|
||||
it("GET /api/v1/auth/config exposes portal settings", async () => {
|
||||
const app = await buildApp({
|
||||
config: {
|
||||
...loadConfig(),
|
||||
authRequired: true,
|
||||
jwtSecret: secret,
|
||||
authIssuer: issuer,
|
||||
authPortalUrl: "http://localhost:5175",
|
||||
staticDir: null,
|
||||
},
|
||||
memory: true,
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: "GET", url: "/api/v1/auth/config" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({
|
||||
required: true,
|
||||
portal_url: "http://localhost:5175",
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("401 without token; 403 without cfdm app; 200 with rights", async () => {
|
||||
const app = await buildApp({
|
||||
config: {
|
||||
...loadConfig(),
|
||||
authRequired: true,
|
||||
jwtSecret: secret,
|
||||
authIssuer: issuer,
|
||||
authPortalUrl: "http://localhost:5175",
|
||||
staticDir: null,
|
||||
},
|
||||
memory: true,
|
||||
});
|
||||
|
||||
const noAuth = await app.inject({ method: "GET", url: "/api/v1/domains" });
|
||||
expect(noAuth.statusCode).toBe(401);
|
||||
|
||||
const tokenNoApp = app.jwt.sign(
|
||||
{
|
||||
sub: "u1",
|
||||
email: "[email protected]",
|
||||
name: "A",
|
||||
apps: ["vps"],
|
||||
permissions: ["cfdm:domains:read"],
|
||||
iss: issuer,
|
||||
},
|
||||
{ expiresIn: "1h" },
|
||||
);
|
||||
const forbiddenApp = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/domains",
|
||||
headers: { authorization: `Bearer ${tokenNoApp}` },
|
||||
});
|
||||
expect(forbiddenApp.statusCode).toBe(403);
|
||||
|
||||
const okToken = app.jwt.sign(
|
||||
{
|
||||
sub: "u2",
|
||||
email: "[email protected]",
|
||||
name: "R",
|
||||
apps: ["cfdm"],
|
||||
permissions: ["cfdm:domains:read"],
|
||||
iss: issuer,
|
||||
},
|
||||
{ expiresIn: "1h" },
|
||||
);
|
||||
const okRead = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/domains",
|
||||
headers: { authorization: `Bearer ${okToken}` },
|
||||
});
|
||||
expect(okRead.statusCode).toBe(200);
|
||||
|
||||
const denyWrite = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/domains",
|
||||
headers: { authorization: `Bearer ${okToken}` },
|
||||
payload: { name: "x" },
|
||||
});
|
||||
expect(denyWrite.statusCode).toBe(403);
|
||||
|
||||
const loginBlocked = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: "admin", password: "admin" },
|
||||
});
|
||||
expect(loginBlocked.statusCode).toBe(403);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user