diff --git a/.project-docs/30-worklog/tasks/20260816-fix-authenticated-ssr-6c3f8a21.md b/.project-docs/30-worklog/tasks/20260816-fix-authenticated-ssr-6c3f8a21.md new file mode 100644 index 0000000..9ee54f3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260816-fix-authenticated-ssr-6c3f8a21.md @@ -0,0 +1,52 @@ +# Task: Fix authenticated SSR through Go identity boundary + +## Identity + +- Task ID: 20260816-fix-authenticated-ssr-6c3f8a21 +- Mode: Feature +- Branch: codex/20260816-fix-authenticated-ssr-6c3f8a21-fix-authenticated-ssr +- Worktree: D:\Datas\OthersProjects\NianAIGC-fix-authenticated-ssr-6c3f8a21 +- Base commit: b26f9679abc343beb64142694add9154a6885b04 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Fix authenticated Next.js SSR in the split production topology by moving per-request session refresh across the internal Go `/api/auth/me` HTTP boundary. +- Add a regression test at the exported `getShellAuthState()` seam and strengthen ACK deployment assertions for the internal Go URL. +- Preserve the existing direct-store path when the internal Go URL is absent so local Next.js full-stack development and tests remain supported. + +## Intent And Constraints + +- Keep the Next production workload database-free; do not restore `ZHINIAN_DATA_BACKEND`, `DATABASE_URL`, RDS CA, or business credentials to Web. +- Preserve signed chunked `zhinian_session` Cookie behavior and per-request account, organization, role, and sessionVersion revalidation in Go. +- Forward only the session Cookie chunks to the internal service; never forward unrelated browser Cookies or expose the internal URL to client code. +- Fail closed on internal identity transport, status, or response-shape errors; do not silently treat infrastructure failure as anonymous. +- Work test-first at the `getShellAuthState()` boundary and keep changes surgical. + +## Outcome + +- `getOptionalAuthSession()` now keeps local full-stack behavior when no internal URL is configured, but in the split production topology it sends only the enumerated `zhinian_session` Cookie chunks to the internal Go `GET /api/auth/me` endpoint. +- The Go response is validated before it can rebuild the server-side session. Authenticated responses require a configured platform identity, an active valid role, matching subject and account IDs, role-consistent auth mode, and organization binding for non-super-admin roles; only allowlisted user fields are retained. Anonymous responses return no session, while transport, non-success status, malformed payload, or identity mismatch errors fail closed. +- ACK Web runtime configuration now supplies `ZHINIAN_GO_INTERNAL_BASE_URL=http://zhinian-go-api:8080`; the value remains server-only and Web remains database-free. +- The manifest checker now pins the internal URL to the Go Service name and port and guards against reintroducing database configuration into Web. +- Added a public-seam regression test for `getShellAuthState()` covering refreshed authenticated state, Cookie allowlisting, anonymous rejection, upstream failure, malformed payloads, and six privilege-boundary response variants found during final review. + +## Verification + +- RED: `cmd /c npx.cmd vitest run tests/auth-current-user-go-bridge.test.ts` failed before the implementation because authenticated SSR entered the Next database store and raised `ZHINIAN_DATA_BACKEND must be explicitly set...`. +- First review RED: the six strict identity cases resolved instead of rejecting before the response validator was tightened; the missing-role case demonstrated authority fallback granting both admin and super-admin shell state. +- GREEN: `cmd /c npx.cmd vitest run tests/auth-current-user-go-bridge.test.ts` — 1 file and 10 tests passed. +- `cmd /c npx.cmd tsc --noEmit --incremental false` — passed. +- `cmd /c npm.cmd test` — 58 files and 183 tests passed. +- `cmd /c npm.cmd run deploy:check` — `ACK manifest assertions passed (9 files)`. +- `cmd /c npm.cmd run build` — Next.js 15.5.18 production build passed; `/create` remains dynamically server-rendered. + +## Follow-ups + +- Build and publish updated Web and deployment artifacts, apply them to the ACK cluster, then smoke-test an authenticated `/create` request. No live deployment was performed by this task. +- Run a server-side ACK dry-run when cluster access is available; local verification is limited to repository tests and static manifest assertions. + +## Promotion Candidates + +- None. This change implements the already accepted Next.js-to-Go SSR identity boundary and does not introduce a new canonical architecture decision. diff --git a/deploy/ack/configmap.yaml b/deploy/ack/configmap.yaml index 57f65f4..b31a1f7 100644 --- a/deploy/ack/configmap.yaml +++ b/deploy/ack/configmap.yaml @@ -8,6 +8,7 @@ data: PORT: "3000" ZHINIAN_AUTH_REQUIRED: "1" ZHINIAN_PUBLIC_BASE_URL: https://REPLACE_WITH_PUBLIC_HOST + ZHINIAN_GO_INTERNAL_BASE_URL: http://zhinian-go-api:8080 --- # Go API runtime settings. Provider endpoints/models use code defaults unless # optional provider/OSS credentials are added to the Deployment. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index cc4bf09..8099b91 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -24,6 +24,11 @@ RDS/服务商凭据,仅共享会话密钥);Go 工作负载 `zhinian-go-api` 独 Node Worker,`worker.yaml` 已弃用保留)。Ingress 按路径分流:页面 → Web, 后端路径 → Go,`/api/internal/worker` → 无端点 deny Service。 +Next.js 的生产 SSR 通过 Web runtime ConfigMap 中的 +`ZHINIAN_GO_INTERNAL_BASE_URL=http://zhinian-go-api:8080` 访问集群内 Go +Service。该变量只供 Next 服务端 SSR 使用,不是公开访问地址,不应放入 +`NEXT_PUBLIC_*` 或暴露给浏览器;公网请求仍通过 Ingress 的公开域名进入。 + Go 镜像构建(四选一,详见 [`backend/README.md`](./backend/README.md)): ```bash diff --git a/lib/server/auth/current-user.ts b/lib/server/auth/current-user.ts index 861084b..d1754a7 100644 --- a/lib/server/auth/current-user.ts +++ b/lib/server/auth/current-user.ts @@ -1,7 +1,13 @@ import { cookies } from "next/headers"; import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config"; import { hasAdminSessionAccess, hasSuperAdminAccess } from "@/lib/auth/permissions"; -import { parseSessionCookieValue, readChunkedCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session"; +import { + chunkedCookieNames, + parseSessionCookieValue, + readChunkedCookieValue, + type AuthSession, + type AuthUser +} from "@/lib/auth/session"; import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; import { loadPlatformAuthorizationSnapshot } from "@/lib/server/auth/platform-authorization-store"; import { authorizePlatformSession } from "@/lib/server/auth/platform-session"; @@ -49,6 +55,127 @@ function localSession(): AuthSession { }; } +type PlatformAuthUser = AuthUser & { + role: NonNullable; + status: "active"; +}; + +type GoCurrentSessionResponse = { + authRequired: boolean; + authConfigured: boolean; +} & ( + | { authenticated: false; authMode: null; user: null } + | { authenticated: true; authMode: AuthSession["authMode"]; user: PlatformAuthUser } +); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function parsePlatformAuthUser(value: unknown): PlatformAuthUser | null { + if (!isRecord(value)) return null; + const role = value.role; + if ( + (role !== "user" && role !== "organization_admin" && role !== "super_admin") || + value.status !== "active" || + typeof value.id !== "string" || + !value.id || + value.subject !== value.id || + typeof value.displayName !== "string" || + typeof value.clientId !== "string" || + !isStringArray(value.authorities) || + !isStringArray(value.scope) + ) { + return null; + } + const organizationId = typeof value.organizationId === "string" && value.organizationId.trim() + ? value.organizationId + : undefined; + if (role !== "super_admin" && !organizationId) return null; + + const user: PlatformAuthUser = { + id: value.id, + subject: value.id, + displayName: value.displayName, + clientId: value.clientId, + role, + status: "active", + authorities: [...value.authorities], + scope: [...value.scope] + }; + if (typeof value.username === "string") user.username = value.username; + if (typeof value.phone === "string") user.phone = value.phone; + if (typeof value.tenantId === "string") user.tenantId = value.tenantId; + if (organizationId) user.organizationId = organizationId; + if (typeof value.organizationName === "string") user.organizationName = value.organizationName; + return user; +} + +function parseGoCurrentSessionResponse(value: unknown): GoCurrentSessionResponse { + if ( + !isRecord(value) || + typeof value.authenticated !== "boolean" || + typeof value.authRequired !== "boolean" || + typeof value.authConfigured !== "boolean" + ) { + throw new Error("Go auth/me returned an invalid response."); + } + if (!value.authenticated) { + if (value.authMode !== null || value.user !== null) { + throw new Error("Go auth/me returned an invalid anonymous response."); + } + return { + authenticated: false, + authRequired: value.authRequired, + authConfigured: value.authConfigured, + authMode: null, + user: null + }; + } + const user = parsePlatformAuthUser(value.user); + if ( + !value.authConfigured || + (value.authMode !== "user" && value.authMode !== "admin") || + !user || + value.authMode !== (user.role === "user" ? "user" : "admin") + ) { + throw new Error("Go auth/me returned an invalid authenticated response."); + } + return { + authenticated: true, + authRequired: value.authRequired, + authConfigured: value.authConfigured, + authMode: value.authMode, + user + }; +} + +async function authorizeSessionThroughGo(session: AuthSession, cookieHeader: string, baseUrl: string) { + const endpoint = new URL(`${baseUrl.replace(/\/+$/, "")}/api/auth/me`); + if ((endpoint.protocol !== "http:" && endpoint.protocol !== "https:") || endpoint.username || endpoint.password) { + throw new AuthConfigurationError("ZHINIAN_GO_INTERNAL_BASE_URL 必须是无凭据的 HTTP(S) 地址。"); + } + const response = await fetch(endpoint.toString(), { + cache: "no-store", + headers: { cookie: cookieHeader } + }); + if (!response.ok) throw new Error(`Go auth/me request failed with status ${response.status}.`); + const currentSession = parseGoCurrentSessionResponse(await response.json()); + if (!currentSession.authenticated) return null; + if (currentSession.user.id !== session.user.id || currentSession.user.clientId !== "platform") { + throw new Error("Go auth/me returned a mismatched authenticated user."); + } + return { + ...session, + authMode: currentSession.authMode, + user: currentSession.user + } satisfies AuthSession; +} + export async function getOptionalAuthSession(): Promise { const config = getAuthRuntimeConfig(); if (!config.sessionSecret) return null; @@ -58,6 +185,18 @@ export async function getOptionalAuthSession(): Promise { config.sessionSecret ); if (!session) return null; + if (session.user.clientId !== "platform") return null; + const internalGoBaseUrl = process.env.ZHINIAN_GO_INTERNAL_BASE_URL?.trim(); + if (internalGoBaseUrl) { + const cookieHeader = chunkedCookieNames(SESSION_COOKIE_NAME) + .map((name) => { + const value = cookieStore.get(name)?.value; + return value === undefined ? null : `${name}=${value}`; + }) + .filter((value): value is string => value !== null) + .join("; "); + return authorizeSessionThroughGo(session, cookieHeader, internalGoBaseUrl); + } const authorization = await authorizePlatformSession(session, loadPlatformAuthorizationSnapshot); return authorization.outcome === "authenticated" ? authorization.session : null; } diff --git a/scripts/check-ack-manifests.mjs b/scripts/check-ack-manifests.mjs index 119e941..f9e2107 100644 --- a/scripts/check-ack-manifests.mjs +++ b/scripts/check-ack-manifests.mjs @@ -20,6 +20,9 @@ assert(!migrationJob.includes("DATABASE_CA_CERT_PATH"), "migration Job must keep const web = read("web.yaml"); assert(/^\s*replicas: 1\s*$/m.test(web), "Web must default to one replica until object storage is shared"); assert(web.includes("path: /api/health"), "Web must use process-level readiness (it is database-free in production)"); +assert(web.includes("name: zhinian-runtime"), "Web must consume the Web runtime ConfigMap"); +assert(!web.includes("ZHINIAN_DATA_BACKEND"), "Web must not select a database backend in production"); +assert(!web.includes("DATABASE_URL"), "Web must not receive DATABASE_URL in production"); assert(!web.includes("zhinian-web-db"), "Web must not hold RDS credentials in production"); assert(!web.includes("rds-ca"), "Web must not mount the RDS CA in production"); @@ -34,6 +37,14 @@ assert(!goApi.includes("DATABASE_SSL_MODE"), "Go API must keep database TLS insi assert(!goApi.includes("DATABASE_CA_CERT_PATH"), "Go API must keep database TLS inside DATABASE_URL"); const configMap = read("configmap.yaml"); +const webRuntime = configMap.split("\n---\n", 1)[0]; +const expectedInternalBaseUrl = "http://zhinian-go-api:8080"; +const internalBaseUrl = webRuntime.match(/^\s*ZHINIAN_GO_INTERNAL_BASE_URL:\s*(\S+)\s*$/m)?.[1]; +assert(internalBaseUrl, "Web runtime ConfigMap must define ZHINIAN_GO_INTERNAL_BASE_URL"); +assert(internalBaseUrl === expectedInternalBaseUrl, "Web runtime ConfigMap must use the cluster-internal Go API URL"); +assert(!webRuntime.includes("ZHINIAN_DATA_BACKEND"), "Web runtime ConfigMap must not select a database backend"); +assert(!webRuntime.includes("DATABASE_URL"), "Web runtime ConfigMap must not carry DATABASE_URL"); +assert(!webRuntime.includes("rds-ca"), "Web runtime ConfigMap must not carry the RDS CA"); assert(configMap.includes("ZHINIAN_GO_EMBEDDED_WORKER: \"true\""), "Go runtime ConfigMap must embed the WorkerLoop"); assert(configMap.includes("GO_BACKEND_HOST: 0.0.0.0"), "Go runtime ConfigMap must listen on the Pod interface"); assert(!configMap.includes("DATABASE_SSL_MODE"), "ConfigMaps must not carry database TLS settings"); @@ -50,6 +61,16 @@ assert(ingress.includes("name: zhinian-web"), "Ingress must route pages/static p const service = read("service.yaml"); assert(service.includes("name: zhinian-public-deny"), "selectorless deny Service is required"); +const goService = goApi.match( + /kind: Service\s+metadata:\s+name:\s+(\S+)[\s\S]*?ports:\s+- name: http\s+port:\s+(\d+)\s+targetPort:\s+(\d+)/, +); +assert(goService, "Go API Service must declare an HTTP name, port, and targetPort"); +const [, serviceName, servicePort, targetPort] = goService; +const parsedInternalBaseUrl = new URL(internalBaseUrl); +assert(parsedInternalBaseUrl.hostname === serviceName, "Web internal Go URL must use the Go API Service name"); +assert(parsedInternalBaseUrl.port === servicePort, "Web internal Go URL must use the Go API Service port"); +assert(targetPort === servicePort, "Go API Service targetPort must match its port"); + console.log(`ACK manifest assertions passed (${files.length} files)`); function read(file) { diff --git a/tests/auth-current-user-go-bridge.test.ts b/tests/auth-current-user-go-bridge.test.ts new file mode 100644 index 0000000..e0ceca7 --- /dev/null +++ b/tests/auth-current-user-go-bridge.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SESSION_COOKIE_NAME } from "@/lib/auth/config"; +import { chunkCookieValue, chunkedCookieName, createSignedJsonValue, type AuthSession } from "@/lib/auth/session"; + +const { cookieValues } = vi.hoisted(() => ({ + cookieValues: new Map(), +})); + +vi.mock("next/headers", () => ({ + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieValues.get(name); + return value === undefined ? undefined : { name, value }; + }, + })), +})); + +import { getShellAuthState } from "@/lib/server/auth/current-user"; + +const authEnvironmentKeys = [ + "NODE_ENV", + "ZHINIAN_AUTH_REQUIRED", + "ZHINIAN_AUTH_SESSION_SECRET", + "ZHINIAN_GO_INTERNAL_BASE_URL", +] as const; +const originalEnvironment = new Map(authEnvironmentKeys.map((key) => [key, process.env[key]])); + +function configureGoBridge() { + Reflect.set(process.env, "NODE_ENV", "production"); + Reflect.set(process.env, "ZHINIAN_AUTH_REQUIRED", "true"); + Reflect.set(process.env, "ZHINIAN_AUTH_SESSION_SECRET", "bridge-test-secret"); + Reflect.set(process.env, "ZHINIAN_GO_INTERNAL_BASE_URL", "http://go-api.internal/"); +} + +async function seedPlatformSessionCookie() { + const session: AuthSession = { + version: 1, + authMode: "user", + issuedAt: Math.floor(Date.now() / 1000) - 10, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + sessionVersion: 7, + user: { + id: "account-1", + subject: "account-1", + displayName: "旧名称", + clientId: "platform", + organizationId: "org-old", + organizationName: "旧组织", + role: "user", + authorities: ["ROLE_USER"], + scope: [], + }, + }; + const signed = await createSignedJsonValue(session, "bridge-test-secret"); + const chunks = chunkCookieValue(signed, 80); + chunks.forEach((value, index) => cookieValues.set(chunkedCookieName(SESSION_COOKIE_NAME, index), value)); + return chunks; +} + +afterEach(() => { + vi.unstubAllGlobals(); + cookieValues.clear(); + for (const key of authEnvironmentKeys) { + const original = originalEnvironment.get(key); + if (original === undefined) Reflect.deleteProperty(process.env, key); + else Reflect.set(process.env, key, original); + } +}); + +describe("authenticated shell state through the Go identity boundary", () => { + it("refreshes the signed platform session and forwards only its cookie chunks", async () => { + configureGoBridge(); + const chunks = await seedPlatformSessionCookie(); + cookieValues.set("theme", "dark"); + cookieValues.set("analytics_id", "do-not-forward"); + + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + authenticated: true, + authRequired: true, + authConfigured: true, + authMode: "admin", + user: { + id: "account-1", + subject: "account-1", + username: "13800138001", + phone: "13800138001", + displayName: "刷新名称", + clientId: "platform", + organizationId: "org-1", + organizationName: "刷新组织", + role: "organization_admin", + status: "active", + authorities: ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"], + scope: [], + }, + }), { status: 200, headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + + await expect(getShellAuthState()).resolves.toEqual({ + user: expect.objectContaining({ + id: "account-1", + displayName: "刷新名称", + organizationId: "org-1", + role: "organization_admin", + }), + authRequired: true, + authConfigured: true, + isAdmin: true, + isSuperAdmin: false, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith("http://go-api.internal/api/auth/me", { + cache: "no-store", + headers: { + cookie: chunks + .map((value, index) => `${chunkedCookieName(SESSION_COOKIE_NAME, index)}=${value}`) + .join("; "), + }, + }); + }); + + it("treats a Go-rejected session as anonymous", async () => { + configureGoBridge(); + await seedPlatformSessionCookie(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + authenticated: false, + authRequired: true, + authConfigured: true, + authMode: null, + user: null, + }), { status: 200, headers: { "content-type": "application/json" } }))); + + await expect(getShellAuthState()).resolves.toEqual({ + user: null, + authRequired: true, + authConfigured: true, + isAdmin: false, + isSuperAdmin: false, + }); + }); + + it.each([ + ["an upstream error", new Response(null, { status: 503 }), "status 503"], + [ + "an invalid authenticated response", + new Response(JSON.stringify({ + authenticated: true, + authRequired: true, + authConfigured: true, + authMode: "admin", + user: null, + }), { status: 200, headers: { "content-type": "application/json" } }), + "invalid authenticated response", + ], + ])("fails closed for %s", async (_caseName, response, expectedMessage) => { + configureGoBridge(); + await seedPlatformSessionCookie(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + + await expect(getShellAuthState()).rejects.toThrow(expectedMessage); + }); + + const validOrganizationAdmin = { + id: "account-1", + subject: "account-1", + username: "13800138001", + phone: "13800138001", + displayName: "刷新名称", + clientId: "platform", + organizationId: "org-1", + organizationName: "刷新组织", + role: "organization_admin", + status: "active", + authorities: ["ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"], + scope: [], + }; + const { role: _role, status: _status, ...userWithoutRoleOrStatus } = validOrganizationAdmin; + const { organizationId: _organizationId, organizationName: _organizationName, ...userWithoutOrganization } = + validOrganizationAdmin; + + it.each([ + ["missing platform role and status", { + authMode: "admin", + authConfigured: true, + user: { ...userWithoutRoleOrStatus, authorities: ["SUPER_ADMIN"] }, + }], + ["a disabled account", { + authMode: "admin", + authConfigured: true, + user: { ...validOrganizationAdmin, status: "disabled" }, + }], + ["a mismatched subject", { + authMode: "admin", + authConfigured: true, + user: { ...validOrganizationAdmin, subject: "other-account" }, + }], + ["a role/authMode mismatch", { + authMode: "user", + authConfigured: true, + user: validOrganizationAdmin, + }], + ["an unconfigured authenticated response", { + authMode: "admin", + authConfigured: false, + user: validOrganizationAdmin, + }], + ["an organization-bound role without an organization", { + authMode: "admin", + authConfigured: true, + user: userWithoutOrganization, + }], + ])("rejects %s", async (_caseName, invalid) => { + configureGoBridge(); + await seedPlatformSessionCookie(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ + authenticated: true, + authRequired: true, + authMode: invalid.authMode, + authConfigured: invalid.authConfigured, + user: invalid.user, + }), { status: 200, headers: { "content-type": "application/json" } }))); + + await expect(getShellAuthState()).rejects.toThrow("invalid authenticated response"); + }); +});