diff --git a/.env.example b/.env.example index ae9443e..f8e74c9 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index d702005..27b89ca 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/README.zh-CN.md b/README.zh-CN.md index 43660b0..4babbd8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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。 diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts index 0e2e9b6..66f185c 100644 --- a/app/api/auth/password/route.ts +++ b/app/api/auth/password/route.ts @@ -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 { 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: { diff --git a/components/auth-login-panel.tsx b/components/auth-login-panel.tsx index 88628c6..9cedf39 100644 --- a/components/auth-login-panel.tsx +++ b/components/auth-login-panel.tsx @@ -27,10 +27,6 @@ export function AuthLoginPanel({ const feedbackRef = useRef(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 = { 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="需要时填写" /> - - diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index cf7d562..df126a8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -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 diff --git a/lib/auth/config.ts b/lib/auth/config.ts index 6e6ba1d..6cd4ca6 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -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 }; diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index a51a2f9..c5e94ab 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -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" } ] }, diff --git a/lib/server/auth/jwt.ts b/lib/server/auth/jwt.ts index cca31da..9463008 100644 --- a/lib/server/auth/jwt.ts +++ b/lib/server/auth/jwt.ts @@ -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."); + } } } } diff --git a/lib/server/auth/password.ts b/lib/server/auth/password.ts new file mode 100644 index 0000000..bb7ef82 --- /dev/null +++ b/lib/server/auth/password.ts @@ -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"); +} diff --git a/tests/auth-password-route.test.ts b/tests/auth-password-route.test.ts new file mode 100644 index 0000000..3aeff98 --- /dev/null +++ b/tests/auth-password-route.test.ts @@ -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, 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"); +} diff --git a/tests/auth-session.test.ts b/tests/auth-session.test.ts index df9fe51..a78f712 100644 --- a/tests/auth-session.test.ts +++ b/tests/auth-session.test.ts @@ -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, privateKey: KeyObject, kid: string): string { diff --git a/tests/random-uuid-polyfill.test.ts b/tests/random-uuid-polyfill.test.ts index 1825788..8b4b1fe 100644 --- a/tests/random-uuid-polyfill.test.ts +++ b/tests/random-uuid-polyfill.test.ts @@ -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}$/ ); });