feat: add local auth billing and usage management

This commit is contained in:
inman committed 2026-08-12 12:13:06 +08:00
1 parent f642b5e71f
commit 196fdde83f
119 files changed
+15695 -2650

No files matched your search

+48 -8
View File
@@ -1,8 +1,10 @@
import { cookies } from "next/headers";
import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config";
import { hasAdminAccess } from "@/lib/auth/permissions";
import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions";
import { parseSessionCookieValue, readChunkedCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session";
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
import { getPlatformOrganization, getPlatformUserById } from "@/lib/server/account-store";
import { authUserFromPlatformRecord } from "@/lib/server/auth/local";
export class AuthRequiredError extends Error {
status = 401;
@@ -25,9 +27,13 @@ export class AuthConfigurationError extends Error {
const localUser: AuthUser = {
id: DEFAULT_OWNER_ID,
subject: DEFAULT_OWNER_ID,
username: "demo",
displayName: "智念演示用户",
username: "13800000000",
phone: "13800000000",
displayName: "智念用户",
clientId: "local-dev",
role: "super_admin",
organizationId: "org-demo",
organizationName: "演示组织",
authorities: ["zhinian_admin"],
scope: []
};
@@ -36,6 +42,7 @@ function localSession(): AuthSession {
const now = Math.floor(Date.now() / 1000);
return {
version: 1,
authMode: "admin",
user: localUser,
issuedAt: now,
expiresAt: now + 24 * 60 * 60
@@ -46,10 +53,22 @@ export async function getOptionalAuthSession(): Promise<AuthSession | null> {
const config = getAuthRuntimeConfig();
if (!config.sessionSecret) return null;
const cookieStore = await cookies();
return parseSessionCookieValue(
const session = await parseSessionCookieValue(
readChunkedCookieValue(SESSION_COOKIE_NAME, (name) => cookieStore.get(name)?.value),
config.sessionSecret
);
if (!session || session.user.clientId !== "platform") return null;
const account = await getPlatformUserById(session.user.id);
if (!account || account.status !== "active") return null;
if (session.sessionVersion && session.sessionVersion !== account.sessionVersion) return null;
const organization = account.organizationId ? await getPlatformOrganization(account.organizationId) : null;
if (account.role !== "super_admin" && account.organizationId && (!organization || organization.status !== "active")) return null;
return {
...session,
authMode: account.role === "user" ? "user" : "admin",
user: authUserFromPlatformRecord(account, organization),
sessionVersion: account.sessionVersion
};
}
export async function requireAppSession(): Promise<AuthSession> {
@@ -82,23 +101,44 @@ export async function requireAdminUser(): Promise<AuthUser> {
export async function requireAdminSession(): Promise<AuthSession> {
const session = await requireAppSession();
if (!hasAdminAccess(session.user)) throw new AdminRequiredError();
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 user = session?.user || (!config.required ? localUser : null);
const shellSession = session || (!config.required ? localSession() : null);
return {
user,
user: shellSession?.user || null,
authRequired: config.required,
authConfigured: config.configured,
isAdmin: hasAdminAccess(user)
isAdmin: hasAdminSessionAccess(shellSession),
isSuperAdmin: hasSuperAdminAccess(shellSession?.user)
};
}
+3 -2
View File
@@ -1,7 +1,7 @@
import { createPublicKey, createVerify } from "node:crypto";
import type { JsonWebKey as CryptoJsonWebKey, KeyObject } from "node:crypto";
import { getAuthRuntimeConfig, type AuthRuntimeConfig } from "@/lib/auth/config";
import type { AuthSession, AuthUser } from "@/lib/auth/session";
import type { AuthMode, AuthSession, AuthUser } from "@/lib/auth/session";
export type AuthTokenClaims = {
iss?: string;
@@ -76,7 +76,7 @@ export function createSessionFromClaims(
claims: AuthTokenClaims,
config: AuthRuntimeConfig,
tokenResponseExpiresIn?: number,
token?: { accessToken?: string; tokenType?: string }
token?: { accessToken?: string; tokenType?: string; authMode?: AuthMode }
): AuthSession {
const now = Math.floor(Date.now() / 1000);
const jwtExpiry = numberClaim(claims.exp);
@@ -84,6 +84,7 @@ export function createSessionFromClaims(
const expiresAt = Math.min(jwtExpiry || responseExpiry || now, responseExpiry || jwtExpiry || now);
return {
version: 1,
authMode: token?.authMode === "admin" ? "admin" : "user",
user: userFromClaims(claims, config),
issuedAt: now,
expiresAt,
+103
View File
@@ -0,0 +1,103 @@
import type { NextResponse } from "next/server";
import { SESSION_COOKIE_NAME, shouldUseSecureAuthCookie } from "@/lib/auth/config";
import type { AuthMode, AuthSession, AuthUser } from "@/lib/auth/session";
import type { PlatformOrganization, PlatformUserRecord } from "@/lib/types";
import { clearSessionCookieValues, setSessionCookieValue } from "@/lib/server/auth/session-cookie";
import { createSessionCookieValue } from "@/lib/auth/session";
import { getPlatformOrganization } from "@/lib/server/account-store";
export const LOCAL_SESSION_TTL_SECONDS = 24 * 60 * 60;
const ipAttempts = new Map<string, { count: number; resetAt: number }>();
export function authUserFromPlatformRecord(user: PlatformUserRecord, organization?: PlatformOrganization | null): AuthUser {
const role = user.role;
const authorities = role === "super_admin"
? ["ROLE_SUPER_ADMIN", "SUPER_ADMIN"]
: role === "organization_admin"
? ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"]
: ["ROLE_USER"];
return {
id: user.id,
subject: user.id,
username: user.phone,
phone: user.phone,
displayName: user.displayName,
clientId: "platform",
organizationId: user.organizationId,
organizationName: organization?.name,
role,
status: user.status,
authorities,
scope: []
};
}
export async function createPlatformSession(user: PlatformUserRecord): Promise<AuthSession> {
const organization = user.organizationId ? await getPlatformOrganization(user.organizationId) : null;
const now = Math.floor(Date.now() / 1000);
const authMode: AuthMode = user.role === "user" ? "user" : "admin";
return {
version: 1,
authMode,
user: authUserFromPlatformRecord(user, organization),
issuedAt: now,
expiresAt: now + LOCAL_SESSION_TTL_SECONDS,
sessionVersion: user.sessionVersion
};
}
export async function setPlatformSessionCookie(
response: NextResponse,
requestUrl: string,
session: AuthSession
) {
const secret = process.env.ZHINIAN_AUTH_SESSION_SECRET || process.env.AUTH_SESSION_SECRET || process.env.NEXTAUTH_SECRET;
if (!secret) throw new Error("ZHINIAN_AUTH_SESSION_SECRET 未配置。");
setSessionCookieValue(
response,
requestUrl,
await createSessionCookieValue(session, secret),
new Date(session.expiresAt * 1000)
);
}
export function clearPlatformSessionCookies(response: NextResponse, requestUrl: string) {
clearSessionCookieValues(response, requestUrl);
response.cookies.set(SESSION_COOKIE_NAME, "", {
httpOnly: true,
sameSite: "lax",
secure: shouldUseSecureAuthCookie(requestUrl),
path: "/",
maxAge: 0
});
}
export function clientIpFromRequest(request: Request): string {
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip")?.trim() ||
"unknown";
}
export function checkIpLoginRateLimit(ip: string): void {
const now = Date.now();
const current = ipAttempts.get(ip);
if (!current || current.resetAt <= now) {
ipAttempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
return;
}
if (current.count >= 30) {
const error = new Error("请求过于频繁,请稍后再试。") as Error & { status: number };
error.status = 429;
throw error;
}
current.count += 1;
}
export function clearIpLoginRateLimit(ip: string): void {
ipAttempts.delete(ip);
}
export function resetLocalAuthRateLimitForTests(): void {
ipAttempts.clear();
}
+2 -1
View File
@@ -92,7 +92,8 @@ export async function completeAuthorizationCallback(request: Request): Promise<N
const claims = await verifyAuthJwt(token.access_token, config);
const session = createSessionFromClaims(claims, config, token.expires_in, {
accessToken: token.access_token,
tokenType: token.token_type
tokenType: token.token_type,
authMode: "user"
});
const response = NextResponse.redirect(new URL(stateCookie.next, request.url));
setSessionCookieValue(
+23 -1
View File
@@ -1,4 +1,26 @@
import { createCipheriv } from "node:crypto";
import { createCipheriv, randomBytes, scrypt as nodeScrypt, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
const scrypt = promisify(nodeScrypt);
const LOCAL_PASSWORD_KEY_LENGTH = 64;
export type LocalPasswordHash = {
hash: string;
salt: string;
};
export async function hashLocalPassword(password: string, salt = randomBytes(16).toString("hex")): Promise<LocalPasswordHash> {
const derived = await scrypt(password, salt, LOCAL_PASSWORD_KEY_LENGTH) as Buffer;
return { hash: derived.toString("hex"), salt };
}
export async function verifyLocalPassword(password: string, hash: string, salt: string): Promise<boolean> {
if (!password || !hash || !salt) return false;
const derived = await scrypt(password, salt, LOCAL_PASSWORD_KEY_LENGTH) as Buffer;
const expected = Buffer.from(hash, "hex");
return expected.length === derived.length && timingSafeEqual(expected, derived);
}
export function prepareAuthPassword(password: string, input: {
passwordEncrypted?: boolean;