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 身份对象(这里只放了 userrole)、密钥、以及一个有效期 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 / 123456Login.jsxlogin(),走 axios request 拦截器(此时还没有 token,不附加头),请求打到 mock/api/login
  2. mock/user.js 校验账号密码,jwt.sign 签出 token,连同 { user } 一起返回。
  3. response 拦截器解包,Login.jsx 拿到 resres.code === 0 时调 setAuth({ token, user }) 写入 store 和 localStorage,随后 navigate 跳转。
  4. 之后访问 /payRequireAuth 从 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 头、拦截器、状态存储、路由守卫这五个环节各自承担什么,再往上叠加安全与过期策略,路径会更清楚。

相关推荐
JimmtButler1 小时前
从 Java 到 JS:我终于把闭包想明白了
javascript·后端
掘金者阿豪2 小时前
Codex 怎么突然变慢了?一个需求跑几十分钟,我才发现它的工作方式已经变了
前端·后端
Zadig2 小时前
Zadig 全面支持 CRD,至此所有 K8s 资源类型均可一键发布!
后端·devops
得物技术2 小时前
EP-Harness:从个人 AI Coding 到团队级 Agent 工作流|得物技术
后端·程序员·架构
用户125758524362 小时前
对象存储 URL 为什么别到处拼:后台附件预览要验这一层
后端·go·ai编程
步行cgn2 小时前
MyBatis 一对多关联映射详解
java·后端
阿弱2 小时前
pi 扩展机制:加载、执行与能力
后端·llm·agent
省长3 小时前
Sa-Token v1.46.0 发布 🚀,来看看有没有令你心动的功能!
java·后端·开源
tech_zjf3 小时前
当 AI 把 Next.js Route 越写越快:我为什么做了 next-route-kit
前端·后端