从一头雾水到跑通全流程:我用一个周末啃透了JWT登录鉴权

登录这个东西,说实话我写了快两年的业务代码了,一直没彻底搞明白。反正后端给什么我就存什么,请求头带上就行,出了问题找后端。直到这次接了个需求,要我从0搭一套登录鉴权的架子,前后端都我自己来,才被迫把每一层都捋了一遍。捋完发现,原来之前很多模糊的点,根本不是难,就是没人给你串起来讲。

一开始我只知道要存个token

最开始做登录的时候,我的认知非常表层:用户填用户名密码,点登录,后端返回一个token,我塞到localStorage里,完事。下次请求的时候,在请求头加个Authorization: Bearer xxx。至于这个token到底是什么,为什么长得奇形怪状的,后端怎么凭一串字符就知道我是谁,完全没想过。

直到这次自己写mock接口,我才反应过来------HTTP是无状态的啊。你第一次请求/login告诉我你是admin,第二次请求/getUserInfo,服务器凭什么还记得你是admin?它又没有记忆。

捋到这我突然想通了。以前公司用的cookie+session方案,本质上是服务器自己记了个小本子。你登录成功,服务器给你发个sessionId写在cookie里,就像给你一个手牌号码。下次你来,掏出手牌号码,服务器翻自己的小本子:哦,3号手牌对应的是admin用户。这个方案在单机部署的时候没问题,可一旦服务器变成了好几台,麻烦就来了。你的小本子存在A机器上,结果下一次请求被负载均衡分到了B机器,B翻不到你的记录,直接让你重新登录。

JWT解决的就是这个问题。它干脆不存小本子了,直接把你的身份信息"写"在令牌上,然后签个名防篡改。就像演唱会门票,上面印着你的名字、座位号、过期时间,门口的保安只需要验证一下票是不是真的、有没有过期,不需要联网去查主办方的数据库。任何一个门口的保安,只要认得防伪标记,都能验票。

把JWT拆开看,其实就是三段字符串拼起来

我之前一直觉得JWT是个什么神秘的加密算法,后来把生成的token往decode工具里一粘,瞬间就不神秘了。它就是用.隔开的三部分,每部分都是base64url编码后的JSON。

拿我demo里生成的token举例:

复制代码
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MjQ1MzAwOTMsImV4cCI6MTcyNDYxNjQ5M30.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

从左到右三段分别是Header、Payload、Signature。

Header里写了用的什么签名算法(这里是HS256)和类型是JWT,解码出来就是:

json 复制代码
{
  "alg": "HS256",
  "typ": "JWT"
}

Payload就是装身份信息的地方,用户名、角色、签发时间、过期时间,都是你自己想塞什么塞什么。demo里解码后是这样:

json 复制代码
{
  "user": "admin",
  "role": "admin",
  "iat": 1724530093,
  "exp": 1724616493
}

这里容易搞混的是,Payload不是加密的,只是编码。谁拿到token都能解码看到里面的内容,所以千万别往Payload里塞密码、身份证号这种敏感东西。我一开始以为JWT是加密的,觉得可以把用户的各种隐私信息都塞进去,后来才发现那等于裸奔。

第三段Signature是核心。它的生成逻辑是:把Header和Payload分别编码后用.拼起来,然后用只有服务器知道的密钥(secret),加上Header里指定的算法,对这整串东西做签名。签名的目的不是加密内容,而是防篡改------如果有人把Payload里的role从user改成admin,再发给服务器,服务器用同样的算法和密钥重新算一遍签名,和传过来的Signature对不上,直接就拒了。

跑了个最小demo,把整条链路打通了

光看原理不过瘾,我直接用Vite+React+zustand+vite-plugin-mock撸了一套完整的登录鉴权。前后端都在一个项目里,不用搭真实的后端服务,mock接口直接模拟JWT的签发和验证。

项目结构很简单:

bash 复制代码
login-demo/
├── mock/
│   └── user.js          # mock接口:登录+鉴权
├── src/
│   ├── api/
│   │   ├── config.js    # axios实例+拦截器
│   │   ├── user.js      # 登录接口
│   │   └── repo.js      # 需鉴权的接口
│   ├── store/
│   │   └── user.js      # zustand全局状态
│   ├── components/
│   │   ├── Nav.jsx      # 导航栏
│   │   └── RequireAuth.jsx  # 路由守卫
│   └── pages/
│       ├── Login.jsx    # 登录页
│       ├── Home.jsx     # 首页(无需登录)
│       └── pay.jsx      # 支付页(需登录)
└── vite.config.js       # 配置mock插件

先装依赖,用到了zustand做状态管理、jsonwebtoken在mock里签token:

bash 复制代码
pnpm i zustand jsonwebtoken

再配一下vite.config.js,让mock插件生效:

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

export default defineConfig({
  plugins: [react(), viteMockServe({
    mockPath: 'mock',    // mock文件所在目录
    localEnabled: true,  // 开发环境启用mock
  })],
})

后端mock:签发和验证一条龙

mock/user.js是整个demo的"后端",里面写了两个接口,一个登录发token,一个受保护的接口验token:

javascript 复制代码
// mock/user.js
import jwt from 'jsonwebtoken';

const secret = 'secret2004$';  // 只有服务器知道的密钥,别泄露!

export default [
    {
        // 登录接口:用户名密码对得上,就发token
        url: '/api/login',
        method: 'post',
        response: (req, res) => {
            const body = req.body;
            if(body.username !== 'admin' || body.password !== '123456'){
                return { code: -1, message: '用户名或密码错误' }
            }
            // sign:把用户身份信息+密钥,生成一个带签名的token
            const token = jwt.sign(
                { user: body.username, role: 'admin' },  // Payload
                secret,                                  // 密钥
                { expiresIn: 86400 }                     // 24小时过期,单位秒
            )
            return {
                code: 0,
                user: { username: body.username },
                token: token,  
            }
        }
    },
    {
        // 受保护接口:请求头带了合法token才返回数据
        url: '/api/repo',
        method: 'get',
        response: (req, res) => {
            // Authorization头的格式是 Bearer <token>,这里要把前缀去掉
            const authHeader = req.headers['authorization'];
            const token = authHeader ? authHeader.split(' ')[1] : null;
            if (!token) {
                return { code: 401, message: 'No token provided' }
            }
            try {
                // verify:用同样的密钥解,失败直接抛异常
                let decoded = jwt.verify(token, secret);
                return { code: 0, data: decoded.user }
            } catch (error) {
                return { code: 401, message: 'Invalid token' }
            }
        }
    }
]

这段代码跑通之后,我用Postman测了一下登录接口,返回结果跟我预期的一模一样:

然后用拿到的token去请求/api/repo,在请求头里加上Authorization: Bearer xxx,顺利拿到了数据:

前端:axios拦截器是个偷懒神器

手动在每个请求里加Authorization头太蠢了,axios拦截器帮你默默地把这件事干了。请求发出去之前拦下来,检查localStorage里有没有token,有就塞到请求头里。响应回来的时候,再顺手把外层的data剥掉,省得每次都写res.data.data。

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

const instance = axios.create({
    baseURL: '/api',
    timeout: 5000,
})

// 请求拦截器:每个请求自动带token
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;
})

export default instance;

登录状态用zustand管,比Context省心太多

登录状态、用户信息这种要跨路由跨组件共享的东西,以前我会写Context+Provider包一层,写起来麻烦,还容易因为rerender搞得头疼。这次试了下zustand,真香。

javascript 复制代码
// src/store/user.js
import { create } from 'zustand';

export const useAuthStore = create(set => ({
    // 初始化就从localStorage读,刷新页面不丢失登录态
    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或者用户信息,直接从store里取,不需要包Provider。登录成功之后调用setAuth,token和用户信息同时写进store和localStorage,刷新页面也不会丢:

路由守卫:没登录就别进来

支付页面这种敏感路由,没登录的话直接踢回登录页。我写了个RequireAuth组件包一下,逻辑简单粗暴------store里有token就渲染子组件,没有就重定向到登录页,顺便把用户本来想去的路径记下,登录成功后直接跳回去,体验好一点。

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

function RequireAuth({ children }) {
    const token = useAuthStore(state => state.token);
    const location = useLocation();
    
    if(!token) {
        // 把当前路径塞到state里,登录页能拿到
        return <Navigate to="/login" replace state={{ from: location.pathname }} />;
    }
    return children;
}

export default RequireAuth;

路由里直接用:

javascript 复制代码
// src/App.jsx
<Route path="/pay" element={
  <RequireAuth>
    <Pay />
  </RequireAuth>
} />

翻完jsonwebtoken的实现逻辑,我愣了一下

demo跑通之后我又翻了翻jsonwebtoken这个库的源码思路,发现两个之前没想过的细节。

一个是签名不是加密 。我之前一直以为signature是把前两段加密了,其实不是。它就是对base64Url(header) + "." + base64Url(payload)这串文本做了一次HMAC-SHA256运算,结果就是signature。HMAC是单向的,你拿signature反推不出原文和密钥,只能拿着原文和密钥重新算一遍,对比结果是不是一样。所以验证的过程本质上是"你把原文(Header+Payload)和签名一起给我,我用同样的密钥再算一遍签名,对上了就是没被篡改过"。

另一个反直觉的点是JWT过期时间校验是verify函数自动做的 。我在demo里只传了expiresIn: 86400,sign的时候会自动在Payload里加iat(签发时间戳)和exp(过期时间戳)。调用verify的时候,只要当前时间超过了exp,不管签名对不对,直接抛TokenExpiredError。不用自己手写时间比较,这点很方便。

Payload里的iat和exp都是Unix时间戳(秒级),不是毫秒级。如果你手动构造Payload,别搞错了单位。

几个我当时差点踩进去的坑

说实话之前如果让我直接写JWT鉴权,大概率会踩这几个坑。这次因为是自己从零搭,每一步都想了想,算是避开了。

Secret写死在前端代码里? 别笑,我真的见过有人这么干。前端代码打包之后用户是能看到的,密钥泄露了等于JWT整个签名机制废了,别人想伪造什么token就伪造什么。密钥必须只存在服务端。我们这个demo里secret写在mock文件里,mock文件只会跑在Vite的node进程里,不会打包到前端,所以是安全的。

token存localStorage怕XSS? 对,只要你的页面有XSS漏洞,攻击者就能通过脚本读到localStorage里的token,然后冒充你。这是事实,但不是说localStorage就不能用。大多数中后台系统、C端产品,只要做好XSS防护(CSP、输入转义、不滥用innerHTML),localStorage是够用的。对安全等级要求特别高的场景,可以考虑把token放在HttpOnly的Cookie里,这样JS读不到,XSS偷不走,但代价是你又得处理CSRF的问题。没有银弹,看业务取舍。

token一旦签发就没法主动作废? 这也是JWT的一个特点。因为服务器不存session,就算用户点了"退出登录",你只是把前端的token删了,这个token本身在过期之前还是有效的。如果token被泄露了,别人拿到照样能用。对登出安全性要求高的场景,一般会在服务端维护一个token黑名单(比如用Redis),或者把token过期时间设短一点,配合refresh token用。

捋完一圈的真实感受

搞完这个demo,我最直观的感受是JWT没有我之前想象得那么"高级"。它就是一个标准化的、带签名的JSON载体,解决的核心问题就是在不依赖服务端存储的情况下,安全地传递用户身份。分布式部署、跨域认证、微服务之间鉴权,这些场景JWT天然就适合,因为不需要做session共享,任何一台服务器只要拿同一个密钥就能验签。

但它也不是万能的。需要主动踢用户下线、需要让某个token立刻失效的场景,纯JWT方案就很别扭,还得配合服务端存储搞黑名单,那反而不如直接用session省事。还有Payload不能放敏感数据这个点,一定要刻在脑子里,别等数据泄露了才后悔。

我目前理解到的程度大概就是这些,有补充的可以评论区唠唠。


顺手捋的核心知识点

写到这顺手把核心的点捋了一遍,我自己回头复盘方便,你们要是懒得翻长文也可以直接看这部分。

1. JWT的本质与三段结构

  • 核心结论 :JWT是一个用.分隔的三段式字符串,由Header(算法+类型)、Payload(身份数据+时间戳)、Signature(防篡改签名)组成,目的是在服务端无存储的前提下安全传递用户身份。
  • 易错提醒:Payload是base64url编码而非加密,任何人拿到token都能解码看到内容,严禁存放密码、密钥等敏感信息。

2. 签名(sign)与验证(verify)的核心机制

  • 核心结论 :sign用服务端密钥对base64(Header)+"."+base64(Payload)做HMAC运算生成Signature;verify用相同的密钥重新计算Signature并与传入值对比,不一致则判定为篡改。
  • 易错提醒:签名是单向运算(无法反推原文和密钥),不是加密。密钥必须仅存服务端,严禁打包进前端代码。

3. 手动实现JWT签发与验证的关键步骤

  • 核心结论 :实现一个最小JWT分为五步------①Header固定声明alg和typ并base64编码;②构造Payload(含user/role/exp等)并base64编码;③用密钥对前两段拼接字符串做HMAC-SHA256并base64编码得到Signature;④签发时三段用.拼接返回;⑤验证时拆分三段,用密钥重算签名比对,同时校验exp是否过期。
  • 易错提醒 :exp和iat是秒级时间戳,不是毫秒级;Authorization头的格式是Bearer <token>,验证时记得split(' ')[1]取实际token。

4. JWT对比Cookie+Session的差异

  • 核心结论:Session方案服务端存会话对象、依赖Cookie传递sessionId,存在分布式共享难题;JWT将身份信息存于token本身、通过签名保证完整性,任何持有相同密钥的服务器都能独立验证,天然适配分布式和跨域场景。
  • 易错提醒:JWT的"无状态"是优点也是缺点------签发后无法在服务端主动作废,只能等过期,需要踢人下线的场景需额外配合Redis黑名单或refresh token机制。

5. axios拦截器的正确用法

  • 核心结论 :request拦截器统一注入Authorization请求头,避免每个接口手动拼接;response拦截器统一剥离外层res.data、处理401跳登录等全局逻辑。
  • 易错提醒 :拦截器里处理完config和response必须return回去,否则请求会被卡住;401处理要避免重复跳转。

6. zustand管理全局登录态的模式

  • 核心结论:登录态(token+user)初始化时从localStorage读取(防刷新丢失),setAuth同时写入store和localStorage,logout双向清除;任何组件直接从store按需取状态,无需Provider包裹。
  • 易错提醒 :user对象存localStorage必须JSON.stringify/JSON.parse,不然取出来是字符串[object Object]。

7. 路由守卫(RequireAuth)的实现要点

  • 核心结论 :高阶路由组件读取token,无token时用<Navigate replace>重定向到登录页,同时将当前路径通过location.state传递给登录页,登录成功后navigate(from, { replace: true })跳回原目的地。
  • 易错提醒:replace要加true,否则用户点浏览器后退会回到被拦截前的路由,又被踢回登录页,形成死循环。

8. token存储的安全取舍

  • 核心结论:localStorage存储方便但有XSS泄露风险(做好CSP和输入转义可降低风险);HttpOnly Cookie防XSS但需额外处理CSRF;安全等级要求越高越偏向Cookie+CSRF Token方案。
  • 易错提醒:认为"JWT比session安全"是认知误区,两者只是实现方式不同,安全与否取决于具体配置(过期时间、密钥强度、传输是否HTTPS等)。
相关推荐
前端兰博2 小时前
05-Redis
redis·后端
databook3 小时前
从手动检查到自动监控:一个数据质量工作流的实现
后端·python·数据分析
【JAVA】玩家3 小时前
Spring核心原理全解析:从零到生产实战
java·后端·spring
excel3 小时前
研究 Vue 3 源码的收获
前端·vue.js
东风破_3 小时前
从 Neo4j 到 GraphRAG:用 Text-to-Cypher 构建图检索 RAG
人工智能·后端
可乐鸡翅yeah_5 小时前
业务中 M3U8 水印相关坑,硬水印和动态水印区别
前端·网络·数据库·ffmpeg·m3u8在线
lerhxx5 小时前
AI 应用如何高效优雅地恢复中断?—— "连接解耦 + 状态持久化"
前端·javascript
计算机魔术师5 小时前
METR 演示 AI 智能体如何篡改 Inspect 评估记录以掩盖不当行为
前端
llqbzllll5 小时前
Spring AI 工具调用不是反射一下就结束:用 2.0.1 跑通失败恢复与调用上限
人工智能·后端