修改登录逻辑
This commit is contained in:
@@ -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
|
||||
|
||||
12
README.md
12
README.md
@@ -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`
|
||||
|
||||
@@ -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 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` |
|
||||
|
||||
@@ -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();
|
||||
|
||||
47
app/auth/admin-login/page.tsx
Normal file
47
app/auth/admin-login/page.tsx
Normal 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)}`;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 组织账号接口
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user