React 基础体系 · 第 44/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 登录与权限:路由、组件、Token 刷新、403 和状态恢复

登录与权限通常被误认为是一个“判断用户是否登录,然后决定显示哪个页面”的问题。实际上,它至少包含四个不同层次:

  1. 身份认证(authentication):服务器确认请求者是谁。
  2. 授权(authorization):服务器判断这个身份是否可以执行某个操作。
  3. 前端路由控制:未认证用户是否可以进入某个页面。
  4. 请求生命周期管理:访问令牌过期后如何刷新,刷新失败后如何恢复状态。

这几个层次相互关联,但不能互相替代。React 组件可以隐藏按钮,却不能提供安全授权;路由可以阻止页面渲染,却不能阻止攻击者直接调用 API;Token 刷新可以恢复会话,却不能改变服务端对权限的最终判断。


一、先建立完整的认证模型

1. 身份认证、授权和会话

设请求为:

R=(u,t,p,r)R = (u, t, p, r)

其中:

  • uu:请求者;
  • tt:凭证,例如 Cookie、Access Token;
  • pp:请求参数和请求体;
  • rr:目标资源与操作,例如 DELETE /api/posts/10

服务端处理请求通常分为两步。

第一步是认证:

Authenticate(t)identity{anonymous}Authenticate(t) \rightarrow identity \cup \{anonymous\}

如果 Token 有效,服务端得到用户身份,例如:

{
  userId: "u_123",
  roles: ["editor"],
  scopes: ["post:read", "post:write"]
}

第二步是授权:

Authorize(identity,r)allowdenyAuthorize(identity, r) \rightarrow allow \lor deny

因此:

  • 没有有效身份,通常返回 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 放在 HttpOnlySecureSameSite Cookie 中;
  • 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;
  • 校验 OriginReferer
  • 对改变状态的请求使用专门的 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 的过期时间为 TeT_e,当前时间为 TnT_n

TnTeAccessToken 失效T_n \geq T_e \Rightarrow AccessToken \text{ 失效}

业务请求收到 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;
  }
}

这个实现包含四个重要约束:

  1. 只有业务请求的 401 才触发刷新;
  2. Refresh 请求本身不再进入相同的 401 重试链;
  3. 同一时刻复用同一个 refreshPromise
  4. 刷新失败后清理认证状态,并将错误交给上层处理。

生产实现还应处理请求体不可重复读取的问题。例如已经消费过的流式 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

“访问受保护路由”的条件是:

status=authenticatedstatus = authenticated

“显示登录页”的条件是:

status=anonymousstatus = 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,并保存原始地址

关键路径是:

  1. 路由先看到 unknown,因此等待恢复;
  2. refresh 成功后,原始深层地址仍然存在;
  3. API 收到 401 时只刷新一次;
  4. 刷新后重试仍收到 403,说明身份有效但权限不足;
  5. 只有 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 执行流程。

因此安全条件必须在服务端成立:

Allow(R)=Authenticated(R)Authorized(identity,resource,action)Allow(R) = Authenticated(R) \land Authorized(identity, resource, action)

前端条件只是用户体验层面的近似:

ShowButton=CachedPermissionUIStateShowButton = CachedPermission \land UIState

两者不是同一个命题。


十二、常见错误与诊断方法

错误一:只判断 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 当作显示数据,服务端必须验证签名、发行者、受众和过期时间。

是否提前刷新

两种常见策略:

  1. 只在 API 收到 401 后刷新;
  2. 根据 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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。