diff --git a/.env.example b/.env.example index 9b3da02..045623d 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,10 @@ ZHINIAN_PUBLIC_BASE_URL=http://127.0.0.1:3000 # 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=app -ZHINIAN_AUTH_CLIENT_SECRET=app +ZHINIAN_AUTH_CLIENT_ID=custom +ZHINIAN_AUTH_CLIENT_SECRET=custom +ZHINIAN_ADMIN_AUTH_CLIENT_ID=app +ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app ZHINIAN_AUTH_SCOPE=server ZHINIAN_AUTH_ISSUER=https://pig4cloud.com ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud diff --git a/README.md b/README.md index 116ccc9..afef2f7 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,10 @@ https://你的域名/api/auth/callback - `ZHINIAN_AUTH_REQUIRED=auto`:生产默认启用;本地可信开发可设为 `0` - `ZHINIAN_AUTH_BASE_URL=https:///auth` -- `ZHINIAN_AUTH_CLIENT_ID=app` -- `ZHINIAN_AUTH_CLIENT_SECRET=app` +- `ZHINIAN_AUTH_CLIENT_ID=custom` +- `ZHINIAN_AUTH_CLIENT_SECRET=custom` +- `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app` +- `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app` - `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud`:按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密 @@ -85,7 +87,7 @@ https://你的域名/api/auth/callback `/create`、`/assets`、`/settings`、`/logs`、`/accounts`、第一方生成/资产 API、以及本地上传和生成结果文件都会受登录态保护。`/logs`、`/settings`、`/accounts` 和 `/api/admin/*` 需要管理员权限。`/api/v1/*` 继续使用 `ZHINIAN_API_KEYS`,不走浏览器 SSO。 -如果认证中心客户端未加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。当前推荐使用运维分配的 `app/app` 客户端;如改用 `customPC` 登录平台用户,需要认证中心把该 clientId 加入 `security.ignore-clients`。 +如果认证中心客户端未加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。普通账号登录默认使用 `custom/custom`;登录页里的“管理员登录”入口使用 `app/app`。两组 client 都需要认证中心允许 password grant。 ## 组织账号管理 @@ -161,8 +163,10 @@ cp .env.example .env.local - `ZHINIAN_AUTH_REQUIRED=auto` - `ZHINIAN_AUTH_BASE_URL` -- `ZHINIAN_AUTH_CLIENT_ID=app` +- `ZHINIAN_AUTH_CLIENT_ID=custom` - `ZHINIAN_AUTH_CLIENT_SECRET` +- `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app` +- `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` - `ZHINIAN_AUTH_SCOPE=server` - `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com` - `ZHINIAN_AUTH_SESSION_SECRET` diff --git a/README.zh-CN.md b/README.zh-CN.md index c96ea3f..3d95dad 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -160,8 +160,10 @@ https://你的域名/api/auth/callback |------|------| | `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` | | `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https:///auth` | -| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID,默认 `app` | -| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥,只能保存在服务端 | +| `ZHINIAN_AUTH_CLIENT_ID` | 普通账号 OAuth2 客户端 ID,默认 `custom` | +| `ZHINIAN_AUTH_CLIENT_SECRET` | 普通账号 OAuth2 客户端密钥,只能保存在服务端 | +| `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID,默认 `app` | +| `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥,只能保存在服务端 | | `ZHINIAN_AUTH_SCOPE` | 默认 `server` | | `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` | | `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密,默认示例 `thanks,pig4cloud` | @@ -171,7 +173,7 @@ https://你的域名/api/auth/callback 受保护范围包括 `/create`、`/assets`、`/settings`、`/logs`、`/accounts`、第一方生成/资产 API,以及本地 `/uploads/*` 和 `/generated-results/*` 文件。普通用户主导航只显示创作和结果;`/logs`、`/settings`、`/accounts` 和 `/api/admin/*` 需要管理员权限。开放 `/api/v1/*` 仍使用 API Key,Worker 仍使用内部 token,不走浏览器 SSO。 -如果认证中心客户端没有加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。当前推荐使用运维分配的 `app/app` 客户端;如改用 `customPC` 登录平台用户,需要认证中心把该 clientId 加入 `security.ignore-clients`。 +如果认证中心客户端没有加入 `security.ignore-clients`,`/oauth2/token` 可能返回“验证码不能为空”。普通账号登录默认使用 `custom/custom`;登录页里的“管理员登录”入口使用 `app/app`。两组 client 都需要认证中心允许 password grant。 ## 组织账号管理 @@ -273,6 +275,8 @@ cp .env.example .env.local | `ZHINIAN_AUTH_BASE_URL` | 统一认证中心地址 | | `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID | | `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 | +| `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID | +| `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥 | | `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 | | `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 | | `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` | diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts index 383833d..97cc481 100644 --- a/app/api/auth/password/route.ts +++ b/app/api/auth/password/route.ts @@ -21,10 +21,6 @@ type PasswordTokenResponse = { 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; @@ -33,7 +29,12 @@ export async function POST(request: Request) { code?: string; randomStr?: string; next?: string; + authMode?: string; }>(request); + const config = getAuthRuntimeConfig({ clientMode: body.authMode === "admin" ? "admin" : "default" }); + if (!config.configured || !config.tokenUrl || !config.clientSecret || !config.sessionSecret) { + throw new PasswordLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500); + } const username = body.username?.trim(); const password = body.password || ""; const code = body.code?.trim(); diff --git a/app/auth/admin-login/page.tsx b/app/auth/admin-login/page.tsx new file mode 100644 index 0000000..e5dfe5b --- /dev/null +++ b/app/auth/admin-login/page.tsx @@ -0,0 +1,47 @@ +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 AdminLoginPage({ + searchParams +}: { + searchParams?: Promise>; +}) { + const params = await searchParams; + const next = safeNextPath(singleParam(params?.next)); + const session = await getOptionalAuthSession(); + if (session) redirect(next); + + const config = getAuthRuntimeConfig({ clientMode: "admin" }); + 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; +} + +function loginHref(path: string, next: string): string { + return `${path}?next=${encodeURIComponent(next)}`; +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index 0868fb1..bcba700 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -29,6 +29,8 @@ export default async function LoginPage({ configured={config.configured} message={message} missing={!config.configured && config.required ? config.missing : []} + alternateHref={loginHref("/auth/admin-login", next)} + alternateLabel="管理员登录" /> ); } @@ -36,3 +38,7 @@ export default async function LoginPage({ function singleParam(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } + +function loginHref(path: string, next: string): string { + return `${path}?next=${encodeURIComponent(next)}`; +} diff --git a/components/auth-login-panel.tsx b/components/auth-login-panel.tsx index 1013c5b..aae5927 100644 --- a/components/auth-login-panel.tsx +++ b/components/auth-login-panel.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import type { FormEvent } from "react"; import Image from "next/image"; +import Link from "next/link"; import { Loader2, LogIn } from "lucide-react"; import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; @@ -10,12 +11,22 @@ export function AuthLoginPanel({ next, configured, message, - missing + missing, + title = "账户登录", + submitLabel = "登录", + authMode, + alternateHref, + alternateLabel }: { next: string; configured: boolean; message?: string | null; missing?: string[]; + title?: string; + submitLabel?: string; + authMode?: string; + alternateHref?: string; + alternateLabel?: string; }) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -40,6 +51,7 @@ export function AuthLoginPanel({ setError(null); try { const payload: Record = { username, password, next }; + if (authMode) payload.authMode = authMode; const response = await fetch("/api/auth/password", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -63,7 +75,7 @@ export function AuthLoginPanel({
-

账户登录

+

{title}

{message || hasMissingConfig || error ? (
@@ -101,9 +113,14 @@ export function AuthLoginPanel({ + {alternateHref && alternateLabel ? ( + + {alternateLabel} + + ) : null}
); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index e949b29..9d07f88 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -45,8 +45,10 @@ NEXT_PUBLIC_APP_URL=https://你的域名 ZHINIAN_AUTH_REQUIRED=auto ZHINIAN_AUTH_BASE_URL=https:///auth -ZHINIAN_AUTH_CLIENT_ID=app -ZHINIAN_AUTH_CLIENT_SECRET=app +ZHINIAN_AUTH_CLIENT_ID=custom +ZHINIAN_AUTH_CLIENT_SECRET=custom +ZHINIAN_ADMIN_AUTH_CLIENT_ID=app +ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app ZHINIAN_AUTH_SCOPE=server ZHINIAN_AUTH_ISSUER=https://pig4cloud.com ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud @@ -89,7 +91,7 @@ ALI_OSS_PUBLIC_BASE_URL= https://你的域名/api/auth/callback ``` -如果使用登录页内的账号密码方式,需要确认客户端支持 `password` 和 `refresh_token` grant,并已加入认证中心配置 `security.ignore-clients`。当前推荐按运维文档使用 `app/app`;如改用 `customPC` 登录平台用户,需要认证中心把 `customPC` 加入 `security.ignore-clients`。 +如果使用登录页内的账号密码方式,需要确认客户端支持 `password` 和 `refresh_token` grant,并已加入认证中心配置 `security.ignore-clients`。普通账号登录使用 `ZHINIAN_AUTH_CLIENT_ID` / `ZHINIAN_AUTH_CLIENT_SECRET`,默认 `custom/custom`;登录页里的“管理员登录”入口使用 `ZHINIAN_ADMIN_AUTH_CLIENT_ID` / `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET`,默认 `app/app`。 ## 组织账号接口 diff --git a/lib/auth/config.ts b/lib/auth/config.ts index a21ec55..710d127 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -19,23 +19,24 @@ export type AuthRuntimeConfig = { clockSkewSeconds: number; }; -export function getAuthRuntimeConfig(): AuthRuntimeConfig { +export type AuthClientMode = "default" | "admin"; + +export function getAuthRuntimeConfig(options: { clientMode?: AuthClientMode } = {}): AuthRuntimeConfig { const authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE")); - const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "app"; - const clientSecret = envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET"); + const client = authClientConfig(options.clientMode || "default"); const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server"; const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com"; const passwordEncryptionKey = envValue("ZHINIAN_AUTH_PASSWORD_ENC_KEY", "AUTH_PASSWORD_ENC_KEY", "AGENTBUS_SSO_PASSWORD_ENC_KEY"); const sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET"); const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED"); const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true; - const hasAnyAuthConfig = Boolean(authBaseUrl || clientSecret || sessionSecret); + const hasAnyAuthConfig = Boolean(authBaseUrl || client.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 && !client.clientSecret) missing.push(client.missingSecretKey); if (wantsConfiguration && !sessionSecret) missing.push("ZHINIAN_AUTH_SESSION_SECRET"); return { @@ -47,8 +48,8 @@ export function getAuthRuntimeConfig(): AuthRuntimeConfig { 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, + clientId: client.clientId, + clientSecret: client.clientSecret, scope, issuer, passwordEncryptionKey, @@ -63,7 +64,7 @@ export function safeNextPath(value: string | null | undefined, fallback = "/crea 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; + if (path.startsWith("/api/auth") || path.startsWith("/auth/login") || path.startsWith("/auth/admin-login")) return fallback; return path; } catch { return fallback; @@ -102,6 +103,25 @@ function envValue(...names: string[]): string | undefined { return undefined; } +function authClientConfig(mode: AuthClientMode): { + clientId: string; + clientSecret?: string; + missingSecretKey: string; +} { + if (mode === "admin") { + return { + clientId: envValue("ZHINIAN_ADMIN_AUTH_CLIENT_ID", "ADMIN_AUTH_CLIENT_ID") || "app", + clientSecret: envValue("ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", "ADMIN_AUTH_CLIENT_SECRET") || "app", + missingSecretKey: "ZHINIAN_ADMIN_AUTH_CLIENT_SECRET" + }; + } + return { + clientId: envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "app", + clientSecret: envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET"), + missingSecretKey: "ZHINIAN_AUTH_CLIENT_SECRET" + }; +} + function boolEnv(name: string): boolean | undefined { const value = process.env[name]?.trim().toLowerCase(); if (!value || value === "auto") return undefined; diff --git a/lib/server/app-settings.ts b/lib/server/app-settings.ts index 5016793..febc61a 100644 --- a/lib/server/app-settings.ts +++ b/lib/server/app-settings.ts @@ -66,8 +66,10 @@ const settingDefinitions: Array<{ ] }, { key: "ZHINIAN_AUTH_BASE_URL", label: "Auth Base URL" }, - { key: "ZHINIAN_AUTH_CLIENT_ID", label: "客户端 ID", defaultValue: "app" }, + { key: "ZHINIAN_AUTH_CLIENT_ID", label: "普通客户端 ID", defaultValue: "custom" }, { key: "ZHINIAN_AUTH_CLIENT_SECRET", label: "客户端密钥", secret: true, type: "password" }, + { key: "ZHINIAN_ADMIN_AUTH_CLIENT_ID", label: "管理员客户端 ID", defaultValue: "app" }, + { key: "ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", label: "管理员客户端密钥", secret: true, type: "password" }, { key: "ZHINIAN_AUTH_SCOPE", label: "Scope", defaultValue: "server" }, { key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" }, { key: "ZHINIAN_AUTH_PASSWORD_ENC_KEY", label: "Password Encryption Key", secret: true, type: "password" }, diff --git a/tests/auth-login-panel.test.ts b/tests/auth-login-panel.test.ts index ad89e16..0ca4934 100644 --- a/tests/auth-login-panel.test.ts +++ b/tests/auth-login-panel.test.ts @@ -11,4 +11,16 @@ describe("AuthLoginPanel", () => { expect(source).not.toContain("/api/auth/captcha"); expect(source).not.toContain("randomStr"); }); + + it("supports switching between normal and admin login pages", async () => { + const panelSource = await readFile(join(process.cwd(), "components", "auth-login-panel.tsx"), "utf8"); + const loginPageSource = await readFile(join(process.cwd(), "app", "auth", "login", "page.tsx"), "utf8"); + const adminPageSource = await readFile(join(process.cwd(), "app", "auth", "admin-login", "page.tsx"), "utf8").catch(() => ""); + + expect(panelSource).toContain("authMode"); + expect(panelSource).toContain("alternateHref"); + expect(loginPageSource).toContain("/auth/admin-login"); + expect(adminPageSource).toContain('authMode="admin"'); + expect(adminPageSource).toContain("/auth/login"); + }); }); diff --git a/tests/auth-password-route.test.ts b/tests/auth-password-route.test.ts index 3aeff98..078772b 100644 --- a/tests/auth-password-route.test.ts +++ b/tests/auth-password-route.test.ts @@ -79,6 +79,59 @@ describe("password auth route AgentBus compatibility", () => { expect(seenBodies[0].has("code")).toBe(false); expect(seenBodies[0].has("randomStr")).toBe(false); }); + + it("uses the admin OAuth client for admin password login", async () => { + for (const [key, value] of Object.entries(baseEnv)) vi.stubEnv(key, value); + vi.stubEnv("ZHINIAN_ADMIN_AUTH_CLIENT_ID", "app"); + vi.stubEnv("ZHINIAN_ADMIN_AUTH_CLIENT_SECRET", "app"); + const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }) as TestJwk; + jwk.kid = "admin-key"; + const accessToken = signJwt({ + iss: baseEnv.ZHINIAN_AUTH_ISSUER, + sub: "admin", + user_id: "admin", + username: "admin", + client_id: "app", + scope: baseEnv.ZHINIAN_AUTH_SCOPE, + exp: Math.floor(Date.now() / 1000) + 600, + iat: Math.floor(Date.now() / 1000) - 10, + nbf: Math.floor(Date.now() / 1000) - 10 + }, privateKey, "admin-key"); + const seenAuthorizations: string[] = []; + + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/oauth2/jwks")) { + return new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }); + } + if (url.endsWith("/oauth2/token")) { + const headers = init?.headers as Record | undefined; + seenAuthorizations.push(headers?.Authorization || headers?.authorization || ""); + return new Response(JSON.stringify({ + access_token: accessToken, + token_type: "bearer", + expires_in: "3600" + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const response = await POST(new Request("https://app.example.com/api/auth/password", { + method: "POST", + body: JSON.stringify({ + username: "admin", + password: "123456", + authMode: "admin", + next: "/accounts" + }) + })); + + expect(response.status).toBe(200); + expect(seenAuthorizations).toEqual([ + `Basic ${Buffer.from("app:app").toString("base64")}` + ]); + }); }); function signJwt(payload: Record, privateKey: KeyObject, kid: string): string {