修改认证中心对接方式
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 Key,Worker 仍使用内部 token,不走浏览器 SSO。
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
lib/server/auth/password.ts
Normal file
21
lib/server/auth/password.ts
Normal 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");
|
||||
}
|
||||
97
tests/auth-password-route.test.ts
Normal file
97
tests/auth-password-route.test.ts
Normal 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");
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}$/
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user