feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user