首页 / React 学习笔记 / 35

REACT · Vol.V · LESSON 35 · 路由与数据获取

认证与路由守卫(对比拦截器)

实战#认证#路由

一次登录的完整旅程

第 30/31 篇会配路由了,第 22 篇会做持久化了——现在把它们串成绕不开的一条链路:认证。前端认证就六步:登录提交账密 → 服务端校验、签发 token → 存起来(刷新还在)→ 之后每个请求自动带上 Authorization: Bearer xxx → token 失效收到 401,清登录态送回登录页 → 登出,回到游客世界。

登录表单 认证服务 校验密码 · 签发 token 存入 authStore localStorage axios 拦截器 注入 Authorization 后端校验 token 通过 → 返回数据 401 清登录态 · 跳登录 ① 提交账密 ② 下发token ③ 每次请求 ④ 自动带上 ⑤ 校验失败 ⑥ 回到登录页
图 1前端认证全景:token 存好之后每次请求由拦截器自动带上,校验失败就清登录态送回登录页,形成闭环。

链路上每个环节都能在后端找到对应物——这正是本篇的讲法:

前端环节本篇落点后端对应物
token 存哪第 2 站Session 放内存还是 Redis
带 token / 401 跳转第 3 站拦截器Feign 拦截器 / AuthenticationEntryPoint
受保护路由第 4 站 RequireAuthauthorizeRequests / preHandle
接口真鉴权第 5 站双保险Spring Security 过滤器链
后端类比:前后端各有一道关卡

你熟悉的链路是「请求先过过滤器链,JwtFilter 校验 token,不过直接 401」。前端把同一件事镜像了一遍:请求发出前先过 axios 拦截器戴 token,路由进入前先过守卫组件验登录态。前后端各设一道关卡,思路同构——Spring Security 里练过的直觉全部平移有效。

token 存哪:一场 XSS 与 CSRF 的权衡

token 拿到手,第一个决策是放哪。主流候选两个:localStorage(前端完全掌控)和 httpOnly Cookie(服务端 Set-Cookie,JS 读不到)。第 22 篇讲过存储层级,这里只算安全账本:

维度localStoragehttpOnly 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 是普通模块对象,谁都能用。完整实现:

stores/authStore.ts · 登录态 store:persist 自动恢复
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 的拦截器就是为这个准备的:请求发出前统一加工,响应回来后统一善后。

lib/api.ts · 请求拦截器戴 token,响应拦截器接 401
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 拦截器 + AuthenticationEntryPoint

请求拦截器 = Feign 的 RequestInterceptor:微服务间调用前统一往请求头塞 token,你在 Spring Cloud 里写过一模一样的东西。响应拦截器接 401 = Spring Security 的 AuthenticationEntryPoint:认证失败不撒手给各 Controller,统一入口处理、统一重定向。axios 拦截器就是前端的 ClientHttpRequestInterceptor——横切关注点收进切面,换请求库也不用动业务。

RequireAuth:路由级的 preHandle

token 能带上接口了,但还有 UX 问题:未登录用户直接在地址栏敲 /dashboard,页面加载了、接口全 401、满屏报错——难看。我们要的是还没进门就拦下:给第 30 篇的路由加一层守卫组件:

router/RequireAuth.tsx · 受保护路由:没登录就重定向
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,登录页取出来接着用:

pages/LoginPage.tsx · 登录成功,原路送回
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} />;
}
为什么用包装组件而不是在 Layout 里写 if?一是声明式——路由表一眼看出哪些路径受保护;二是可组合——RequireAuth 里再包 RequireAdmin 就是角色守卫,这正是第 23 篇「组合优于继承」的又一次应用。

双保险:前端守卫是 UX,后端鉴权才是安全

最重要的话放在开头:前端守卫挡不住任何攻击者。RequireAuth 运行在用户浏览器里,JS 源码人人可见、人人可改——手改 localStorage 塞个 token,守卫立刻形同虚设;更直接地,curl 一行就能带 token 打你的接口,根本不经过任何路由。所以安全红线只有一条:后端每个接口都必须自己鉴权,Security 过滤器链、拦截器、@PreAuthorize 一道都不能少。

前端路由守卫(RequireAuth)后端接口鉴权(Security / 拦截器)
住在哪用户浏览器里的 JS你的服务器
防谁正常用户误入受限页面(体验)一切未授权请求(安全)
失败表现重定向到登录页401 / 403
能否绕过能:改代码或直接发请求不能:这是最终裁判

正确姿势是双保险:前端守卫负责 UX——不让用户看到注定失败的页面,把 401 消化成优雅的跳转;后端鉴权负责安全——守卫被绕过时,每个接口照样拒绝,401/403 是最后一道墙。

后端类比:别把校验写在 Swagger 文档里

这道理后端你也天天讲:参数校验不能只靠前端表单,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 · 评论