feat(auth): implement portal SSO and local admin authentication
Build and Push CFDM Docker Image / build-and-push (push) Successful in 1m57s
Build and Push CFDM Docker Image / create-release (push) Skipped
Build and Push CFDM Docker Image / update-wiki (push) Successful in 6s

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:
Denozordec
2026-07-18 18:25:29 +07:00
parent 60e15ca40a
commit 6a6cb34eeb
22 changed files with 1101 additions and 97 deletions
+24 -1
View File
@@ -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(/\/$/, ""),
};
}
+2 -2
View File
@@ -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) {
+137
View File
@@ -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;
}
+101 -3
View File
@@ -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" });
+16
View File
@@ -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,