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)
- [2.1 `App.jsx` 如何声明受保护路由](#2.1
- [3. 用户填写账号密码并提交](#3. 用户填写账号密码并提交)
-
- [3.1 `Login.jsx` 如何保存表单输入](#3.1
Login.jsx如何保存表单输入) - [3.2 表单校验如何从输入状态推导](#3.2 表单校验如何从输入状态推导)
- [3.3 提交表单后调用 `login(formData)`](#3.3 提交表单后调用
login(formData))
- [3.1 `Login.jsx` 如何保存表单输入](#3.1
- [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)
- [5.1 `vite.config.js` 如何注册 Mock](#5.1
- [6. Zustand 和 `localStorage` 保存登录状态](#6. Zustand 和
localStorage保存登录状态) -
- [6.1 `setAuth` 为什么要同时写两个地方](#6.1
setAuth为什么要同时写两个地方) - [6.2 登录成功后如何回到原页面](#6.2 登录成功后如何回到原页面)
- [6.3 `Nav.jsx` 如何响应登录状态变化](#6.3
Nav.jsx如何响应登录状态变化)
- [6.1 `setAuth` 为什么要同时写两个地方](#6.1
- [7. `getRepo()` 发起受保护请求](#7.
getRepo()发起受保护请求) -
- [7.1 当前代码中 `getRepo()` 的真实触发时机](#7.1 当前代码中
getRepo()的真实触发时机) - [7.2 `api/repo.js` 如何发起请求](#7.2
api/repo.js如何发起请求)
- [7.1 当前代码中 `getRepo()` 的真实触发时机](#7.1 当前代码中
- [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验证了什么)
- [9.1 `/api/repo` 如何提取 Token](#9.1
- [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.jsx 和 Pay.jsx 只是页面占位,其中 Pay 用来代表需要登录后才能访问的页面。store/todos.js 是另一个 Zustand 状态仓示例,没有进入登录鉴权链路。模板样式和 Vite 默认资源不参与身份数据流,因此不展开。
1.2 三种状态不能混为一谈
整个项目同时存在三类状态:
| 状态 | 保存位置 | 解决的问题 |
|---|---|---|
| 表单状态 | Login.jsx 的 formData |
用户当前输入了什么 |
| 前端登录状态 | Zustand 的 token 和 user |
各组件如何知道当前是否登录 |
| 持久化状态 | 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 可以直接访问,支付页 /pay 被 RequireAuth 包裹。
访问 /pay 时,React Router 不会立刻把 Pay 显示出来,而是先渲染外层的 RequireAuth。这里的嵌套关系是:
text
RequireAuth
└── Pay
传入 RequireAuth 的 <Pay /> 会成为它的 children。守卫允许访问时返回 children,拒绝访问时返回跳转组件。
lazy 会按页面拆分构建产物,只有访问对应路由时才加载页面代码。Suspense 的 fallback 是异步页面尚未加载完成时的临时内容。这部分解决的是加载体验,不负责鉴权。
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;
代码按下面的顺序执行:
useLocation()获取当前地址信息。useAuthStore(state => state.token)从 Zustand 读取 Token。if (!token)判断 Token 是否为空。- Token 为空时返回
<Navigate>,跳转到/login。 - 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 用于读取守卫保存的 from,login 负责发送请求,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 }));
};
当用户名输入框变化时,name 是 username;密码输入框变化时,name 是 password。[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}>
errors 和 isValid 都可以由 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.code、res.token 和 res.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 错误'
}
}
url 和 method 必须同时匹配:
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 | user、role |
保存身份声明 |
| 密钥 | 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 内存状态,并通知订阅 token 或 user 的组件重新渲染。
页面刷新时,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 会替换登录页历史记录,避免用户登录成功后点击后退又回到登录表单。
6.3 Nav.jsx 如何响应登录状态变化
导航栏同样订阅 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;登录后 user 和 token 有值,显示用户名和 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 验证承担身份可信度判断,两者不能互相替代。