JWT 登录鉴权 Demo 拆解

背景: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 里的登录鉴权链路是:

  1. 用户在 /login 提交 admin / 123456,Login.jsx 调 login(),走 axios request 拦截器(此时还没有 token,不附加头),请求打到 mock 的 /api/login。
  2. mock/user.js 校验账号密码,jwt.sign 签出 token,连同 { user } 一起返回。
  3. response 拦截器解包,Login.jsx 拿到 res,res.code === 0 时调 setAuth({ token, user }) 写入 store 和 localStorage,随后 navigate 跳转。
  4. 之后访问 /pay,RequireAuth 从 store 读到 token,放行。
  5. 页面发起 /api/repo 请求时,request 拦截器从 localStorage 取出 token,写进 Authorization: Bearer ...。
  6. 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 头、拦截器、状态存储、路由守卫这五个环节各自承担什么,再往上叠加安全与过期策略,路径会更清楚。

相关推荐
学长毕设2 分钟前
基于SpringBoot的野生动物公益保护平台设计与实现-附源码
java·spring boot·后端
梦里寻码23 分钟前
我用 NestJS + Vue3 写了一套完整的研发管理系统(58 表 / 229 API / 130 项断言,全开源
前端·后端·算法
鶴哥只手遮天31 分钟前
《Redis 8.10.3 深度剖析》系列 ·第 10 章 Hash 一:`listpack`、`hashtable` 与 `LISTPACK_EX`
redis·后端·开源
johnjincaaaa35 分钟前
WebAssembly 逆向实战:从 0 搞懂猿人学第 15 题
后端
Gopher_HBo40 分钟前
Go CPU 剖析:四角色白话机制 + 实测实验
后端
猿人谷1 小时前
Surrogate Model:让进化算法学会“预测”,为什么它正在成为智能优化的重要技术?
后端·算法·架构
the局外人2 小时前
LangChain ToolRuntime 入门:context、state 与 store 的实际用法
后端·langchain·agent
回家路上绕了弯2 小时前
当 AI 越来越能干,我们如何理解自己的价值?
后端
陈随易2 小时前
Node.js 2026上半年都有哪些性能提升?
前端·后端·程序员
hhcgchpspk2 小时前
flask服务端页面预防被爬措施
后端·python·flask·网络爬虫·防爬