JWT 登录不是存个 token:React 鉴权真正要闭合的是一条数据流

70 阅读6分钟

JWT 登录不是存个 token:React 鉴权真正要闭合的是一条数据流

很多登录 Demo 在拿到 token 后就结束了,但工程里的问题才刚开始:谁负责刷新恢复,谁负责给请求加头,路由守卫能防住什么,token 过期后又由谁收尾?

本文基于一个 React、Zustand、Axios、React Router 和 Vite Mock 组成的项目,给出一个判断:JWT 登录的关键不是某个 API,而是签发、状态、传输、页面访问与服务端校验能否形成闭环。 文中的代码来自静态分析,运行未验证;演示凭据与签名密钥不展示。

摘要:从一次登录请求出发,还原 token 在 mock 服务、Zustand、localStorage、Axios 拦截器和路由守卫之间的流动;同时划清前端放行与服务端验签的边界,并提供生产化自检表。

先把五个角色摆对位置

项目中的职责可以先压缩成一张表:

角色做什么不应该被误认为
Login收集凭据、调用接口、写入状态鉴权中心
Zustand跨组件共享 token 与用户持久化数据库
localStorage刷新后恢复登录态安全保险箱
Axios自动附加 Bearer token权限判断器
RequireAuth控制页面是否渲染服务端安全边界
API验签并继续做授权单纯的数据返回层

只要把其中两层混为一谈,就容易出现“页面进不去但接口能调”或“本地有 token 就以为已经安全”的错觉。

一次登录之后,token 到底去了哪里

登录页提交表单后,请求 POST /api/login。模拟接口校验演示凭据,用 jwt.sign 签发 JWT,载荷包含 user 和 role,有效期是 86400 秒,也就是 24 小时。

const token = jwt.sign(
  { user: body.username, role: 'admin' },
  signingSecret,
  { expiresIn: 86400 }
);

登录成功后,页面调用 Zustand 的 setAuth。这个 action 同时更新内存状态和 localStorage:前者让导航栏、路由守卫立即响应;后者让页面刷新后还能恢复身份。

setAuth: ({ token, user }) => {
  localStorage.setItem('token', token);
  localStorage.setItem('user', JSON.stringify(user));
  set({ token, user });
}

退出时也必须反向清理两层。如果只清 Zustand,刷新后会从 localStorage 再次读到 token;只清 localStorage,当前页面又可能继续保持已登录 UI。

这里还要划一条安全边界:JWT 的签名不等于加密,Payload 可以解码,不应放密码或秘密;localStorage 也能被同源脚本读取,一旦发生 XSS,token 泄露影响会被放大。存储方案必须结合实际威胁模型决定。

Bearer token 不该散落在每个请求里

项目创建了统一 Axios 实例,baseURL 是 /api,超时为 5000 毫秒。请求拦截器在请求发出前读取 token,并补上标准 Bearer 头;响应拦截器则直接解包 res.data。

instance.interceptors.request.use(config => {
  const authState = readAuthState();
  if (authState) {
    config.headers.Authorization = buildBearerHeader(authState);
  }
  return config;
});

instance.interceptors.response.use(res => res.data);

于是业务 API 可以保持很薄:

export const login = data => instance.post('/login', data);
export const getRepo = () => instance.get('/repo');

集中处理的价值不只是少写几行代码,更重要的是协议一致:不会某个接口写 Token,另一个写 Bearer,还有一个忘记带头。反过来,如果项目混用了另一个 Axios 实例,这套拦截器就不会生效,排错时要先确认 import 来源。

RequireAuth 拦的是页面,不是攻击者

受保护的 /pay 路由被 RequireAuth 包裹。组件只从 Zustand 读取 token:没有就跳到 /login,有就渲染子组件。

function RequireAuth({ children }) {
  const isAuthenticated = useAuthStore(state => Boolean(state.token));
  if (!isAuthenticated) return <Navigate to="/login" replace />;
  return children;
}

它解决的是用户体验:未登录用户不应先看到支付页,再等待接口报错。replace 还避免登录前的受保护页面留在历史记录里。

但字符串非空不能证明 token 有效。它可能已经过期、被伪造或被撤销。任何人都可以绕过 React 页面直接构造 HTTP 请求,所以接口仍必须独立完成验签与授权。

真正的认证边界在 jwt.verify

模拟 /api/repo 从 Authorization 中拆出 token,并调用 jwt.verify。成功后读取解码结果,失败返回业务 code: 401。

try {
  const decoded = jwt.verify(token, signingSecret);
  return { code: 0, data: decoded.user };
} catch {
  return { code: 401, msg: 'Invalid token' };
}

这一步确认签名和有效期,但当前 Demo 还有三个值得观察的缺口:

  1. 缺少 Authorization 时直接 split,异常可能发生在 try 之前;
  2. Axios 没有统一处理 401,过期 token 不会自动清理或跳转;
  3. App 挂载时无条件请求受保护资源,即使尚未登录。

第三点尤其容易被忽略。是否发请求本身就应依赖认证状态,而不是让后端承担所有无效流量。

401 应该成为一条明确协议

一个可维护的实现需要先区分场景,再决定动作:

场景客户端动作服务端动作
未携带 token导航登录或保持游客态返回明确的未认证响应
token 格式错误清理本地身份拒绝请求,不尝试容错猜测
token 过期按设计刷新或重新登录返回可识别的过期原因
token 签名无效清理并阻断记录安全事件所需信息,但不泄露秘密
token 有效但无权限保持登录态,提示无权限返回 403 并执行资源级授权

不要把所有失败都粗暴理解为“退出登录”。如果未来加入 refresh token,还要避免多个并发请求同时触发刷新,形成刷新风暴。当前项目没有这部分实现,因此这里只给出设计边界,不声称已有运行结果。

一张鉴权闭环自检表

可以按下面顺序审查任何 React JWT 项目:

  • 登录响应是否只返回必要身份数据与 token;
  • 签名密钥是否完全留在服务端并脱离源码;
  • 内存状态与持久化状态是否同时写入、同时清理;
  • 所有业务请求是否复用同一个 Axios 实例;
  • Authorization 是否严格使用 Bearer <token>;
  • 前端路由守卫是否仅被当作体验层;
  • 每个受保护接口是否验签并继续做授权;
  • 缺头、格式错、过期、伪造是否有独立处理;
  • 401/403 是否有清晰、一致的客户端协议;
  • token 存储是否结合 XSS、CSRF 威胁评估;
  • JWT Payload 是否避免密码、密钥和敏感数据;
  • 受保护请求是否只在合适的认证状态触发。

可迁移的判断

JWT 登录不是“登录接口 + localStorage”的组合,而是一条跨越 UI、状态、HTTP 客户端和服务端的数据流。Zustand 让状态可共享,Axios 让传输协议统一,React Router 改善页面体验;只有服务端验签与授权才能完成安全判断。

审查现有项目时,先沿着 签发 → 保存 → 携带 → 页面放行 → 服务端验签 走一遍,再检查失败路径。下一步最小实验可以是:补齐缺失请求头处理与统一 401 响应,然后分别用无 token、过期 token 和伪造 token 验证行为。

标签: React、JWT、Zustand、Axios、前端鉴权