diff --git a/.env.example b/.env.example index 5c161ca..d3531c5 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,21 @@ NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000 ZHINIAN_RUNTIME_DIR=.runtime ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000 +# Account login / Web SSO. +# Production requires login by default. Set ZHINIAN_AUTH_REQUIRED=0 only for trusted local development. +ZHINIAN_AUTH_REQUIRED=auto +ZHINIAN_AUTH_BASE_URL=https:///auth +ZHINIAN_AUTH_CLIENT_ID=customPC +ZHINIAN_AUTH_CLIENT_SECRET= +ZHINIAN_AUTH_SCOPE=server +ZHINIAN_AUTH_ISSUER=https://pig4cloud.com +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= +ZHINIAN_AUTH_TOKEN_URL= +ZHINIAN_AUTH_JWKS_URL= +ZHINIAN_AUTH_LOGOUT_URL= + # Public API v1 and worker task management. # Format: clientId:key,anotherClient:anotherKey ZHINIAN_API_KEYS=demo-agent:change-me-public-api-key diff --git a/README.md b/README.md index 6f5f38e..d702005 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,30 @@ Docker 部署默认使用 `docker-compose.yml` 同时启动 Web 服务和 `zhini 主导航只保留创作、结果、设置,不包含工作台、项目、模板中心、Billing 或桌面端入口。 +## 账户登录 / SSO + +发布环境默认要求账户登录。Web 登录支持两种方式:一是 OAuth2 Authorization Code,用户跳转到认证中心登录,本服务在 `/api/auth/callback` 后端换 token;二是在本项目登录页直接输入账号、密码和图形验证码,由本服务后端调用 `${AUTH_BASE}/oauth2/token` 的 password grant。两种方式都会通过 `${AUTH_BASE}/oauth2/jwks` 本地验签 JWT,再写入 HttpOnly 会话 cookie。 + +需要在认证中心客户端配置中加入回调地址: + +```text +https://你的域名/api/auth/callback +``` + +关键环境变量: + +- `ZHINIAN_AUTH_REQUIRED=auto`:生产默认启用;本地可信开发可设为 `0` +- `ZHINIAN_AUTH_BASE_URL=https:///auth` +- `ZHINIAN_AUTH_CLIENT_ID=customPC` +- `ZHINIAN_AUTH_CLIENT_SECRET` +- `ZHINIAN_AUTH_SCOPE=server` +- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` +- `ZHINIAN_AUTH_SESSION_SECRET`:长随机字符串,用于签名本地登录态 + +`/create`、`/assets`、`/settings`、第一方生成/资产 API、以及本地上传和生成结果文件都会受登录态保护。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。 + +如果认证中心客户端未加入 `security.ignore-clients`,登录页的账号密码方式会要求图形验证码;验证码图片由 `/api/auth/captcha` 代理 `${AUTH_BASE}/code/image` 获取。 + ## 图片创作引擎 图片生成和局部重绘支持在设置页「状态」里按功能切换创作引擎: @@ -116,6 +140,13 @@ cp .env.example .env.local 核心配置: +- `ZHINIAN_AUTH_REQUIRED=auto` +- `ZHINIAN_AUTH_BASE_URL` +- `ZHINIAN_AUTH_CLIENT_ID=customPC` +- `ZHINIAN_AUTH_CLIENT_SECRET` +- `ZHINIAN_AUTH_SCOPE=server` +- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` +- `ZHINIAN_AUTH_SESSION_SECRET` - `IMAGE_GENERATE_ENGINE=jimeng` 或 `evolink` - `IMAGE_INPAINT_ENGINE=jimeng` 或 `evolink` - `VOLCENGINE_ACCESS_KEY_ID` diff --git a/README.zh-CN.md b/README.zh-CN.md index 37a2357..57e5bca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -138,6 +138,32 @@ npm run info | `/assets` | 历史任务与资产 | | `/settings` | 接口、引擎和服务配置 | +## 账户登录 / SSO + +发布环境默认启用账户登录保护。Web 端支持 OAuth2 Authorization Code 和账号密码验证码两种登录方式:授权码模式会跳转认证中心,平台后端在 `/api/auth/callback` 用授权码换 token;账号密码方式会在本项目登录页提交账号、密码和图形验证码,由后端调用 `${AUTH_BASE}/oauth2/token` 的 password grant。两种方式都会通过 JWKS 本地验签 JWT,然后写入 HttpOnly 会话 cookie。 + +认证中心客户端需要配置回调地址: + +```text +https://你的域名/api/auth/callback +``` + +核心配置: + +| 变量 | 说明 | +|------|------| +| `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` | +| `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https:///auth` | +| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID,默认 `customPC` | +| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥,只能保存在服务端 | +| `ZHINIAN_AUTH_SCOPE` | 默认 `server` | +| `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` | +| `ZHINIAN_AUTH_SESSION_SECRET` | 本地会话签名密钥,使用长随机字符串 | + +受保护范围包括 `/create`、`/assets`、`/settings`、第一方生成/资产 API,以及本地 `/uploads/*` 和 `/generated-results/*` 文件。开放 `/api/v1/*` 仍使用 API Key,Worker 仍使用内部 token,不走浏览器 SSO。 + +如果认证中心客户端没有加入 `security.ignore-clients`,账号密码登录会要求图形验证码;验证码图片由 `/api/auth/captcha` 代理 `${AUTH_BASE}/code/image` 获取。 + ## 引擎说明 ### 图片生成 @@ -216,6 +242,11 @@ cp .env.example .env.local |------|------| | `APP_PORT` | Docker Compose 对外暴露端口,默认 `3000` | | `NEXT_PUBLIC_APP_URL` | 对外访问地址,用于生成回调/本地文件 URL | +| `ZHINIAN_AUTH_REQUIRED` | 账户登录保护策略 | +| `ZHINIAN_AUTH_BASE_URL` | 统一认证中心地址 | +| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID | +| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 | +| `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 | | `ZHINIAN_API_KEYS` | 开放 API Key,格式 `clientId:key,clientId2:key2` | | `ZHINIAN_INTERNAL_WORKER_TOKEN` | 内部 Worker tick 接口令牌 | | `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 | diff --git a/app/api/assets/[id]/download/route.ts b/app/api/assets/[id]/download/route.ts index 4dc5a7e..125e4ed 100644 --- a/app/api/assets/[id]/download/route.ts +++ b/app/api/assets/[id]/download/route.ts @@ -1,14 +1,16 @@ import { getAsset } from "@/lib/server/data-store"; import { jsonError } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { readAssetForDownload } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const asset = await getAsset(id); - if (!asset) return jsonError("资产不存在", 404); + if (!asset || asset.ownerId !== user.id) return jsonError("资产不存在", 404); const file = await readAssetForDownload(asset); if (!file) return jsonError("资产文件不可下载", 404); return new Response(new Uint8Array(file.bytes), { diff --git a/app/api/assets/[id]/inpaint/route.ts b/app/api/assets/[id]/inpaint/route.ts index a0b3640..082fd82 100644 --- a/app/api/assets/[id]/inpaint/route.ts +++ b/app/api/assets/[id]/inpaint/route.ts @@ -1,5 +1,6 @@ import { getAsset } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { saveMaskDataUrl } from "@/lib/server/storage"; import { submitImageJob } from "@/lib/server/generation-service"; @@ -8,9 +9,10 @@ export const runtime = "nodejs"; export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const asset = await getAsset(id); - if (!asset) return jsonError(new Error("Asset not found."), 404); + if (!asset || asset.ownerId !== user.id) return jsonError(new Error("Asset not found."), 404); const body = await readJsonBody<{ prompt?: string; maskDataUrl?: string; @@ -31,6 +33,7 @@ export async function POST(request: Request, context: { params: Promise<{ id: st } if (!maskUrl) throw new Error("maskDataUrl or maskUrl is required for inpainting."); const job = await submitImageJob({ + ownerId: user.id, capability: "image.inpaint", prompt: body.prompt || "删除", imageUrls: [asset.url, maskUrl], diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts index 6256fa5..f613281 100644 --- a/app/api/assets/[id]/route.ts +++ b/app/api/assets/[id]/route.ts @@ -1,14 +1,16 @@ import { deleteAsset, getAsset } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { deleteStoredAsset } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const asset = await getAsset(id); - if (!asset) return jsonError("资产不存在", 404); + if (!asset || asset.ownerId !== user.id) return jsonError("资产不存在", 404); await deleteStoredAsset(asset); await deleteAsset(id); return jsonOk({ ok: true, deletedAssetId: id }); diff --git a/app/api/assets/[id]/upscale/route.ts b/app/api/assets/[id]/upscale/route.ts index 93b1eb5..3d4c1c4 100644 --- a/app/api/assets/[id]/upscale/route.ts +++ b/app/api/assets/[id]/upscale/route.ts @@ -1,5 +1,6 @@ import { getAsset } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { submitImageJob } from "@/lib/server/generation-service"; @@ -7,14 +8,16 @@ export const runtime = "nodejs"; export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const asset = await getAsset(id); - if (!asset) return jsonError(new Error("Asset not found."), 404); + if (!asset || asset.ownerId !== user.id) return jsonError(new Error("Asset not found."), 404); const body = await readJsonBody<{ resolution?: "4k" | "8k"; scale?: number; }>(request); const job = await submitImageJob({ + ownerId: user.id, capability: "image.upscale", imageUrls: [asset.url], inputAssetIds: [asset.id], diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts index b045b5b..835eef5 100644 --- a/app/api/assets/route.ts +++ b/app/api/assets/route.ts @@ -1,13 +1,14 @@ import { createAsset, listAssets } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; -import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import type { AssetKind } from "@/lib/types"; export const runtime = "nodejs"; export async function GET() { try { - return jsonOk({ assets: await listAssets(DEFAULT_OWNER_ID) }); + const user = await requireAppUser(); + return jsonOk({ assets: await listAssets(user.id) }); } catch (error) { return jsonError(error, 500); } @@ -22,9 +23,10 @@ export async function POST(request: Request) { tags?: string[]; source?: "upload" | "generated" | "edited" | "upscaled" | "external" | "seed"; }>(request); + const user = await requireAppUser(); if (!body.url) throw new Error("url is required"); const asset = await createAsset({ - ownerId: DEFAULT_OWNER_ID, + ownerId: user.id, kind: body.kind || "image", name: body.name || "外部图片", url: body.url, diff --git a/app/api/assets/upload/route.ts b/app/api/assets/upload/route.ts index 0ea89da..2868f0b 100644 --- a/app/api/assets/upload/route.ts +++ b/app/api/assets/upload/route.ts @@ -1,17 +1,19 @@ import { jsonError, jsonOk } from "@/lib/server/api"; -import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; +import { requireAppUser } from "@/lib/server/auth/current-user"; +import { requestOrigin } from "@/lib/server/runtime"; import { saveUploadAsset } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function POST(request: Request) { try { + const user = await requireAppUser(); const form = await request.formData(); const files = form.getAll("files").filter((item): item is File => item instanceof File); if (!files.length) throw new Error("No files uploaded."); const assets = await Promise.all(files.map(async (file) => { return saveUploadAsset({ - ownerId: DEFAULT_OWNER_ID, + ownerId: user.id, bytes: Buffer.from(await file.arrayBuffer()), fileName: file.name, contentType: file.type || "application/octet-stream", diff --git a/app/api/auth/callback/route.ts b/app/api/auth/callback/route.ts new file mode 100644 index 0000000..f978906 --- /dev/null +++ b/app/api/auth/callback/route.ts @@ -0,0 +1,11 @@ +import { completeAuthorizationCallback, redirectToLoginWithError } from "@/lib/server/auth/oauth"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + return await completeAuthorizationCallback(request); + } catch { + return redirectToLoginWithError(request, "callback_failed"); + } +} diff --git a/app/api/auth/captcha/route.ts b/app/api/auth/captcha/route.ts new file mode 100644 index 0000000..68501b7 --- /dev/null +++ b/app/api/auth/captcha/route.ts @@ -0,0 +1,25 @@ +import { getAuthRuntimeConfig } from "@/lib/auth/config"; +import { jsonError } from "@/lib/server/api"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + const config = getAuthRuntimeConfig(); + if (!config.authBaseUrl) throw new Error("认证中心地址未配置。"); + const randomStr = new URL(request.url).searchParams.get("randomStr")?.trim(); + if (!randomStr) throw new Error("randomStr is required."); + const response = await fetch(`${config.authBaseUrl}/code/image?randomStr=${encodeURIComponent(randomStr)}`, { + cache: "no-store" + }); + if (!response.ok) throw new Error(`验证码获取失败:${response.status}`); + return new Response(new Uint8Array(await response.arrayBuffer()), { + headers: { + "Content-Type": response.headers.get("content-type") || "image/png", + "Cache-Control": "no-store" + } + }); + } catch (error) { + return jsonError(error, 500); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..1fa2da2 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,11 @@ +import { createAuthorizeRedirect, redirectToLoginWithError } from "@/lib/server/auth/oauth"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + try { + return await createAuthorizeRedirect(request); + } catch { + return redirectToLoginWithError(request, "auth_not_configured"); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..0033dc5 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,11 @@ +import { clearAuthCookies } from "@/lib/server/auth/oauth"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + return clearAuthCookies(request); +} + +export async function POST(request: Request) { + return clearAuthCookies(request); +} diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts new file mode 100644 index 0000000..11eaa4c --- /dev/null +++ b/app/api/auth/me/route.ts @@ -0,0 +1,16 @@ +import { getAuthRuntimeConfig } from "@/lib/auth/config"; +import { jsonOk } from "@/lib/server/api"; +import { getOptionalAuthSession } from "@/lib/server/auth/current-user"; + +export const runtime = "nodejs"; + +export async function GET() { + const config = getAuthRuntimeConfig(); + const session = await getOptionalAuthSession(); + return jsonOk({ + authenticated: Boolean(session), + authRequired: config.required, + authConfigured: config.configured, + user: session?.user || null + }); +} diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts new file mode 100644 index 0000000..0e2e9b6 --- /dev/null +++ b/app/api/auth/password/route.ts @@ -0,0 +1,119 @@ +import { SESSION_COOKIE_NAME, getAuthRuntimeConfig, safeNextPath, shouldUseSecureAuthCookie } from "@/lib/auth/config"; +import { createSessionCookieValue } from "@/lib/auth/session"; +import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt"; + +export const runtime = "nodejs"; + +type PasswordTokenResponse = { + access_token?: string; + refresh_token?: string; + expires_in?: string | number; + token_type?: string; + error?: string; + error_description?: string; + msg?: string; + message?: string; + [key: string]: unknown; +}; + +export async function POST(request: Request) { + try { + const config = getAuthRuntimeConfig(); + if (!config.configured || !config.tokenUrl || !config.clientSecret || !config.sessionSecret) { + throw new PasswordLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500); + } + const body = await readJsonBody<{ + username?: string; + password?: string; + code?: string; + randomStr?: string; + next?: string; + }>(request); + const username = body.username?.trim(); + const password = body.password || ""; + const code = body.code?.trim(); + const randomStr = body.randomStr?.trim(); + if (!username || !password || !code || !randomStr) throw new PasswordLoginError("账号、密码和验证码不能为空。"); + + const token = await exchangePasswordToken({ + tokenUrl: config.tokenUrl, + clientId: config.clientId, + clientSecret: config.clientSecret, + scope: config.scope, + username, + password, + code, + randomStr + }); + if (!token.access_token) throw new PasswordLoginError("认证中心没有返回 access_token。", 502); + const claims = await verifyAuthJwt(token.access_token, config); + const session = createSessionFromClaims(claims, config, parseExpiresIn(token.expires_in)); + const response = jsonOk({ + ok: true, + redirectTo: safeNextPath(body.next), + user: session.user + }); + response.cookies.set(SESSION_COOKIE_NAME, await createSessionCookieValue(session, config.sessionSecret), { + httpOnly: true, + sameSite: "lax", + secure: shouldUseSecureAuthCookie(request.url), + path: "/", + expires: new Date(session.expiresAt * 1000) + }); + return response; + } catch (error) { + return jsonError(error, 401); + } +} + +async function exchangePasswordToken(input: { + tokenUrl: string; + clientId: string; + clientSecret: string; + scope: string; + username: string; + password: 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); + const response = await fetch(input.tokenUrl, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + body: form + }); + const payload = await response.json().catch(() => ({})) as PasswordTokenResponse; + if (!response.ok) { + throw new PasswordLoginError(payload.error_description || payload.msg || payload.message || payload.error || "登录失败。", response.status); + } + return payload; +} + +function parseExpiresIn(value: string | number | undefined): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +class PasswordLoginError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "PasswordLoginError"; + this.status = status; + } +} diff --git a/app/api/generations/image/[id]/retry/route.ts b/app/api/generations/image/[id]/retry/route.ts index 8e97779..b8f4d8b 100644 --- a/app/api/generations/image/[id]/retry/route.ts +++ b/app/api/generations/image/[id]/retry/route.ts @@ -1,4 +1,5 @@ import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { retryImageJob } from "@/lib/server/generation-service"; @@ -6,8 +7,9 @@ export const runtime = "nodejs"; export async function POST(request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; - const job = await retryImageJob(id, requestOrigin(request)); + const job = await retryImageJob(id, requestOrigin(request), user.id); return jsonOk({ job }, { status: 202 }); } catch (error) { return jsonError(error); diff --git a/app/api/generations/image/[id]/route.ts b/app/api/generations/image/[id]/route.ts index 2600d1d..2c176d2 100644 --- a/app/api/generations/image/[id]/route.ts +++ b/app/api/generations/image/[id]/route.ts @@ -1,14 +1,16 @@ import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { deleteStoredAsset } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const job = await getGenerationJob(id); - if (!job) return jsonError(new Error("Generation job not found."), 404); + if (!job || job.ownerId !== user.id) return jsonError(new Error("Generation job not found."), 404); return jsonOk({ job }); } catch (error) { return jsonError(error, 500); @@ -17,9 +19,10 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const job = await getGenerationJob(id); - if (!job || job.capability === "video.generate") return jsonError("任务不存在", 404); + if (!job || job.ownerId !== user.id || job.capability === "video.generate") return jsonError("任务不存在", 404); const deletedAssetIds: string[] = []; for (const assetId of job.outputAssetIds) { const asset = await getAsset(assetId); diff --git a/app/api/generations/image/route.ts b/app/api/generations/image/route.ts index 884da00..16f27b0 100644 --- a/app/api/generations/image/route.ts +++ b/app/api/generations/image/route.ts @@ -1,5 +1,6 @@ import { getGenerationJob, listGenerationJobs } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { submitImageJob } from "@/lib/server/generation-service"; import { assemblePrompt, type PromptAssemblyInput, type PromptMaterial } from "@/lib/prompt/assembler"; @@ -9,7 +10,8 @@ export const runtime = "nodejs"; export async function GET() { try { - const jobs = (await listGenerationJobs()).filter((job) => job.capability !== "video.generate"); + const user = await requireAppUser(); + const jobs = (await listGenerationJobs(user.id)).filter((job) => job.capability !== "video.generate"); return jsonOk({ jobs }); } catch (error) { return jsonError(error, 500); @@ -18,6 +20,7 @@ export async function GET() { export async function POST(request: Request) { try { + const user = await requireAppUser(); const body = await readJsonBody<{ capability?: EnabledImageCapability; prompt?: string; @@ -41,6 +44,7 @@ export async function POST(request: Request) { .filter((material) => material.type === "image") .map((material) => material.url); const job = await submitImageJob({ + ownerId: user.id, capability, prompt: body.prompt || assembled?.prompt, imageUrls: body.imageUrls || materialImages, diff --git a/app/api/generations/video/[id]/route.ts b/app/api/generations/video/[id]/route.ts index a11e6a8..df62cc6 100644 --- a/app/api/generations/video/[id]/route.ts +++ b/app/api/generations/video/[id]/route.ts @@ -1,14 +1,16 @@ import { deleteAsset, deleteGenerationJob, getAsset, getGenerationJob } from "@/lib/server/data-store"; import { jsonError, jsonOk } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { deleteStoredAsset } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const job = await getGenerationJob(id); - if (!job) return jsonError(new Error("Generation job not found."), 404); + if (!job || job.ownerId !== user.id) return jsonError(new Error("Generation job not found."), 404); return jsonOk({ job }); } catch (error) { return jsonError(error, 500); @@ -17,9 +19,10 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st export async function DELETE(_request: Request, context: { params: Promise<{ id: string }> }) { try { + const user = await requireAppUser(); const { id } = await context.params; const job = await getGenerationJob(id); - if (!job || job.capability !== "video.generate") return jsonError("任务不存在", 404); + if (!job || job.ownerId !== user.id || job.capability !== "video.generate") return jsonError("任务不存在", 404); const deletedAssetIds: string[] = []; for (const assetId of job.outputAssetIds) { const asset = await getAsset(assetId); diff --git a/app/api/generations/video/route.ts b/app/api/generations/video/route.ts index 647f006..392794d 100644 --- a/app/api/generations/video/route.ts +++ b/app/api/generations/video/route.ts @@ -1,5 +1,6 @@ import { listGenerationJobs } from "@/lib/server/data-store"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { requestOrigin } from "@/lib/server/runtime"; import { submitVideoJob, type SubmitVideoJobInput } from "@/lib/server/video-generation-service"; @@ -7,7 +8,8 @@ export const runtime = "nodejs"; export async function GET() { try { - const jobs = (await listGenerationJobs()).filter((job) => job.capability === "video.generate"); + const user = await requireAppUser(); + const jobs = (await listGenerationJobs(user.id)).filter((job) => job.capability === "video.generate"); return jsonOk({ jobs }); } catch (error) { return jsonError(error, 500); @@ -16,8 +18,9 @@ export async function GET() { export async function POST(request: Request) { try { + const user = await requireAppUser(); const body = await readJsonBody>(request); - const job = await submitVideoJob(body, requestOrigin(request)); + const job = await submitVideoJob({ ...body, ownerId: user.id }, requestOrigin(request)); return jsonOk({ job }, { status: 202 }); } catch (error) { return jsonError(error); diff --git a/app/api/health/route.ts b/app/api/health/route.ts index fff87aa..95b139f 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,4 +1,5 @@ import { jsonOk } from "@/lib/server/api"; +import { authConfigSummary, getAuthRuntimeConfig } from "@/lib/auth/config"; import { getEffectiveImageEngine, getEvolinkImageSettings, shouldMockEvolinkApi } from "@/lib/evolink/image-client"; import { getVisibleImageCapabilities } from "@/lib/jimeng/capabilities"; import { shouldMockVisualApi } from "@/lib/volcengine/visual-client"; @@ -8,6 +9,7 @@ export const runtime = "nodejs"; export async function GET() { const evolink = getEvolinkImageSettings(); + const auth = getAuthRuntimeConfig(); return jsonOk({ ok: true, appId: "zhinian-web-studio", @@ -15,6 +17,7 @@ export async function GET() { visualApiMode: shouldMockVisualApi() ? "mock" : "volcengine", evolinkMode: shouldMockEvolinkApi() ? "mock" : "evolink", seedanceMode: shouldMockSeedance() ? "mock" : "seedance", + authMode: authConfigSummary(auth), capabilities: [ ...getVisibleImageCapabilities().map((capability) => { const engine = getEffectiveImageEngine(capability.id); diff --git a/app/api/prompt/assemble/route.ts b/app/api/prompt/assemble/route.ts index 8e330f2..20779fd 100644 --- a/app/api/prompt/assemble/route.ts +++ b/app/api/prompt/assemble/route.ts @@ -1,10 +1,12 @@ import { assemblePrompt, type PromptAssemblyInput } from "@/lib/prompt/assembler"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; export const runtime = "nodejs"; export async function POST(request: Request) { try { + await requireAppUser(); const body = await readJsonBody>(request); return jsonOk(assemblePrompt(body)); } catch (error) { diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 95ad579..fc80b21 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,10 +1,12 @@ import { getApiSettings, saveApiSettings } from "@/lib/server/app-settings"; import { jsonError, jsonOk, readJsonBody } from "@/lib/server/api"; +import { requireAppUser } from "@/lib/server/auth/current-user"; export const runtime = "nodejs"; export async function GET() { try { + await requireAppUser(); return jsonOk(await getApiSettings()); } catch (error) { return jsonError(error, 500); @@ -13,6 +15,7 @@ export async function GET() { export async function POST(request: Request) { try { + await requireAppUser(); const body = await readJsonBody<{ values?: Record }>(request); return jsonOk(await saveApiSettings(body.values || {})); } catch (error) { diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..0868fb1 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from "next/navigation"; +import { AuthLoginPanel } from "@/components/auth-login-panel"; +import { getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; +import { getOptionalAuthSession } from "@/lib/server/auth/current-user"; + +const errorMessages: Record = { + auth_not_configured: "认证配置不完整,请先在服务器环境变量中配置 SSO。", + callback_failed: "登录回调处理失败,请重新登录。", + state_invalid: "登录状态已失效,请重新登录。" +}; + +export default async function LoginPage({ + searchParams +}: { + searchParams?: Promise>; +}) { + const params = await searchParams; + const next = safeNextPath(singleParam(params?.next)); + const session = await getOptionalAuthSession(); + if (session) redirect(next); + + const config = getAuthRuntimeConfig(); + const errorCode = singleParam(params?.error); + const message = errorCode ? errorMessages[errorCode] || "登录失败,请重新登录。" : null; + + return ( + + ); +} + +function singleParam(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} diff --git a/app/generated-results/[...path]/route.ts b/app/generated-results/[...path]/route.ts index b2156c5..639ff06 100644 --- a/app/generated-results/[...path]/route.ts +++ b/app/generated-results/[...path]/route.ts @@ -1,9 +1,14 @@ +import { getAssetByStoragePath } from "@/lib/server/data-store"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { readLocalServedFile } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) { + const user = await requireAppUser(); const { path } = await context.params; + const asset = await getAssetByStoragePath(["generated-results", ...path].join("/")); + if (!asset || asset.ownerId !== user.id) return new Response("Not found", { status: 404 }); const file = await readLocalServedFile("generated-results", path); if (!file) return new Response("Not found", { status: 404 }); return new Response(new Uint8Array(file.bytes), { diff --git a/app/globals.css b/app/globals.css index 30786d1..5ae54f5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2455,7 +2455,7 @@ button:active:not(:disabled), } .settings-tabs { - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); } .asset-view-switch button, @@ -2513,3 +2513,225 @@ button:active:not(:disabled), margin-bottom: 10px; } } + +.topbar-account { + justify-self: end; + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.account-chip { + min-height: 38px; + max-width: 190px; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.72); + color: var(--muted-strong); + font-size: 13px; + font-weight: 800; + white-space: nowrap; +} + +.account-chip span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.account-chip svg, +.logout-button svg, +.login-link svg { + width: 16px; + height: 16px; + flex: 0 0 auto; +} + +.logout-button { + width: 38px; + min-height: 38px; + padding: 0; + border-radius: 999px; +} + +.auth-shell { + min-height: 100dvh; + background: + linear-gradient(90deg, rgba(220, 227, 221, 0.55) 1px, transparent 1px), + linear-gradient(180deg, rgba(220, 227, 221, 0.45) 1px, transparent 1px), + var(--bg); + background-size: 44px 44px; +} + +.auth-main { + width: 100%; + min-height: 100dvh; + padding: 0; +} + +.auth-page { + min-height: 100dvh; + width: min(1120px, 100%); + margin: 0 auto; + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(340px, 440px); + align-items: center; + gap: clamp(28px, 6vw, 72px); + padding: clamp(36px, 8vw, 76px) 24px; +} + +.auth-brand-panel { + display: grid; + justify-items: start; + gap: 16px; +} + +.auth-brand-logo { + display: block; + width: min(292px, 76vw); + height: auto; + object-fit: contain; +} + +.auth-brand-panel h1 { + margin: 0; + max-width: 9em; + font-size: clamp(36px, 5vw, 58px); + line-height: 1.05; + font-weight: 900; + color: var(--ink); +} + +.auth-panel { + width: 100%; + display: grid; + justify-items: stretch; + gap: 18px; + padding: 28px; +} + +.auth-panel h2 { + margin: 0; + font-size: 24px; + line-height: 1.2; +} + +.auth-password-form { + width: 100%; + display: grid; + gap: 14px; +} + +.auth-password-form .field { + margin-bottom: 0; +} + +.auth-captcha-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 118px; + gap: 8px; + align-items: center; +} + +.auth-captcha-button { + height: 44px; + border: 1px solid var(--line); + border-radius: 8px; + background: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + overflow: hidden; +} + +.auth-captcha-button img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.auth-submit, +.auth-submit.button { + width: 100%; + min-height: 46px; +} + +@media (max-width: 920px) { + .topbar { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .top-nav { + justify-self: center; + } + + .account-chip { + max-width: 150px; + } +} + +@media (max-width: 700px) { + .topbar-account { + grid-column: 1 / -1; + justify-self: stretch; + justify-content: flex-end; + } + + .account-chip { + max-width: min(260px, calc(100vw - 92px)); + } + + .auth-page { + grid-template-columns: 1fr; + align-content: center; + gap: 24px; + padding: 34px 14px; + } + + .auth-brand-panel { + justify-items: center; + text-align: center; + gap: 10px; + } + + .auth-brand-panel h1 { + font-size: 34px; + } + + .auth-panel { + padding: 20px; + } +} + +@media (max-width: 560px) { + .topbar { + grid-template-columns: auto minmax(0, 1fr); + } + + .topbar-account { + padding-top: 0; + } + + .account-chip { + min-height: 34px; + max-width: calc(100vw - 78px); + padding: 0 9px; + font-size: 12px; + } + + .logout-button { + width: 34px; + min-height: 34px; + } + + .auth-captcha-row { + grid-template-columns: minmax(0, 1fr) 104px; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index cd2e9ef..c25e852 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { AppShell } from "@/components/app-shell"; +import { getShellAuthState } from "@/lib/server/auth/current-user"; import "./globals.css"; export const metadata: Metadata = { @@ -7,11 +8,12 @@ export const metadata: Metadata = { description: "智念AIGC平台:统一创作图片、视频、局部重绘与智能超清。" }; -export default function RootLayout({ children }: { children: React.ReactNode }) { +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const auth = await getShellAuthState(); return ( - {children} + {children} ); diff --git a/app/uploads/[...path]/route.ts b/app/uploads/[...path]/route.ts index 18327ec..6086675 100644 --- a/app/uploads/[...path]/route.ts +++ b/app/uploads/[...path]/route.ts @@ -1,9 +1,14 @@ +import { getAssetByStoragePath } from "@/lib/server/data-store"; +import { requireAppUser } from "@/lib/server/auth/current-user"; import { readLocalServedFile } from "@/lib/server/storage"; export const runtime = "nodejs"; export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) { + const user = await requireAppUser(); const { path } = await context.params; + const asset = await getAssetByStoragePath(["uploads", ...path].join("/")); + if (!asset || asset.ownerId !== user.id) return new Response("Not found", { status: 404 }); const file = await readLocalServedFile("uploads", path); if (!file) return new Response("Not found", { status: 404 }); return new Response(new Uint8Array(file.bytes), { diff --git a/components/app-shell.tsx b/components/app-shell.tsx index 3e773a6..4a9f95a 100644 --- a/components/app-shell.tsx +++ b/components/app-shell.tsx @@ -6,11 +6,15 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Archive, + LogIn, + LogOut, Settings, - Sparkles + Sparkles, + UserCircle } from "lucide-react"; import clsx from "clsx"; import { revealChildren, runScopedMotion } from "@/lib/ui/motion"; +import type { AuthUser } from "@/lib/auth/session"; const nav = [ { href: "/create", label: "创作", icon: Sparkles }, @@ -18,45 +22,76 @@ const nav = [ { href: "/settings", label: "设置", icon: Settings } ]; -export function AppShell({ children }: { children: React.ReactNode }) { +export function AppShell({ + children, + user, + authRequired +}: { + children: React.ReactNode; + user?: AuthUser | null; + authRequired?: boolean; +}) { const pathname = usePathname(); const shellRef = useRef(null); + const isAuthPage = pathname.startsWith("/auth"); useEffect(() => { return runScopedMotion(shellRef, (scope) => revealChildren(scope, "[data-shell-animate]")); }, []); return ( -
- 跳到主要内容 -
- - -
-
智念AIGC平台
-
- - -
-
{children}
+ ) : null} +
+ + ) : null} +
{children}
); } diff --git a/components/auth-login-panel.tsx b/components/auth-login-panel.tsx new file mode 100644 index 0000000..88628c6 --- /dev/null +++ b/components/auth-login-panel.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { FormEvent } from "react"; +import Image from "next/image"; +import { Loader2, LogIn, RefreshCw } from "lucide-react"; +import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; + +export function AuthLoginPanel({ + next, + configured, + message, + missing +}: { + next: string; + configured: boolean; + message?: string | null; + missing?: string[]; +}) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [randomStr, setRandomStr] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const screenRef = useRef(null); + const feedbackRef = useRef(null); + const hasMissingConfig = !configured && Boolean(missing?.length); + + useEffect(() => { + refreshCaptcha(); + }, []); + + useEffect(() => { + return runScopedMotion(screenRef, (scope) => revealChildren(scope)); + }, []); + + useEffect(() => { + pulseFeedback(feedbackRef.current); + }, [error, message, missing?.join(",")]); + + const captchaSrc = useMemo(() => { + if (!randomStr) return ""; + return `/api/auth/captcha?randomStr=${encodeURIComponent(randomStr)}`; + }, [randomStr]); + + function refreshCaptcha() { + setCode(""); + setRandomStr(crypto.randomUUID()); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!configured || submitting) return; + setSubmitting(true); + setError(null); + try { + const response = await fetch("/api/auth/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password, code, randomStr, next }) + }); + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "登录失败"); + window.location.assign(payload.redirectTo || next || "/create"); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + refreshCaptcha(); + } finally { + setSubmitting(false); + } + } + + return ( +
+
+ +

智念AIGC平台

+
+ +
+

账户登录

+ + {message || hasMissingConfig || error ? ( +
+ {message ?
{message}
: null} + {hasMissingConfig ? ( +
+ 缺少配置:{missing?.join("、")} +
+ ) : null} + {error ?
{error}
: null} +
+ ) : null} + +
+ + + + + +
+
+
+ ); +} diff --git a/components/create-studio.tsx b/components/create-studio.tsx index f92af35..4f7d3f3 100644 --- a/components/create-studio.tsx +++ b/components/create-studio.tsx @@ -68,7 +68,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo const [videoDuration, setVideoDuration] = useState(VIDEO_DURATION_DEFAULT); const [videoResolution, setVideoResolution] = useState("720p"); const [mentionState, setMentionState] = useState(null); - const [activeMentionIndex, setActiveMentionIndex] = useState(0); + const [activeMentionIndex, setActiveMentionIndex] = useState(null); const [promptScrollTop, setPromptScrollTop] = useState(0); const [materialPage, setMaterialPage] = useState(1); const studioRef = useRef(null); @@ -90,7 +90,7 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo }, [materials, mentionState]); useEffect(() => { - setActiveMentionIndex(0); + setActiveMentionIndex(null); }, [mentionState?.query, mentionSuggestions.length]); useEffect(() => { @@ -240,15 +240,15 @@ export function CreateStudio({ initialMode = "image" }: { initialMode?: StudioMo if (!mentionSuggestions.length) return; if (event.key === "ArrowDown") { event.preventDefault(); - setActiveMentionIndex((index) => (index + 1) % mentionSuggestions.length); + setActiveMentionIndex((index) => index === null ? 0 : (index + 1) % mentionSuggestions.length); } if (event.key === "ArrowUp") { event.preventDefault(); - setActiveMentionIndex((index) => (index - 1 + mentionSuggestions.length) % mentionSuggestions.length); + setActiveMentionIndex((index) => index === null ? mentionSuggestions.length - 1 : (index - 1 + mentionSuggestions.length) % mentionSuggestions.length); } if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); - selectMention(mentionSuggestions[activeMentionIndex] || mentionSuggestions[0]); + selectMention(mentionSuggestions[activeMentionIndex ?? 0] || mentionSuggestions[0]); } } diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx index eb3cec0..74970b6 100644 --- a/components/settings-panel.tsx +++ b/components/settings-panel.tsx @@ -28,6 +28,7 @@ type SettingsPayload = { visual: string; evolink: string; seedance: string; + auth: string; data: string; }; capabilities: Array<{ @@ -219,6 +220,7 @@ export function SettingsPanel() { + @@ -282,7 +284,14 @@ function ServiceBadge({ label, value, ready }: { label: string; value: string; r ); } +function authModeLabel(mode?: string) { + if (mode === "configured") return "已启用"; + if (mode === "missing") return "待配置"; + return "未启用"; +} + function shortGroupLabel(id: string, title: string) { + if (id === "auth") return "登录"; if (id === "visual") return "图片"; if (id === "evolink") return "EvoLink"; if (id === "seedance") return "视频"; diff --git a/docs/API.md b/docs/API.md index 9a2aa5f..f3cf403 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # 智念AIGC平台开放 API 对接说明 -本文面向服务端对接方。所有开放接口位于 `/api/v1`,使用 API Key 鉴权。 +本文面向服务端对接方。所有开放接口位于 `/api/v1`,使用 API Key 鉴权;浏览器后台的 SSO 登录不会影响这些服务端接口。 OpenAPI JSON: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 78b6360..f0d3e3e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -42,6 +42,14 @@ PORT=3000 HOSTNAME=0.0.0.0 NEXT_PUBLIC_APP_URL=https://你的域名 +ZHINIAN_AUTH_REQUIRED=auto +ZHINIAN_AUTH_BASE_URL=https:///auth +ZHINIAN_AUTH_CLIENT_ID=customPC +ZHINIAN_AUTH_CLIENT_SECRET=请替换为认证中心客户端密钥 +ZHINIAN_AUTH_SCOPE=server +ZHINIAN_AUTH_ISSUER=https://pig4cloud.com +ZHINIAN_AUTH_SESSION_SECRET=请替换为强随机会话密钥 + ZHINIAN_API_KEYS=partner-a:请替换为强随机key ZHINIAN_INTERNAL_WORKER_TOKEN=请替换为强随机token ZHINIAN_WEBHOOK_SECRET=请替换为webhook签名密钥 @@ -69,6 +77,14 @@ ALI_OSS_PUBLIC_BASE_URL= 如果不配置真实供应商密钥,mock 配置会保留本地验收能力,但生产对接应配置真实密钥。 +认证中心客户端需要允许回调地址: + +```text +https://你的域名/api/auth/callback +``` + +如果使用登录页内的账号密码验证码方式,还需要确认 `customPC` 客户端支持 `password` 和 `refresh_token` grant。未加入 `security.ignore-clients` 时,验证码会通过 `${ZHINIAN_AUTH_BASE_URL}/code/image` 获取。 + ## 常用运维命令 ```bash @@ -155,7 +171,8 @@ curl https://你的域名/api/v1/openapi.json 确认: -- Web 页面可访问 +- 未登录访问 Web 页面会跳转到 `/auth/login` +- 登录后 Web 页面可访问 - `/api/health` 返回 `ok: true` - `/api/v1/capabilities` 使用 API Key 可访问 - `zhinian-worker` 日志持续输出 `claimed=...` diff --git a/findings.md b/findings.md index 8caef22..962f24f 100644 --- a/findings.md +++ b/findings.md @@ -141,3 +141,38 @@ - Jimeng image generation supports the current `scale` parameter, so UI presets should submit numeric text-influence values for that engine. - EvoLink image generation does not use Jimeng `scale`; the per-request engine-aware control should submit EvoLink `quality` instead. - Current local `/api/health` reports `image.generate` using EvoLink, so `/create` should show `生成质量` options rather than `文本影响`. + +## 2026-05-29 Account Login / SSO Findings +- User requested account login before release so the project is safe to use. +- Provided SSO guide recommends OAuth2 Authorization Code for Web SSO: redirect to `${AUTH_BASE}/oauth2/authorize`, receive `code` and `state`, then exchange code server-side at `${AUTH_BASE}/oauth2/token`. +- OAuth client defaults in the guide use `client_id=customPC` and `scope=server`; `client_secret` must stay on the server. +- Access tokens are JWTs; resource services should verify locally with JWKS from `${AUTH_BASE}/oauth2/jwks` rather than calling auth on every request. +- Minimum JWT checks from the guide: RS256 signature, `exp`, `nbf`/`iat`, issuer `https://pig4cloud.com`, OAuth client id, scope/authority requirements. +- Logout endpoint is `DELETE ${AUTH_BASE}/token/logout`, but local session deletion remains required because existing JWTs may stay valid until `exp`. +- Current app has no login middleware or session helper; pages are client components under a global shell. +- Current local data store defaults all assets/jobs to `DEFAULT_OWNER_ID = "demo-merchant"`, so account login must also address per-user owner IDs for first-party UI APIs. +- Public API v1 already has separate API key auth through `ZHINIAN_API_KEYS`; SSO should preserve that server-to-server surface. +- First-party UI APIs that currently need session ownership include `/api/assets`, `/api/assets/upload`, `/api/assets/:id/*`, `/api/generations/image*`, `/api/generations/video*`, and `/api/settings`. +- Generation services already accept optional `ownerId`, so route handlers can pass the authenticated owner without rewriting provider dispatch or worker logic. +- Retry helpers currently preserve the original request payload; they need to override `ownerId` on retry so a user cannot retry another user's job if they know the id. +- `/uploads/*` and `/generated-results/*` serve local runtime files directly; middleware should protect these paths for cookie-authenticated browser sessions. +- `/api/v1/*` and `/api/internal/worker/tick` must remain outside browser SSO middleware because they use API keys and worker tokens. +- Implemented browser SSO with signed HttpOnly `zhinian_session` cookies; middleware validates the signed session instead of exposing JWTs to client JavaScript. +- JWT access tokens are verified in the callback using RS256 and configured JWKS, with issuer, client id, expiry, not-before, issued-at, and scope checks. +- Local file routes now resolve `storagePath` back to an asset record and require the current owner to match before serving bytes. +- Public API v1 remains API-key based and can still read/download its assets through public API routes even when browser SSO protects the Web UI. + +## 2026-05-29 Password Captcha Login Findings +- User provided live auth configuration and a password grant sample; real secret values remain only in the ignored local environment file. +- `${AUTH_BASE}/code/image?randomStr=...` returns a PNG captcha image. +- The password grant sample successfully returns a JWT access token, refresh token, expected client/user claims, tenant id, and `server` scope. +- The local `/api/auth/password` endpoint verified the returned JWT with JWKS and created a signed browser session for the authenticated user. +- Browser form login with the displayed math captcha succeeded and redirected to `/create`; the topbar showed the authenticated username and logout button. +- Logout cleared the session and returned to `/auth/login?loggedOut=1`. + +## 2026-05-29 Standalone Login Page Findings +- Login routes under `/auth/*` should not render the shared app topbar; the login page is a standalone entry surface. +- The login page now intentionally presents only the NIANXX logo, `智念AIGC平台`, and the account/password/captcha form. +- The visible `统一认证中心` OAuth entry was removed from the login page after user feedback. +- The standalone login page uses the existing GSAP motion helper layer (`runScopedMotion`, `revealChildren`, `pulseFeedback`) for consistent app motion. +- Browser viewport checks passed at 1280x800 and 390x844: no topbar, no SSO link text, logo and platform name present, login panel present, and no horizontal overflow. diff --git a/lib/auth/config.ts b/lib/auth/config.ts new file mode 100644 index 0000000..6e6ba1d --- /dev/null +++ b/lib/auth/config.ts @@ -0,0 +1,119 @@ +export const SESSION_COOKIE_NAME = "zhinian_session"; +export const AUTH_STATE_COOKIE_NAME = "zhinian_auth_state"; + +export type AuthRuntimeConfig = { + required: boolean; + configured: boolean; + missing: string[]; + authBaseUrl?: string; + authorizeUrl?: string; + tokenUrl?: string; + jwksUrl?: string; + logoutUrl?: string; + clientId: string; + clientSecret?: string; + scope: string; + issuer: string; + sessionSecret?: string; + clockSkewSeconds: number; +}; + +export function getAuthRuntimeConfig(): AuthRuntimeConfig { + const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE")); + const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "customPC"; + 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 sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET"); + const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED"); + const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true; + const hasAnyAuthConfig = Boolean(authBaseUrl || clientSecret || sessionSecret); + const required = disabled ? false : explicitRequired ?? (process.env.NODE_ENV === "production" || Boolean(authBaseUrl)); + const wantsConfiguration = required || hasAnyAuthConfig; + const missing: string[] = []; + + if (wantsConfiguration && !authBaseUrl) missing.push("ZHINIAN_AUTH_BASE_URL"); + if (wantsConfiguration && !clientSecret) missing.push("ZHINIAN_AUTH_CLIENT_SECRET"); + if (wantsConfiguration && !sessionSecret) missing.push("ZHINIAN_AUTH_SESSION_SECRET"); + + return { + required, + configured: wantsConfiguration && missing.length === 0, + missing, + authBaseUrl, + authorizeUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_AUTHORIZE_URL", "/oauth2/authorize"), + tokenUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_TOKEN_URL", "/oauth2/token"), + jwksUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_JWKS_URL", "/oauth2/jwks"), + logoutUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_LOGOUT_URL", "/token/logout"), + clientId, + clientSecret, + scope, + issuer, + sessionSecret, + clockSkewSeconds: numberEnv("ZHINIAN_AUTH_CLOCK_SKEW_SECONDS") ?? 60 + }; +} + +export function safeNextPath(value: string | null | undefined, fallback = "/create"): string { + if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback; + try { + const parsed = new URL(value, "http://zhinian.local"); + if (parsed.origin !== "http://zhinian.local") return fallback; + const path = `${parsed.pathname}${parsed.search}${parsed.hash}`; + if (path.startsWith("/api/auth") || path.startsWith("/auth/login")) return fallback; + return path; + } catch { + return fallback; + } +} + +export function authConfigSummary(config = getAuthRuntimeConfig()) { + if (!config.required) return "disabled"; + return config.configured ? "configured" : "missing"; +} + +export function shouldUseSecureAuthCookie(requestUrl?: string): boolean { + const explicit = boolEnv("ZHINIAN_AUTH_COOKIE_SECURE"); + if (explicit !== undefined) return explicit; + const configured = envValue("NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL"); + const candidate = configured || requestUrl; + if (!candidate) return false; + try { + return new URL(candidate).protocol === "https:"; + } catch { + return false; + } +} + +function endpointUrl(authBaseUrl: string | undefined, overrideKey: string, path: string) { + const override = envValue(overrideKey); + if (override) return override.replace(/\/$/, ""); + return authBaseUrl ? `${authBaseUrl}${path}` : undefined; +} + +function envValue(...names: string[]): string | undefined { + for (const name of names) { + const value = process.env[name]?.trim(); + if (value) return value; + } + return undefined; +} + +function boolEnv(name: string): boolean | undefined { + const value = process.env[name]?.trim().toLowerCase(); + if (!value || value === "auto") return undefined; + if (["1", "true", "yes", "on"].includes(value)) return true; + if (["0", "false", "no", "off"].includes(value)) return false; + return undefined; +} + +function numberEnv(name: string): number | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function trimTrailingSlash(value?: string) { + return value?.replace(/\/$/, ""); +} diff --git a/lib/auth/session.ts b/lib/auth/session.ts new file mode 100644 index 0000000..7fe43cd --- /dev/null +++ b/lib/auth/session.ts @@ -0,0 +1,99 @@ +export type AuthUser = { + id: string; + subject: string; + username?: string; + displayName: string; + clientId: string; + tenantId?: string; + authorities: string[]; + scope: string[]; +}; + +export type AuthSession = { + version: 1; + user: AuthUser; + issuedAt: number; + expiresAt: number; +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +export async function createSignedJsonValue(value: unknown, secret: string): Promise { + const payload = bytesToBase64Url(textEncoder.encode(JSON.stringify(value))); + const signature = await signPayload(payload, secret); + return `${payload}.${signature}`; +} + +export async function parseSignedJsonValue(value: string | undefined, secret: string): Promise { + if (!value) return null; + const [payload, signature, extra] = value.split("."); + if (!payload || !signature || extra !== undefined) return null; + const expected = await signPayload(payload, secret); + if (!constantTimeEqual(signature, expected)) return null; + try { + return JSON.parse(textDecoder.decode(base64UrlToBytes(payload))) as T; + } catch { + return null; + } +} + +export async function createSessionCookieValue(session: AuthSession, secret: string): Promise { + return createSignedJsonValue(session, secret); +} + +export async function parseSessionCookieValue( + value: string | undefined, + secret: string, + nowSeconds = Math.floor(Date.now() / 1000) +): Promise { + const session = await parseSignedJsonValue(value, secret); + if (!session || session.version !== 1) return null; + if (!session.user?.id || !session.user.clientId || !session.expiresAt) return null; + if (session.expiresAt <= nowSeconds) return null; + return { + ...session, + user: { + ...session.user, + authorities: Array.isArray(session.user.authorities) ? session.user.authorities : [], + scope: Array.isArray(session.user.scope) ? session.user.scope : [] + } + }; +} + +async function signPayload(payload: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + textEncoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload)); + return bytesToBase64Url(new Uint8Array(signature)); +} + +function constantTimeEqual(left: string, right: string): boolean { + if (left.length !== right.length) return false; + let diff = 0; + for (let index = 0; index < left.length; index += 1) { + diff |= left.charCodeAt(index) ^ right.charCodeAt(index); + } + return diff === 0; +} + +function bytesToBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.slice(index, index + 0x8000)); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function base64UrlToBytes(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} diff --git a/lib/server/api.ts b/lib/server/api.ts index f56c575..e4dd464 100644 --- a/lib/server/api.ts +++ b/lib/server/api.ts @@ -5,9 +5,10 @@ export function jsonOk(payload: T, init?: ResponseInit) { } export function jsonError(error: unknown, status = 400) { + const resolvedStatus = statusFromError(error) || status; return NextResponse.json({ error: error instanceof Error ? error.message : String(error) - }, { status }); + }, { status: resolvedStatus }); } export async function readJsonBody>(request: Request): Promise { @@ -17,3 +18,9 @@ export async function readJsonBody>(request: R return {} as T; } } + +function statusFromError(error: unknown): number | undefined { + if (typeof error !== "object" || error === null || !("status" in error)) return undefined; + const status = Number((error as { status?: unknown }).status); + return Number.isInteger(status) && status >= 400 && status <= 599 ? status : undefined; +} diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index b10909d..a51a2f9 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { getEvolinkImageSettings, getSelectedImageEngine, shouldMockEvolinkApi, type ImageCreationEngine } from "@/lib/evolink/image-client"; +import { authConfigSummary, getAuthRuntimeConfig } from "@/lib/auth/config"; import { getJimengCapabilities } from "@/lib/jimeng/capabilities"; import { getSeedanceConfig, shouldMockSeedance } from "@/lib/seedance/client"; import { rootDir } from "@/lib/server/runtime"; @@ -47,6 +48,30 @@ const settingDefinitions: Array<{ description: string; fields: FieldDefinition[]; }> = [ + { + id: "auth", + title: "账户登录 SSO", + description: "用于发布环境的统一认证中心登录;client_secret 与 session secret 只保存在服务端。", + fields: [ + { + key: "ZHINIAN_AUTH_REQUIRED", + label: "登录保护", + type: "select", + defaultValue: "auto", + options: [ + { label: "自动", value: "auto" }, + { label: "启用", value: "1" }, + { label: "停用", value: "0" } + ] + }, + { key: "ZHINIAN_AUTH_BASE_URL", label: "Auth Base URL" }, + { key: "ZHINIAN_AUTH_CLIENT_ID", label: "客户端 ID", defaultValue: "customPC" }, + { 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_SESSION_SECRET", label: "会话签名密钥", secret: true, type: "password" } + ] + }, { id: "visual", title: "即梦图片 API", @@ -143,6 +168,7 @@ export async function getApiSettings() { }) })); const seedance = getSeedanceConfig(); + const auth = getAuthRuntimeConfig(); const engineAssignments = buildEngineAssignments(fileEnv); return { envPath: envFilePath(), @@ -150,6 +176,7 @@ export async function getApiSettings() { visual: shouldMockVisualApi() ? "mock" : "real", evolink: shouldMockEvolinkApi() ? "mock" : "real", seedance: shouldMockSeedance() ? "mock" : "real", + auth: authConfigSummary(auth), data: process.env.SUPABASE_SERVICE_ROLE_KEY ? "supabase" : "local" }, capabilities: [ diff --git a/lib/server/auth/current-user.ts b/lib/server/auth/current-user.ts new file mode 100644 index 0000000..39075a3 --- /dev/null +++ b/lib/server/auth/current-user.ts @@ -0,0 +1,64 @@ +import { cookies } from "next/headers"; +import { SESSION_COOKIE_NAME, getAuthRuntimeConfig } from "@/lib/auth/config"; +import { parseSessionCookieValue, type AuthSession, type AuthUser } from "@/lib/auth/session"; +import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; + +export class AuthRequiredError extends Error { + status = 401; + + constructor(message = "请先登录。") { + super(message); + this.name = "AuthRequiredError"; + } +} + +export class AuthConfigurationError extends Error { + status = 503; + + constructor(message: string) { + super(message); + this.name = "AuthConfigurationError"; + } +} + +const localUser: AuthUser = { + id: DEFAULT_OWNER_ID, + subject: DEFAULT_OWNER_ID, + username: "demo", + displayName: "智念演示用户", + clientId: "local-dev", + authorities: [], + scope: [] +}; + +export async function getOptionalAuthSession(): Promise { + const config = getAuthRuntimeConfig(); + if (!config.sessionSecret) return null; + const cookieStore = await cookies(); + return parseSessionCookieValue(cookieStore.get(SESSION_COOKIE_NAME)?.value, config.sessionSecret); +} + +export async function requireAppUser(): Promise { + const session = await getOptionalAuthSession(); + if (session) return session.user; + const config = getAuthRuntimeConfig(); + if (!config.required) return localUser; + if (!config.configured) { + throw new AuthConfigurationError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`); + } + throw new AuthRequiredError(); +} + +export async function getShellAuthState(): Promise<{ + user: AuthUser | null; + authRequired: boolean; + authConfigured: boolean; +}> { + const config = getAuthRuntimeConfig(); + const session = await getOptionalAuthSession(); + return { + user: session?.user || null, + authRequired: config.required, + authConfigured: config.configured + }; +} diff --git a/lib/server/auth/jwt.ts b/lib/server/auth/jwt.ts new file mode 100644 index 0000000..cca31da --- /dev/null +++ b/lib/server/auth/jwt.ts @@ -0,0 +1,191 @@ +import { createPublicKey, createVerify } from "node:crypto"; +import type { JsonWebKey as CryptoJsonWebKey, KeyObject } from "node:crypto"; +import { getAuthRuntimeConfig, type AuthRuntimeConfig } from "@/lib/auth/config"; +import type { AuthSession, AuthUser } from "@/lib/auth/session"; + +export type AuthTokenClaims = { + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + iat?: number; + nbf?: number; + jti?: string; + scope?: string | string[]; + client_id?: string; + clientId?: string; + user_id?: string | number; + username?: string; + tenant_id?: string | number; + dept_id?: string | number; + authorities?: string[] | string; + [claim: string]: unknown; +}; + +type JwtHeader = { + alg?: string; + kid?: string; + typ?: string; + [key: string]: unknown; +}; + +type Jwks = { + keys?: JwksKey[]; +}; + +type JwksKey = CryptoJsonWebKey & { + kid?: string; + alg?: string; + use?: string; +}; + +let jwksCache: { + url: string; + fetchedAt: number; + keys: JwksKey[]; +} | null = null; + +export class JwtVerificationError extends Error { + constructor(message: string) { + super(message); + this.name = "JwtVerificationError"; + } +} + +export async function verifyAuthJwt(token: string, config = getAuthRuntimeConfig()): Promise { + if (!config.jwksUrl) throw new JwtVerificationError("JWKS URL is not configured."); + const parts = token.split("."); + if (parts.length !== 3) throw new JwtVerificationError("Invalid JWT format."); + const header = parseJwtPart(parts[0]); + const claims = parseJwtPart(parts[1]); + + if (header.alg !== "RS256") throw new JwtVerificationError("Unsupported JWT algorithm."); + const publicKey = await getPublicKeyForHeader(header, config.jwksUrl); + const verifier = createVerify("RSA-SHA256"); + verifier.update(`${parts[0]}.${parts[1]}`); + verifier.end(); + if (!verifier.verify(publicKey, base64UrlToBuffer(parts[2]))) { + throw new JwtVerificationError("JWT signature verification failed."); + } + + validateClaims(claims, config); + return claims; +} + +export function createSessionFromClaims( + claims: AuthTokenClaims, + config: AuthRuntimeConfig, + tokenResponseExpiresIn?: number +): AuthSession { + const now = Math.floor(Date.now() / 1000); + const jwtExpiry = numberClaim(claims.exp); + const responseExpiry = tokenResponseExpiresIn ? now + tokenResponseExpiresIn : undefined; + const expiresAt = Math.min(jwtExpiry || responseExpiry || now, responseExpiry || jwtExpiry || now); + return { + version: 1, + user: userFromClaims(claims, config), + issuedAt: now, + expiresAt + }; +} + +export function userFromClaims(claims: AuthTokenClaims, config = getAuthRuntimeConfig()): AuthUser { + const clientId = stringClaim(claims.client_id) || stringClaim(claims.clientId) || config.clientId; + const subject = stringClaim(claims.sub) || stringClaim(claims.username) || stringClaim(claims.user_id) || "unknown"; + const principalId = stringClaim(claims.user_id) || subject; + const username = stringClaim(claims.username) || stringClaim(claims.sub); + return { + id: `auth:${sanitizeOwnerPart(clientId)}:${sanitizeOwnerPart(principalId)}`, + subject, + username, + displayName: username || `用户 ${principalId}`, + clientId, + tenantId: stringClaim(claims.tenant_id), + authorities: stringListClaim(claims.authorities), + scope: stringListClaim(claims.scope) + }; +} + +export function clearJwksCacheForTests() { + jwksCache = null; +} + +async function getPublicKeyForHeader(header: JwtHeader, jwksUrl: string): Promise { + const keys = await fetchJwksKeys(jwksUrl); + const key = keys.find((item) => { + if (item.kty !== "RSA") return false; + if (!header.kid) return true; + return item.kid === header.kid; + }); + if (!key) throw new JwtVerificationError("JWT key id was not found in JWKS."); + return createPublicKey({ key, format: "jwk" }); +} + +async function fetchJwksKeys(jwksUrl: string): Promise { + const now = Date.now(); + if (jwksCache?.url === jwksUrl && now - jwksCache.fetchedAt < 5 * 60 * 1000) return jwksCache.keys; + const response = await fetch(jwksUrl, { cache: "no-store" }); + if (!response.ok) throw new JwtVerificationError(`JWKS request failed: ${response.status}`); + const payload = await response.json() as Jwks; + const keys = Array.isArray(payload.keys) ? payload.keys : []; + jwksCache = { url: jwksUrl, fetchedAt: now, keys }; + return keys; +} + +function validateClaims(claims: AuthTokenClaims, config: AuthRuntimeConfig) { + const now = Math.floor(Date.now() / 1000); + const skew = config.clockSkewSeconds; + const exp = numberClaim(claims.exp); + if (!exp || exp <= now - skew) throw new JwtVerificationError("JWT has expired."); + const nbf = numberClaim(claims.nbf); + if (nbf && nbf > now + skew) throw new JwtVerificationError("JWT is not active yet."); + const iat = numberClaim(claims.iat); + 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."); + 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."); + } + } +} + +function parseJwtPart(value: string): T { + try { + return JSON.parse(base64UrlToBuffer(value).toString("utf8")) as T; + } catch { + throw new JwtVerificationError("Invalid JWT JSON."); + } +} + +function base64UrlToBuffer(value: string): Buffer { + return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64"); +} + +function numberClaim(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function stringClaim(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value.trim(); + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function stringListClaim(value: unknown): string[] { + if (Array.isArray(value)) return value.map(stringClaim).filter((item): item is string => Boolean(item)); + if (typeof value === "string") return value.split(/\s+/).map((item) => item.trim()).filter(Boolean); + return []; +} + +function sanitizeOwnerPart(value: string): string { + return value.replace(/[^A-Za-z0-9_.:@-]+/g, "_").slice(0, 96) || "unknown"; +} diff --git a/lib/server/auth/oauth.ts b/lib/server/auth/oauth.ts new file mode 100644 index 0000000..5c28a64 --- /dev/null +++ b/lib/server/auth/oauth.ts @@ -0,0 +1,170 @@ +import { NextResponse } from "next/server"; +import { + AUTH_STATE_COOKIE_NAME, + SESSION_COOKIE_NAME, + getAuthRuntimeConfig, + safeNextPath, + shouldUseSecureAuthCookie, + type AuthRuntimeConfig +} from "@/lib/auth/config"; +import { + createSessionCookieValue, + createSignedJsonValue, + parseSignedJsonValue +} from "@/lib/auth/session"; +import { createSessionFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt"; +import { requestOrigin } from "@/lib/server/runtime"; + +export type AuthStateCookie = { + state: string; + next: string; + createdAt: number; +}; + +type TokenResponse = { + access_token?: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + scope?: string; + [key: string]: unknown; +}; + +export class OAuthLoginError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "OAuthLoginError"; + this.status = status; + } +} + +export function authRedirectUri(request: Request): string { + return new URL("/api/auth/callback", requestOrigin(request)).toString(); +} + +export async function createAuthorizeRedirect(request: Request): Promise { + const config = requireConfiguredAuth(); + const requestUrl = new URL(request.url); + const state = crypto.randomUUID(); + const next = safeNextPath(requestUrl.searchParams.get("next")); + const authorizeUrl = new URL(config.authorizeUrl || ""); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("client_id", config.clientId); + authorizeUrl.searchParams.set("redirect_uri", authRedirectUri(request)); + authorizeUrl.searchParams.set("scope", config.scope); + authorizeUrl.searchParams.set("state", state); + + const response = NextResponse.redirect(authorizeUrl); + response.cookies.set(AUTH_STATE_COOKIE_NAME, await createSignedJsonValue({ + state, + next, + createdAt: Math.floor(Date.now() / 1000) + } satisfies AuthStateCookie, config.sessionSecret || ""), { + httpOnly: true, + sameSite: "lax", + secure: shouldUseSecureAuthCookie(request.url), + path: "/", + maxAge: 10 * 60 + }); + return response; +} + +export async function completeAuthorizationCallback(request: Request): Promise { + const config = requireConfiguredAuth(); + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) throw new OAuthLoginError("授权回调缺少 code 或 state。"); + + const stateCookie = await parseSignedJsonValue( + getCookieValue(request, AUTH_STATE_COOKIE_NAME), + config.sessionSecret || "" + ); + const now = Math.floor(Date.now() / 1000); + if (!stateCookie || stateCookie.state !== state || now - stateCookie.createdAt > 10 * 60) { + throw new OAuthLoginError("登录状态已失效,请重新登录。"); + } + + const token = await exchangeAuthorizationCode(code, authRedirectUri(request), config); + if (!token.access_token) throw new OAuthLoginError("认证中心没有返回 access_token。"); + const claims = await verifyAuthJwt(token.access_token, config); + const session = createSessionFromClaims(claims, config, token.expires_in); + const response = NextResponse.redirect(new URL(stateCookie.next, request.url)); + response.cookies.set( + SESSION_COOKIE_NAME, + await createSessionCookieValue(session, config.sessionSecret || ""), + { + httpOnly: true, + sameSite: "lax", + secure: shouldUseSecureAuthCookie(request.url), + path: "/", + expires: new Date(session.expiresAt * 1000) + } + ); + response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url)); + return response; +} + +export function clearAuthCookies(request: Request, redirectTo = "/auth/login?loggedOut=1"): NextResponse { + const response = NextResponse.redirect(new URL(redirectTo, request.url)); + response.cookies.set(SESSION_COOKIE_NAME, "", clearCookieOptions(request.url)); + response.cookies.set(AUTH_STATE_COOKIE_NAME, "", clearCookieOptions(request.url)); + return response; +} + +export function redirectToLoginWithError(request: Request, error: string): NextResponse { + const loginUrl = new URL("/auth/login", request.url); + loginUrl.searchParams.set("error", error); + return NextResponse.redirect(loginUrl); +} + +async function exchangeAuthorizationCode( + code: string, + redirectUri: string, + config: AuthRuntimeConfig +): Promise { + if (!config.tokenUrl || !config.clientSecret) throw new OAuthLoginError("认证 token endpoint 未配置。", 500); + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", redirectUri); + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + Authorization: `Basic ${Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64")}`, + "Content-Type": "application/x-www-form-urlencoded" + }, + body + }); + const payload = await response.json().catch(() => ({})) as TokenResponse & { msg?: string; error_description?: string; error?: string }; + if (!response.ok) { + throw new OAuthLoginError(payload.error_description || payload.msg || payload.error || "授权码换取 token 失败。", response.status); + } + return payload; +} + +function requireConfiguredAuth(): AuthRuntimeConfig { + const config = getAuthRuntimeConfig(); + if (!config.configured) { + throw new OAuthLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500); + } + return config; +} + +function getCookieValue(request: Request, name: string): string | undefined { + const cookie = request.headers.get("cookie") || ""; + const prefix = `${name}=`; + return cookie.split(/;\s*/).find((entry) => entry.startsWith(prefix))?.slice(prefix.length); +} + +function clearCookieOptions(requestUrl: string) { + return { + httpOnly: true, + sameSite: "lax" as const, + secure: shouldUseSecureAuthCookie(requestUrl), + path: "/", + maxAge: 0 + }; +} diff --git a/lib/server/data-store.ts b/lib/server/data-store.ts index 647e7bf..adc1bcf 100644 --- a/lib/server/data-store.ts +++ b/lib/server/data-store.ts @@ -53,6 +53,17 @@ export async function getAsset(id: string): Promise { return state.assets.find((asset) => asset.id === id) || null; } +export async function getAssetByStoragePath(storagePath: string): Promise { + const supabase = getSupabaseAdmin(); + if (supabase) { + const { data, error } = await supabase.from("assets").select("*").eq("storage_path", storagePath).maybeSingle(); + if (error) throw new Error(error.message); + return data ? assetFromRow(data) : null; + } + const state = await readState(); + return state.assets.find((asset) => asset.storagePath === storagePath) || null; +} + export async function createAsset(input: AssetInput): Promise { const now = new Date().toISOString(); const asset: Asset = { diff --git a/lib/server/generation-service.ts b/lib/server/generation-service.ts index f22742c..e5cca90 100644 --- a/lib/server/generation-service.ts +++ b/lib/server/generation-service.ts @@ -320,12 +320,14 @@ async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise< }); } -export async function retryImageJob(jobId: string, origin: string): Promise { +export async function retryImageJob(jobId: string, origin: string, ownerId?: string): Promise { const job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); + if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`); const input = (job.requestPayload.input || {}) as SubmitImageJobInput; return submitImageJob({ ...input, + ownerId: ownerId || job.ownerId, capability: job.capability as EnabledImageCapability, retryOf: job.id }, origin); diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index aa8e03d..ca6036c 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -157,11 +157,12 @@ export async function syncVideoJob(jobId: string, origin: string): Promise { +export async function retryVideoJob(jobId: string, origin: string, ownerId?: string): Promise { const job = await getGenerationJob(jobId); if (!job) throw new Error(`Generation job not found: ${jobId}`); + if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`); const input = (job.requestPayload.input || {}) as SubmitVideoJobInput; - return submitVideoJob({ ...input, retryOf: job.id }, origin); + return submitVideoJob({ ...input, ownerId: ownerId || job.ownerId, retryOf: job.id }, origin); } async function completeMockVideoJob(job: GenerationJob): Promise { diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..133f7a5 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,44 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE_NAME, getAuthRuntimeConfig, safeNextPath } from "@/lib/auth/config"; +import { parseSessionCookieValue } from "@/lib/auth/session"; + +export async function middleware(request: NextRequest) { + const config = getAuthRuntimeConfig(); + if (!config.required) return NextResponse.next(); + + const pathname = request.nextUrl.pathname; + if (config.configured && config.sessionSecret) { + const session = await parseSessionCookieValue( + request.cookies.get(SESSION_COOKIE_NAME)?.value, + config.sessionSecret + ); + if (session) return NextResponse.next(); + } + + if (pathname.startsWith("/api/")) { + return NextResponse.json({ + error: config.configured ? "请先登录。" : "认证配置不完整。" + }, { status: config.configured ? 401 : 503 }); + } + + const loginUrl = new URL("/auth/login", request.url); + loginUrl.searchParams.set("next", safeNextPath(`${pathname}${request.nextUrl.search}`)); + if (!config.configured) loginUrl.searchParams.set("error", "auth_not_configured"); + return NextResponse.redirect(loginUrl); +} + +export const config = { + matcher: [ + "/", + "/create/:path*", + "/assets/:path*", + "/settings/:path*", + "/image-edit/:path*", + "/uploads/:path*", + "/generated-results/:path*", + "/api/assets/:path*", + "/api/generations/:path*", + "/api/prompt/:path*", + "/api/settings/:path*" + ] +}; diff --git a/next.config.ts b/next.config.ts index 02fb0c9..7e136c0 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + devIndicators: false, experimental: { serverActions: { bodySizeLimit: "12mb" diff --git a/progress.md b/progress.md index c80be21..f0d8949 100644 --- a/progress.md +++ b/progress.md @@ -238,6 +238,70 @@ | 2026-05-29 | Browser REPL variable names collided across verification cells | 1 | Reused or renamed persistent variables instead of redeclaring constants | | 2026-05-29 | White logo required a dark frame on the light topbar and looked off-brand | 1 | Switched to black/blue logo, generated a transparent cropped asset, and removed frame styling | +## Session: 2026-05-29 - Account Login and SSO Protection + +### Phase 13: Account Login and SSO Protection +- **Status:** in_progress +- Actions taken: + - Restored existing planning context and started a new Phase 13 for account login. + - Read the provided SSO integration guide. + - Confirmed the app currently has no login middleware/session helper and uses a fixed `demo-merchant` owner for first-party data. + - Started tracing routes and data access boundaries. + - Inspected first-party asset, generation, settings, health, upload, and file-serving routes. + - Confirmed public API v1 and worker routes use separate token mechanisms and should be preserved. + - Added auth config parsing, signed session cookies, OAuth2 authorize/callback/logout routes, JWT/JWKS verification, and current-user helpers. + - Added login page and topbar login/user/logout state. + - Added middleware protection for Web pages, first-party APIs, and local file-serving routes. + - Threaded authenticated owner IDs through first-party asset and generation routes. + - Added local file ownership checks through `storagePath`. + - Added SSO settings fields, health/settings status, env examples, README docs, deployment notes, and focused auth tests. + - Ran `npm test`: 8 files / 25 tests passed. + - Ran `npm run build`: production build passed with middleware and auth routes included. + - Browser-checked `/create` and `/auth/login` at desktop and 390px widths: no horizontal overflow, login config-missing state renders correctly. + - Verified forced-auth middleware behavior with a temporary dev server: `/create` redirects to `/auth/login`, first-party `/api/assets` returns 503 when auth is missing config, and `/api/v1/openapi.json` remains public. +- **Status:** complete + +## Test Results - Account Login and SSO Protection +| Test | Input | Expected | Actual | Status | +|------|-------|----------|--------|--------| +| Unit tests | `npm test` | All tests pass | 8 files / 25 tests passed | pass | +| Production build | `npm run build` | Build succeeds | Build succeeded | pass | +| Browser layout | `/create`, `/auth/login`, 1280px and 390px | No horizontal overflow | Confirmed | pass | +| Middleware redirect | `GET /create` with `ZHINIAN_AUTH_REQUIRED=1` and missing auth config | Redirect to login | 307 to `/auth/login?next=%2Fcreate&error=auth_not_configured` | pass | +| First-party API guard | `GET /api/assets` with required auth and missing config | 503 JSON | `{"error":"认证配置不完整。"}` | pass | +| Public API preservation | `GET /api/v1/openapi.json` with required auth and missing config | 200 | 200 OK | pass | + +## Error Log - Account Login and SSO Protection +| Timestamp | Error | Attempt | Resolution | +|-----------|-------|---------|------------| +| 2026-05-29 | zsh expanded unquoted `[id]` dynamic route paths again while reading route files | 1 | Re-ran the reads with single-quoted route paths | +| 2026-05-29 | `npm run build` failed because DOM `JsonWebKey` type does not include JWKS `kid` | 1 | Switched verifier typing to Node crypto `JsonWebKey` with a local `kid` extension | + +## Session: 2026-05-29 - Password Captcha Login + +### Phase 14: Password Captcha Login +- **Status:** complete +- Actions taken: + - Safely inspected the provided captcha and password grant response shape without printing tokens. + - Added `components/auth-login-panel.tsx` for account/password/captcha login on `/auth/login`. + - Added `/api/auth/captcha` to proxy image captcha requests to the auth service. + - Added `/api/auth/password` to call password grant server-side, verify the returned JWT, and set the same signed session cookie. + - Kept the original OAuth Authorization Code link as a secondary login option. + - Updated README, Chinese README, and deployment docs. + - Ran `npm test`: 8 files / 25 tests passed. + - Ran `npm run build`: production build passed. + - Restarted the dev server on `127.0.0.1:3001`. + - Browser-tested password captcha login with the user-provided test account; login reached `/create`, the topbar showed the authenticated username, and logout returned to `/auth/login?loggedOut=1`. + +## Test Results - Password Captcha Login +| Test | Input | Expected | Actual | Status | +|------|-------|----------|--------|--------| +| Captcha endpoint | `${AUTH_BASE}/code/image?randomStr=...` | Image response | PNG 100x40 | pass | +| Password grant | Provided password grant sample | JWT token response | 200 with access/refresh tokens, sanitized in logs | pass | +| Local password login API | `POST /api/auth/password` | Signed session cookie | 200 and `zhinian_session` set | pass | +| Browser login | `/auth/login` form | Redirect to `/create` | `/create` rendered with authenticated username in topbar | pass | +| Logout | Click topbar logout | Return to login | `/auth/login?loggedOut=1` | pass | + ## Session: 2026-05-29 - Server One-Command Deployment Support ### Docker and Script Deployment @@ -316,6 +380,26 @@ - Desktop and mobile screenshots showed the create-page controls fitting cleanly. - Browser page errors list was empty. +## Session: 2026-05-29 - Standalone Login Page Polish + +### Implementation +- **Status:** complete +- Actions taken: + - Updated the app shell so `/auth/*` pages render without the shared topbar and skip link. + - Reworked `/auth/login` into a standalone branded surface with logo, platform name, and account/password/captcha login box. + - Removed the visible `统一认证中心` login action from the login page. + - Kept login motion on the existing GSAP helper layer for scoped reveal and feedback animation. + +### Verification +- **Status:** complete +- Results: + - In-app browser mobile-width check confirmed no topbar, no SSO link, logo/platform name present, login panel present, and no horizontal overflow. + - Browser viewport checks at 1280x800 and 390x844 confirmed the same layout invariants. + - `npm test`: 8 files / 25 tests passed. + - `npm run build`: production build succeeded and included `/auth/login`. + - After restarting the dev server on `127.0.0.1:3001`, the login page still rendered without the topbar or SSO entry. + - Earlier auth verification for this session passed form login, redirect to `/create`, and logout back to `/auth/login`. + ## Error Log - Server Deployment Support | Timestamp | Error | Attempt | Resolution | |-----------|-------|---------|------------| diff --git a/task_plan.md b/task_plan.md index b83abde..3a3f360 100644 --- a/task_plan.md +++ b/task_plan.md @@ -4,7 +4,7 @@ Add EvoLink GPT Image 2 as a selectable image creation engine in the settings flow, while preserving the existing Jimeng/Volcengine image engine and the current task/asset workflow. ## Current Phase -Complete - latest update: Task management and public API v1 +Complete - latest update: Standalone Login Page Polish ## Phases @@ -94,6 +94,30 @@ Complete - latest update: Task management and public API v1 - [x] Verify tests, production build, and desktop/mobile create-page layout - **Status:** complete +### Phase 13: Account Login and SSO Protection +- [x] Trace existing page/API access and data ownership boundaries +- [x] Add OAuth2 Authorization Code login, callback, logout, and current-user session helpers +- [x] Verify JWT locally with JWKS and validate issuer/client/scope claims +- [x] Protect browser pages and first-party UI APIs while preserving public API key and worker endpoints +- [x] Thread authenticated owner IDs through assets and generation jobs +- [x] Add focused tests, docs, env examples, and run verification +- **Status:** complete + +### Phase 14: Password Captcha Login +- [x] Verify auth captcha endpoint and password grant response shape without printing tokens +- [x] Add `/api/auth/captcha` proxy and `/api/auth/password` session-issuing login endpoint +- [x] Add account/password/captcha form to `/auth/login` +- [x] Verify login creates a session and logout returns to the login page +- **Status:** complete + +### Phase 15: Standalone Login Page Polish +- [x] Remove the shared top bar from `/auth/*` pages +- [x] Keep only the logo, platform name, and account login form on the login page +- [x] Remove the visible unified-auth/SSO login entry from the login page +- [x] Apply the existing GSAP motion helper layer to the standalone login layout +- [x] Verify desktop and mobile login layout has no horizontal overflow +- **Status:** complete + ## Key Questions 1. How should the selected image engine be stored and exposed in settings? 2. Which current capabilities should EvoLink handle first? @@ -114,6 +138,11 @@ Complete - latest update: Task management and public API v1 | Use Docker Compose as the primary server deployment path | Gives server operators one command, persistent local runtime data, and a restart policy | | Implement multi-task support as task management, not an external message queue | Matches user preference and keeps deployment simpler for this server product | | Use API Key auth for public API v1 | Fastest stable server-to-server integration model for other AI systems | +| Use OAuth2 Authorization Code for the browser UI login | Matches the provided SSO guide and keeps `client_secret` on the server | +| Derive first-party owner ids from verified JWT claims as `auth::` | Gives logged-in users isolated assets and jobs without changing the storage schema | +| Keep `/api/v1/*` outside SSO middleware | Existing partner integrations authenticate with API keys and must not be redirected to browser login | +| Add password grant login as a first-class browser login path | The provided auth service currently accepts `customPC` password login with image captcha while `/oauth2/authorize` returns 400 for the local callback | +| Hide the unified-auth/SSO entry from the login page | The user wants a focused branded login screen with only logo, platform name, and the account login form | ## Errors Encountered | Error | Attempt | Resolution | diff --git a/tests/auth-session.test.ts b/tests/auth-session.test.ts new file mode 100644 index 0000000..df9fe51 --- /dev/null +++ b/tests/auth-session.test.ts @@ -0,0 +1,124 @@ +import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createSessionCookieValue, parseSessionCookieValue, type AuthSession } from "@/lib/auth/session"; +import { type AuthRuntimeConfig } from "@/lib/auth/config"; +import { clearJwksCacheForTests, userFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt"; + +type TestJwk = JsonWebKey & { + kid?: string; + alg?: string; + use?: string; +}; + +const authConfig: AuthRuntimeConfig = { + required: true, + configured: true, + missing: [], + authBaseUrl: "https://gateway.example.com/auth", + authorizeUrl: "https://gateway.example.com/auth/oauth2/authorize", + tokenUrl: "https://gateway.example.com/auth/oauth2/token", + jwksUrl: "https://gateway.example.com/auth/oauth2/jwks", + logoutUrl: "https://gateway.example.com/auth/token/logout", + clientId: "customPC", + clientSecret: "client-secret", + scope: "server", + issuer: "https://pig4cloud.com", + sessionSecret: "test-session-secret-with-enough-entropy", + clockSkewSeconds: 60 +}; + +describe("SSO auth helpers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + clearJwksCacheForTests(); + }); + + it("round-trips signed session cookies and rejects tampering or expiry", async () => { + const session: AuthSession = { + version: 1, + issuedAt: 100, + expiresAt: 200, + user: { + id: "auth:customPC:1", + subject: "zhangsan", + username: "zhangsan", + displayName: "张三", + clientId: "customPC", + authorities: ["ROLE_1"], + scope: ["server"] + } + }; + + const cookie = await createSessionCookieValue(session, authConfig.sessionSecret || ""); + expect(await parseSessionCookieValue(cookie, authConfig.sessionSecret || "", 150)).toMatchObject({ + user: { id: "auth:customPC:1", displayName: "张三" } + }); + expect(await parseSessionCookieValue(`${cookie.slice(0, -1)}x`, authConfig.sessionSecret || "", 150)).toBeNull(); + expect(await parseSessionCookieValue(cookie, authConfig.sessionSecret || "", 201)).toBeNull(); + }); + + it("verifies RS256 JWTs from JWKS and maps stable owner ids", async () => { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }) as TestJwk; + jwk.kid = "kid-1"; + jwk.alg = "RS256"; + jwk.use = "sig"; + 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, + scope: "server", + client_id: "customPC", + clientId: "customPC", + user_id: 1, + username: "zhangsan", + tenant_id: 2, + authorities: ["ROLE_1", "sys_user_view"] + }, privateKey, "kid-1"); + + const claims = await verifyAuthJwt(token, authConfig); + expect(userFromClaims(claims, authConfig)).toMatchObject({ + id: "auth:customPC:1", + displayName: "zhangsan", + tenantId: "2", + authorities: ["ROLE_1", "sys_user_view"], + scope: ["server"] + }); + }); + + it("rejects JWTs for another OAuth client", async () => { + const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }) as TestJwk; + jwk.kid = "kid-2"; + vi.stubGlobal("fetch", async () => new Response(JSON.stringify({ keys: [jwk] }), { status: 200 })); + + const token = signJwt({ + iss: authConfig.issuer, + exp: Math.floor(Date.now() / 1000) + 600, + scope: "server", + client_id: "other-client", + sub: "zhangsan" + }, privateKey, "kid-2"); + + await expect(verifyAuthJwt(token, authConfig)).rejects.toThrow("client id"); + }); +}); + +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"); +}