修改登录逻辑

This commit is contained in:
2026-07-06 15:54:27 +08:00
parent fe66794168
commit 53b1842de8
12 changed files with 198 additions and 28 deletions

View File

@@ -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://<gateway-domain>/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

View File

@@ -74,8 +74,10 @@ https://你的域名/api/auth/callback
- `ZHINIAN_AUTH_REQUIRED=auto`:生产默认启用;本地可信开发可设为 `0`
- `ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/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`

View File

@@ -160,8 +160,10 @@ https://你的域名/api/auth/callback
|------|------|
| `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` |
| `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https://<gateway-domain>/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 KeyWorker 仍使用内部 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` |

View File

@@ -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();

View File

@@ -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<string, string> = {
auth_not_configured: "认证配置不完整,请先在服务器环境变量中配置 SSO。",
callback_failed: "登录回调处理失败,请重新登录。",
state_invalid: "登录状态已失效,请重新登录。"
};
export default async function AdminLoginPage({
searchParams
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
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 (
<AuthLoginPanel
next={next}
configured={config.configured}
message={message}
missing={!config.configured && config.required ? config.missing : []}
title="管理员登录"
submitLabel="管理员登录"
authMode="admin"
alternateHref={loginHref("/auth/login", next)}
alternateLabel="普通账号登录"
/>
);
}
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)}`;
}

View File

@@ -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)}`;
}

View File

@@ -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<string, string> = { 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({
</section>
<section className="panel auth-panel" data-animate>
<h2></h2>
<h2>{title}</h2>
{message || hasMissingConfig || error ? (
<div ref={feedbackRef}>
@@ -101,9 +113,14 @@ export function AuthLoginPanel({
</label>
<button className="button primary auth-submit" type="submit" disabled={!configured || submitting || !username.trim() || !password} data-animate>
{submitting ? <Loader2 className="spin" size={18} /> : <LogIn size={18} />}
{submitLabel}
</button>
</form>
{alternateHref && alternateLabel ? (
<Link className="button auth-alternate-link" href={alternateHref}>
{alternateLabel}
</Link>
) : null}
</section>
</div>
);

View File

@@ -45,8 +45,10 @@ NEXT_PUBLIC_APP_URL=https://你的域名
ZHINIAN_AUTH_REQUIRED=auto
ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/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`
## 组织账号接口

View File

@@ -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;

View File

@@ -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" },

View File

@@ -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");
});
});

View File

@@ -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<string, string> | 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<string, unknown>, privateKey: KeyObject, kid: string): string {