diff --git a/console/src/api/modules/auth.test.ts b/console/src/api/modules/auth.test.ts index a5ded19..406c0a3 100644 --- a/console/src/api/modules/auth.test.ts +++ b/console/src/api/modules/auth.test.ts @@ -12,6 +12,7 @@ function mockFetch(status: number, body: unknown) { ok: status >= 200 && status < 300, status, statusText: status === 200 ? "OK" : "Bad Request", + headers: { get: () => "application/json" }, json: () => Promise.resolve(body), text: () => Promise.resolve(typeof body === "string" ? body : JSON.stringify(body)), @@ -143,7 +144,7 @@ describe("authApi.updateProfile", () => { mockFetch(200, { token: "t", username: "alice" }); await authApi.updateProfile({ current_password: "oldpass" }); const headers = (fetch as any).mock.calls[0][1].headers; - expect(headers.Authorization).toBe("Bearer my-token"); + expect(headers.get?.("Authorization")).toBe("Bearer my-token"); }); it("throws detail error on update failure", async () => { diff --git a/console/src/api/modules/auth.ts b/console/src/api/modules/auth.ts index 34124f3..448b013 100644 --- a/console/src/api/modules/auth.ts +++ b/console/src/api/modules/auth.ts @@ -1,4 +1,4 @@ -import { getApiUrl, getApiToken, setAuthToken } from "../config"; +import { getApiUrl, setAuthToken } from "../config"; import type { PermissionsDetail, Role } from "../../auth/types"; // 用户资料(含 RBAC 字段) @@ -93,22 +93,12 @@ export interface PermissionInfo { module: string; } -function authHeaders(): Record { - const token = getApiToken(); - return token ? { Authorization: `Bearer ${token}` } : {}; -} +// 复用统一请求封装(401 自愈 + 友好提示):auth 模块的受保护接口 +// (/auth/me、/permissions/me、select-identity、bind-phone 等)在 token 失效时 +// 同样能先尝试从本地会话自愈,而不是裸抛 401 导致权限/身份信息静默缺失。 +import { request } from "../request"; +export { request }; -async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(getApiUrl(path), { - headers: { "Content-Type": "application/json", ...authHeaders() }, - ...init, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.detail || `Request failed: ${res.status}`); - } - return res.json() as Promise; -} export const authApi = { login: async (username: string, password: string): Promise => { diff --git a/console/src/api/request.ts b/console/src/api/request.ts index 2d7fa82..f5fabb0 100644 --- a/console/src/api/request.ts +++ b/console/src/api/request.ts @@ -1,4 +1,5 @@ import { getApiUrl, clearAuthToken, setAuthToken } from "./config"; +import { useAuthStore } from "../auth/authStore"; import { buildAuthHeaders } from "./authHeaders"; import { Modal } from "antd"; @@ -107,6 +108,12 @@ function promptReauth(): void { onOk() { authNoticeShown = false; clearAuthToken(); + // 同步清空内存中的身份状态,避免登录页/其他页面残留旧用户身份 + try { + useAuthStore.getState().logout(); + } catch { + /* authStore 不可用时仅清 token */ + } if (window.location.pathname !== "/login") { window.location.href = "/login"; } diff --git a/console/src/auth/authStore.ts b/console/src/auth/authStore.ts index 20ba258..d88c774 100644 --- a/console/src/auth/authStore.ts +++ b/console/src/auth/authStore.ts @@ -34,21 +34,26 @@ export const useAuthStore = create((set, get) => ({ switchingPort: false, init: () => { + const token = getApiToken(); + const tokenClaims = token ? decodeToken(token) : null; const stored = getStoredUser(); if (stored && stored.username && stored.role) { + // 防错配:localStorage token 归属的账号与旧 profile 不是同一用户时, + // 以 token 为准(旧 profile 可能是上一个账号残留 → 身份状态错误) + if (tokenClaims?.sub && stored.id && tokenClaims.sub !== stored.id) { + set({ user: claimsToUser(tokenClaims), initialized: true }); + void get().refreshPermissions(); + return; + } set({ user: stored, initialized: true }); // 后台刷新权限详情(不阻断启动,失败降级为已有 role/capabilities 判断) void get().refreshPermissions(); return; } - const token = getApiToken(); - if (token) { - const claims = decodeToken(token); - if (claims) { - set({ user: claimsToUser(claims), initialized: true }); - void get().refreshPermissions(); - return; - } + if (tokenClaims) { + set({ user: claimsToUser(tokenClaims), initialized: true }); + void get().refreshPermissions(); + return; } set({ user: null, initialized: true }); }, diff --git a/src/pineagents/app/auth_token_store.py b/src/pineagents/app/auth_token_store.py index 63efb94..f0c7e39 100644 --- a/src/pineagents/app/auth_token_store.py +++ b/src/pineagents/app/auth_token_store.py @@ -21,7 +21,11 @@ _SESSION_FILE = WORKING_DIR / "server_auth_session.json" # ── 兼容层:仅 token ───────────────────────────────────────── def save_auth_token(token: str) -> None: - """持久化服务端 token;空 token 不写入(不覆盖已有)。""" + """持久化服务端 token;空 token 不写入(不覆盖已有)。 + + 写入后同时失效 server_client 的 5 秒 token 缓存,避免登录/换身份后的 + 短窗口内后端内部转发仍带旧 token / 空 token → 401 或身份错乱。 + """ if not token: logger.info("save_auth_token: empty token, skip") return @@ -36,13 +40,24 @@ def save_auth_token(token: str) -> None: except OSError: pass logger.info("save_auth_token: saved token len=%d to %s", len(token), _TOKEN_FILE) + _invalidate_token_cache() except Exception: # noqa: BLE001 logger.warning("Failed to persist auth token", exc_info=True) def load_auth_token() -> str: - """读取持久化的服务端 token;无则返回空串。""" + """读取持久化的服务端 token;无则返回空串。 + + 读取源与 ``load_auth_session`` 统一:优先完整登录态文件(token+user), + 回退旧 token 文件。避免两个文件不同步(如扫码登录只写过 token 文件)时, + 后端内部转发读到旧 token / 空 token → 401 或身份错乱。 + """ try: + if _SESSION_FILE.exists(): + data = json.loads(_SESSION_FILE.read_text(encoding="utf-8")) + token = data.get("token", "") or "" + if token: + return token if not _TOKEN_FILE.exists(): logger.debug("load_auth_token: no token file at %s", _TOKEN_FILE) return "" @@ -65,6 +80,15 @@ def clear_auth_token() -> None: # ── 完整登录态(token + 用户资料,含 MQTT 凭证) ────────────── +def _invalidate_token_cache() -> None: + """失效 server_client 的 token 内存缓存(延迟导入避免循环依赖)。""" + try: + from .server_client import invalidate_token_cache as _f + _f() + except Exception: # noqa: BLE001 + logger.warning("Failed to invalidate token cache", exc_info=True) + + def save_auth_session(token: str, user: dict | None) -> None: """持久化完整登录态:token + 登录响应用户资料(含 mqtt 凭证)。""" if not token: @@ -81,6 +105,7 @@ def save_auth_session(token: str, user: dict | None) -> None: except OSError: pass logger.info("save_auth_session: saved token len=%d to %s", len(token), _SESSION_FILE) + _invalidate_token_cache() except Exception: # noqa: BLE001 logger.warning("Failed to persist auth session", exc_info=True) diff --git a/src/pineagents/app/routers/auth.py b/src/pineagents/app/routers/auth.py index ff548fb..01b5212 100644 --- a/src/pineagents/app/routers/auth.py +++ b/src/pineagents/app/routers/auth.py @@ -342,7 +342,9 @@ async def wx_qr_poll(scene: str, background_tasks: BackgroundTasks): """轮询微信扫码登录状态:转发到服务端。""" data = await forward("GET", "/auth/wx-qr/poll", params={"scene": scene}) if data.get("status") == "done" and data.get("token"): + # 与主登录路径一致:token 与完整登录态双写,保证 /auth/session 读到一致身份 save_auth_token(data["token"]) + save_auth_session(data["token"], data) background_tasks.add_task(_sync_agents_after_login, data["token"]) return data @@ -359,6 +361,7 @@ async def mp_qr_poll(scene: str, background_tasks: BackgroundTasks): data = await forward("GET", "/auth/mp-qr/poll", params={"scene": scene}) if data.get("status") == "done" and data.get("token"): save_auth_token(data["token"]) + save_auth_session(data["token"], data) background_tasks.add_task(_sync_agents_after_login, data["token"]) return data