修改登录逻辑
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.
|
# Production requires login by default. Set ZHINIAN_AUTH_REQUIRED=0 only for trusted local development.
|
||||||
ZHINIAN_AUTH_REQUIRED=auto
|
ZHINIAN_AUTH_REQUIRED=auto
|
||||||
ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/auth
|
ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/auth
|
||||||
ZHINIAN_AUTH_CLIENT_ID=app
|
ZHINIAN_AUTH_CLIENT_ID=custom
|
||||||
ZHINIAN_AUTH_CLIENT_SECRET=app
|
ZHINIAN_AUTH_CLIENT_SECRET=custom
|
||||||
|
ZHINIAN_ADMIN_AUTH_CLIENT_ID=app
|
||||||
|
ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app
|
||||||
ZHINIAN_AUTH_SCOPE=server
|
ZHINIAN_AUTH_SCOPE=server
|
||||||
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
|
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
|
||||||
ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud
|
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_REQUIRED=auto`:生产默认启用;本地可信开发可设为 `0`
|
||||||
- `ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/auth`
|
- `ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/auth`
|
||||||
- `ZHINIAN_AUTH_CLIENT_ID=app`
|
- `ZHINIAN_AUTH_CLIENT_ID=custom`
|
||||||
- `ZHINIAN_AUTH_CLIENT_SECRET=app`
|
- `ZHINIAN_AUTH_CLIENT_SECRET=custom`
|
||||||
|
- `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app`
|
||||||
|
- `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app`
|
||||||
- `ZHINIAN_AUTH_SCOPE=server`
|
- `ZHINIAN_AUTH_SCOPE=server`
|
||||||
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
|
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
|
||||||
- `ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud`:按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密
|
- `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。
|
`/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_REQUIRED=auto`
|
||||||
- `ZHINIAN_AUTH_BASE_URL`
|
- `ZHINIAN_AUTH_BASE_URL`
|
||||||
- `ZHINIAN_AUTH_CLIENT_ID=app`
|
- `ZHINIAN_AUTH_CLIENT_ID=custom`
|
||||||
- `ZHINIAN_AUTH_CLIENT_SECRET`
|
- `ZHINIAN_AUTH_CLIENT_SECRET`
|
||||||
|
- `ZHINIAN_ADMIN_AUTH_CLIENT_ID=app`
|
||||||
|
- `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET`
|
||||||
- `ZHINIAN_AUTH_SCOPE=server`
|
- `ZHINIAN_AUTH_SCOPE=server`
|
||||||
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
|
- `ZHINIAN_AUTH_ISSUER=https://pig4cloud.com`
|
||||||
- `ZHINIAN_AUTH_SESSION_SECRET`
|
- `ZHINIAN_AUTH_SESSION_SECRET`
|
||||||
|
|||||||
@@ -160,8 +160,10 @@ https://你的域名/api/auth/callback
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` |
|
| `ZHINIAN_AUTH_REQUIRED` | `auto` 默认策略;生产启用,本地可信开发可设 `0` |
|
||||||
| `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https://<gateway-domain>/auth` |
|
| `ZHINIAN_AUTH_BASE_URL` | 认证服务网关地址,例如 `https://<gateway-domain>/auth` |
|
||||||
| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID,默认 `app` |
|
| `ZHINIAN_AUTH_CLIENT_ID` | 普通账号 OAuth2 客户端 ID,默认 `custom` |
|
||||||
| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥,只能保存在服务端 |
|
| `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_SCOPE` | 默认 `server` |
|
||||||
| `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` |
|
| `ZHINIAN_AUTH_ISSUER` | JWT issuer,默认 `https://pig4cloud.com` |
|
||||||
| `ZHINIAN_AUTH_PASSWORD_ENC_KEY` | 按认证中心 `security.encode-key` 对 password grant 的密码做 AES-CFB 加密,默认示例 `thanks,pig4cloud` |
|
| `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。
|
受保护范围包括 `/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_BASE_URL` | 统一认证中心地址 |
|
||||||
| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID |
|
| `ZHINIAN_AUTH_CLIENT_ID` | OAuth2 客户端 ID |
|
||||||
| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 |
|
| `ZHINIAN_AUTH_CLIENT_SECRET` | OAuth2 客户端密钥 |
|
||||||
|
| `ZHINIAN_ADMIN_AUTH_CLIENT_ID` | 管理员登录入口 OAuth2 客户端 ID |
|
||||||
|
| `ZHINIAN_ADMIN_AUTH_CLIENT_SECRET` | 管理员登录入口 OAuth2 客户端密钥 |
|
||||||
| `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 |
|
| `ZHINIAN_AUTH_SESSION_SECRET` | 本地登录态签名密钥 |
|
||||||
| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 |
|
| `ZHINIAN_ADMIN_AUTHORITIES` | 管理员权限码白名单 |
|
||||||
| `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` |
|
| `ZHINIAN_ADMIN_USERS` | 管理员账号白名单,默认 `ceshiop` |
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ type PasswordTokenResponse = {
|
|||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const config = getAuthRuntimeConfig();
|
|
||||||
if (!config.configured || !config.tokenUrl || !config.clientSecret || !config.sessionSecret) {
|
|
||||||
throw new PasswordLoginError(`认证配置不完整:${config.missing.join(", ") || "未知配置"}`, 500);
|
|
||||||
}
|
|
||||||
const body = await readJsonBody<{
|
const body = await readJsonBody<{
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -33,7 +29,12 @@ export async function POST(request: Request) {
|
|||||||
code?: string;
|
code?: string;
|
||||||
randomStr?: string;
|
randomStr?: string;
|
||||||
next?: string;
|
next?: string;
|
||||||
|
authMode?: string;
|
||||||
}>(request);
|
}>(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 username = body.username?.trim();
|
||||||
const password = body.password || "";
|
const password = body.password || "";
|
||||||
const code = body.code?.trim();
|
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}
|
configured={config.configured}
|
||||||
message={message}
|
message={message}
|
||||||
missing={!config.configured && config.required ? config.missing : []}
|
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 {
|
function singleParam(value: string | string[] | undefined): string | undefined {
|
||||||
return Array.isArray(value) ? value[0] : value;
|
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 { useEffect, useRef, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
import { Loader2, LogIn } from "lucide-react";
|
import { Loader2, LogIn } from "lucide-react";
|
||||||
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||||
|
|
||||||
@@ -10,12 +11,22 @@ export function AuthLoginPanel({
|
|||||||
next,
|
next,
|
||||||
configured,
|
configured,
|
||||||
message,
|
message,
|
||||||
missing
|
missing,
|
||||||
|
title = "账户登录",
|
||||||
|
submitLabel = "登录",
|
||||||
|
authMode,
|
||||||
|
alternateHref,
|
||||||
|
alternateLabel
|
||||||
}: {
|
}: {
|
||||||
next: string;
|
next: string;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
missing?: string[];
|
missing?: string[];
|
||||||
|
title?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
authMode?: string;
|
||||||
|
alternateHref?: string;
|
||||||
|
alternateLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -40,6 +51,7 @@ export function AuthLoginPanel({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, string> = { username, password, next };
|
const payload: Record<string, string> = { username, password, next };
|
||||||
|
if (authMode) payload.authMode = authMode;
|
||||||
const response = await fetch("/api/auth/password", {
|
const response = await fetch("/api/auth/password", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -63,7 +75,7 @@ export function AuthLoginPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="panel auth-panel" data-animate>
|
<section className="panel auth-panel" data-animate>
|
||||||
<h2>账户登录</h2>
|
<h2>{title}</h2>
|
||||||
|
|
||||||
{message || hasMissingConfig || error ? (
|
{message || hasMissingConfig || error ? (
|
||||||
<div ref={feedbackRef}>
|
<div ref={feedbackRef}>
|
||||||
@@ -101,9 +113,14 @@ export function AuthLoginPanel({
|
|||||||
</label>
|
</label>
|
||||||
<button className="button primary auth-submit" type="submit" disabled={!configured || submitting || !username.trim() || !password} data-animate>
|
<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} />}
|
{submitting ? <Loader2 className="spin" size={18} /> : <LogIn size={18} />}
|
||||||
登录
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{alternateHref && alternateLabel ? (
|
||||||
|
<Link className="button auth-alternate-link" href={alternateHref}>
|
||||||
|
{alternateLabel}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,8 +45,10 @@ NEXT_PUBLIC_APP_URL=https://你的域名
|
|||||||
|
|
||||||
ZHINIAN_AUTH_REQUIRED=auto
|
ZHINIAN_AUTH_REQUIRED=auto
|
||||||
ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/auth
|
ZHINIAN_AUTH_BASE_URL=https://<gateway-domain>/auth
|
||||||
ZHINIAN_AUTH_CLIENT_ID=app
|
ZHINIAN_AUTH_CLIENT_ID=custom
|
||||||
ZHINIAN_AUTH_CLIENT_SECRET=app
|
ZHINIAN_AUTH_CLIENT_SECRET=custom
|
||||||
|
ZHINIAN_ADMIN_AUTH_CLIENT_ID=app
|
||||||
|
ZHINIAN_ADMIN_AUTH_CLIENT_SECRET=app
|
||||||
ZHINIAN_AUTH_SCOPE=server
|
ZHINIAN_AUTH_SCOPE=server
|
||||||
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
|
ZHINIAN_AUTH_ISSUER=https://pig4cloud.com
|
||||||
ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud
|
ZHINIAN_AUTH_PASSWORD_ENC_KEY=thanks,pig4cloud
|
||||||
@@ -89,7 +91,7 @@ ALI_OSS_PUBLIC_BASE_URL=
|
|||||||
https://你的域名/api/auth/callback
|
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;
|
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 authBaseUrl = trimTrailingSlash(envValue("ZHINIAN_AUTH_BASE_URL", "AUTH_BASE"));
|
||||||
const clientId = envValue("ZHINIAN_AUTH_CLIENT_ID", "AUTH_CLIENT_ID") || "app";
|
const client = authClientConfig(options.clientMode || "default");
|
||||||
const clientSecret = envValue("ZHINIAN_AUTH_CLIENT_SECRET", "AUTH_CLIENT_SECRET");
|
|
||||||
const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server";
|
const scope = envValue("ZHINIAN_AUTH_SCOPE", "AUTH_SCOPE") || "server";
|
||||||
const issuer = envValue("ZHINIAN_AUTH_ISSUER", "AUTH_ISSUER") || "https://pig4cloud.com";
|
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 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 sessionSecret = envValue("ZHINIAN_AUTH_SESSION_SECRET", "AUTH_SESSION_SECRET", "NEXTAUTH_SECRET");
|
||||||
const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED");
|
const explicitRequired = boolEnv("ZHINIAN_AUTH_REQUIRED");
|
||||||
const disabled = boolEnv("ZHINIAN_AUTH_DISABLED") === true;
|
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 required = disabled ? false : explicitRequired ?? (process.env.NODE_ENV === "production" || Boolean(authBaseUrl));
|
||||||
const wantsConfiguration = required || hasAnyAuthConfig;
|
const wantsConfiguration = required || hasAnyAuthConfig;
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
if (wantsConfiguration && !authBaseUrl) missing.push("ZHINIAN_AUTH_BASE_URL");
|
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");
|
if (wantsConfiguration && !sessionSecret) missing.push("ZHINIAN_AUTH_SESSION_SECRET");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -47,8 +48,8 @@ export function getAuthRuntimeConfig(): AuthRuntimeConfig {
|
|||||||
tokenUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_TOKEN_URL", "/oauth2/token"),
|
tokenUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_TOKEN_URL", "/oauth2/token"),
|
||||||
jwksUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_JWKS_URL", "/oauth2/jwks"),
|
jwksUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_JWKS_URL", "/oauth2/jwks"),
|
||||||
logoutUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_LOGOUT_URL", "/token/logout"),
|
logoutUrl: endpointUrl(authBaseUrl, "ZHINIAN_AUTH_LOGOUT_URL", "/token/logout"),
|
||||||
clientId,
|
clientId: client.clientId,
|
||||||
clientSecret,
|
clientSecret: client.clientSecret,
|
||||||
scope,
|
scope,
|
||||||
issuer,
|
issuer,
|
||||||
passwordEncryptionKey,
|
passwordEncryptionKey,
|
||||||
@@ -63,7 +64,7 @@ export function safeNextPath(value: string | null | undefined, fallback = "/crea
|
|||||||
const parsed = new URL(value, "http://zhinian.local");
|
const parsed = new URL(value, "http://zhinian.local");
|
||||||
if (parsed.origin !== "http://zhinian.local") return fallback;
|
if (parsed.origin !== "http://zhinian.local") return fallback;
|
||||||
const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
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;
|
return path;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -102,6 +103,25 @@ function envValue(...names: string[]): string | undefined {
|
|||||||
return 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 {
|
function boolEnv(name: string): boolean | undefined {
|
||||||
const value = process.env[name]?.trim().toLowerCase();
|
const value = process.env[name]?.trim().toLowerCase();
|
||||||
if (!value || value === "auto") return undefined;
|
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_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_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_SCOPE", label: "Scope", defaultValue: "server" },
|
||||||
{ key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" },
|
{ key: "ZHINIAN_AUTH_ISSUER", label: "Issuer", defaultValue: "https://pig4cloud.com" },
|
||||||
{ key: "ZHINIAN_AUTH_PASSWORD_ENC_KEY", label: "Password Encryption Key", secret: true, type: "password" },
|
{ 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("/api/auth/captcha");
|
||||||
expect(source).not.toContain("randomStr");
|
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("code")).toBe(false);
|
||||||
expect(seenBodies[0].has("randomStr")).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 {
|
function signJwt(payload: Record<string, unknown>, privateKey: KeyObject, kid: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user