REACT · Vol.V · LESSON 35 · 路由与数据获取
认证与路由守卫(对比拦截器)
一次登录的完整旅程
第 30/31 篇会配路由了,第 22 篇会做持久化了——现在把它们串成绕不开的一条链路:认证。前端认证就六步:登录提交账密 → 服务端校验、签发 token → 存起来(刷新还在)→ 之后每个请求自动带上 Authorization: Bearer xxx → token 失效收到 401,清登录态送回登录页 → 登出,回到游客世界。
链路上每个环节都能在后端找到对应物——这正是本篇的讲法:
| 前端环节 | 本篇落点 | 后端对应物 |
|---|---|---|
| token 存哪 | 第 2 站 | Session 放内存还是 Redis |
| 带 token / 401 跳转 | 第 3 站拦截器 | Feign 拦截器 / AuthenticationEntryPoint |
| 受保护路由 | 第 4 站 RequireAuth | authorizeRequests / preHandle |
| 接口真鉴权 | 第 5 站双保险 | Spring Security 过滤器链 |
你熟悉的链路是「请求先过过滤器链,JwtFilter 校验 token,不过直接 401」。前端把同一件事镜像了一遍:请求发出前先过 axios 拦截器戴 token,路由进入前先过守卫组件验登录态。前后端各设一道关卡,思路同构——Spring Security 里练过的直觉全部平移有效。
token 存哪:一场 XSS 与 CSRF 的权衡
token 拿到手,第一个决策是放哪。主流候选两个:localStorage(前端完全掌控)和 httpOnly Cookie(服务端 Set-Cookie,JS 读不到)。第 22 篇讲过存储层级,这里只算安全账本:
| 维度 | localStorage | httpOnly Cookie |
|---|---|---|
| 谁能读 | 页面里任何 JS 随手可读 | 只有浏览器自动携带,JS 读不到 |
| XSS 风险 | 高:注入一段脚本就能偷走 token | 低:脚本偷不走 token(但请求仍可能被伪造) |
| CSRF 风险 | 天然免疫:靠手动加 Authorization 头,跨站表单不会自动带上 | 有:浏览器对同域自动带 Cookie,需 SameSite / CSRF Token 防御 |
| 前后端分离友好度 | 好:跨域只需带头 | 需配 SameSite、withCredentials 等凭据策略 |
看明白了吗?这是安全权衡而非对错题:localStorage 把 token 暴露给 JS,XSS 得手就全丢;httpOnly cookie 挡住了偷,却把 CSRF 的攻击面迎进来。业界常见落地是组合拳:短命的 access token 放内存或 localStorage(丢了影响小),长命的 refresh token 放 httpOnly cookie(JS 摸不到,专用来续签)——两害相权,各取其短。
登录态是跨路由、跨组件共享的全局状态——第 15 篇的 Context 和第 18 篇的 Zustand 都能干。这里选 Zustand:登录页、路由守卫、axios 拦截器都要读写它,而拦截器在组件树之外,Context 够不着;store 是普通模块对象,谁都能用。完整实现:
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface User { id: number; name: string; roles: string[]; }
interface AuthState {
token: string | null;
user: User | null;
login: (token: string, user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
// 登录成功:token 和用户信息一次写入,自动同步到 localStorage
login: (token, user) => set({ token, user }),
// 登出:一次清空,存储同步清掉
logout: () => set({ token: null, user: null }),
}),
{
name: "auth-storage",
// 只持久化数据字段,action 不落盘(第 22 篇的 partialize)
partialize: (s) => ({ token: s.token, user: s.user }),
}
)
);
组件里照常 useAuthStore((s) => s.user) 订阅;组件外(马上要写的拦截器)用 useAuthStore.getState() 直接读——一个 store 两套用法,刷新页面后 persist 自动恢复登录态。
axios 拦截器:发请求前统一戴帽
如果每个组件发请求前都手写一遍 headers.Authorization = ...,那就是把同一个横切逻辑复制五十遍。axios 的拦截器就是为这个准备的:请求发出前统一加工,响应回来后统一善后。
import axios from "axios";
import { useAuthStore } from "../stores/authStore";
export const api = axios.create({ baseURL: "/api" });
// 请求拦截器:每个请求发出前,自动带上 token
api.interceptors.request.use((config) => {
// 组件外读 store:getState() 拿当前快照,不需要 Hook
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:401 统一处理,别让每个组件自己兜底
api.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
// token 过期或无效:清登录态,送回登录页
useAuthStore.getState().logout();
window.location.href = "/login";
}
return Promise.reject(error);
}
);
二十行,覆盖了全景图的 ③④⑤ 三步,之后所有组件只管 api.get("/todos")。两个细节:读 token 用 getState() 而不是 Hook——拦截器不是组件,用不了 Hook,Zustand 的模块级 store 恰好补位;401 跳转用 window.location.href 而不是 navigate——拦截器在 Router 上下文之外拿不到路由 API,用整页刷新换简单可靠,这笔账划算。
请求拦截器 = Feign 的 RequestInterceptor:微服务间调用前统一往请求头塞 token,你在 Spring Cloud 里写过一模一样的东西。响应拦截器接 401 = Spring Security 的 AuthenticationEntryPoint:认证失败不撒手给各 Controller,统一入口处理、统一重定向。axios 拦截器就是前端的 ClientHttpRequestInterceptor——横切关注点收进切面,换请求库也不用动业务。
RequireAuth:路由级的 preHandle
token 能带上接口了,但还有 UX 问题:未登录用户直接在地址栏敲 /dashboard,页面加载了、接口全 401、满屏报错——难看。我们要的是还没进门就拦下:给第 30 篇的路由加一层守卫组件:
import { type ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
export function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token);
const location = useLocation();
if (!token) {
// 记下「想来而没来成」的页面,登录后原路送回
return (
<Navigate to="/login" state={{ from: location.pathname }} replace />
);
}
return <>{children}</>;
}
// 用法:路由表里包一层即生效(对照 authorizeRequests 按路径配置)
// <Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />
对照后端,这就是拦截器的 preHandle 返回 false 直接拦截,或 Spring Security 的 requestMatchers("/dashboard/**").authenticated()——声明「这条路径需要登录」,校验逻辑集中在包装组件里,路由表保持声明式。而 state={{ from: location.pathname }} 是「登录后回到原页面」的关键:重定向时把原目的地塞进 location state,登录页取出来接着用:
import { useLocation, useNavigate } from "react-router-dom";
import { useAuthStore } from "../stores/authStore";
import { api } from "../lib/api";
import { AuthForm } from "../components/AuthForm"; // 表单 UI 略,第 21 篇专讲
function LoginPage() {
const login = useAuthStore((s) => s.login);
const navigate = useNavigate();
const location = useLocation();
// RequireAuth 重定向时塞进来的原目的地;直接来登录页则去首页
const from = (location.state as { from?: string } | null)?.from ?? "/dashboard";
async function handleSubmit(username: string, password: string) {
// 后端校验账密并签发 token(全景图的第①②步)
const res = await api.post("/auth/login", { username, password });
login(res.data.token, res.data.user);
// replace:登录页不该留在历史记录里,回退键跳过它
navigate(from, { replace: true });
}
return <AuthForm onSubmit={handleSubmit} />;
}
双保险:前端守卫是 UX,后端鉴权才是安全
最重要的话放在开头:前端守卫挡不住任何攻击者。RequireAuth 运行在用户浏览器里,JS 源码人人可见、人人可改——手改 localStorage 塞个 token,守卫立刻形同虚设;更直接地,curl 一行就能带 token 打你的接口,根本不经过任何路由。所以安全红线只有一条:后端每个接口都必须自己鉴权,Security 过滤器链、拦截器、@PreAuthorize 一道都不能少。
| 前端路由守卫(RequireAuth) | 后端接口鉴权(Security / 拦截器) | |
|---|---|---|
| 住在哪 | 用户浏览器里的 JS | 你的服务器 |
| 防谁 | 正常用户误入受限页面(体验) | 一切未授权请求(安全) |
| 失败表现 | 重定向到登录页 | 401 / 403 |
| 能否绕过 | 能:改代码或直接发请求 | 不能:这是最终裁判 |
正确姿势是双保险:前端守卫负责 UX——不让用户看到注定失败的页面,把 401 消化成优雅的跳转;后端鉴权负责安全——守卫被绕过时,每个接口照样拒绝,401/403 是最后一道墙。
这道理后端你也天天讲:参数校验不能只靠前端表单,DTO 的 @NotNull @Size 必须服务端再验一遍——因为绕过页面直接打接口的人永远存在。前端守卫与后端鉴权的关系,就是「表单校验」与「@Valid 校验」的关系:前者是体验,后者是契约。记住这句,你就永远不会天真地问「前端已经挡了,后端还要鉴权吗」。
- 认证全景六步:登录 → 拿 token → 存储 → 请求带 token → 401 处理 → 登出,每步都有后端对应物。
- token 存储是 XSS 与 CSRF 的权衡:localStorage 怕 XSS、天然抗 CSRF,httpOnly cookie 反之;常见组合是短命 access token + httpOnly refresh token。
- axios 请求拦截器统一注入 Authorization(对照 Feign RequestInterceptor),响应拦截器统一接 401;getState() 让组件外也能读写登录态。
- RequireAuth 包装组件 = 路由级 preHandle:没 token 就 Navigate 重定向,state.from 记住原页面,登录后原路送回。
- 双保险铁律:前端守卫只是 UX、可被绕过;真正的安全在后端每个接口的鉴权,缺一不可。
本章回顾
这一篇把前面攒的零件拼成认证闭环:Zustand(第 18 篇)+ persist(第 22 篇)管登录态,axios 拦截器管 token 注入与 401 兜底,RequireAuth 守在路由入口(第 30 篇),登录后凭 location.state 原路返回。同样重要的是态度:前端守卫是体验层,接口级鉴权才是安全层,双保险缺一不可。至此取数、三态、竞态、认证全部就位,只差最后一个高频场景:数据量大时列表怎么翻——分页与无限滚动,下一篇第 36 篇见。
Comments · 评论