feat(auth): 鉴权片2 —— 前端登录闭环 + 保护路由 + 去掉 header 兜底
把 JWT 鉴权从后端核心闭环到端到端: 后端: - middleware.RequireAuth:上下文无已验证 uid 则 401;挂在 owner 作用域业务路由组。 - 路由拆 公开/受保护:公开=auth/health + 按 task_id 寻址的 SSE 与报告导出 (EventSource/下载无法带 Bearer);受保护=tasks/memory/kb*/agents/reports/billing。 - userID(c) 去掉 X-User-ID 兜底,仅信任 JWT 注入的 uid。 - 修 CORS:Allow-Headers 增 Authorization(否则浏览器拦截带 Bearer 的请求)。 前端: - lib/api:token 存 localStorage + Bearer 头(不再发 X-User-ID)+ authRegister/Login/Me + 401 清令牌并广播 sdx:logout;submitTask/report/memory/列表加载走 Bearer 与 401 守卫。 - views/Login:登录/注册全屏门。 - App:启动校验令牌 → 无则渲染 Login,有则进主应用;identity.userId=已验证 user.id; 监听 sdx:logout 回登录页。 - TopBar:去掉可编辑身份输入,改显登录用户 + 登出。 实跑验证(docker+gateway+preview): - RequireAuth:无 token /kb/list、/agents → 401;/health → 200;带 token → 200。 - 前端:无 token 显登录门;注入有效 token 重载 → 进主应用、顶栏显 Dexter、KB 加载本人库、 隔离徽标显雪花 uid。控制台无错、生产构建通过。 - 过程中发现并修复 CORS 缺 Authorization 头的真实 bug。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,17 +10,100 @@ export interface Identity {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// ---- JWT 令牌存储(localStorage)----
|
||||
const TOKEN_KEY = "sdx_token";
|
||||
let authToken: string = typeof localStorage !== "undefined" ? localStorage.getItem(TOKEN_KEY) ?? "" : "";
|
||||
|
||||
export function setToken(t: string): void {
|
||||
authToken = t;
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, t);
|
||||
} catch {
|
||||
/* 隐私模式忽略 */
|
||||
}
|
||||
}
|
||||
export function clearToken(): void {
|
||||
authToken = "";
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
export function getToken(): string {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// bearer 把 JWT 放进请求头(无令牌则不带)。
|
||||
function bearer(): Record<string, string> {
|
||||
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
|
||||
}
|
||||
|
||||
// guard401 在收到 401 时清理令牌并广播登出事件(App 监听后回到登录页)。
|
||||
function guard401(res: Response): Response {
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
if (typeof window !== "undefined") window.dispatchEvent(new Event("sdx:logout"));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---- 鉴权 API ----
|
||||
export async function authRegister(email: string, password: string, name: string): Promise<AuthUser> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string };
|
||||
if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `注册失败: ${res.status}`);
|
||||
setToken(data.token);
|
||||
return data.user;
|
||||
}
|
||||
|
||||
export async function authLogin(email: string, password: string): Promise<AuthUser> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = (await res.json()) as { token?: string; user?: AuthUser; error?: string };
|
||||
if (!res.ok || !data.token || !data.user) throw new Error(data.error ?? `登录失败: ${res.status}`);
|
||||
setToken(data.token);
|
||||
return data.user;
|
||||
}
|
||||
|
||||
// authMe 用当前令牌取登录用户;无效/过期返回 null(用于应用启动校验)。
|
||||
export async function authMe(): Promise<AuthUser | null> {
|
||||
if (!authToken) return null;
|
||||
const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() });
|
||||
if (!res.ok) {
|
||||
clearToken();
|
||||
return null;
|
||||
}
|
||||
const data = (await res.json()) as { user?: AuthUser };
|
||||
return data.user ?? null;
|
||||
}
|
||||
|
||||
export function logout(): void {
|
||||
clearToken();
|
||||
}
|
||||
|
||||
// submitTask: POST /api/v1/tasks,返回 task_id。
|
||||
export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/tasks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-User-ID": id.userId,
|
||||
"X-Session-ID": id.sessionId,
|
||||
},
|
||||
body: JSON.stringify(dsl),
|
||||
});
|
||||
const res = guard401(
|
||||
await fetch(`${GATEWAY}/api/v1/tasks`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||||
body: JSON.stringify(dsl),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`submit failed: ${res.status} ${await res.text()}`);
|
||||
const data = (await res.json()) as { task_id: string };
|
||||
return data.task_id;
|
||||
@@ -91,9 +174,10 @@ export interface IngestEvent {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// idHeaders 把身份带进请求头 —— 网关据此把知识库锁进 owner 作用域(隔离)。
|
||||
// idHeaders 把身份带进请求头:JWT(Bearer) 作鉴权与 owner 作用域;X-Session-ID 作多轮会话标识。
|
||||
// 不再发 X-User-ID —— owner 由网关从已验证 JWT 取(伪造头无效)。
|
||||
function idHeaders(id: Identity): Record<string, string> {
|
||||
return { "X-User-ID": id.userId, "X-Session-ID": id.sessionId };
|
||||
return { ...bearer(), "X-Session-ID": id.sessionId };
|
||||
}
|
||||
|
||||
export interface KbInfo {
|
||||
@@ -103,7 +187,7 @@ export interface KbInfo {
|
||||
|
||||
// listKb: GET /api/v1/kb/list —— 当前用户的知识库列表(owner 隔离)。
|
||||
export async function listKb(id: Identity): Promise<KbInfo[]> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) });
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/kb/list`, { headers: idHeaders(id) }));
|
||||
const data = (await res.json()) as { kbs?: KbInfo[]; error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `list failed: ${res.status}`);
|
||||
return data.kbs ?? [];
|
||||
@@ -141,7 +225,7 @@ export interface AgentInfo {
|
||||
}
|
||||
|
||||
export async function listAgents(id: Identity): Promise<AgentInfo[]> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) });
|
||||
const res = guard401(await fetch(`${GATEWAY}/api/v1/agents`, { headers: idHeaders(id) }));
|
||||
const data = (await res.json()) as { agents?: AgentInfo[]; error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `list agents failed: ${res.status}`);
|
||||
return data.agents ?? [];
|
||||
@@ -288,15 +372,13 @@ export async function searchKb(id: Identity, kb: string, q: string, topK = 5): P
|
||||
// generateReport: POST /api/v1/reports —— 触发报告生成,返回 task_id。
|
||||
// 用 streamTokens(task_id) 看实时进度,完成后用 reportDownloadUrl(task_id) 下载 Word。
|
||||
export async function generateReport(id: Identity, topic: string, kb?: string): Promise<string> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/reports`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-User-ID": id.userId,
|
||||
"X-Session-ID": id.sessionId,
|
||||
},
|
||||
body: JSON.stringify({ topic, kb: kb ?? "" }),
|
||||
});
|
||||
const res = guard401(
|
||||
await fetch(`${GATEWAY}/api/v1/reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||||
body: JSON.stringify({ topic, kb: kb ?? "" }),
|
||||
}),
|
||||
);
|
||||
const data = (await res.json()) as { task_id?: string; error?: string };
|
||||
if (!res.ok || !data.task_id) throw new Error(data.error ?? `report failed: ${res.status}`);
|
||||
return data.task_id;
|
||||
@@ -318,15 +400,13 @@ export async function setMemory(
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${GATEWAY}/api/v1/memory`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-User-ID": id.userId,
|
||||
"X-Session-ID": id.sessionId,
|
||||
},
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
const res = guard401(
|
||||
await fetch(`${GATEWAY}/api/v1/memory`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...idHeaders(id) },
|
||||
body: JSON.stringify({ key, value }),
|
||||
}),
|
||||
);
|
||||
const data = (await res.json()) as { message?: string; error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `memory failed: ${res.status}`);
|
||||
return data.message ?? "ok";
|
||||
|
||||
Reference in New Issue
Block a user