背景:HTTP 无状态,服务器凭什么认出你
HTTP 协议本身是无状态的。服务器处理完一个请求,不会主动记住「刚刚是谁在访问」。用户登录之后,下一次请求到来时,服务器需要一种方式重新识别他的身份。
传统方案是 cookie/session。服务器在内存里维护一个 session 对象,把 sessionId 写进 cookie,浏览器每次请求自动带上。这套方案在单机环境里工作正常,但有一个前提:session 数据必须能被「下一次收到请求的那台机器」读到。请求一旦被负载均衡转发到另一台服务器,而 session 恰好存在原来那台机器的内存里,用户就会被当成未登录。分布式环境下,要维持这套方案,通常还得额外引入共享存储来同步 session。
JWT 换了一条路。它不依赖服务端存储,而是把身份信息编码进 token 本身,任何一台持有密钥的服务器都能把它解出来。这就是这个 Demo 要演示的第一件事:在无状态的 HTTP 上,用一个可校验的 token 回答「你是谁」。
核心只有两个动作:sign 与 verify
Demo 里模拟后端的逻辑写在 mock/user.js,第一行就定了基调:
javascript
import jwt from 'jsonwebtoken';
const secret = 'secret819!$'
secret 是加盐用的密钥。JWT 的整套机制收敛到两个动作上------签发(sign)和校验(verify),密钥贯穿其中。
sign:把身份对象加密成 token。 登录接口校验完账号密码后,做这样一件事:
php
const token = jwt.sign(
{ user: body.username, role: 'admin' },
secret,
{ expiresIn: 86400 }
)
jwt.sign 接收三样东西:一个 JSON 身份对象(这里只放了 user 和 role)、密钥、以及一个有效期 expiresIn: 86400(秒,即一天)。返回的 token 是一长串不可读的字符串,但里面确实承载着这段身份信息。签发是单向操作,拿到 token 的人无法从它反推出密钥,只能用它去做校验。
verify:把 token 解回身份对象。 受保护接口拿到 token 后做反向操作:
ini
let decoded = jwt.verify(token, secret);
return { code: 0, data: decoded.user }
jwt.verify 用同一个密钥把 token 解回 JSON。这里有一个关键点:签发和解码用的是同一把 secret。这也正是 JWT 适用于分布式场景的原因------密钥可以配置在每一台自己的服务器上,任何一台签发的 token,其他机器都能用同一把密钥解出来,不需要共享内存里的 session。
token 怎么从浏览器送到服务器
签出来的 token 要回到用户手里,再由用户每次请求带回来。Demo 里走的是 HTTP 请求头 Authorization:
makefile
Authorization: Bearer <token>
Bearer 之后跟一串鉴权码。mock/user.js 的 /api/repo 接口就是从这个头里把 token 抠出来的:
ini
const auth = req.headers['authorization'];
if (!auth) {
return { code: 401, msg: 'No token' }
}
const token = auth.split(' ')[1];
auth.split(' ')[1] 拆出 Bearer 后面的那一串。没有头,直接返回 401;有头但校验失败,也返回 401。这层逻辑前端看不到,但正是它守住了受保护接口的门。
前端这一侧,token 放在哪里
后端签发的 token 落在前端,需要一个落脚点。Demo 选的是 localStorage,同时用 zustand 把它管起来。
src/store/user.js 里创建了一个 auth store:
javascript
export const useAuthStore = create(set => ({
token: localStorage.getItem('token') || '',
user: JSON.parse(localStorage.getItem('user')) || null,
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 })
}
}))
这里能看到几个设计取舍。token 和 user 既存在 localStorage 里(刷新页面后状态还在),也同步进 store 的 state 里(React 组件能响应式地读到)。setAuth 负责「登录写入」,logout 负责「登出清除」。登录与否、用户是谁,这些是全局状态,跨路由共享,所以不适合用父子组件传参去传递,交给一个全局 store 统一管理更直接。
选择 zustand 的理由在 readme 里写得很朴素:它是轻量级状态管理,和 react + react-router-dom 凑成一套前端技术栈。项目里同时还有一个 store/todos.js,演示了「大型项目里拆多个子 store」的写法,中小型项目则继续用传统状态共享即可。
axios 拦截器:默默补上的请求头
token 存在 localStorage 里,但每次请求都手动去读、手动塞进 header,会很重复。Demo 把这部分交给 axios 拦截器。
src/api/config.js 建了一个 axios 实例,挂上两个拦截器:
javascript
const instance = axios.create({
baseURL: '/api',
timeout: 5000
})
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.data
})
request 拦截器在每个请求发出之前 把它拦下来:从 localStorage 取出 token,若有就写进 config.headers['Authorization'],再放行。于是业务代码里发起登录、拉取数据时,完全不用关心 token 这回事,请求头被默默补上了。
response 拦截器则把 res.data 直接返回。这样 api/user.js 里的 login 函数拿到手的就是后端返回的 { code, user, token } 这个对象本身,而不是一整层 axios 响应壳。两个拦截器各做一件事,把「鉴权」和「解包」从业务代码里抽了出去。
路由守卫:没登录就别进受保护页面
token 到位之后,剩最后一道门:有些页面只有登录后才能访问。Demo 用 RequireAuth 组件做路由守卫。
src/components/RequireAuth.jsx:
javascript
function RequireAuth({ children }) {
const token = useAuthStore(state => state.token)
if (!token) {
return <Navigate to="/login" replace />
}
return children
}
组件从 store 里读出 token,没有 token 就 <Navigate to="/login"> 重定向到登录页,有 token 才渲染子组件。在 App.jsx 里,只有 /pay 页面被它包了起来:
xml
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
} />
/ 和 /login 不加守卫,任何人可见;/pay 加了守卫,未登录会被拦回登录页。Nav.jsx 则反过来按 token 决定导航条长什么样:未登录显示 Login 链接,已登录显示用户名和 Logout 按钮。
整条链路串起来看
把上面的模块按一次真实请求的时序拼起来,Demo 里的登录鉴权链路是:
- 用户在
/login提交admin / 123456,Login.jsx调login(),走 axios request 拦截器(此时还没有 token,不附加头),请求打到mock的/api/login。 mock/user.js校验账号密码,jwt.sign签出 token,连同{ user }一起返回。- response 拦截器解包,
Login.jsx拿到res,res.code === 0时调setAuth({ token, user })写入 store 和 localStorage,随后navigate跳转。 - 之后访问
/pay,RequireAuth从 store 读到 token,放行。 - 页面发起
/api/repo请求时,request 拦截器从 localStorage 取出 token,写进Authorization: Bearer ...。 mock/user.js的/api/repo从请求头拆出 token,jwt.verify校验通过,返回decoded.user。
token 在这条链路上完成了「签发 → 存储 → 自动携带 → 校验」的闭环。签发和校验两端各持一份 secret,中间不依赖任何服务端内存状态,这正是 JWT 能在无状态、分布式环境里站住脚的原因。
一个需要点出来的边界
这个 Demo 的价值在于把链路讲清楚,但它默认了几件在生产环境里需要另行处理的事:
secret硬编码在mock/user.js里,直接进了前端可读的代码。真实项目里密钥应从环境变量或配置中心读取,绝不应该出现在客户端可访问的包里。- 前后端共用一把对称密钥,属于 HS256 一类签发的简化演示。生产环境更常见的是用非对称密钥(RS256 等),服务端只持私钥签发,校验方只持公钥,私钥不扩散。
localStorage存 token 有 XSS 风险:脚本一旦注入,就能读走 token。更谨慎的方案是 httpOnly cookie 或内存持有并配合刷新策略。
这些是 Demo 之外要考虑的约束,不影响它作为「JWT 登录鉴权最小闭环」的教学价值。理解 sign/verify、Bearer 头、拦截器、状态存储、路由守卫这五个环节各自承担什么,再往上叠加安全与过期策略,路径会更清楚。