275 lines
8.7 KiB
TypeScript
275 lines
8.7 KiB
TypeScript
import { cookies } from "next/headers";
|
|
import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config";
|
|
import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions";
|
|
import {
|
|
chunkedCookieNames,
|
|
parseSessionCookieValue,
|
|
readChunkedCookieValue,
|
|
type AuthSession,
|
|
type AuthUser
|
|
} from "@/lib/auth/session";
|
|
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
|
import { loadPlatformAuthorizationSnapshot } from "@/lib/server/auth/platform-authorization-store";
|
|
import { authorizePlatformSession } from "@/lib/server/auth/platform-session";
|
|
|
|
export class AuthRequiredError extends Error {
|
|
status = 401;
|
|
|
|
constructor(message = "请先登录。") {
|
|
super(message);
|
|
this.name = "AuthRequiredError";
|
|
}
|
|
}
|
|
|
|
export class AuthConfigurationError extends Error {
|
|
status = 503;
|
|
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "AuthConfigurationError";
|
|
}
|
|
}
|
|
|
|
const localUser: AuthUser = {
|
|
id: DEFAULT_OWNER_ID,
|
|
subject: DEFAULT_OWNER_ID,
|
|
username: "13800000000",
|
|
phone: "13800000000",
|
|
displayName: "智念用户",
|
|
clientId: "local-dev",
|
|
role: "super_admin",
|
|
organizationId: "org-demo",
|
|
organizationName: "演示组织",
|
|
authorities: ["zhinian_admin"],
|
|
scope: []
|
|
};
|
|
|
|
function localSession(): AuthSession {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return {
|
|
version: 1,
|
|
authMode: "admin",
|
|
user: localUser,
|
|
issuedAt: now,
|
|
expiresAt: now + 24 * 60 * 60
|
|
};
|
|
}
|
|
|
|
type PlatformAuthUser = AuthUser & {
|
|
role: NonNullable<AuthUser["role"]>;
|
|
status: "active";
|
|
};
|
|
|
|
type GoCurrentSessionResponse = {
|
|
authRequired: boolean;
|
|
authConfigured: boolean;
|
|
} & (
|
|
| { authenticated: false; authMode: null; user: null }
|
|
| { authenticated: true; authMode: AuthSession["authMode"]; user: PlatformAuthUser }
|
|
);
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isStringArray(value: unknown): value is string[] {
|
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
}
|
|
|
|
function parsePlatformAuthUser(value: unknown): PlatformAuthUser | null {
|
|
if (!isRecord(value)) return null;
|
|
const role = value.role;
|
|
if (
|
|
(role !== "user" && role !== "organization_admin" && role !== "super_admin") ||
|
|
value.status !== "active" ||
|
|
typeof value.id !== "string" ||
|
|
!value.id ||
|
|
value.subject !== value.id ||
|
|
typeof value.displayName !== "string" ||
|
|
typeof value.clientId !== "string" ||
|
|
!isStringArray(value.authorities) ||
|
|
!isStringArray(value.scope)
|
|
) {
|
|
return null;
|
|
}
|
|
const organizationId = typeof value.organizationId === "string" && value.organizationId.trim()
|
|
? value.organizationId
|
|
: undefined;
|
|
if (role !== "super_admin" && !organizationId) return null;
|
|
|
|
const user: PlatformAuthUser = {
|
|
id: value.id,
|
|
subject: value.id,
|
|
displayName: value.displayName,
|
|
clientId: value.clientId,
|
|
role,
|
|
status: "active",
|
|
authorities: [...value.authorities],
|
|
scope: [...value.scope]
|
|
};
|
|
if (typeof value.username === "string") user.username = value.username;
|
|
if (typeof value.phone === "string") user.phone = value.phone;
|
|
if (typeof value.tenantId === "string") user.tenantId = value.tenantId;
|
|
if (organizationId) user.organizationId = organizationId;
|
|
if (typeof value.organizationName === "string") user.organizationName = value.organizationName;
|
|
return user;
|
|
}
|
|
|
|
function parseGoCurrentSessionResponse(value: unknown): GoCurrentSessionResponse {
|
|
if (
|
|
!isRecord(value) ||
|
|
typeof value.authenticated !== "boolean" ||
|
|
typeof value.authRequired !== "boolean" ||
|
|
typeof value.authConfigured !== "boolean"
|
|
) {
|
|
throw new Error("Go auth/me returned an invalid response.");
|
|
}
|
|
if (!value.authenticated) {
|
|
if (value.authMode !== null || value.user !== null) {
|
|
throw new Error("Go auth/me returned an invalid anonymous response.");
|
|
}
|
|
return {
|
|
authenticated: false,
|
|
authRequired: value.authRequired,
|
|
authConfigured: value.authConfigured,
|
|
authMode: null,
|
|
user: null
|
|
};
|
|
}
|
|
const user = parsePlatformAuthUser(value.user);
|
|
if (
|
|
!value.authConfigured ||
|
|
(value.authMode !== "user" && value.authMode !== "admin") ||
|
|
!user ||
|
|
value.authMode !== (user.role === "user" ? "user" : "admin")
|
|
) {
|
|
throw new Error("Go auth/me returned an invalid authenticated response.");
|
|
}
|
|
return {
|
|
authenticated: true,
|
|
authRequired: value.authRequired,
|
|
authConfigured: value.authConfigured,
|
|
authMode: value.authMode,
|
|
user
|
|
};
|
|
}
|
|
|
|
async function authorizeSessionThroughGo(session: AuthSession, cookieHeader: string, baseUrl: string) {
|
|
const endpoint = new URL(`${baseUrl.replace(/\/+$/, "")}/api/auth/me`);
|
|
if ((endpoint.protocol !== "http:" && endpoint.protocol !== "https:") || endpoint.username || endpoint.password) {
|
|
throw new AuthConfigurationError("ZHINIAN_GO_INTERNAL_BASE_URL 必须是无凭据的 HTTP(S) 地址。");
|
|
}
|
|
const response = await fetch(endpoint.toString(), {
|
|
cache: "no-store",
|
|
headers: { cookie: cookieHeader }
|
|
});
|
|
if (!response.ok) throw new Error(`Go auth/me request failed with status ${response.status}.`);
|
|
const currentSession = parseGoCurrentSessionResponse(await response.json());
|
|
if (!currentSession.authenticated) return null;
|
|
if (currentSession.user.id !== session.user.id || currentSession.user.clientId !== "platform") {
|
|
throw new Error("Go auth/me returned a mismatched authenticated user.");
|
|
}
|
|
return {
|
|
...session,
|
|
authMode: currentSession.authMode,
|
|
user: currentSession.user
|
|
} satisfies AuthSession;
|
|
}
|
|
|
|
export async function getOptionalAuthSession(): Promise<AuthSession | null> {
|
|
const config = getAuthRuntimeConfig();
|
|
if (!config.sessionSecret) return null;
|
|
const cookieStore = await cookies();
|
|
const session = await parseSessionCookieValue(
|
|
readChunkedCookieValue(SESSION_COOKIE_NAME, (name) => cookieStore.get(name)?.value),
|
|
config.sessionSecret
|
|
);
|
|
if (!session) return null;
|
|
if (session.user.clientId !== "platform") return null;
|
|
const internalGoBaseUrl = process.env.ZHINIAN_GO_INTERNAL_BASE_URL?.trim();
|
|
if (internalGoBaseUrl) {
|
|
const cookieHeader = chunkedCookieNames(SESSION_COOKIE_NAME)
|
|
.map((name) => {
|
|
const value = cookieStore.get(name)?.value;
|
|
return value === undefined ? null : `${name}=${value}`;
|
|
})
|
|
.filter((value): value is string => value !== null)
|
|
.join("; ");
|
|
return authorizeSessionThroughGo(session, cookieHeader, internalGoBaseUrl);
|
|
}
|
|
const authorization = await authorizePlatformSession(session, loadPlatformAuthorizationSnapshot);
|
|
return authorization.outcome === "authenticated" ? authorization.session : null;
|
|
}
|
|
|
|
export async function requireAppSession(): Promise<AuthSession> {
|
|
const session = await getOptionalAuthSession();
|
|
if (session) return session;
|
|
const config = getAuthRuntimeConfig();
|
|
if (!config.required) return localSession();
|
|
if (!config.configured) {
|
|
throw new AuthConfigurationError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`);
|
|
}
|
|
throw new AuthRequiredError();
|
|
}
|
|
|
|
export async function requireAppUser(): Promise<AuthUser> {
|
|
return (await requireAppSession()).user;
|
|
}
|
|
|
|
export class AdminRequiredError extends Error {
|
|
status = 403;
|
|
|
|
constructor(message = "需要管理员权限。") {
|
|
super(message);
|
|
this.name = "AdminRequiredError";
|
|
}
|
|
}
|
|
|
|
export async function requireAdminUser(): Promise<AuthUser> {
|
|
return (await requireAdminSession()).user;
|
|
}
|
|
|
|
export async function requireAdminSession(): Promise<AuthSession> {
|
|
const session = await requireAppSession();
|
|
if (!hasAdminSessionAccess(session)) throw new AdminRequiredError();
|
|
return session;
|
|
}
|
|
|
|
export class SuperAdminRequiredError extends Error {
|
|
status = 403;
|
|
|
|
constructor(message = "需要超级管理员权限。") {
|
|
super(message);
|
|
this.name = "SuperAdminRequiredError";
|
|
}
|
|
}
|
|
|
|
export async function requireSuperAdminSession(): Promise<AuthSession> {
|
|
const session = await requireAppSession();
|
|
if (!hasSuperAdminAccess(session.user)) throw new SuperAdminRequiredError();
|
|
return session;
|
|
}
|
|
|
|
export async function requireSuperAdminUser(): Promise<AuthUser> {
|
|
return (await requireSuperAdminSession()).user;
|
|
}
|
|
|
|
export async function getShellAuthState(): Promise<{
|
|
user: AuthUser | null;
|
|
authRequired: boolean;
|
|
authConfigured: boolean;
|
|
isAdmin: boolean;
|
|
isSuperAdmin: boolean;
|
|
}> {
|
|
const config = getAuthRuntimeConfig();
|
|
const session = await getOptionalAuthSession();
|
|
const shellSession = session || (!config.required ? localSession() : null);
|
|
return {
|
|
user: shellSession?.user || null,
|
|
authRequired: config.required,
|
|
authConfigured: config.configured,
|
|
isAdmin: hasAdminSessionAccess(shellSession),
|
|
isSuperAdmin: hasSuperAdminAccess(shellSession?.user)
|
|
};
|
|
}
|