From 9a3070c74907fa8c2c7b70e6b8e6d2d8f6aa3dd1 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 6 Jul 2026 16:31:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95=E6=8A=A5?= =?UTF-8?q?=E9=94=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 ++ README.md | 4 ++ README.zh-CN.md | 4 ++ app/api/auth/password/route.ts | 13 +++++-- docs/DEPLOYMENT.md | 4 +- lib/auth/config.ts | 11 +++++- lib/server/app-settings.ts | 2 + tests/auth-password-route.test.ts | 62 +++++++++++++++++++++++++++++++ 8 files changed, 98 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 045623d..775c229 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ ZHINIAN_AUTH_CLIENT_ID=custom ZHINIAN_AUTH_CLIENT_SECRET=custom ZHINIAN_ADMIN_AUTH_CLIENT_ID=app ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app +# Optional tenant for platform password login. Defaults to ZHINIAN_ORG_TENANT_ID when empty. +ZHINIAN_AUTH_TENANT_ID= +# Optional tenant for admin password login; leave empty for the default admin tenant. +ZHINIAN_ADMIN_AUTH_TENANT_ID= ZHINIAN_AUTH_SCOPE=server ZHINIAN_AUTH_ISSUER=https://pig4cloud.com ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud diff --git a/README.md b/README.md index afef2f7..62fc8c6 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,8 @@ https://你的域名/api/auth/callback - `ZHINIAN_AUTH_CLIENT_SECRET=custom` - `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app` - `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app` +- `ZHINIAN_AUTH_TENANT_ID`:普通账号 password grant 的 `tenantId`;为空时复用 `ZHINIAN_ORG_TENANT_ID` +- `ZHINIAN_ADMIN_AUTH_TENANT_ID`:管理员 password grant 的 `tenantId`,通常留空 - `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud`:按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密 @@ -167,6 +169,8 @@ cp .env.example .env.local - `ZHINIAN_AUTH_CLIENT_SECRET` - `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app` - `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` +- `ZHINIAN_AUTH_TENANT_ID` +- `ZHINIAN_ADMIN_AUTH_TENANT_ID` - `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_SESSION_SECRET` diff --git a/README.zh-CN.md b/README.zh-CN.md index 3d95dad..656624e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -164,6 +164,8 @@ https://你的域名/api/auth/callback | `ZHINIAN_AUTH_CLIENT_SECRET` | 普通账号 OAuth2 客户端密钥,只能保存在服务端 | | `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID,默认 `app` | | `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥,只能保存在服务端 | +| `ZHINIAN_AUTH_TENANT_ID` | 普通账号 password grant 的 `tenantId`;为空时复用 `ZHINIAN_ORG_TENANT_ID` | +| `ZHINIAN_ADMIN_AUTH_TENANT_ID` | 管理员 password grant 的 `tenantId`,通常留空 | | `ZHINIAN_AUTH_SCOPE` | 默认 `server` | | `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` | | `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密,默认示例 `thanks,pig4cloud` | @@ -277,6 +279,8 @@ cp .env.example .env.local | `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 | | `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID | | `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥 | +| `ZHINIAN_AUTH_TENANT_ID` | 普通账号 password grant 租户 ID;为空时复用 `ZHINIAN_ORG_TENANT_ID` | +| `ZHINIAN_ADMIN_AUTH_TENANT_ID` | 管理员 password grant 租户 ID,通常留空 | | `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 | | `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 | | `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` | diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts index 97cc481..5c24725 100644 --- a/app/api/auth/password/route.ts +++ b/app/api/auth/password/route.ts @@ -46,6 +46,7 @@ export async function POST(request: Request) { clientId: config.clientId, clientSecret: config.clientSecret, scope: config.scope, + tenantId: config.tenantId, username, password: prepareAuthPassword(password, { passwordEncrypted: body.password_encrypted || body.passwordEncrypted, @@ -82,6 +83,7 @@ async function exchangePasswordToken(input: { clientId: string; clientSecret: string; scope: string; + tenantId?: string; username: string; password: string; code?: string; @@ -92,14 +94,17 @@ async function exchangePasswordToken(input: { form.set("scope", input.scope); form.set("username", input.username); form.set("password", input.password); + if (input.tenantId) form.set("tenantId", input.tenantId); if (input.code) form.set("code", input.code); if (input.randomStr) form.set("randomStr", input.randomStr); + const headers: Record = { + Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded" + }; + if (input.tenantId) headers.tenantId = input.tenantId; const response = await fetch(input.tokenUrl, { method: "POST", - headers: { - Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`, - "Content-Type": "application/x-www-form-urlencoded" - }, + headers, body: form }); const payload = await response.json().catch(() => ({})) as PasswordTokenResponse; diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9d07f88..d807c87 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -49,6 +49,8 @@ ZHINIAN_AUTH_CLIENT_ID=custom ZHINIAN_AUTH_CLIENT_SECRET=custom ZHINIAN_ADMIN_AUTH_CLIENT_ID=app ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app +ZHINIAN_AUTH_TENANT_ID= +ZHINIAN_ADMIN_AUTH_TENANT_ID= ZHINIAN_AUTH_SCOPE=server ZHINIAN_AUTH_ISSUER=https://pig4cloud.com ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud @@ -91,7 +93,7 @@ ALI_OSS_PUBLIC_BASE_URL= https://你的域名/api/auth/callback ``` -如果使用登录页内的账号密码方式,需要确认客户端支持 `password` 和 `refresh_token` grant,并已加入认证中心配置 `security.ignore-clients`。普通账号登录使用 `ZHINIAN_AUTH_CLIENT_ID` / `ZHINIAN_AUTH_CLIENT_SECRET`,默认 `custom/custom`;登录页里的“管理员登录”入口使用 `ZHINIAN_ADMIN_AUTH_CLIENT_ID` / `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET`,默认 `app/app`。 +如果使用登录页内的账号密码方式,需要确认客户端支持 `password` 和 `refresh_token` grant,并已加入认证中心配置 `security.ignore-clients`。普通账号登录使用 `ZHINIAN_AUTH_CLIENT_ID` / `ZHINIAN_AUTH_CLIENT_SECRET`,默认 `custom/custom`,并会把 `ZHINIAN_AUTH_TENANT_ID` 作为 `tenantId` 传给认证中心;`ZHINIAN_AUTH_TENANT_ID` 为空时复用 `ZHINIAN_ORG_TENANT_ID`。登录页里的“管理员登录”入口使用 `ZHINIAN_ADMIN_AUTH_CLIENT_ID` / `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET`,默认 `app/app`;管理员租户只在显式配置 `ZHINIAN_ADMIN_AUTH_TENANT_ID` 时传递。 ## 组织账号接口 diff --git a/lib/auth/config.ts b/lib/auth/config.ts index 710d127..020bc5a 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -15,6 +15,7 @@ export type AuthRuntimeConfig = { scope: string; issuer: string; passwordEncryptionKey?: string; + tenantId?: string; sessionSecret?: string; clockSkewSeconds: number; }; @@ -23,10 +24,12 @@ export type AuthClientMode = "default" | "admin"; export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = {}): AuthRuntimeConfig { const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE")); - const client = authClientConfig(options.clientMode || "default"); + const clientMode = options.clientMode || "default"; + const client = authClientConfig(clientMode); const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server"; const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com"; const passwordEncryptionKey = envValue("ZHINIAN_AUTH_PASSWORD_ENC_KEY", "AUTH_PASSWORD_ENC_KEY", "AGENTBUS_SSO_PASSWORD_ENC_KEY"); + const tenantId = authTenantId(clientMode); const sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET"); const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED"); const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true; @@ -53,6 +56,7 @@ export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = scope, issuer, passwordEncryptionKey, + tenantId, sessionSecret, clockSkewSeconds: numberEnv("ZHINIAN_AUTH_CLOCK_SKEW_SECONDS") ?? 60 }; @@ -122,6 +126,11 @@ function authClientConfig(mode: AuthClientMode): { }; } +function authTenantId(mode: AuthClientMode): string | undefined { + if (mode === "admin") return envValue("ZHINIAN_ADMIN_AUTH_TENANT_ID", "ADMIN_AUTH_TENANT_ID"); + return envValue("ZHINIAN_AUTH_TENANT_ID", "AUTH_TENANT_ID", "ZHINIAN_ORG_TENANT_ID"); +} + function boolEnv(name: string): boolean | undefined { const value = process.env[name]?.trim().toLowerCase(); if (!value || value === "auto") return undefined; diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index febc61a..c0ed948 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -70,6 +70,8 @@ const settingDefinitions: Array<{ { key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" }, { key: "ZHINIAN_ADMIN_AUTH_CLIENT_ID", label: "管理员客户端 ID", defaultValue: "app" }, { key: "ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", label: "管理员客户端密钥", secret: true, type: "password" }, + { key: "ZHINIAN_AUTH_TENANT_ID", label: "普通登录租户 ID", description: "为空时复用 ZHINIAN_ORG_TENANT_ID" }, + { key: "ZHINIAN_ADMIN_AUTH_TENANT_ID", label: "管理员登录租户 ID", description: "通常留空,避免管理员登录被普通租户影响" }, { key: "ZHINIAN_AUTH_SCOPE", label: "Scope", defaultValue: "server" }, { key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" }, { key: "ZHINIAN_AUTH_PASSWORD_ENC_KEY", label: "Password Encryption Key", secret: true, type: "password" }, diff --git a/tests/auth-password-route.test.ts b/tests/auth-password-route.test.ts index 078772b..d040149 100644 --- a/tests/auth-password-route.test.ts +++ b/tests/auth-password-route.test.ts @@ -80,6 +80,57 @@ describe("password auth route AgentBus compatibility", () => { expect(seenBodies[0].has("randomStr")).toBe(false); }); + 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 = []; + + 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 }); + }); + + const response = await POST(new Request("https://app.example.com/api/auth/password", { + method: "POST", + body: JSON.stringify({ + username: "platform@example.com", + password: "123456" + }) + })); + + expect(response.status).toBe(200); + expect(seenBodies[0].get("tenantId")).toBe("999"); + expect(seenTenantHeaders).toEqual(["999"]); + }); + 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"); @@ -134,6 +185,17 @@ describe("password auth route AgentBus compatibility", () => { }); }); +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; + return record[name] ?? record[name.toLowerCase()] ?? null; +} + function signJwt(payload: Record, privateKey: KeyObject, kid: string): string { const header = base64UrlJson({ alg: "RS256", typ: "JWT", kid }); const body = base64UrlJson(payload);