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 小时。
js
const token = jwt.sign(
{ user: body.username, role: 'admin' },
signingSecret,
{ expiresIn: 86400 }
);
登录成功后,页面调用 Zustand 的 setAuth。这个 action 同时更新内存状态和 localStorage:前者让导航栏、路由守卫立即响应;后者让页面刷新后还能恢复身份。
js
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。
js
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 可以保持很薄:
js
export const login = data => instance.post('/login', data);
export const getRepo = () => instance.get('/repo');
集中处理的价值不只是少写几行代码,更重要的是协议一致:不会某个接口写 Token,另一个写 Bearer,还有一个忘记带头。反过来,如果项目混用了另一个 Axios 实例,这套拦截器就不会生效,排错时要先确认 import 来源。
RequireAuth 拦的是页面,不是攻击者
受保护的 /pay 路由被 RequireAuth 包裹。组件只从 Zustand 读取 token:没有就跳到 /login,有就渲染子组件。
jsx
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。
js
try {
const decoded = jwt.verify(token, signingSecret);
return { code: 0, data: decoded.user };
} catch {
return { code: 401, msg: 'Invalid token' };
}
这一步确认签名和有效期,但当前 Demo 还有三个值得观察的缺口:
- 缺少 Authorization 时直接
split,异常可能发生在try之前; - Axios 没有统一处理 401,过期 token 不会自动清理或跳转;
- 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、前端鉴权