修复登录报错问题

This commit is contained in:
2026-07-06 16:31:11 +08:00
parent 53b1842de8
commit 9a3070c749
8 changed files with 98 additions and 6 deletions

View File

@@ -19,6 +19,10 @@ ZHINIAN_AUTH_CLIENT_ID=custom
ZHINIAN_AUTH_CLIENT_SECRET=custom ZHINIAN_AUTH_CLIENT_SECRET=custom
ZHINIAN_ADMIN_AUTH_CLIENT_ID=app ZHINIAN_ADMIN_AUTH_CLIENT_ID=app
ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=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_SCOPE=server
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud

View File

@@ -78,6 +78,8 @@ https://你的域名/api/auth/callback
- `ZHINIAN_AUTH_CLIENT_SECRET=custom` - `ZHINIAN_AUTH_CLIENT_SECRET=custom`
- `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app` - `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app`
- `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=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_SCOPE=server`
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
- `ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud`:按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密 - `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_AUTH_CLIENT_SECRET`
- `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app` - `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app`
- `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` - `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET`
- `ZHINIAN_AUTH_TENANT_ID`
- `ZHINIAN_ADMIN_AUTH_TENANT_ID`
- `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_SCOPE=server`
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
- `ZHINIAN_AUTH_SESSION_SECRET` - `ZHINIAN_AUTH_SESSION_SECRET`

View File

@@ -164,6 +164,8 @@ https://你的域名/api/auth/callback
| `ZHINIAN_AUTH_CLIENT_SECRET` | 普通账号 OAuth2 客户端密钥,只能保存在服务端 | | `ZHINIAN_AUTH_CLIENT_SECRET` | 普通账号 OAuth2 客户端密钥,只能保存在服务端 |
| `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID默认 `app` | | `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID默认 `app` |
| `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥,只能保存在服务端 | | `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_SCOPE` | 默认 `server` |
| `ZHINIAN_AUTH_ISSUER` | JWT issuer默认 `https://pig4cloud.com` | | `ZHINIAN_AUTH_ISSUER` | JWT issuer默认 `https://pig4cloud.com` |
| `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密,默认示例 `thanks,pig4cloud` | | `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_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 |
| `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID | | `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID |
| `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥 | | `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_AUTH_SESSION_SECRET` | 本地登录态签名密钥 |
| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 | | `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 |
| `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` | | `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` |

View File

@@ -46,6 +46,7 @@ export async function POST(request: Request) {
clientId: config.clientId, clientId: config.clientId,
clientSecret: config.clientSecret, clientSecret: config.clientSecret,
scope: config.scope, scope: config.scope,
tenantId: config.tenantId,
username, username,
password: prepareAuthPassword(password, { password: prepareAuthPassword(password, {
passwordEncrypted: body.password_encrypted || body.passwordEncrypted, passwordEncrypted: body.password_encrypted || body.passwordEncrypted,
@@ -82,6 +83,7 @@ async function exchangePasswordToken(input: {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
scope: string; scope: string;
tenantId?: string;
username: string; username: string;
password: string; password: string;
code?: string; code?: string;
@@ -92,14 +94,17 @@ async function exchangePasswordToken(input: {
form.set("scope", input.scope); form.set("scope", input.scope);
form.set("username", input.username); form.set("username", input.username);
form.set("password", input.password); form.set("password", input.password);
if (input.tenantId) form.set("tenantId", input.tenantId);
if (input.code) form.set("code", input.code); if (input.code) form.set("code", input.code);
if (input.randomStr) form.set("randomStr", input.randomStr); if (input.randomStr) form.set("randomStr", input.randomStr);
const headers: Record<string, string> = {
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, { const response = await fetch(input.tokenUrl, {
method: "POST", method: "POST",
headers: { headers,
Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded"
},
body: form body: form
}); });
const payload = await response.json().catch(() => ({})) as PasswordTokenResponse; const payload = await response.json().catch(() => ({})) as PasswordTokenResponse;

View File

@@ -49,6 +49,8 @@ ZHINIAN_AUTH_CLIENT_ID=custom
ZHINIAN_AUTH_CLIENT_SECRET=custom ZHINIAN_AUTH_CLIENT_SECRET=custom
ZHINIAN_ADMIN_AUTH_CLIENT_ID=app ZHINIAN_ADMIN_AUTH_CLIENT_ID=app
ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app
ZHINIAN_AUTH_TENANT_ID=
ZHINIAN_ADMIN_AUTH_TENANT_ID=
ZHINIAN_AUTH_SCOPE=server ZHINIAN_AUTH_SCOPE=server
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud
@@ -91,7 +93,7 @@ ALI_OSS_PUBLIC_BASE_URL=
https://你的域名/api/auth/callback 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` 时传递
## 组织账号接口 ## 组织账号接口

View File

@@ -15,6 +15,7 @@ export type AuthRuntimeConfig = {
scope: string; scope: string;
issuer: string; issuer: string;
passwordEncryptionKey?: string; passwordEncryptionKey?: string;
tenantId?: string;
sessionSecret?: string; sessionSecret?: string;
clockSkewSeconds: number; clockSkewSeconds: number;
}; };
@@ -23,10 +24,12 @@ export type AuthClientMode = "default" | "admin";
export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = {}): AuthRuntimeConfig { export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = {}): AuthRuntimeConfig {
const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE")); 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 scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server";
const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com"; 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 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 sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET");
const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED"); const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED");
const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true; const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true;
@@ -53,6 +56,7 @@ export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } =
scope, scope,
issuer, issuer,
passwordEncryptionKey, passwordEncryptionKey,
tenantId,
sessionSecret, sessionSecret,
clockSkewSeconds: numberEnv("ZHINIAN_AUTH_CLOCK_SKEW_SECONDS") ?? 60 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 { function boolEnv(name: string): boolean | undefined {
const value = process.env[name]?.trim().toLowerCase(); const value = process.env[name]?.trim().toLowerCase();
if (!value || value === "auto") return undefined; if (!value || value === "auto") return undefined;

View File

@@ -70,6 +70,8 @@ const settingDefinitions: Array<{
{ key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" }, { 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_ID", label: "管理员客户端 ID", defaultValue: "app" },
{ key: "ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", label: "管理员客户端密钥", secret: true, type: "password" }, { 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_SCOPE", label: "Scope", defaultValue: "server" },
{ key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" }, { key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" },
{ key: "ZHINIAN_AUTH_PASSWORD_ENC_KEY", label: "Password Encryption Key", secret: true, type: "password" }, { key: "ZHINIAN_AUTH_PASSWORD_ENC_KEY", label: "Password Encryption Key", secret: true, type: "password" },

View File

@@ -80,6 +80,57 @@ describe("password auth route AgentBus compatibility", () => {
expect(seenBodies[0].has("randomStr")).toBe(false); 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<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 });
});
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 () => { it("uses the admin OAuth client for admin password login", async () => {
for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value); 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_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<string, string>;
return record[name] ?? record[name.toLowerCase()] ?? null;
}
function signJwt(payload: Record<string, unknown>, privateKey: KeyObject, kid: string): string { function signJwt(payload: Record<string, unknown>, privateKey: KeyObject, kid: string): string {
const header = base64UrlJson({ alg: "RS256", typ: "JWT", kid }); const header = base64UrlJson({ alg: "RS256", typ: "JWT", kid });
const body = base64UrlJson(payload); const body = base64UrlJson(payload);