概述
交友项目是 App 端与服务端分离、服务端还要集群部署的项目,登录这件事就不能再照搬传统 Web 的 Session 那一套。这篇只讲清楚 JWT 是什么、为什么需要它、三段结构各自干什么、和 Session 比优劣在哪、什么时候该用什么时候不该用;具体怎么写工具类、怎么处理异常,留给同系列的《JWT 入门案例》篇。
纲要
- 会话管理的起点:HTTP 无状态带来的"你是谁"问题
- Session 方案怎么工作,以及它在多端、集群场景下的三条硬伤
- Token 方案的核心思路:服务端不存会话,验签即信任
- JWT 的三段结构:Header / Payload / Signature
- 一段真实 token 的逐段拆解与结构表格
- 验签流程(Mermaid 时序图):为什么改了 payload 就会验签失败
- 对称签名(HS256/HS512)与非对称签名(RS256)的取舍
- JWT 与 Session 的对比表格,以及本项目为什么选 JWT
- JWT 的短板与应对:主动失效、体积、保密性、续期
- JWT 在交友项目登录流程中的位置
- 常见误解澄清
从"你是谁"这个问题说起
HTTP 协议本身是无状态的。这句话翻译成人话就是:服务端处理完一个请求就把你忘了,下一个请求进来,它根本不知道这是刚才那个人发的。可业务上又必须知道------查"我"的资料、发"我"的动态、看"我"关注了谁,都得先确定请求方是谁。
这套动作叫用户鉴权,拆开是两件事:判断有没有登录,判断有没有权限。前者是"你是谁",后者是"你能干什么"。这篇只涉及前者。
传统 Web 项目里,答案是 Session。流程不复杂:
- 用户提交账号密码,服务端校验通过;
- 服务端在内存或存储里建一份会话数据,key 是
JSESSIONID之类的随机串,value 是用户身份; - 这个 id 通过响应头的
Set-Cookie写给浏览器; - 浏览器后续请求自动带上 Cookie,服务端拿 id 一查,就知道是谁。
问题出在三个地方,而且是越往后越躲不掉。
服务端要存数据。 每个在线用户对应一条会话记录,用户量上去了,内存扛不住就得上 Redis 之类的集中存储。存是存下来了,但这意味着每次请求都要多一次存储查询------鉴权从一个纯计算动作变成了 IO 动作。
集群下会话不共享。 交友项目的服务端是集群部署的,两台三台机器一起对外提供服务。用户在 A 机器登录,会话记在 A 的内存里;下一次请求被负载均衡分到 B 机器,B 上根本没有这条会话,直接判定未登录。业界有几种解法:session 复制(机器之间互相同步,量大了同步开销很可观)、粘性会话(按 ip 或 cookie 把同一用户钉在同一台机器,机器宕了会话就丢)、独立 session 存储(把会话抽到 Redis,所有机器共享)。能用,但每一条都在给系统加复杂度。
跨端不友好。 交友项目是多端访问:iOS、Android、还有 Web 页面。Cookie 是浏览器体系里的东西,App 和小程序对它的支持并不一致,指望 Cookie 自动携带本身就不可靠。
三条痛点指向同一个根因:会话状态被放在了服务端。
Token 方案:把身份信息交给客户端
换一个思路。服务端不存会话了,登录成功后把"这个用户是谁"这段信息,编码成一个字符串交给客户端自己保管;客户端每次请求把这个字符串带上;服务端拿到之后不查库,而是验证这段信息有没有被动过手脚------验过了,就信任里面写的身份。
这个字符串就是 JWT,全称 JSON Web Token。它的定位很明确:用于对应用程序上的用户进行身份验证的标记。用了 JWT,就不需要再保存用户的 Cookie 或其它 session 数据。
这个转变带来两个直接结果:服务端省掉了会话存储和查询,集群里任何一台机器都能独立完成鉴权------因为它不需要知道别的机器上有什么。这就是所谓无状态。代价也在后面写着:服务端不再持有会话,也就没法"撤销"它,这部分留到短板那一节讲。
JWT 的三段结构
JWT 是一个由点号分隔的三段式字符串,形如 A.B.C:
- A 段 Header:头部信息,声明 token 类型和签名算法;
- B 段 Payload:载荷,真正存身份数据的地方;
- C 段 Signature:签名,用于校验前两段有没有被篡改。
先看两段真实内容。Header 解出来是一个 JSON,通常只有两个字段:
json
{
"alg": "HS512",
"typ": "JWT"
}
alg 是签名算法,typ 固定为 JWT。Payload 也是 JSON,存的是 claims(声明),交友项目里登录后塞进去的大致是这样:
json
{
"id": 1,
"mobile": "13800138000",
"exp": 1618390870
}
Payload 里的 claims 按用途分三类,这个分类值得记住:
| 类别 | 说明 | 例子 |
|---|---|---|
| 标准声明 | JWT 规范预定义的字段,建议但不强制使用 | exp 过期时间、iat 签发时间、iss 签发者、sub 主题 |
| 公共声明 | 使用者自定义,但需要在 IANA 注册以免冲突 | 一般业务项目用不到 |
| 私有声明 | 双方约定即可,最常用的一类 | 用户 id、手机号、昵称 |
exp 是标准声明里最关键的,服务端验签时会顺便检查它,过期直接判定失效------这是 JWT 唯一自带的"时效控制"手段。
Signature 是对前两段做的签名 ,计算方式是把 base64Url(header) + "." + base64Url(payload) 拼起来,用服务端的密钥按 alg 指定的算法算一个摘要。密钥只有服务端知道,所以客户端拿不出合法的第三段。
这里有一个初学者最大的误解,必须先钉死:Payload 只是做了 Base64Url 编码,不是加密。Base64Url 是一种编码方式,不是加密算法,任何人都能反解。
讲义里把 JWT 描述成"经过加密处理与校验处理的字符串",这里的"加密"是个不严谨的说法,需要纠正:JWT 前两段是可逆的编码,第三段才是签名。签名保证的是数据没被篡改,加密保证的才是数据不被读取,两回事。 这也是为什么敏感数据------密码、账户金额这类------绝对不能写进 JWT。
三段拆解汇总成一张表:
| 段 | 内容 | 编码方式 | 是否可读 | 作用 |
|---|---|---|---|---|
| Header | {"alg":"HS512","typ":"JWT"} |
Base64Url | 可读 | 声明类型与签名算法 |
| Payload | claims JSON(如 id、mobile、exp) |
Base64Url | 可读 | 承载身份信息 |
| Signature | 前两段加密钥算出的摘要 | Base64Url(对摘要) | 不可逆推密钥 | 防篡改 |
用一段真实 token 印证"可读不可改"
下面这段 token 是用 HS512 加密钥 mfxs123 构造的,跟工程里 JwtUtils 的算法和密钥口径一致:
text
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibW9iaWxlIjoiMTM4MDAxMzgwMDAiLCJleHAiOjE2MTgzOTA4NzB9.YDLThmZquceSMNmvF2QBvFp9GwhoziNnm0aOa7kAhu_L9USgsGVslseXPgOWXndPxCLaA6mq1horWzRUos9xBw
把它按点号切开,标上位置:
text
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9 . eyJpZCI6MSwibW9iaWxlIjoiMTM4MDAxMzgwMDAiLCJleHAiOjE2MTgzOTA4NzB9 . YDLThmZquceSMNmvF2QBvFp9GwhoziNnm0aOa7kAhu_L9USgsGVslseXPgOWXndPxCLaA6mq1horWzRUos9xBw
└────────── Header ──────────┘ └────────────────────── Payload ──────────────────────┘ └────────────────────────── Signature ──────────────────────────┘
{"alg":"HS512","typ":"JWT"} {"id":1,"mobile":"13800138000","exp":1618390870}
把整串贴到官方测试网站 jwt.io,右侧会直接展示三段解码结果:Header 一行、Payload 一行,都是明文可读的 JSON,只有 Signature 一栏显示 Invalid Signature 之类的提示------因为 jwt.io 不知道你的密钥是 mfxs123。在密钥框里填上 mfxs123,提示就变成签名有效。
这个演示想说明的就是"可读不可改":
- 可读 ------不需要密钥,任何人 Base64Url 反解就能看到
id是 1、手机号是多少。手改一下 Payload 里的id,把它改成999; - 不可改------Payload 一变,服务端用同样的密钥重新算出的签名就和第三段对不上,验签直接失败。而攻击者没有密钥,算不出能匹配的第三段。
所以 JWT 的安全边界很清楚:它保护的是完整性(没被改),不是机密性(不被看见)。
验签是怎么工作的
服务端拿到 token 后并不"解密"它,而是重新算一遍签名做比对。整个校验流程:
#mermaid-svg-8dT19vHnFtaT2bZE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8dT19vHnFtaT2bZE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8dT19vHnFtaT2bZE .error-icon{fill:#552222;}#mermaid-svg-8dT19vHnFtaT2bZE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8dT19vHnFtaT2bZE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8dT19vHnFtaT2bZE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8dT19vHnFtaT2bZE .marker.cross{stroke:#333333;}#mermaid-svg-8dT19vHnFtaT2bZE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8dT19vHnFtaT2bZE p{margin:0;}#mermaid-svg-8dT19vHnFtaT2bZE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8dT19vHnFtaT2bZE .cluster-label text{fill:#333;}#mermaid-svg-8dT19vHnFtaT2bZE .cluster-label span{color:#333;}#mermaid-svg-8dT19vHnFtaT2bZE .cluster-label span p{background-color:transparent;}#mermaid-svg-8dT19vHnFtaT2bZE .label text,#mermaid-svg-8dT19vHnFtaT2bZE span{fill:#333;color:#333;}#mermaid-svg-8dT19vHnFtaT2bZE .node rect,#mermaid-svg-8dT19vHnFtaT2bZE .node circle,#mermaid-svg-8dT19vHnFtaT2bZE .node ellipse,#mermaid-svg-8dT19vHnFtaT2bZE .node polygon,#mermaid-svg-8dT19vHnFtaT2bZE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8dT19vHnFtaT2bZE .rough-node .label text,#mermaid-svg-8dT19vHnFtaT2bZE .node .label text,#mermaid-svg-8dT19vHnFtaT2bZE .image-shape .label,#mermaid-svg-8dT19vHnFtaT2bZE .icon-shape .label{text-anchor:middle;}#mermaid-svg-8dT19vHnFtaT2bZE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8dT19vHnFtaT2bZE .rough-node .label,#mermaid-svg-8dT19vHnFtaT2bZE .node .label,#mermaid-svg-8dT19vHnFtaT2bZE .image-shape .label,#mermaid-svg-8dT19vHnFtaT2bZE .icon-shape .label{text-align:center;}#mermaid-svg-8dT19vHnFtaT2bZE .node.clickable{cursor:pointer;}#mermaid-svg-8dT19vHnFtaT2bZE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8dT19vHnFtaT2bZE .arrowheadPath{fill:#333333;}#mermaid-svg-8dT19vHnFtaT2bZE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8dT19vHnFtaT2bZE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8dT19vHnFtaT2bZE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8dT19vHnFtaT2bZE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8dT19vHnFtaT2bZE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8dT19vHnFtaT2bZE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8dT19vHnFtaT2bZE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8dT19vHnFtaT2bZE .cluster text{fill:#333;}#mermaid-svg-8dT19vHnFtaT2bZE .cluster span{color:#333;}#mermaid-svg-8dT19vHnFtaT2bZE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8dT19vHnFtaT2bZE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8dT19vHnFtaT2bZE rect.text{fill:none;stroke-width:0;}#mermaid-svg-8dT19vHnFtaT2bZE .icon-shape,#mermaid-svg-8dT19vHnFtaT2bZE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8dT19vHnFtaT2bZE .icon-shape p,#mermaid-svg-8dT19vHnFtaT2bZE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8dT19vHnFtaT2bZE .icon-shape .label rect,#mermaid-svg-8dT19vHnFtaT2bZE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8dT19vHnFtaT2bZE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8dT19vHnFtaT2bZE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8dT19vHnFtaT2bZE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
不一致
一致
已过期
未过期
客户端请求携带 token
token 是否为空或格式非法
判定无效 返回未登录
按点号切成三段 取出 header 与 payload
用服务端密钥 + header 中的 alg 重新计算签名
计算结果与第三段是否一致
签名不匹配 说明被篡改 判定无效
检查 exp 是否已过期
判定失效
信任 payload 中的 claims 取出用户 id
放行 进入业务逻辑
关键在于签名算法的确定性:同样的输入加同样的密钥,算出来的摘要一定相同。攻击者哪怕只改动 Payload 里的一个字符,反解出来的 JSON 就变了,重算的签名必然对不上。这就是 JWT 防篡改的全部原理,没有更玄的东西。
签名算法按密钥的使用方式分成两大类,这也是选型时要做的第一个决定:
| 算法类型 | 代表 | 密钥特征 | 签发与验签 | 适用场景 |
|---|---|---|---|---|
| 对称签名 | HS256 / HS512 | 同一个密钥 | 同一个密钥既签又验 | 单体应用、可信内网,签发方和验签方是同一个服务 |
| 非对称签名 | RS256 | 一对公私钥 | 私钥签发,公钥验签 | 多系统共享身份、需要第三方或网关独立验签的场景 |
对称签名的优点是快、实现简单,缺点是密钥必须共享------任何需要验签的一方都得拿到密钥,而拿到密钥就意味着能伪造 token。非对称签名把"能验"和"能签"分开:资源服务只拿公钥,只能验不能签,密钥泄露的风险面小很多,代价是计算更慢、密钥管理更麻烦。交友项目内部用的是 HS512 对称方案,签发和验签都在自己项目里,够用。
JWT 与 Session 的对比
这张表是这篇最该带走的东西:
| 维度 | Session | JWT |
|---|---|---|
| 状态存在哪 | 服务端(内存 / Redis) | 客户端(由客户端保存和携带) |
| 服务端存储压力 | 有,在线用户越多压力越大 | 无,服务端不落盘 |
| 分布式友好度 | 差,要靠 session 复制 / 粘性会话 / 独立存储解决 | 好,任何节点独立验签即可 |
| 能否主动失效 | 可以,删掉服务端那条会话就生效 | 不行,签发后在有效期内一直有效,这是最大短板 |
| 续期方式 | 服务端可控,滑动过期很自然 | 需要另发新 token 或引入 refresh token |
| 跨端支持 | 依赖 Cookie,App / 小程序支持不一致 | 放在请求头里手动携带,各端统一 |
| 典型适用场景 | 传统 Web 后台、单机或强管控内网 | 移动端 App、前后端分离、微服务 |
先把结论说在前面:不是 JWT 一定更好。 如果项目就是一个传统 Web 后台,用户都在浏览器里,那 Session 反而省事------能主动踢人、续期简单、只传一个几十字节的 sessionId,JWT 那几条优势一条都用不上。技术选型看的从来是场景,不是流行度。
交友项目选 JWT,理由是可以逐条对上的:客户端是 App,Cookie 这条路不好走;前后端分离,接口是给多端共用的;服务端集群部署,会话共享要额外搭一套;后续走微服务,网关和各服务都要能独立验签,无状态的 token 天然合适。四条理由指向同一个答案。
JWT 的不足之处
把缺点摆清楚,比光说优点有用。这里每条都给应对方式:
| 短板 | 具体表现 | 应对方式 |
|---|---|---|
| 无法主动失效 | 用户登出、改密码、被封号,旧 token 在到期前依然有效 | 缩短有效期 + 服务端维护黑名单 / 白名单,或引入版本号校验 |
| 体积比 sessionId 大 | sessionId 通常几十字节,一个带若干 claims 的 token 上百字节起步,每次请求都带 | Payload 只放必要字段,别把整份用户对象塞进去 |
| Payload 不保密 | Base64Url 编码可被任何人解开 | 敏感信息一律不放,绝不放密码、金额、身份证号 |
| 泄露即被冒充 | token 被截获,持有者就是"合法用户" | 全链路走 HTTPS,配合短有效期把窗口压小 |
| 续期要额外设计 | 有效期到了就只能重新登录,直接给长有效期又等于放大风险 | 双 token 方案:短效 access token + 长效 refresh token,仅 refresh 接口可续 |
| 服务端仍要做事 | 有人误以为用了 JWT 服务端什么都不用管 | 验签、过期检查、必要时查黑名单,一个都不能省 |
针对"无法主动失效"再补一句:业界常见的折中是黑名单------登出时把该 token 的标识塞进 Redis,验签通过后再查一次黑名单;或者干脆把有效期设得很短(比如 30 分钟),用"过期自然失效"替代"主动失效"。两种方案都不是银弹,黑名单等于又把状态放回了服务端,只是放得比 Session 少得多。这个取舍本身也说明了一件事:无状态不是免费的。
JWT 在交友项目登录流程里的位置
把 JWT 放回到整个登录链路里,它处在"签发"和"校验"两个点上:
服务端 客户端 App 服务端 客户端 App #mermaid-svg-OYVSAe8RKHYGMCNQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OYVSAe8RKHYGMCNQ .error-icon{fill:#552222;}#mermaid-svg-OYVSAe8RKHYGMCNQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OYVSAe8RKHYGMCNQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OYVSAe8RKHYGMCNQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OYVSAe8RKHYGMCNQ .marker.cross{stroke:#333333;}#mermaid-svg-OYVSAe8RKHYGMCNQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OYVSAe8RKHYGMCNQ p{margin:0;}#mermaid-svg-OYVSAe8RKHYGMCNQ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OYVSAe8RKHYGMCNQ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OYVSAe8RKHYGMCNQ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-OYVSAe8RKHYGMCNQ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-OYVSAe8RKHYGMCNQ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-OYVSAe8RKHYGMCNQ .sequenceNumber{fill:white;}#mermaid-svg-OYVSAe8RKHYGMCNQ #sequencenumber{fill:#333;}#mermaid-svg-OYVSAe8RKHYGMCNQ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-OYVSAe8RKHYGMCNQ .messageText{fill:#333;stroke:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OYVSAe8RKHYGMCNQ .labelText,#mermaid-svg-OYVSAe8RKHYGMCNQ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .loopText,#mermaid-svg-OYVSAe8RKHYGMCNQ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OYVSAe8RKHYGMCNQ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-OYVSAe8RKHYGMCNQ .noteText,#mermaid-svg-OYVSAe8RKHYGMCNQ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-OYVSAe8RKHYGMCNQ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OYVSAe8RKHYGMCNQ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OYVSAe8RKHYGMCNQ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OYVSAe8RKHYGMCNQ .actorPopupMenu{position:absolute;}#mermaid-svg-OYVSAe8RKHYGMCNQ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-OYVSAe8RKHYGMCNQ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OYVSAe8RKHYGMCNQ .actor-man circle,#mermaid-svg-OYVSAe8RKHYGMCNQ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-OYVSAe8RKHYGMCNQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 校验通过 校验不通过 提交手机号 + 验证码 校验验证码,查用户,不存在则自动注册 用用户 id / 手机号构造 claims,签发 token 响应 token 本地保存 token 后续请求,请求头携带 token 拦截器取出 token,验签 + 检查 exp 从 claims 取出用户 id,放行 返回业务数据 返回 401 未登录 / 鉴权失败
服务端这边要做的事其实就两件:登录成功时颁发 token,往里面写用户信息(注意不写敏感内容);数据接口校验 token,通过 token 取回用户信息。 剩下的活都在客户端:保存 token,每次请求记得带上。
第二步"从请求头取 token、验签、塞进上下文"这套统一动作,通常放在拦截器里做------那是后面章节的内容,这里只负责指出 JWT 在链路中的位置。
工程里承载这两个动作的是 JwtUtils,它在 mf-commons 模块里,被各服务共用:
text
mf
├── mf-commons # 公共模块,放跨服务复用的工具
│ └── src/main/java/com/mf/commons/utils
│ └── JwtUtils.java # 签发 getToken / 验签 verifyToken / 取 claims getClaims
├── mf-app-server # 面向 App 的服务,登录接口在这里签发 token
├── mf-dubbo-interface # Dubbo 接口定义
└── mf-dubbo-service # Dubbo 服务实现
JwtUtils 是一个静态工具类,有效期常量 TOKEN_TIME_OUT = 3_600(单位秒,即 1 小时),签名算法 HS512,密钥是硬编码在类里的 mfxs123。这是课程练手工程的现状------密钥硬编码、有效期写死,作为教学演示没问题,真要上线都得外置到配置文件并区分环境。密钥怎么放、异常怎么分开处理,正是《JWT 入门案例》要展开的部分。
常见误解澄清
几个反复被问到、也反复被写错的点:
| 误解 | 实际情况 |
|---|---|
| JWT 是加密的,别人看不到内容 | 前两段只是 Base64Url 编码,明文可读;第三段是签名。签名保完整性,加密才保机密性 |
| 用了 JWT 就绝对安全了 | token 泄露就能被冒充。必须配 HTTPS,并且有效期不能设太长 |
| JWT 能主动登出 | 不能,服务端没有会话可删。要靠短有效期、黑名单或 token 版本号来补 |
| 用了 JWT 服务端就不用管了 | 验签、过期检查、按需查黑名单,都是服务端必须做的 |
| Payload 越大越好,多放点省得查库 | Payload 每次请求都要传输,膨胀直接增加带宽和解析开销;只放标识类字段 |
| 把密码 / 金额放进 Payload 更省事 | Payload 可被任何人解开,这等于把敏感数据写在明信片上 |
总结
JWT 要解决的问题很朴素:HTTP 无状态,服务端怎么知道请求来自哪个用户。Session 用"服务端存一份、客户端拿 id"解决,在多端访问和集群部署的场景下暴露了存储压力、会话不共享、Cookie 不通用三条硬伤。JWT 换个方向,把身份信息编码成字符串交给客户端,服务端改成"验签即信任",从而做到无状态。
结构上它是 Header.Payload.Signature 三段:Header 声明算法,Payload 承载 claims,Signature 用密钥对前两段签名防篡改。记住一句话就够用------Payload 是可读的,签名是不可改的,这两件事别混。
选型上它不是银弹。主动失效做不了、体积比 sessionId 大、payload 不保密、续期要额外设计,这些都是真实成本。交友项目选它,是因为 App 多端 + 前后端分离 + 集群 + 微服务这四条正好踩在 JWT 的舒适区里。
原理到这就够了,接下来是动手:用 io.jsonwebtoken:jjwt 把生成、解析、验签的工具类写出来,把过期和被篡改两类异常分别触发一次看看效果。