React JWT 登录鉴权实战:从无状态 HTTP 到路由守卫、Zustand 与 Axios 拦截器

React JWT 登录鉴权项目实战:沿着登录流程读懂路由守卫、Axios、Zustand 与 Mock

  • 前言
  • [1. 先建立项目整体结构](#1. 先建立项目整体结构)
    • [1.1 鉴权链路中的模块分工](#1.1 鉴权链路中的模块分工)
    • [1.2 三种状态不能混为一谈](#1.2 三种状态不能混为一谈)
  • [2. 用户访问受保护页面](#2. 用户访问受保护页面)
    • [2.1 `App.jsx` 如何声明受保护路由](#2.1 App.jsx 如何声明受保护路由)
    • [2.2 `RequireAuth` 如何判断是否登录](#2.2 RequireAuth 如何判断是否登录)
    • [2.3 为什么跳转时要保存 `from`](#2.3 为什么跳转时要保存 from)
  • [3. 用户填写账号密码并提交](#3. 用户填写账号密码并提交)
    • [3.1 `Login.jsx` 如何保存表单输入](#3.1 Login.jsx 如何保存表单输入)
    • [3.2 表单校验如何从输入状态推导](#3.2 表单校验如何从输入状态推导)
    • [3.3 提交表单后调用 `login(formData)`](#3.3 提交表单后调用 login(formData))
  • [4. `api/user.js` 和 Axios 把请求发到哪里](#4. api/user.js 和 Axios 把请求发到哪里)
    • [4.1 登录接口封装](#4.1 登录接口封装)
    • [4.2 `api/config.js` 决定最终地址](#4.2 api/config.js 决定最终地址)
  • [5. Vite Mock 模拟后端登录](#5. Vite Mock 模拟后端登录)
    • [5.1 `vite.config.js` 如何注册 Mock](#5.1 vite.config.js 如何注册 Mock)
    • [5.2 Mock 如何校验账号密码](#5.2 Mock 如何校验账号密码)
    • [5.3 `jwt.sign` 如何生成 Token](#5.3 jwt.sign 如何生成 Token)
  • [6. Zustand 和 `localStorage` 保存登录状态](#6. Zustand 和 localStorage 保存登录状态)
    • [6.1 `setAuth` 为什么要同时写两个地方](#6.1 setAuth 为什么要同时写两个地方)
    • [6.2 登录成功后如何回到原页面](#6.2 登录成功后如何回到原页面)
    • [6.3 `Nav.jsx` 如何响应登录状态变化](#6.3 Nav.jsx 如何响应登录状态变化)
  • [7. `getRepo()` 发起受保护请求](#7. getRepo() 发起受保护请求)
    • [7.1 当前代码中 `getRepo()` 的真实触发时机](#7.1 当前代码中 getRepo() 的真实触发时机)
    • [7.2 `api/repo.js` 如何发起请求](#7.2 api/repo.js 如何发起请求)
  • [8. Axios 拦截器自动携带 Token](#8. Axios 拦截器自动携带 Token)
    • [8.1 为什么每个接口不需要手动写认证头](#8.1 为什么每个接口不需要手动写认证头)
    • [8.2 登录请求为什么不会带 Token](#8.2 登录请求为什么不会带 Token)
  • [9. Mock 读取并验证 `Authorization`](#9. Mock 读取并验证 Authorization)
    • [9.1 `/api/repo` 如何提取 Token](#9.1 /api/repo 如何提取 Token)
    • [9.2 `jwt.verify` 验证了什么](#9.2 jwt.verify 验证了什么)
  • [10. 整条链路的前后关系](#10. 整条链路的前后关系)
    • [10.1 数据在每一步如何变化](#10.1 数据在每一步如何变化)
    • [10.2 登录前后状态对照](#10.2 登录前后状态对照)
    • [10.3 必须分清的安全边界](#10.3 必须分清的安全边界)
  • 总结

前言

登录鉴权不是某一个函数完成的功能,而是一条由路由、页面、请求层、状态仓库和服务端验证共同组成的数据链路。只有沿着用户操作顺序阅读代码,才能理解 Token 从哪里产生、保存在哪里、什么时候被带上、由谁验证,以及登录后为什么能够回到原来的页面。

这套 React 登录项目的核心流程如下:

text 复制代码
用户访问受保护页面
        ↓
RequireAuth 判断是否登录
        ↓
没登录 → 跳转 /login
        ↓
填写 username / password
        ↓
Login.jsx 调用 login(formData)
        ↓
api/user.js
        ↓
Axios → POST /api/login
        ↓
Mock 模拟后端验证账号密码
        ↓
JWT 生成 token
        ↓
返回 token + user
        ↓
Zustand 保存登录状态
localStorage 保存 token
        ↓
navigate 回原来的页面
        ↓
之后调用 getRepo()
        ↓
Axios 请求拦截器自动携带 token
        ↓
GET /api/repo
        ↓
Mock 读取 Authorization
        ↓
jwt.verify 验证 token
        ↓
成功返回数据 / 失败返回 401

这条链路中,React Router 负责页面访问体验,Zustand 负责前端状态共享,localStorage 负责刷新后的状态恢复,Axios 负责发送请求和携带 Token,JWT 校验才负责判断凭证是否可信。

1. 先建立项目整体结构

1.1 鉴权链路中的模块分工

模块 所处环节 核心作用
src/App.jsx 路由入口 声明普通页面和受保护页面,调用 getRepo()
src/components/RequireAuth.jsx 访问保护 判断 Zustand 中是否存在 Token
src/pages/Login.jsx 登录交互 收集账号密码、调用登录接口、保存身份并跳转
src/api/user.js 登录请求 /api/login 发送账号密码
src/api/config.js Axios 配置 设置 /api 前缀并自动添加认证请求头
vite.config.js Mock 注册 让 Vite 开发服务识别 mock 中的接口规则
mock/user.js 模拟服务端 校验账号密码、签发 JWT、验证 JWT
src/store/user.js 身份状态 保存 Token、用户信息并实现退出
src/api/repo.js 受保护请求 请求 /api/repo,验证 Token 携带效果
src/components/Nav.jsx 状态展示 根据登录状态切换登录、用户和退出按钮

Home.jsxPay.jsx 只是页面占位,其中 Pay 用来代表需要登录后才能访问的页面。store/todos.js 是另一个 Zustand 状态仓示例,没有进入登录鉴权链路。模板样式和 Vite 默认资源不参与身份数据流,因此不展开。

1.2 三种状态不能混为一谈

整个项目同时存在三类状态:

状态 保存位置 解决的问题
表单状态 Login.jsxformData 用户当前输入了什么
前端登录状态 Zustand 的 tokenuser 各组件如何知道当前是否登录
持久化状态 localStorage 页面刷新后如何恢复 Token 和用户信息

这三类状态会在登录成功时汇合:接口返回身份数据,setAuth 同时更新 Zustand 和 localStorage,界面随 Zustand 更新,刷新后再由 localStorage 恢复。

2. 用户访问受保护页面

2.1 App.jsx 如何声明受保护路由

应用的路由结构位于 App.jsx

javascript 复制代码
import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
// 路由守卫组件
import RequireAuth from './components/RequireAuth';
import Nav from './components/Nav';
import { getRepo } from './api/repo';

const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));


function App() {
  // 组件状态几乎都不放在component,  放到store
  useEffect(() => {
    (async () => {
      const res = await getRepo();
      console.log(res);
    })();
  }, []);
  return (
    <Router>
      <Nav />
      <Suspense fallback={<div>loading...</div>}>
        <Routes>
          <Route path="/" element={<Home />}/> 
          <Route path="/login" element={<Login />}/>
          <Route path="/pay" element={
            <RequireAuth>
              <Pay />
            </RequireAuth>
          }/>
           
        </Routes>
      </Suspense>
    </Router>
  )
}
export default App

BrowserRouter 提供路由环境,Routes 负责匹配当前地址,Route 定义地址与页面之间的关系。首页 / 和登录页 /login 可以直接访问,支付页 /payRequireAuth 包裹。

访问 /pay 时,React Router 不会立刻把 Pay 显示出来,而是先渲染外层的 RequireAuth。这里的嵌套关系是:

text 复制代码
RequireAuth
└── Pay

传入 RequireAuth<Pay /> 会成为它的 children。守卫允许访问时返回 children,拒绝访问时返回跳转组件。

lazy 会按页面拆分构建产物,只有访问对应路由时才加载页面代码。Suspensefallback 是异步页面尚未加载完成时的临时内容。这部分解决的是加载体验,不负责鉴权。

2.2 RequireAuth 如何判断是否登录

路由守卫的当前实现如下:

javascript 复制代码
import {
    Navigate,
    useLocation
} from 'react-router-dom'
import { useAuthStore } from '../store/user';

function RequireAuth({ children }) {
    const location = useLocation();
    const token=useAuthStore(state=>state.token);
    if(!token){
        return <Navigate to="/login" state={{ from: location.pathname }} replace/>
    }
  return children;
}
export default RequireAuth;

代码按下面的顺序执行:

  1. useLocation() 获取当前地址信息。
  2. useAuthStore(state => state.token) 从 Zustand 读取 Token。
  3. if (!token) 判断 Token 是否为空。
  4. Token 为空时返回 <Navigate>,跳转到 /login
  5. Token 存在时返回 children,也就是 <Pay />

这里判断的是前端是否保存了 Token ,不是 Token 是否真实有效。即使用户手动向 localStorage 写入一段无效字符串,前端守卫也可能放行页面。真正的 Token 校验会在请求 /api/repo 时由 jwt.verify 完成。

RequireAuth 的作用是控制页面跳转,不能代替服务端接口鉴权。

2.3 为什么跳转时要保存 from

未登录时返回的是:

javascript 复制代码
return <Navigate to="/login" state={{ from: location.pathname }} replace/>

假设用户正在访问 /pay,此时:

javascript 复制代码
location.pathname === '/pay'

路由 State 会携带:

javascript 复制代码
{
  from: '/pay'
}

因此跳转过程不是简单的:

text 复制代码
/pay → /login

而是:

text 复制代码
/pay
→ 保存 from=/pay
→ /login

replace 表示用登录页替换当前历史记录,避免用户在跳转后反复后退到刚刚被拦截的页面。

3. 用户填写账号密码并提交

3.1 Login.jsx 如何保存表单输入

登录页先引入路由、接口、状态仓库和样式:

javascript 复制代码
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { login } from '../api/user';
import { useAuthStore } from '../store/user'; 
import styles from './Login.module.css';

useNavigate 用于登录成功后的跳转,useLocation 用于读取守卫保存的 fromlogin 负责发送请求,useAuthStore 用于取得 setAuth

javascript 复制代码
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from || '/';

const setAuth = useAuthStore(state => state.setAuth); // 👈 zustand 设置状态

const [formData, setFormData] = useState({ username: '', password: '' });

location.state?.from 使用可选链:当 state 不存在时不会报错,而是得到 undefined,再通过 || '/' 使用首页作为默认目标。

formData 是当前表单的唯一数据来源:

javascript 复制代码
{
  username: '',
  password: ''
}

输入框通过 value 读取状态,通过 onChange 写回状态,这种模式叫作受控组件

javascript 复制代码
const handleChange = e => {
  const { name, value } = e.target;
  setFormData(prev => ({ ...prev, [name]: value }));
};

当用户名输入框变化时,nameusername;密码输入框变化时,namepassword[name] 是计算属性名,让两个输入框共用一个函数。...prev 保留没有被本次输入修改的字段。

3.2 表单校验如何从输入状态推导

当前校验逻辑直接根据 formData 计算:

javascript 复制代码
// 表单验证逻辑
const errors = { username: '', password: '' };

if (!formData.username.trim()) {
  errors.username = '用户名不能为空';
} else if (formData.username.length < 3) {
  errors.username = '用户名至少3位';
}

if (!formData.password.trim()) {
  errors.password = '密码不能为空';
} else if (formData.password.length < 6) {
  errors.password = '密码至少6位';
}
const isValid = !errors.username && !errors.password;

trim() 会去除首尾空格,因此只输入空格仍会被判断为空。用户名至少 3 位,密码至少 6 位。错误信息最终显示在输入框下方:

javascript 复制代码
{errors.username && <div className={styles.error}>{errors.username}</div>}

errors.username 是空字符串时,逻辑与表达式返回空值,不显示错误;有内容时才渲染提示。

按钮通过下面的属性控制是否允许提交:

javascript 复制代码
<button type="submit" disabled={!isValid}>

errorsisValid 都可以由 formData 得到,因此它们不需要再保存成独立 State。数据关系始终保持为:

text 复制代码
formData
   ↓
errors
   ↓
isValid

3.3 提交表单后调用 login(formData)

表单通过 onSubmit 绑定登录函数:

javascript 复制代码
<form onSubmit={handleLogin}>

点击登录按钮后执行:

javascript 复制代码
const handleLogin = async e => {
  e.preventDefault();
  try {
    const res = await login(formData); // 发起登录请求
    if (res.code === 0) {
      setAuth({ token: res.token, user: res.user }); // 使用 zustand 更新状态
      navigate(from, { replace: true }); // 登录成功后跳转
    } else {
      alert(res.message || '登录失败');
    }
  } catch (err) {
    console.error(err);
    alert('登录失败');
  }
};

e.preventDefault() 阻止浏览器执行表单默认提交,避免页面整体刷新。await login(formData) 会暂停当前异步函数,等待接口返回结果。

这里区分两种失败:

失败类型 表现 进入的分支
账号或密码错误 请求完成,但 res.code !== 0 else
网络、超时等异常 Promise 变为 rejected catch

此时流程从页面层进入请求层:

text 复制代码
Login.jsx
→ login(formData)
→ api/user.js

4. api/user.js 和 Axios 把请求发到哪里

4.1 登录接口封装

api/user.js 的代码如下:

javascript 复制代码
import axios from './config';

export const login = async (data) => {
  const res = await axios.post('/login', data);
  return res.data;
}

这里的 axios 并不是直接从 axios 依赖中导入,而是来自 ./config,因此它使用的是项目配置过的 Axios 实例。

data 就是登录页传入的:

javascript 复制代码
{
  username: 'admin',
  password: '123456'
}

axios.post('/login', data) 表示:

参数 当前值 作用
请求方法 POST 向服务端提交数据
请求路径 /login baseURL 组合
请求体 data 携带用户名和密码

await 得到的 res 是完整 Axios 响应,res.data 才是接口返回的业务数据。因此 login() 返回 res.data 后,Login.jsx 可以直接访问 res.coderes.tokenres.user

4.2 api/config.js 决定最终地址

Axios 实例的创建代码如下:

javascript 复制代码
import axios from 'axios';
const instance = axios.create({
  baseURL: '/api',
  timeout: 5000
})
//拦截每个请求 request, 使用一个配置
instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  // 请求配置对象
  return config;
})

instance.interceptors.response.use(res => {
  return res;
})

export default instance

登录阶段还没有 Token,请求拦截器不会添加 Authorization,但 baseURL 仍然有效:

text 复制代码
baseURL: /api
请求路径: /login
最终路径: /api/login

如果 Vite 开发服务运行在 http://localhost:5173,浏览器看到的完整地址就是:

text 复制代码
http://localhost:5173/api/login

timeout: 5000 表示请求超过 5 秒仍未完成时,Axios 会按超时异常处理。响应拦截器当前只是原样返回 res,所以 api/user.js 仍然需要手动取 res.data

5. Vite Mock 模拟后端登录

5.1 vite.config.js 如何注册 Mock

Mock 能够接收 /api/login,是因为 Vite 注册了 vite-plugin-mock

javascript 复制代码
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteMockServe } from 'vite-plugin-mock'

export default defineConfig({
  plugins: [
    react(),
    viteMockServe({
      mockPath: 'mock',
      enable: true
    })
  ],
})

mockPath: 'mock' 表示从 mock 目录加载接口规则,enable: true 表示启用 Mock。开发环境中,浏览器发送 /api/login 后,由 Vite 开发服务中的 Mock 逻辑处理,不会继续访问一个真实业务后端。

Mock 扮演的是后端角色,只适合本地学习和联调。生产环境中的账号校验、Token 签发和密钥保管必须由真实后端完成。

5.2 Mock 如何校验账号密码

登录接口规则位于 mock/user.js

javascript 复制代码
{
  url: '/api/login',
  method: 'post', 
  timeout: 2000,
  response: req => {
    const body = req.body;
    console.log(body);
    if (body.username !== 'admin' || body.password !== '123456') {
      return {
        code: -1,
        message: 'username or password 错误'
      }
    }

urlmethod 必须同时匹配:

text 复制代码
POST /api/login

req.body 就是 Axios 发送的 formData。判断条件使用逻辑或 ||

javascript 复制代码
body.username !== 'admin' || body.password !== '123456'

用户名或密码只要有一个不正确,就返回:

javascript 复制代码
{
  code: -1,
  message: 'username or password 错误'
}

timeout: 2000 模拟两秒接口延迟,让登录页能够表现出真实异步请求的等待过程。

5.3 jwt.sign 如何生成 Token

账号密码正确后执行:

javascript 复制代码
const token = jwt.sign(
  { 
    user: body.username,
    role: 'admin'
  },
  secret,
  {
    expiresIn: 86400
  }
)

jwt.sign 接收三部分信息:

参数 当前内容 作用
Payload userrole 保存身份声明
密钥 secret 生成签名
配置 expiresIn: 86400 设置 24 小时有效期

JWT 最终由三段组成:

text 复制代码
Header.Payload.Signature

Payload 可以被解码查看,因此不能放密码。Signature 用来证明前两段没有被篡改。JWT 在这里解决的是身份声明完整性,不是对身份信息进行保密加密。

签发完成后返回:

javascript 复制代码
return {
  code: 0,  // 未有错误
  user: {
    username: body.username
  },
  token: token
}

响应沿着原路径返回:

text 复制代码
mock/user.js
→ Axios Response
→ api/user.js 取 res.data
→ Login.jsx 得到 res

6. Zustand 和 localStorage 保存登录状态

6.1 setAuth 为什么要同时写两个地方

用户身份状态集中在 store/user.js

javascript 复制代码
// 全局负责 提供用户身份状态存储
// 创建store 
import { create } from 'zustand';
// hooks 编程 自定义hooks 
export const useAuthStore = create(set => ({
  // set 修改状态的方法 
  token: localStorage.getItem('token')|| '',
  user:  JSON.parse(localStorage.getItem('user')) || null,
  // actions  动作 
  setAuth:({ token, user }) => {
    localStorage.setItem('token', token);
    localStorage.setItem('user', JSON.stringify(user));
    set({
      token,
      user
    })
  },
  logout: () => {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    set({
      token: '',
      user:null
    })
  }
}))

Zustand 和 localStorage 解决的问题不同:

存储位置 优点 局限
Zustand 更新后组件能自动重新渲染 刷新页面后内存状态消失
localStorage 刷新后数据仍然存在 自身不是 React 响应式状态

登录成功后,Login.jsx 调用:

javascript 复制代码
setAuth({ token: res.token, user: res.user });

setAuth 首先保存持久化数据:

javascript 复制代码
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));

localStorage 只能保存字符串,因此 user 需要通过 JSON.stringify 转换。随后:

javascript 复制代码
set({
  token,
  user
})

更新 Zustand 内存状态,并通知订阅 tokenuser 的组件重新渲染。

页面刷新时,Store 会重新执行初始化代码:

javascript 复制代码
token: localStorage.getItem('token')|| '',
user:  JSON.parse(localStorage.getItem('user')) || null,

这就是登录状态能够跨刷新恢复的原因。

6.2 登录成功后如何回到原页面

身份保存完成后,Login.jsx 执行:

javascript 复制代码
navigate(from, { replace: true }); // 登录成功后跳转

假设 RequireAuth 之前保存的是:

javascript 复制代码
from === '/pay'

现在就会跳回 /pay。路由再次渲染 RequireAuth 时,Zustand 已经有 Token:

text 复制代码
RequireAuth 读取 token
→ token 不为空
→ 跳过 Navigate
→ return children
→ 显示 Pay

replace: true 会替换登录页历史记录,避免用户登录成功后点击后退又回到登录表单。

导航栏同样订阅 Zustand:

javascript 复制代码
import { Link } from 'react-router-dom'
import { useAuthStore } from '../store/user';

function Nav() {
  const token = useAuthStore(state => state.token);
  const user = useAuthStore(state => state.user);
  const logout = useAuthStore(state => state.logout);
  const handleLogout = () => {
    logout();
  }
  return (
    <nav style={{padding: 0, borderBottom: '1px solid #ccc'}}>
      <Link to="/">Home</Link>
      <Link to="/pay">Pay</Link>
      {!token && <Link to="/login">Login</Link>}
      {user && <span>{user.username}</span>}
      {token && <button onClick={handleLogout}>Logout</button>}
    </nav>
  )
}
export default Nav;

登录前 token 为空,显示 Login;登录后 usertoken 有值,显示用户名和 Logout。这是 React 条件渲染:

text 复制代码
!token 为真 → 显示 Login
user 为真   → 显示 username
token 为真  → 显示 Logout

点击退出时调用 logout(),它同时删除 localStorage 中的身份数据并清空 Zustand。Store 更新后导航栏自动重新渲染。

7. getRepo() 发起受保护请求

7.1 当前代码中 getRepo() 的真实触发时机

流程图把 getRepo() 放在登录并跳回页面之后,是为了说明"登录后的请求如何携带 Token"。但当前代码的实际触发条件必须单独讲清楚:

javascript 复制代码
useEffect(() => {
  (async () => {
    const res = await getRepo();
    console.log(res);
  })();
}, []);

这段 Effect 位于 App.jsx,依赖数组是 [],所以它在 App 挂载后执行一次

App 挂载时的状态 请求表现
用户从未登录 没有 Token,首次 /api/repo 返回业务码 401
localStorage 已保存 Token 拦截器携带 Token,验证成功
当前页面完成登录并调用 navigate App 通常不会重新挂载,Effect 不会仅因路由变化再次执行
登录后刷新页面 App 重新挂载,getRepo() 使用已保存 Token 再次请求

因此,当前实现并不是"登录成功后自动调用一次 getRepo()"。要观察带 Token 的请求,可以先登录,再刷新页面。这里只解释当前运行事实,不改变现有调用方式。

开发模式下,React StrictMode 可能额外执行一次 Effect 来检查副作用,所以控制台中可能看到重复请求。这是开发检查行为,不代表生产环境一定发送两次。

7.2 api/repo.js 如何发起请求

受保护请求封装如下:

javascript 复制代码
import axios from './config';
export const getRepo = async () => {
  const res = await axios.get('repo');
  console.log(res);
  return res;
}

这里仍然使用 ./config 导出的 Axios 实例,所以 baseURL: '/api' 会参与地址组合:

text 复制代码
baseURL: /api
请求路径: repo
最终路径: /api/repo

login() 不同,getRepo() 返回的是完整 Axios Response,没有取 res.data。因此调用方拿到的主要结构是:

text 复制代码
res
├── data
├── status
├── headers
└── config

Mock 的业务结果位于 res.data 中。当前 App.jsx 直接打印整个 res,便于同时观察请求配置和响应内容。

8. Axios 拦截器自动携带 Token

8.1 为什么每个接口不需要手动写认证头

所有使用统一 Axios 实例的请求,在真正发出前都会经过:

javascript 复制代码
instance.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  // 请求配置对象
  return config;
})

getRepo() 为例,执行顺序是:

text 复制代码
axios.get('repo')
        ↓
Axios 创建 config
        ↓
请求拦截器执行
        ↓
localStorage.getItem('token')
        ↓
存在 Token
        ↓
写入 Authorization
        ↓
return config
        ↓
发送 GET /api/repo

最终请求头类似:

http 复制代码
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Bearer 表示当前使用承载令牌认证方案,后面必须有一个空格,再连接 Token。

拦截器每次请求都从 localStorage 读取,因此登录后的请求可以取得刚刚保存的 Token。return config 不能省略,因为 Axios 需要继续使用这个配置对象发送请求。

8.2 登录请求为什么不会带 Token

登录前 localStorage 中没有 Token:

javascript 复制代码
const token = localStorage.getItem('token');

结果是 null,所以:

javascript 复制代码
if (token) {

条件不成立,不写 Authorization。这符合登录接口的职责:用户此时还没有凭证,只能通过账号密码申请凭证。

登录成功后,setAuth 保存 Token。下一次使用该 Axios 实例发送请求时,拦截器才能读取并自动携带它。

9. Mock 读取并验证 Authorization

9.1 /api/repo 如何提取 Token

受保护接口规则如下:

javascript 复制代码
{
  // 401
  url: '/api/repo',
  method: 'get',
  response: req => {
    // Bearer XXXX
    try {
      const token = req.headers['authorization']?.split(' ')[1];
      console.log(token);
      let decoded = jwt.verify(token, secret);
      console.log(decoded);
      return {
        code: 0,
        data: decoded.user
      }
    } catch {
      return {
        code: 401,
        msg: 'Invalid token'
      }
    }
  }
}

请求头的值由两部分组成:

text 复制代码
Bearer Token内容

执行:

javascript 复制代码
req.headers['authorization']?.split(' ')[1]

时,split(' ') 按空格拆分:

javascript 复制代码
['Bearer', 'Token内容']

数组索引 [1] 取出真正的 Token。?. 是可选链,未登录请求缺少 authorization 时不会在 split 处直接中断,而是得到 undefined,随后由 try...catch 统一处理验证失败。

9.2 jwt.verify 验证了什么

javascript 复制代码
let decoded = jwt.verify(token, secret);

jwt.verify 会完成几项检查:

  • Token 格式是否正确。
  • 签名是否能使用同一个 secret 验证。
  • Payload 是否被修改。
  • exp 表示的有效期是否已经结束。

验证成功后,decoded 中包含签发时写入的身份声明:

javascript 复制代码
{
  user: 'admin',
  role: 'admin',
  iat: 生成时间,
  exp: 过期时间
}

接口返回:

javascript 复制代码
return {
  code: 0,
  data: decoded.user
}

验证失败时,jwt.verify 会抛出异常,控制流进入:

javascript 复制代码
catch {
  return {
    code: 401,
    msg: 'Invalid token'
  }
}

这里的 code: 401 位于响应体中,是业务码。Mock 不一定真的把 HTTP 状态设置为 401,所以 Axios 可能仍把它当作一次正常完成的 HTTP 请求。判断结果时需要查看 res.data.code

10. 整条链路的前后关系

10.1 数据在每一步如何变化

步骤 输入 执行模块 输出
访问 /pay 当前地址 App.jsx 匹配受保护路由
判断登录 Zustand Token RequireAuth.jsx 放行或跳转
填写表单 用户输入 Login.jsx formData
提交登录 formData api/user.js POST /api/login
验证账号 username、password mock/user.js 成功或失败
签发凭证 user、role、secret jwt.sign Token
保存身份 token、user store/user.js Zustand 与持久化状态
返回目标页 from navigate /pay
请求资源 getRepo() api/repo.js GET /api/repo
携带凭证 localStorage Token Axios 拦截器 Authorization 请求头
验证凭证 Bearer Token jwt.verify 解码身份或 401

10.2 登录前后状态对照

状态项 登录前 登录后 退出后
Zustand token '' JWT 字符串 ''
Zustand user null { username: 'admin' } null
localStorage.token 不存在 JWT 字符串 被删除
localStorage.user 不存在 JSON 字符串 被删除
/pay 被守卫拦截 守卫放行 再次被拦截
Axios 认证头 不添加 自动添加 不添加

10.3 必须分清的安全边界

容易混淆的判断 实际含义
Zustand 中有 Token 前端认为用户已经登录
RequireAuth 放行 页面允许展示
Axios 带上 Token 请求携带了身份凭证
jwt.verify 成功 Token 签名和有效期通过校验
Payload 中有 role 具备角色声明,但仍需要服务端执行授权

localStorage 中存在 Token 不能证明 Token 合法,隐藏导航按钮也不能阻止用户直接请求接口。生产环境中的敏感接口必须执行服务端校验。示例把 secret 放在 Mock 中是为了本地学习,正式前端不能持有签名密钥。

总结

这套登录项目的主线是凭证的产生、保存、携带和验证。用户访问 /pay 时先经过 RequireAuth,未登录则保存目标地址并跳转 /login;登录页把受控表单数据交给 api/user.js,Axios 组合出 POST /api/login,Vite Mock 校验账号密码并使用 jwt.sign 生成 Token;setAuth 同时更新 Zustand 和 localStorage,随后 navigate 返回原页面。访问受保护接口时,Axios 拦截器读取 Token 并写入 Authorization,Mock 再通过 jwt.verify 判断凭证是否有效。路由守卫解决页面体验,JWT 验证承担身份可信度判断,两者不能互相替代。

相关推荐
爱酱丶1 小时前
VS Code 快速生成Vue3 + TypeScript + Setup 基础空模板
前端·javascript·typescript
Maxkim1 小时前
在 GitHub 仓库里配一个 AI Code Reviewer,自动审查 PR
前端·javascript
忆江南1 小时前
Flutter 图片库与 Dio 库优化实战指南
前端
用户921080262861 小时前
AI 对话为什么需要 Markdown 解析器:从纯文本气泡到专业内容渲染
前端
NeverSettle_1 小时前
Agent 如何快速调用公司接口?——CLI + Skill 实践与踩坑
前端·javascript·后端
环境栈笔记1 小时前
指纹浏览器怎么用:从 Profile、代理到环境检测的完整上手流程
前端·人工智能·后端·自动化
sugar__salt1 小时前
三列布局与 TypeScript 工具类型 Pick / Omit / Partial 详解
前端·javascript·typescript
IMPYLH2 小时前
HTML 的 <html> 元素
前端·javascript·html
IT_陈寒2 小时前
SpringBoot自动配置的坑,把我整不会了
前端·人工智能·后端