登录鉴权:新增用户名口令门 + token 换登录两种模式

- config 新增 auth_login 段(login_config.py):
  - username_login.require_password 开启后,用户名直登必须带 ?password=,口令留空默认 123ewq
  - token_login 用上游 token 调 getTokenInfo 换用户名再登录,用户名不在库自动注册;含 verify_ssl/ca_cert_path 处理 HTTPS 自签证书
- auth.py:口令门、/login/token 端点、共享 _get_or_create_user_by_username 与 _fetch_username_from_token;登录类报错文案改中文
- auth_middleware:/login/token 加入免鉴权白名单
- 前端 api.ts/LoginPage:透传 ?password=、?authToken=;修复错误体为对象时显示 [object Object] 的问题
- 文档:backend/CLAUDE.md + docs/AUTH_LOGIN.md 接口说明

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-02 17:39:56 +08:00
1 parent 5aa1fdca04
commit c484481964
10 files changed
+531 -56

No files matched your search

+52 -6
View File
@@ -17,24 +17,70 @@ export async function listAllUsers(): Promise<UserListItem[]> {
return res.json() as Promise<UserListItem[]>;
}
// Normalize a FastAPI error body into a human-readable string. `detail` may be
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
// array of validation errors — anything but a string would otherwise render as
// the dreaded "[object Object]".
function extractErrorMessage(body: unknown, fallback = "Login failed."): string {
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) {
const msg = detail
.map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d)))
.filter(Boolean)
.join("; ");
return msg || fallback;
}
if (detail && typeof detail === "object") {
const obj = detail as { message?: string; msg?: string };
return obj.message ?? obj.msg ?? fallback;
}
return fallback;
}
export async function loginByUsername(
username: string,
password?: string | null,
): Promise<LoginByUsernameResponse> {
// When the username-login password gate is enabled server-side, the shared
// secret must be passed as ?password=. Omitted when no password is provided
// so the passwordless flow keeps working unchanged.
const base = `${getBackendBaseURL()}/api/v1/auth/login/username`;
const url =
password != null && password !== ""
? `${base}?password=${encodeURIComponent(password)}`
: base;
const response = await apiFetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
});
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
}
return response.json() as Promise<LoginByUsernameResponse>;
}
export async function loginByToken(
token: string,
): Promise<LoginByUsernameResponse> {
// Token-exchange login: the gateway resolves the upstream token to a username
// and signs the matching local account in.
const response = await apiFetch(
`${getBackendBaseURL()}/api/v1/auth/login/username`,
`${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
},
);
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "Login failed." }));
throw new Error(error.detail ?? "Login failed.");
const error = await response.json().catch(() => null);
throw new Error(extractErrorMessage(error));
}
return response.json() as Promise<LoginByUsernameResponse>;
}
+18 -8
View File
@@ -4,7 +4,7 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTheme } from "@/contexts/ThemeContext";
import { loginByUsername } from "@/core/auth/api";
import { loginByToken, loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
persistExternalLoginParams,
@@ -40,6 +40,10 @@ export default function LoginPage() {
// 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage
const yUserId = searchParams.get("yUserId")?.trim() ?? "";
const toolToken = searchParams.get("token")?.trim() ?? "";
// 用户名直登口令门:开启后端开关时,口令从 ?password= 透传
const gatePassword = searchParams.get("password") ?? "";
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get("authToken")?.trim() ?? "";
const externalParams = useMemo(() => ({
theme: searchParams.get("theme") ?? "",
ticket: searchParams.get("ticket") ?? "",
@@ -71,13 +75,19 @@ export default function LoginPage() {
setStatus("loading");
setErrorMessage("");
try {
if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
let auth;
if (authToken) {
// token 换登录:后端用 authToken 调上游接口换出用户名再登录
auth = await loginByToken(authToken);
} else {
if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
const auth = await loginByUsername(resolvedUsername);
auth = await loginByUsername(resolvedUsername, gatePassword);
}
if (cancelled) {
return;
}
@@ -100,7 +110,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]);
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, authToken, gatePassword]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">