修复:登录后仍 401 / 身份状态不正确的根因链路
- auth_token_store: load 读取源统一为 session 文件优先(消除两文件不同步); save 后自动失效 server_client 的 5 秒 token 缓存(invalidate_token_cache 此前零调用) - auth.py: 微信/小程序扫码登录成功后补写完整 session(与主登录路径对齐) - auth.ts: 受保护接口复用统一 request 封装(401 会话自愈,不再裸抛导致权限/身份静默缺失) - authStore: init 检测 token 与旧 profile 的 sub 不一致时以 token 为准(防账号残留错配) - request: 401 确认重新登录时同步清空内存 authStore 身份 - auth.test: 适配统一 request(headers.get)
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (username: string, password: string): Promise<LoginResponse> => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -34,21 +34,26 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
switchingPort: false,
|
||||
|
||||
init: () => {
|
||||
const token = getApiToken();
|
||||
const tokenClaims = token ? decodeToken(token) : null;
|
||||
const stored = getStoredUser<AuthUser>();
|
||||
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 });
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user