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
+101
-183
@@ -1,212 +1,130 @@
|
||||
import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearJwksCacheForTests } from "@/lib/server/auth/jwt";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseSessionCookieValue } from "@/lib/auth/session";
|
||||
import {
|
||||
createPlatformOrganization,
|
||||
createPlatformUser,
|
||||
updatePlatformUser
|
||||
} from "@/lib/server/account-store";
|
||||
import { resetLocalAuthRateLimitForTests } from "@/lib/server/auth/local";
|
||||
import type { PlatformUserRecord } from "@/lib/types";
|
||||
import { POST } from "@/app/api/auth/password/route";
|
||||
|
||||
type TestJwk = JsonWebKey & {
|
||||
kid?: string;
|
||||
alg?: string;
|
||||
use?: string;
|
||||
};
|
||||
const SESSION_SECRET = "test-platform-session-secret-with-enough-entropy";
|
||||
let runtimeDir = "";
|
||||
let ordinaryUser: PlatformUserRecord;
|
||||
|
||||
const baseEnv = {
|
||||
ZHINIAN_AUTH_REQUIRED: "1",
|
||||
ZHINIAN_AUTH_BASE_URL: "https://gateway.example.com/auth",
|
||||
ZHINIAN_AUTH_CLIENT_ID: "agentbus-client",
|
||||
ZHINIAN_AUTH_CLIENT_SECRET: "client-secret",
|
||||
ZHINIAN_AUTH_SCOPE: "server",
|
||||
ZHINIAN_AUTH_ISSUER: "https://pig4cloud.com",
|
||||
ZHINIAN_AUTH_SESSION_SECRET: "test-session-secret-with-enough-entropy"
|
||||
};
|
||||
describe("platform password auth route", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-auth-"));
|
||||
vi.stubEnv("ZHINIAN_DATA_DIR", runtimeDir);
|
||||
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
|
||||
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", SESSION_SECRET);
|
||||
vi.stubEnv("ZHINIAN_AUTH_DISABLED", "");
|
||||
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
|
||||
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
|
||||
resetLocalAuthRateLimitForTests();
|
||||
|
||||
describe("password auth route AgentBus compatibility", () => {
|
||||
afterEach(() => {
|
||||
const organization = await createPlatformOrganization("测试组织");
|
||||
ordinaryUser = await createPlatformUser({
|
||||
phone: "13800138000",
|
||||
displayName: "测试用户",
|
||||
password: "TestPass123",
|
||||
role: "user",
|
||||
organizationId: organization.id
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetLocalAuthRateLimitForTests();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
clearJwksCacheForTests();
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("encrypts password with the configured AES-CFB key and does not require captcha fields", async () => {
|
||||
for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value);
|
||||
vi.stubEnv("ZHINIAN_AUTH_PASSWORD_ENC_KEY", "thanks,pig4cloud");
|
||||
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: "jwk" }) as TestJwk;
|
||||
jwk.kid = "agentbus-key";
|
||||
const accessToken = signJwt({
|
||||
iss: baseEnv.ZHINIAN_AUTH_ISSUER,
|
||||
sub: "subject-42",
|
||||
user_id: "remote-42",
|
||||
username: "user@example.com",
|
||||
client_id: baseEnv.ZHINIAN_AUTH_CLIENT_ID,
|
||||
scope: baseEnv.ZHINIAN_AUTH_SCOPE,
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
iat: Math.floor(Date.now() / 1000) - 10,
|
||||
nbf: Math.floor(Date.now() / 1000) - 10
|
||||
}, privateKey, "agentbus-key");
|
||||
const seenBodies: URLSearchParams[] = [];
|
||||
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/oauth2/jwks")) {
|
||||
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/oauth2/token")) {
|
||||
seenBodies.push(new URLSearchParams(String(init?.body)));
|
||||
return new Response(JSON.stringify({
|
||||
access_token: accessToken,
|
||||
refresh_token: "refresh-token-1",
|
||||
token_type: "bearer",
|
||||
expires_in: "3600"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const response = await POST(new Request("https://app.example.com/api/auth/password", {
|
||||
it("logs in every role with the platform phone/password session", async () => {
|
||||
const response = await POST(new Request("http://127.0.0.1/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "x-forwarded-for": "192.0.2.10" },
|
||||
body: JSON.stringify({
|
||||
username: "user@example.com",
|
||||
password: "123456",
|
||||
next: "/create"
|
||||
phone: "138 0013-8000",
|
||||
password: "TestPass123",
|
||||
next: "/assets"
|
||||
})
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(seenBodies).toHaveLength(1);
|
||||
expect(seenBodies[0].get("grant_type")).toBe("password");
|
||||
expect(seenBodies[0].get("username")).toBe("user@example.com");
|
||||
expect(seenBodies[0].get("password")).toBe("YehdBPev");
|
||||
expect(seenBodies[0].has("code")).toBe(false);
|
||||
expect(seenBodies[0].has("randomStr")).toBe(false);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
redirectTo: "/assets",
|
||||
authMode: "user",
|
||||
user: { id: ordinaryUser.id, phone: "13800138000", role: "user", clientId: "platform" }
|
||||
});
|
||||
expect(await parseSessionCookieValue(
|
||||
response.cookies.get("zhinian_session")?.value,
|
||||
SESSION_SECRET
|
||||
)).toMatchObject({
|
||||
authMode: "user",
|
||||
sessionVersion: 1,
|
||||
user: { id: ordinaryUser.id, role: "user", clientId: "platform" }
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the organization tenant id to platform password login", async () => {
|
||||
for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value);
|
||||
vi.stubEnv("ZHINIAN_ORG_TENANT_ID", "999");
|
||||
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: "jwk" }) as TestJwk;
|
||||
jwk.kid = "tenant-key";
|
||||
const accessToken = signJwt({
|
||||
iss: baseEnv.ZHINIAN_AUTH_ISSUER,
|
||||
sub: "platform-user",
|
||||
user_id: "platform-user",
|
||||
username: "platform@example.com",
|
||||
client_id: baseEnv.ZHINIAN_AUTH_CLIENT_ID,
|
||||
scope: baseEnv.ZHINIAN_AUTH_SCOPE,
|
||||
tenant_id: "999",
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
iat: Math.floor(Date.now() / 1000) - 10,
|
||||
nbf: Math.floor(Date.now() / 1000) - 10
|
||||
}, privateKey, "tenant-key");
|
||||
const seenBodies: URLSearchParams[] = [];
|
||||
const seenTenantHeaders: Array<string | null> = [];
|
||||
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/oauth2/jwks")) {
|
||||
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/oauth2/token")) {
|
||||
seenBodies.push(new URLSearchParams(String(init?.body)));
|
||||
seenTenantHeaders.push(headerValue(init?.headers, "tenantId"));
|
||||
return new Response(JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: "bearer",
|
||||
expires_in: "3600"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
it("logs in a super administrator through the same endpoint", async () => {
|
||||
const admin = await createPlatformUser({
|
||||
phone: "13900139000",
|
||||
displayName: "平台超级管理员",
|
||||
password: "AdminPass123",
|
||||
role: "super_admin"
|
||||
});
|
||||
|
||||
const response = await POST(new Request("https://app.example.com/api/auth/password", {
|
||||
const response = await POST(new Request("http://127.0.0.1/api/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: "platform@example.com",
|
||||
password: "123456"
|
||||
})
|
||||
body: JSON.stringify({ phone: admin.phone, password: "AdminPass123", authMode: "admin" })
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(seenBodies[0].get("tenantId")).toBe("999");
|
||||
expect(seenTenantHeaders).toEqual(["999"]);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
authMode: "admin",
|
||||
user: { id: admin.id, role: "super_admin" }
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the admin OAuth client for admin password login", async () => {
|
||||
for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value);
|
||||
vi.stubEnv("ZHINIAN_ADMIN_AUTH_CLIENT_ID", "app");
|
||||
vi.stubEnv("ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", "app");
|
||||
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: "jwk" }) as TestJwk;
|
||||
jwk.kid = "admin-key";
|
||||
const accessToken = signJwt({
|
||||
iss: baseEnv.ZHINIAN_AUTH_ISSUER,
|
||||
sub: "admin",
|
||||
user_id: "admin",
|
||||
username: "admin",
|
||||
client_id: "app",
|
||||
scope: baseEnv.ZHINIAN_AUTH_SCOPE,
|
||||
exp: Math.floor(Date.now() / 1000) + 600,
|
||||
iat: Math.floor(Date.now() / 1000) - 10,
|
||||
nbf: Math.floor(Date.now() / 1000) - 10
|
||||
}, privateKey, "admin-key");
|
||||
const seenAuthorizations: string[] = [];
|
||||
it("locks an account after five failed passwords", async () => {
|
||||
let lastResponse: Response | undefined;
|
||||
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
||||
lastResponse = await POST(new Request("http://127.0.0.1/api/auth/password", {
|
||||
method: "POST",
|
||||
headers: { "x-forwarded-for": "192.0.2.11" },
|
||||
body: JSON.stringify({ phone: ordinaryUser.phone, password: "wrong-pass" })
|
||||
}));
|
||||
expect(lastResponse.status).toBe(attempt === 5 ? 423 : 401);
|
||||
}
|
||||
await expect(lastResponse?.json()).resolves.toMatchObject({ error: "登录失败次数过多,请 15 分钟后再试。" });
|
||||
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/oauth2/jwks")) {
|
||||
return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/oauth2/token")) {
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
seenAuthorizations.push(headers?.Authorization || headers?.authorization || "");
|
||||
return new Response(JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: "bearer",
|
||||
expires_in: "3600"
|
||||
}), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
});
|
||||
|
||||
const response = await POST(new Request("https://app.example.com/api/auth/password", {
|
||||
const lockedResponse = await POST(new Request("http://127.0.0.1/api/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: "admin",
|
||||
password: "123456",
|
||||
authMode: "admin",
|
||||
next: "/accounts"
|
||||
})
|
||||
body: JSON.stringify({ phone: ordinaryUser.phone, password: "TestPass123" })
|
||||
}));
|
||||
expect(lockedResponse.status).toBe(423);
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(seenAuthorizations).toEqual([
|
||||
`Basic ${Buffer.from("app:app").toString("base64")}`
|
||||
]);
|
||||
it("rejects disabled accounts and duplicate phone identities", async () => {
|
||||
await expect(createPlatformUser({
|
||||
phone: ordinaryUser.phone,
|
||||
displayName: "重复账号",
|
||||
password: "AnotherPass123",
|
||||
role: "user",
|
||||
organizationId: ordinaryUser.organizationId
|
||||
})).rejects.toThrow("该手机号已创建账号。");
|
||||
|
||||
await updatePlatformUser(ordinaryUser.id, { status: "disabled" });
|
||||
const response = await POST(new Request("http://127.0.0.1/api/auth/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ phone: ordinaryUser.phone, password: "TestPass123" })
|
||||
}));
|
||||
expect(response.status).toBe(403);
|
||||
await expect(response.json()).resolves.toEqual({ error: "账号已停用,请联系管理员。" });
|
||||
});
|
||||
});
|
||||
|
||||
function headerValue(headers: HeadersInit | undefined, name: string): string | null {
|
||||
if (!headers) return null;
|
||||
if (headers instanceof Headers) return headers.get(name);
|
||||
if (Array.isArray(headers)) {
|
||||
const found = headers.find(([key]) => key.toLowerCase() === name.toLowerCase());
|
||||
return found?.[1] ?? null;
|
||||
}
|
||||
const record = headers as Record<string, string>;
|
||||
return record[name] ?? record[name.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
function signJwt(payload: Record<string, unknown>, privateKey: KeyObject, kid: string): string {
|
||||
const header = base64UrlJson({ alg: "RS256", typ: "JWT", kid });
|
||||
const body = base64UrlJson(payload);
|
||||
const signingInput = `${header}.${body}`;
|
||||
const signer = createSign("RSA-SHA256");
|
||||
signer.update(signingInput);
|
||||
signer.end();
|
||||
const signature = signer.sign(privateKey).toString("base64url");
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
function base64UrlJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
}
|
||||
Reference in new issue
Block a user