修改认证中心对接方式

This commit is contained in:
2026-06-04 12:02:53 +08:00
parent fb0229ba06
commit ce358df201
13 changed files with 195 additions and 24 deletions

View File

@@ -19,6 +19,7 @@ ZHINIAN_AUTH_CLIENT_ID=customPC
ZHINIAN_AUTH_CLIENT_SECRET=
ZHINIAN_AUTH_SCOPE=server
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
ZHINIAN_AUTH_PASSWORD_ENC_KEY=
ZHINIAN_AUTH_SESSION_SECRET=change-me-to-a-long-random-secret
# Optional overrides when endpoints do not follow AUTH_BASE defaults.
ZHINIAN_AUTH_AUTHORIZE_URL=

View File

@@ -76,6 +76,7 @@ https://你的域名/api/auth/callback
- `ZHINIAN_AUTH_CLIENT_SECRET`
- `ZHINIAN_AUTH_SCOPE=server`
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
- `ZHINIAN_AUTH_PASSWORD_ENC_KEY`:按 AgentBus 方式对 password grant 的密码做 AES-CFB 加密;留空则透传明文
- `ZHINIAN_AUTH_SESSION_SECRET`:长随机字符串,用于签名本地登录态
`/create``/assets``/settings`、第一方生成/资产 API、以及本地上传和生成结果文件都会受登录态保护。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。

View File

@@ -162,6 +162,7 @@ https://你的域名/api/auth/callback
| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥,只能保存在服务端 |
| `ZHINIAN_AUTH_SCOPE` | 默认 `server` |
| `ZHINIAN_AUTH_ISSUER` | JWT issuer默认 `https://pig4cloud.com` |
| `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按 AgentBus 方式对 password grant 的密码做 AES-CFB 加密;留空则透传明文 |
| `ZHINIAN_AUTH_SESSION_SECRET` | 本地会话签名密钥,使用长随机字符串 |
受保护范围包括 `/create``/assets``/settings`、第一方生成/资产 API以及本地 `/uploads/*``/generated-results/*` 文件。开放 `/api/v1/*` 仍使用 API KeyWorker 仍使用内部 token不走浏览器 SSO。

View File

@@ -2,6 +2,7 @@ import { SESSION_COOKIE_NAME, getAuthRuntimeConfig, safeNextPath, shouldUseSecur
import { createSessionCookieValue } from "@/lib/auth/session";
import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api";
import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt";
import { prepareAuthPassword } from "@/lib/server/auth/password";
export const runtime = "nodejs";
@@ -26,6 +27,8 @@ export async function POST(request: Request) {
const body = await readJsonBody<{
username?: string;
password?: string;
password_encrypted?: boolean;
passwordEncrypted?: boolean;
code?: string;
randomStr?: string;
next?: string;
@@ -34,7 +37,7 @@ export async function POST(request: Request) {
const password = body.password || "";
const code = body.code?.trim();
const randomStr = body.randomStr?.trim();
if (!username || !password || !code || !randomStr) throw new PasswordLoginError("账号、密码和验证码不能为空。");
if (!username || !password) throw new PasswordLoginError("账号和密码不能为空。");
const token = await exchangePasswordToken({
tokenUrl: config.tokenUrl,
@@ -42,7 +45,10 @@ export async function POST(request: Request) {
clientSecret: config.clientSecret,
scope: config.scope,
username,
password,
password: prepareAuthPassword(password, {
passwordEncrypted: body.password_encrypted || body.passwordEncrypted,
passwordEncryptionKey: config.passwordEncryptionKey
}),
code,
randomStr
});
@@ -74,16 +80,16 @@ async function exchangePasswordToken(input: {
scope: string;
username: string;
password: string;
code: string;
randomStr: string;
code?: string;
randomStr?: string;
}): Promise<PasswordTokenResponse> {
const form = new URLSearchParams();
form.set("grant_type", "password");
form.set("scope", input.scope);
form.set("username", input.username);
form.set("password", input.password);
form.set("code", input.code);
form.set("randomStr", input.randomStr);
if (input.code) form.set("code", input.code);
if (input.randomStr) form.set("randomStr", input.randomStr);
const response = await fetch(input.tokenUrl, {
method: "POST",
headers: {

View File

@@ -27,10 +27,6 @@ export function AuthLoginPanel({
const feedbackRef = useRef<HTMLDivElement | null>(null);
const hasMissingConfig = !configured && Boolean(missing?.length);
useEffect(() => {
refreshCaptcha();
}, []);
useEffect(() => {
return runScopedMotion(screenRef, (scope) => revealChildren(scope));
}, []);
@@ -55,14 +51,19 @@ export function AuthLoginPanel({
setSubmitting(true);
setError(null);
try {
const payload: Record<string, string> = { username, password, next };
if (code.trim() && randomStr) {
payload.code = code.trim();
payload.randomStr = randomStr;
}
const response = await fetch("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, code, randomStr, next })
body: JSON.stringify(payload)
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "登录失败");
window.location.assign(payload.redirectTo || next || "/create");
const result = await response.json().catch(() => ({})) as { error?: string; redirectTo?: string };
if (!response.ok) throw new Error(result.error || "登录失败");
window.location.assign(result.redirectTo || next || "/create");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
refreshCaptcha();
@@ -123,15 +124,22 @@ export function AuthLoginPanel({
value={code}
onChange={(event) => setCode(event.target.value)}
disabled={!configured || submitting}
placeholder="请输入验证码"
placeholder="需要时填写"
/>
<button className="auth-captcha-button" type="button" onClick={refreshCaptcha} disabled={!configured || submitting} aria-label="刷新验证码" title="刷新验证码">
<button
className="auth-captcha-button"
type="button"
onClick={refreshCaptcha}
disabled={!configured || submitting}
aria-label="刷新验证码"
title="刷新验证码"
>
{captchaSrc ? <img src={captchaSrc} alt="验证码" /> : <RefreshCw size={18} />}
</button>
</div>
</label>
<button className="button primary auth-submit" type="submit" disabled={!configured || submitting || !username.trim() || !password || !code.trim()} data-animate>
<button className="button primary auth-submit" type="submit" disabled={!configured || submitting || !username.trim() || !password} data-animate>
{submitting ? <Loader2 className="spin" size={18} /> : <LogIn size={18} />}
</button>

View File

@@ -49,6 +49,7 @@ ZHINIAN_AUTH_CLIENT_ID=customPC
ZHINIAN_AUTH_CLIENT_SECRET=请替换为认证中心客户端密钥
ZHINIAN_AUTH_SCOPE=server
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
ZHINIAN_AUTH_PASSWORD_ENC_KEY=
ZHINIAN_AUTH_SESSION_SECRET=请替换为强随机会话密钥
ZHINIAN_API_KEYS=partner-a:请替换为强随机key

View File

@@ -14,6 +14,7 @@ export type AuthRuntimeConfig = {
clientSecret?: string;
scope: string;
issuer: string;
passwordEncryptionKey?: string;
sessionSecret?: string;
clockSkewSeconds: number;
};
@@ -24,6 +25,7 @@ export function getAuthRuntimeConfig(): AuthRuntimeConfig {
const clientSecret = envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET");
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 sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET");
const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED");
const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true;
@@ -49,6 +51,7 @@ export function getAuthRuntimeConfig(): AuthRuntimeConfig {
clientSecret,
scope,
issuer,
passwordEncryptionKey,
sessionSecret,
clockSkewSeconds: numberEnv("ZHINIAN_AUTH_CLOCK_SKEW_SECONDS") ?? 60
};

View File

@@ -69,6 +69,7 @@ const settingDefinitions: Array<{
{ key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" },
{ 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" },
{ key: "ZHINIAN_AUTH_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" }
]
},

View File

@@ -143,12 +143,14 @@ function validateClaims(claims: AuthTokenClaims, config: AuthRuntimeConfig) {
if (iat && iat > now + skew) throw new JwtVerificationError("JWT issued-at is in the future.");
if (claims.iss !== config.issuer) throw new JwtVerificationError("JWT issuer is not trusted.");
const clientId = stringClaim(claims.client_id) || stringClaim(claims.clientId);
if (clientId !== config.clientId) throw new JwtVerificationError("JWT client id is not allowed.");
if (clientId && clientId !== config.clientId) throw new JwtVerificationError("JWT client id is not allowed.");
const requiredScopes = config.scope.split(/\s+/).filter(Boolean);
if (requiredScopes.length) {
const tokenScopes = new Set(stringListClaim(claims.scope));
for (const scope of requiredScopes) {
if (!tokenScopes.has(scope)) throw new JwtVerificationError("JWT scope is not allowed.");
if (tokenScopes.size > 0) {
for (const scope of requiredScopes) {
if (!tokenScopes.has(scope)) throw new JwtVerificationError("JWT scope is not allowed.");
}
}
}
}

View File

@@ -0,0 +1,21 @@
import { createCipheriv } from "node:crypto";
export function prepareAuthPassword(password: string, input: {
passwordEncrypted?: boolean;
passwordEncryptionKey?: string;
}): string {
if (input.passwordEncrypted) return password;
const key = input.passwordEncryptionKey?.trim();
if (!key) return password;
return encryptPasswordCFB(password, key);
}
export function encryptPasswordCFB(password: string, key: string): string {
const keyBytes = Buffer.from(key);
if (![16, 24, 32].includes(keyBytes.length)) {
throw new Error("password encryption key must be 16, 24, or 32 bytes");
}
const algorithm = `aes-${keyBytes.length * 8}-cfb`;
const cipher = createCipheriv(algorithm, keyBytes, keyBytes);
return Buffer.concat([cipher.update(password, "utf8"), cipher.final()]).toString("base64");
}

View File

@@ -0,0 +1,97 @@
import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { clearJwksCacheForTests } from "@/lib/server/auth/jwt";
import { POST } from "@/app/api/auth/password/route";
type TestJwk = JsonWebKey & {
kid?: string;
alg?: string;
use?: string;
};
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("password auth route AgentBus compatibility", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
clearJwksCacheForTests();
});
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", {
method: "POST",
body: JSON.stringify({
username: "user@example.com",
password: "123456",
next: "/create"
})
}));
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);
});
});
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");
}

View File

@@ -106,6 +106,28 @@ describe("SSO auth helpers", () => {
await expect(verifyAuthJwt(token, authConfig)).rejects.toThrow("client id");
});
it("accepts JWTs without optional client and scope claims like AgentBus", async () => {
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = publicKey.export({ format: "jwk" }) as TestJwk;
jwk.kid = "kid-3";
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }));
const token = signJwt({
iss: authConfig.issuer,
sub: "zhangsan",
exp: Math.floor(Date.now() / 1000) + 600,
iat: Math.floor(Date.now() / 1000) - 10,
nbf: Math.floor(Date.now() / 1000) - 10,
user_id: 1,
username: "zhangsan"
}, privateKey, "kid-3");
await expect(verifyAuthJwt(token, authConfig)).resolves.toMatchObject({
sub: "zhangsan",
user_id: 1
});
});
});
function signJwt(payload: Record<string, unknown>, privateKey: KeyObject, kid: string): string {

View File

@@ -2,19 +2,26 @@ import { describe, expect, it } from "vitest";
import vm from "node:vm";
import { randomUUIDPolyfillScript } from "@/lib/client/random-uuid-polyfill";
type RandomUUIDContext = {
crypto?: {
randomUUID?: () => string;
getRandomValues?: (bytes: Uint8Array) => Uint8Array;
};
};
describe("client randomUUID polyfill", () => {
it("defines crypto.randomUUID when crypto is missing", () => {
const context = {};
const context: RandomUUIDContext = {};
vm.runInNewContext(randomUUIDPolyfillScript, context);
expect(context.crypto.randomUUID()).toMatch(
expect(context.crypto?.randomUUID?.()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
it("defines crypto.randomUUID when only getRandomValues is available", () => {
const context = {
const context: RandomUUIDContext = {
crypto: {
getRandomValues(bytes: Uint8Array) {
for (let index = 0; index < bytes.length; index += 1) bytes[index] = index;
@@ -25,7 +32,7 @@ describe("client randomUUID polyfill", () => {
vm.runInNewContext(randomUUIDPolyfillScript, context);
expect(context.crypto.randomUUID()).toMatch(
expect(context.crypto?.randomUUID?.()).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});