React 基础体系 · 第 44/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 登录与权限:路由、组件、Token 刷新、403 和状态恢复
登录与权限通常被误认为是一个“判断用户是否登录,然后决定显示哪个页面”的问题。实际上,它至少包含四个不同层次:
- 身份认证(authentication):服务器确认请求者是谁。
- 授权(authorization):服务器判断这个身份是否可以执行某个操作。
- 前端路由控制:未认证用户是否可以进入某个页面。
- 请求生命周期管理:访问令牌过期后如何刷新,刷新失败后如何恢复状态。
这几个层次相互关联,但不能互相替代。React 组件可以隐藏按钮,却不能提供安全授权;路由可以阻止页面渲染,却不能阻止攻击者直接调用 API;Token 刷新可以恢复会话,却不能改变服务端对权限的最终判断。
一、先建立完整的认证模型
1. 身份认证、授权和会话
设请求为:
其中:
- :请求者;
- :凭证,例如 Cookie、Access Token;
- :请求参数和请求体;
- :目标资源与操作,例如
DELETE /api/posts/10。
服务端处理请求通常分为两步。
第一步是认证:
如果 Token 有效,服务端得到用户身份,例如:
{
userId: "u_123",
roles: ["editor"],
scopes: ["post:read", "post:write"]
}
第二步是授权:
因此:
- 没有有效身份,通常返回 401 Unauthorized;
- 身份有效,但没有权限,通常返回 403 Forbidden。
这两个状态的处理动作不同:
| 状态 | 语义 | 前端通常动作 |
|---|---|---|
| 401 | 当前请求没有通过认证 | 尝试刷新 Token;刷新失败则回到登录页 |
| 403 | 身份已经被识别,但不允许执行该操作 | 显示无权页面或错误提示,不应盲目刷新 Token |
“Forbidden”并不表示 Token 一定过期。用户即使拥有一个完全有效的 Token,也可能访问某个租户、项目或资源时收到 403。
2. 登录并不等于权限提升
登录接口一般完成以下工作:
用户名和密码
↓
服务端校验凭证
↓
创建会话或签发 Token
↓
客户端进入 authenticated 状态
登录只能证明“你是谁”,不能自动证明“你能做什么”。
例如,以下用户都可能成功登录:
type User = {
id: string;
name: string;
roles: string[];
};
但权限可能不同:
const user = {
id: "u_1",
name: "Alice",
roles: ["viewer"],
};
const user = {
id: "u_2",
name: "Bob",
roles: ["admin"],
};
前端可以根据 roles 优化界面,但服务端必须再次检查权限。前端传来的角色、按钮状态和路由状态都不属于可信安全边界。
二、Token 的职责:访问令牌与刷新令牌
1. Access Token
Access Token 是访问业务 API 的短期凭证。浏览器请求通常会将它放在:
Authorization: Bearer <access-token>
它的特点是:
- 生命周期较短;
- 发送频率高;
- 泄露后可被直接用于调用 API;
- 过期后需要重新获取。
2. Refresh Token
Refresh Token 用于向认证服务换取新的 Access Token。常见设计是:
- Refresh Token 放在
HttpOnly、Secure、SameSiteCookie 中; - JavaScript 不能通过
document.cookie读取它; - 刷新接口通过 Cookie 识别会话;
- 新的 Access Token 只保存在内存或受控状态中。
例如:
POST /api/auth/refresh
Cookie: refresh_token=...
响应:
{
"accessToken": "eyJ..."
}
HttpOnly 只能降低 XSS 直接读取 Refresh Token 的风险,不能消除 XSS。恶意脚本仍可能在当前页面中发起请求。因此仍然需要输出编码、依赖安全、CSP 和 CSRF 防护。
如果认证完全依赖 Cookie,则浏览器会自动带上 Cookie,服务端需要考虑 CSRF。常见措施包括:
SameSite=Lax或更严格策略;- CSRF Token;
- 校验
Origin或Referer; - 对改变状态的请求使用专门的 CSRF 防护。
如果 Access Token 只放在内存中,则页面完全刷新后它会消失。这不是 bug,而是安全与持久性的取舍。页面恢复时可以调用 /api/auth/refresh 或 /api/me 重新建立会话。
三、React 中应该建模哪些状态
不要只使用一个布尔值:
const [isLoggedIn, setIsLoggedIn] = useState(false);
因为首次加载时,“尚未检查登录状态”和“确认未登录”不是同一件事。更完整的状态至少需要三种认证阶段:
type AuthStatus =
| "unknown"
| "authenticated"
| "anonymous";
type AuthState = {
status: AuthStatus;
user: User | null;
accessToken: string | null;
error: string | null;
};
状态含义如下:
unknown:应用正在恢复会话,不能立即把用户当作未登录;authenticated:已经确认存在有效用户;anonymous:已经确认不存在有效会话。
如果省略 unknown,页面刷新时会出现这种错误流程:
页面刚加载
↓
isLoggedIn 默认 false
↓
立即跳转到 /login
↓
refresh 请求稍后成功
↓
用户被错误地短暂踢到登录页
这称为认证闪烁(auth flicker)。它不仅影响体验,也可能破坏用户正在访问的深层 URL。
四、用 Redux Toolkit 管理认证状态
Redux Toolkit(RTK)适合管理跨路由共享的认证状态。它的职责是保存当前已知状态,不应把 Redux 当成安全边界。
下面是一个认证 Slice:
// authSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export type User = {
id: string;
name: string;
roles: string[];
};
export type AuthStatus =
| "unknown"
| "authenticated"
| "anonymous";
type AuthState = {
status: AuthStatus;
user: User | null;
accessToken: string | null;
error: string | null;
};
const initialState: AuthState = {
status: "unknown",
user: null,
accessToken: null,
error: null,
};
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
sessionRestored: (
state,
action: PayloadAction<{
user: User;
accessToken: string;
}>
) => {
state.status = "authenticated";
state.user = action.payload.user;
state.accessToken = action.payload.accessToken;
state.error = null;
},
loggedOut: (state) => {
state.status = "anonymous";
state.user = null;
state.accessToken = null;
state.error = null;
},
loginFailed: (state, action: PayloadAction<string>) => {
state.status = "anonymous";
state.user = null;
state.accessToken = null;
state.error = action.payload;
},
accessTokenUpdated: (state, action: PayloadAction<string>) => {
state.status = "authenticated";
state.accessToken = action.payload;
},
},
});
export const {
sessionRestored,
loggedOut,
loginFailed,
accessTokenUpdated,
} = authSlice.actions;
export default authSlice.reducer;
Store 配置:
// store.ts
import { configureStore } from "@reduxjs/toolkit";
import authReducer from "./authSlice";
export const store = configureStore({
reducer: {
auth: authReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Provider:
// main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./store";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>
);
这里没有把 Refresh Token 放进 Redux。若将其持久化到 localStorage,任何能够执行页面 JavaScript 的 XSS 都可能读取它。是否持久化 Access Token 是安全模型决策,不能因为“刷新页面方便”就默认写入本地存储。
五、路由保护不是服务端授权
1. ProtectedRoute 的职责
路由保护组件只回答一个前端问题:
当前客户端是否应该渲染这个页面?
它不能回答:
这个请求在服务端是否一定会被允许?
使用 React Router 的声明式路由时,可以这样实现:
// ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "./store";
export function ProtectedRoute() {
const location = useLocation();
const status = useSelector((state: RootState) => state.auth.status);
if (status === "unknown") {
return <div>正在恢复会话……</div>;
}
if (status === "anonymous") {
return (
<Navigate
to="/login"
replace
state={{
returnTo: location.pathname + location.search + location.hash,
}}
/>
);
}
return <Outlet />;
}
路由配置:
// App.tsx
import {
BrowserRouter,
Routes,
Route,
} from "react-router-dom";
import { ProtectedRoute } from "./ProtectedRoute";
import { LoginPage } from "./LoginPage";
import { DashboardPage } from "./DashboardPage";
import { ForbiddenPage } from "./ForbiddenPage";
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/admin" element={<AdminPage />} />
</Route>
<Route path="/403" element={<ForbiddenPage />} />
</Routes>
</BrowserRouter>
);
}
function AdminPage() {
return <div>管理员页面</div>;
}
Outlet 表示受保护路由的子路由渲染位置。Navigate 会执行客户端导航,replace 防止用户按浏览器后退按钮重新回到已经无效的受保护地址。
2. 登录后的状态恢复
登录前的目标地址通常通过 location.state 传给登录页:
// LoginPage.tsx
import { FormEvent, useState } from "react";
import {
useLocation,
useNavigate,
} from "react-router-dom";
import { useDispatch } from "react-redux";
import type { AppDispatch } from "./store";
import { sessionRestored, loginFailed } from "./authSlice";
import { apiLogin } from "./authApi";
type LoginLocationState = {
returnTo?: string;
};
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch<AppDispatch>();
const [message, setMessage] = useState("");
const state = location.state as LoginLocationState | null;
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
try {
const result = await apiLogin(email, password);
dispatch(
sessionRestored({
user: result.user,
accessToken: result.accessToken,
})
);
const returnTo = getSafeReturnTo(state?.returnTo);
navigate(returnTo, { replace: true });
} catch {
dispatch(loginFailed("邮箱或密码错误"));
setMessage("登录失败,请检查输入");
}
}
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">登录</button>
{message && <p>{message}</p>}
</form>
);
}
function getSafeReturnTo(value: string | undefined): string {
if (!value) return "/dashboard";
// 只允许站内绝对路径,阻止跳转到外部站点。
if (value.startsWith("/") && !value.startsWith("//")) {
return value;
}
return "/dashboard";
}
returnTo 必须验证。直接执行:
window.location.href = returnTo;
如果 returnTo 来自攻击者控制的查询参数,就可能形成开放重定向。上例只允许以单个 / 开头的站内路径。
3. 路由权限与资源权限不是一回事
可以额外做一个前端角色门:
import { Navigate, Outlet } from "react-router-dom";
import { useSelector } from "react-redux";
import type { RootState } from "./store";
function RequireRole({ role }: { role: string }) {
const user = useSelector((state: RootState) => state.auth.user);
if (!user) {
return <Navigate to="/login" replace />;
}
if (!user.roles.includes(role)) {
return <Navigate to="/403" replace />;
}
return <Outlet />;
}
但它的意义主要是减少无意义的页面渲染。真正的安全判断必须发生在服务端:
GET /api/admin/users
Authorization: Bearer ...
即使浏览器通过了 RequireRole,服务端仍然必须检查当前用户是否拥有访问 /api/admin/users 的权限。
六、Token 刷新的完整请求流程
1. 为什么要在 401 后刷新
假设 Access Token 的过期时间为 ,当前时间为 :
业务请求收到 401 后,客户端可以执行:
业务请求
↓ 401
调用 refresh
↓
拿到新 Access Token
↓
只重试原业务请求一次
不能无限重试,否则会形成:
请求 → 401 → refresh → 请求 → 401 → refresh → ...
必须记录当前请求是否已经重试过。
2. API 类型与登录接口
// api.ts
import type { User } from "./authSlice";
type LoginResponse = {
accessToken: string;
user: User;
};
export async function apiLogin(
email: string,
password: string
): Promise<LoginResponse> {
const response = await fetch("/api/auth/login", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error(`LOGIN_FAILED_${response.status}`);
}
return response.json() as Promise<LoginResponse>;
}
credentials: "include" 适用于认证依赖跨请求 Cookie 的场景。若前后端跨源,还需要服务端正确配置 CORS,并且不能在允许凭证时使用 Access-Control-Allow-Origin: *。
3. 单飞刷新:处理并发 401
实际页面中可能同时发出多个请求:
请求 A ─┐
请求 B ─┼─ 同时收到 401
请求 C ─┘
如果三个请求分别调用 refresh,就会产生竞态,甚至导致 Refresh Token 轮换时后发请求失效。
常见实现是“单飞”或 single-flight:同一时刻只允许一个刷新请求,其余请求等待同一个 Promise。
// authenticatedFetch.ts
import { store } from "./store";
import {
accessTokenUpdated,
loggedOut,
} from "./authSlice";
let refreshPromise: Promise<string> | null = null;
async function refreshAccessToken(): Promise<string> {
if (!refreshPromise) {
refreshPromise = fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`REFRESH_FAILED_${response.status}`);
}
const data = (await response.json()) as {
accessToken: string;
};
store.dispatch(accessTokenUpdated(data.accessToken));
return data.accessToken;
})
.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
type RequestOptions = RequestInit & {
retryAfterRefresh?: boolean;
};
export async function authenticatedFetch(
input: RequestInfo | URL,
options: RequestOptions = {}
): Promise<Response> {
const {
retryAfterRefresh = true,
headers: inputHeaders,
...fetchOptions
} = options;
const headers = new Headers(inputHeaders);
const token = store.getState().auth.accessToken;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
let response = await fetch(input, {
...fetchOptions,
headers,
credentials: "include",
});
if (response.status !== 401 || !retryAfterRefresh) {
return response;
}
try {
const newToken = await refreshAccessToken();
const retryHeaders = new Headers(inputHeaders);
retryHeaders.set("Authorization", `Bearer ${newToken}`);
response = await fetch(input, {
...fetchOptions,
headers: retryHeaders,
credentials: "include",
});
return response;
} catch (error) {
store.dispatch(loggedOut());
throw error;
}
}
这个实现包含四个重要约束:
- 只有业务请求的 401 才触发刷新;
- Refresh 请求本身不再进入相同的 401 重试链;
- 同一时刻复用同一个
refreshPromise; - 刷新失败后清理认证状态,并将错误交给上层处理。
生产实现还应处理请求体不可重复读取的问题。例如已经消费过的流式 body 不能简单地再次传给 fetch。JSON、字符串和可重复读取的普通请求体通常较容易重试;上传流、文件流和非幂等操作需要单独设计。
4. 为什么不能把所有错误都当作 401
错误分类应保持语义:
const response = await authenticatedFetch("/api/posts/1");
if (response.status === 403) {
// 身份有效但无权
return <Navigate to="/403" replace />;
}
if (response.status === 404) {
// 资源不存在,或服务端出于安全原因隐藏资源
}
if (!response.ok) {
// 网络错误、5xx 或其他业务错误
}
如果 403 也触发刷新,结果通常是:
权限不足
↓
刷新 Token
↓
权限仍然不足
↓
再次请求
这不仅没有解决问题,还可能制造无意义的请求风暴。
七、登录状态恢复:首次加载、刷新和失效
1. 初始恢复流程
页面首次加载时,Redux Store 会重新从初始值创建,因此 Access Token 如果只在内存中保存,就会丢失。应用需要显式恢复:
// AuthBootstrap.tsx
import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { sessionRestored, loggedOut } from "./authSlice";
import type { AppDispatch } from "./store";
export function AuthBootstrap({
children,
}: {
children: React.ReactNode;
}) {
const dispatch = useDispatch<AppDispatch>();
const [done, setDone] = useState(false);
useEffect(() => {
let cancelled = false;
async function restore() {
try {
const response = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!response.ok) {
dispatch(loggedOut());
return;
}
const data = (await response.json()) as {
accessToken: string;
user: {
id: string;
name: string;
roles: string[];
};
};
if (!cancelled) {
dispatch(sessionRestored(data));
}
} catch {
if (!cancelled) {
dispatch(loggedOut());
}
} finally {
if (!cancelled) {
setDone(true);
}
}
}
restore();
return () => {
cancelled = true;
};
}, [dispatch]);
if (!done) {
return <div>正在检查登录状态……</div>;
}
return children;
}
根组件包裹方式:
export function AppRoot() {
return (
<AuthBootstrap>
<App />
</AuthBootstrap>
);
}
这里的 cancelled 不是为了阻止网络请求,而是防止组件卸载后异步结果继续更新已不存在的组件。React 19 的开发模式下,Strict Mode 可能让某些副作用在开发期间被额外检查,因此初始化逻辑应具备幂等性,服务端 refresh 接口也应正确处理重复调用。
2. 恢复状态的状态机
可以将认证过程形式化为有限状态机:
unknown
├── refresh 成功 ──> authenticated
└── refresh 失败 ──> anonymous
authenticated
├── logout ────────> anonymous
├── refresh 成功 ──> authenticated
└── refresh 失败 ──> anonymous
“访问受保护路由”的条件是:
“显示登录页”的条件是:
而在 unknown 状态下,既不能渲染受保护内容,也不能直接认为用户未登录。这个中间状态是避免错误跳转的关键。
3. 多标签页状态同步
用户在一个标签页退出后,另一个标签页中的 Redux 状态不会自动改变。可以使用 BroadcastChannel 同步登出事件:
// authSync.ts
import { store } from "./store";
import { loggedOut } from "./authSlice";
const channel = new BroadcastChannel("auth-events");
channel.addEventListener("message", (event) => {
if (event.data?.type === "LOGOUT") {
store.dispatch(loggedOut());
}
});
export function broadcastLogout() {
store.dispatch(loggedOut());
channel.postMessage({ type: "LOGOUT" });
}
这只是浏览器标签页之间的协调机制,不是安全机制。服务端仍然必须使会话失效,不能依赖客户端广播来撤销权限。
八、403 的页面、组件和错误边界
1. 页面级 403
当整个路由不允许访问时,可以导航到统一的 403 页面:
export function ForbiddenPage() {
return (
<main>
<h1>403</h1>
<p>你的账号没有访问此页面的权限。</p>
<a href="/dashboard">返回工作台</a>
</main>
);
}
适合整页拒绝的场景:
- 管理后台入口;
- 某个组织或租户不可访问;
- 页面所需权限全部缺失。
2. 组件级 403
当页面整体可访问,但某个操作没有权限时,不应该把整个页面跳走:
type DeleteButtonProps = {
canDelete: boolean;
onDelete: () => void;
};
export function DeleteButton({
canDelete,
onDelete,
}: DeleteButtonProps) {
if (!canDelete) {
return <span title="没有删除权限">删除不可用</span>;
}
return (
<button type="button" onClick={onDelete}>
删除
</button>
);
}
更重要的是,按钮即使显示出来,删除 API 仍可能返回 403:
async function deletePost(id: string) {
const response = await authenticatedFetch(`/api/posts/${id}`, {
method: "DELETE",
});
if (response.status === 403) {
throw new Error("FORBIDDEN");
}
if (!response.ok) {
throw new Error("DELETE_FAILED");
}
}
这是正常情况,因为权限可能在页面打开后发生变化:
- 管理员撤销了用户角色;
- 用户切换了租户;
- 资源所有者发生改变;
- 服务端权限规则更新;
- 前端缓存的用户信息已经过期。
前端权限信息是缓存和提示,不是服务端事实。
九、React Router 数据加载与 API 错误的边界
React Router 的数据路由可以在进入页面前执行 loader。一个简化示例:
import {
createBrowserRouter,
RouterProvider,
redirect,
} from "react-router-dom";
async function dashboardLoader() {
const response = await authenticatedFetch("/api/dashboard");
if (response.status === 403) {
throw redirect("/403");
}
if (response.status === 401) {
throw redirect("/login");
}
if (!response.ok) {
throw new Response("Dashboard load failed", {
status: response.status,
});
}
return response.json();
}
const router = createBrowserRouter([
{
path: "/dashboard",
loader: dashboardLoader,
element: <DashboardPage />,
},
]);
export function RouterApp() {
return <RouterProvider router={router} />;
}
这里需要区分两种失败:
redirect("/403"):明确告诉路由系统进行导航;throw new Response(...):交给路由错误边界处理。
但 loader 仍然运行在浏览器中,不能替代服务端鉴权。它的价值在于:
- 进入页面前加载数据;
- 统一处理路由级 401、403;
- 避免页面先显示空壳再发现无权。
不同 React Router 版本和框架模式的 loader、错误边界配置可能不同,应以项目实际版本文档为准。使用声明式 Routes 时,则通常在组件内部处理加载和错误状态。
十、完整时序:从访问深层链接到 Token 过期
下面的流程同时包含路由、组件、状态恢复和刷新:
sequenceDiagram
participant B as 浏览器
participant R as React Router
participant A as Auth Store
participant S as 服务端
B->>R: 访问 /admin/settings
R->>A: 读取 auth.status
A-->>R: unknown
R-->>B: 显示恢复会话界面
B->>S: POST /api/auth/refresh
S-->>B: 200 + accessToken + user
B->>A: dispatch(sessionRestored)
A-->>R: authenticated
R-->>B: 渲染 /admin/settings
B->>S: GET /api/admin/settings
S-->>B: 401
B->>S: POST /api/auth/refresh
S-->>B: 200 + new accessToken
B->>A: dispatch(accessTokenUpdated)
B->>S: 重试 GET /api/admin/settings
S-->>B: 403
R-->>B: 显示无权页面或组件提示
B->>S: POST /api/auth/refresh
S-->>B: 401/403
B->>A: dispatch(loggedOut)
R-->>B: 跳转 /login,并保存原始地址
关键路径是:
- 路由先看到
unknown,因此等待恢复; - refresh 成功后,原始深层地址仍然存在;
- API 收到 401 时只刷新一次;
- 刷新后重试仍收到 403,说明身份有效但权限不足;
- 只有 refresh 失败才回到匿名状态并跳转登录。
十一、服务端边界:浏览器代码不能决定安全结果
一个正确的服务端处理过程至少应类似:
async function getAdminSettings(request: Request) {
const token = readBearerToken(request);
const identity = await authenticate(token);
if (!identity) {
return new Response("Unauthorized", { status: 401 });
}
const allowed = await can(
identity,
"admin_settings:read"
);
if (!allowed) {
return new Response("Forbidden", { status: 403 });
}
return Response.json(await loadAdminSettings());
}
客户端的如下代码都不能成为授权依据:
if (user.roles.includes("admin")) {
showAdminButton();
}
它最多决定按钮是否显示。攻击者可以:
- 修改 Redux 状态;
- 手动构造请求;
- 删除组件限制;
- 直接调用 API;
- 修改浏览器中的 JavaScript 执行流程。
因此安全条件必须在服务端成立:
前端条件只是用户体验层面的近似:
两者不是同一个命题。
十二、常见错误与诊断方法
错误一:只判断 token !== null
if (token) {
return <Dashboard />;
}
Token 可能已经过期、签名无效、被服务端撤销,或者对应用户权限已经改变。客户端存在 Token 只说明“曾经拿到过凭证”,不能证明当前请求有效。
诊断方法:
- 查看实际请求的 HTTP 状态;
- 检查
Authorization是否在重试时使用了新 Token; - 检查服务端日志中的 Token 解析和过期原因;
- 检查系统时间是否严重偏差。
错误二:把 401 和 403 都跳到登录页
如果用户收到 403 却被要求重新登录,重新登录通常不会改变角色权限,用户会陷入无效循环。
正确分支应是:
401 → refresh → 失败则登录
403 → 显示无权,不刷新
但具体项目也可能把某些会话撤销场景返回 403。此时应以服务端 API 契约为准,不能仅凭状态码猜测。
错误三:多个 401 各自刷新
表现包括:
- 同一时间出现大量
/refresh请求; - 刷新 Token 轮换后部分请求失败;
- 用户偶发被登出;
- 网络面板中请求顺序难以解释。
应使用共享 Promise、请求队列或成熟的 HTTP 客户端拦截器机制,并确保 refresh 本身不会再次进入刷新逻辑。
错误四:刷新成功但原请求仍使用旧 Token
错误实现可能这样写:
await refreshAccessToken();
return fetch(input, options);
如果 options.headers 中已经保留旧的 Authorization,重试仍会携带旧 Token。正确做法是使用刷新结果重新构造请求头,而不是复用已经固化的旧 Header。
错误五:登录后总是跳到固定首页
固定执行:
navigate("/dashboard");
会丢失用户原本访问的地址,例如:
/admin/reports?page=2
应保存路径、查询参数和必要的哈希片段,并验证它是站内地址。若原地址对应权限不足,登录后仍应由服务端返回 403,再显示无权结果。
错误六:把用户信息永久持久化却不处理失效
持久化的 user 对象可能只是旧缓存。用户角色改变后,UI 仍可能显示过时按钮。
恢复时应以服务端返回为准:
本地缓存只用于初始外观
↓
调用 session / refresh / me
↓
用服务端结果覆盖缓存
缓存不能替代会话验证。
十三、实现取舍
Access Token 放在哪里
| 位置 | 优点 | 主要风险 |
|---|---|---|
| 内存 | 页面脚本难以直接持久读取;刷新后自动消失 | 页面刷新需要恢复流程 |
sessionStorage |
单标签页内可恢复 | 仍可被 XSS 读取 |
localStorage |
浏览器重启后仍可保留 | XSS 可读取,持久暴露时间更长 |
| Cookie | 浏览器自动管理 | 需要处理 CSRF、Cookie 属性和跨源策略 |
不存在脱离威胁模型的绝对答案。若使用 HttpOnly Cookie 作为完整会话,也可以不在 JavaScript 中管理 Access Token,但此时所有请求都要依赖 Cookie,CSRF 设计更加重要。
是否在前端解析 JWT
前端可以解析 JWT payload 以便显示用户名或提前提示过期,但解析不等于验证签名:
const payload = JSON.parse(atob(token.split(".")[1]));
这段代码不能证明 Token 可信,也不能替代服务端校验。前端可将 JWT 当作显示数据,服务端必须验证签名、发行者、受众和过期时间。
是否提前刷新
两种常见策略:
- 只在 API 收到 401 后刷新;
- 根据 Token 的
exp在接近过期时提前刷新。
提前刷新可以减少用户操作时遇到的首次 401,但需要处理浏览器休眠、系统时钟误差、多个标签页和刷新失败。无论采用哪种策略,都必须保留 401 兜底逻辑,因为服务端可能主动撤销会话。
十四、一条可验证的端到端测试路径
在开发环境中,可以按以下路径验证实现:
场景一:匿名用户访问受保护地址
输入:
GET /dashboard
预期:
auth.status = unknown
→ 执行 refresh
→ refresh 返回 401
→ auth.status = anonymous
→ 跳转 /login
→ 保存 /dashboard
场景二:登录成功后恢复地址
输入:
在 /login 提交正确账号密码
预期:
POST /api/auth/login = 200
→ dispatch(sessionRestored)
→ navigate("/dashboard", { replace: true })
场景三:Access Token 过期
输入:
GET /api/orders
预期:
第一次请求 = 401
→ 只发起一个 POST /api/auth/refresh
→ 得到新 Token
→ 重试 GET /api/orders
→ 返回 200
如果同时发起五个业务请求,预期仍然只有一个 refresh 请求,其余请求等待同一个刷新 Promise。
场景四:权限不足
输入:
GET /api/admin/users
预期:
Token 有效
→ 服务端鉴权通过
→ 角色检查失败
→ 返回 403
→ 前端显示 /403 或局部无权提示
不应再次刷新 Token。
场景五:Refresh Token 失效
输入:
业务请求返回 401
refresh 返回 401
预期:
dispatch(loggedOut)
→ 清空用户和 Access Token
→ 跳转 /login
→ 保存原始地址
如果刷新失败后仍然不断请求 refresh,说明重试边界没有闭合;如果清空状态但页面仍显示受保护内容,说明路由读取的不是同一个认证状态源。
结语
React 登录与权限系统的正确分层是:
服务端认证:确认是谁
服务端授权:确认能否执行
React 状态:保存当前已知会话
路由组件:控制页面是否渲染
请求封装:处理 Token 注入与刷新
403 处理:表达身份有效但权限不足
状态恢复:在刷新、登出和失效后重新建立一致状态
最容易出错的地方不是登录表单,而是边界条件:首次加载时的 unknown、并发 401、刷新失败、403 不应刷新、深层 URL 恢复、跨标签页登出,以及前端权限信息与服务端真实权限不一致。
只要明确“路由和组件是用户体验层,服务端才是安全边界”,再用状态机管理恢复过程、用单飞机制管理刷新并发、用不同路径区分 401 与 403,认证系统的行为就能从偶然可用变成可以推导、测试和诊断的工程系统。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Router 数据路由:Loader、Action、Revalidation 和错误边界
- 下一篇:TanStack Query:缓存键、失效、乐观更新、分页和离线
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论