资讯详情

资讯详情

从Session到JWT:无状态SSO在微服务中的落地实践

公司内部两套业务系统各自为政每个系统都维护一套账号密码用户烦、运维也烦——这个场景在三年前我负责的那条业务线上真实上演过。当时团队里现状很分裂老系统还在用 Session 挂会话新系统已经拆成了前后端分离的微服务结构。要做 SSO单点登录传统以 Redis 存会话的统一认证中心方案虽然成熟但对微服务那一侧并不友好。权衡之后我们改用 JWT 实现了一套“无状态”的 SSO登录状态不落库、业务侧不查会话表靠一个签发的令牌完成跨系统身份确认。这套方案实测下来稳定了不少中途也踩了不少坑。这篇就把整套设计、关键代码、坑点自查完整梳理一遍适合正在做多系统登录打通、或者在 Session 与 Token 之间犹豫不决的开发者参考。1. 无状态 SSO 的整体设计思路1.1 SSO 要解决的本质问题先拆一下 SSO 到底在解决什么。普通人理解单点登录就是“一个系统登录、所有系统免登”但把这个诉求落到工程上其实压缩成了三个问题账号体系是否统一、登录态如何跨系统传递、以及每个系统靠什么判定用户“有效登录”。账号体系通常相对好办统一往认证中心对接即可。真正麻烦的是登录态。传统方案里一套系统登录后会在服务端种一个 Session ID服务端内存或 Redis 里存着这个 Session 对应的用户信息。可一旦系统变多每个业务系统维持自己的 Session 互不相同用户还得挨个登。于是出现了基于 Redis 共享 Session 的集中式方案所有系统共用同一个 Redis用户登录后写一份会话数据别人拿 Session ID 来查就行。这种方案在单体架构下很顺手但对我们当时那种微服务加前后端分离的结构来说有个绕不开的痛点每个服务为了判断用户身份都要去查一次 Redis。查询本身的延迟倒不高但服务多了以后会话存储成了强依赖Redis 一抖动所有接口的鉴权跟着抖。更别提移动端、第三方对接这类非浏览器场景Session 天然是以“浏览器 Cookie 身份标识”为模型设计的用起来别扭。这些问题推着我们往“无状态”方向走。1.2 “无状态”到底无掉了什么很多人第一次听到“无状态”会以为整个系统都不需要存储了。不是的。这里的无状态精确讲是两层第一认证中心不再为每个在线用户维护一份“会话记录”用户登录后得到的是一张可独立验证身份的票据第二业务系统也不需要查任何公共会话存储来判断用户是否登录拿到票据、本地验签、通过就算数。这个转变的本质是“身份信息跟着用户走而不是存在服务器里”。你可以把它类比成实体门票游客进场时检票员验一下票面上的防伪信息就可以直接放行并不需要给每个游客在数据库里建一条“在场记录”。每个景区入口业务系统只要认得门票的签发方就能自行判断这张票是否有效。有状态方案里服务端是身份的“唯一裁判”无状态方案里“裁判”变成了签名算法和密钥。这个变化带来了几个直接收益业务系统彻底去掉会话存储依赖、认证中心不再承担在线用户规模的压力、新系统接入 SSO 时只需要接入验签能力不必共享任何存储。当然代价也明显——后面要提到的令牌吊销难、登出不及时都是无状态的“债”选型前心里要有数。2. JWT 为什么适合做无状态票据2.1 一段 JWT 的解剖JWTJSON Web Token是一串经过编码的 JSON 结构标准形态是三个被点号隔开的字符串。随便截一段eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 .eyJpc3MiOiJhdXRoY2VudGVyIiwic3ViIjoiYTAwMSIsImV4cCI6MTc wMDAwMDAwMCwianRpIjoiYjEifQ .签名部分第一部分是 Header记录令牌类型和签名算法第二部分是 Payload也就是负载存放声明信息第三部分是签名。Payload 里的字段都有约定俗称的缩写含义iss是签发者sub是主体通常是用户 IDexp是过期时间戳nbf是生效时间iat是签发时间jti是令牌唯一 ID。这些字段不是给业务硬凑的规范而是 JWT 这套体系自己判断令牌“是否有效”时的关键依据。需要注意的是JWT 的 Payload 只做了 Base64 编码并没有加密。用肉眼或者任意一个 Base64 解码工具就能直接看到里面的内容所以任何敏感信息都不该往 Payload 里写。签名只保证“内容没被篡改、确实由签发方生成”并不提供保密性这一点项目里新人经常弄混贴个“加密令牌”的标签到处传实属误解。2.2 签名算法选型HS256 还是 RS256JWT 标准支持很多签名算法实际项目里绕不开的就是 HS256 和 RS256 的选择。HS256 是对称签名签发和验签用同一个密钥RS256 是非对称签名签发用私钥验签用公钥。这个差异直接决定你整个 SSO 的信任边界。对比维度HS256对称RS256非对称密钥签发验签同一个密钥签发用私钥验签用公钥密钥分发所有业务系统都要持有同一个 secret业务系统只需要公钥泄露影响一处泄露全部失守公钥泄露不影响私钥签发运维复杂度简单但要严格保管 secret稍微麻烦要管私钥存储适用场景认证中心和业务系统边界清晰、规模可控业务系统多、有第三方接入、信任边界要收紧我当时选择 RS256 不是因为它绝对更安全而是因为我们的业务系统有外部合作方接入不可能把统一 secret 发给别人。用 RS256 之后每家只需要拿到公钥验签私钥牢牢锁在认证中心这边安全边界清晰很多。内部系统全是自己人、接入方也不多的情况下HS256 完全够用也没必要上非对称增加复杂度。2.3 时间窗口exp、nbf 和 leewayJWT 的有效性判断核心就是时间窗口。签发时设置nbf生效时间和exp过期时间业务系统验签时校验“当前时间是否落在这个区间内”。看起来简单实际工程里坑不少。最典型的是服务器时钟偏差。认证中心和业务系统如果部署在不同机器哪怕之间只差个几百毫秒也可能出现“令牌还没生效”“令牌已过期”这类偶发错误。标准解法是引入 leeway即宽容时间。常见的 JWT 库基本都支持验签时传入 leeway 参数我习惯把 leeway 设成 30 秒到 60 秒既能容忍轻微时钟偏移又不至于让令牌实际寿命被明显拉长。另一个工程习惯是认证中心和所有业务系统的服务器都统一走 NTP 时间同步这一步看着基础却能从根上把时间类问题压到最少。exp的设置也是一门权衡时间短安全但体验差时间长了风险大。我们内部默认设 2 小时配合后面要讲的刷新机制整体体验和安全性比较均衡。2.4 从认证中心到业务系统的完整时序把流程画清楚整篇文章的表就没白列。一套标准无状态 SSO 登录流程长这样用户访问业务系统 A未登录。系统 A 把用户重定向到认证中心的登录页并带上redirect_uri参数也就是登录完成后要跳回的地址。用户在认证中心输入账号密码认证中心校验通过。认证中心签发 JWT通过 URL 参数或者重定向方式把令牌带回系统 A。系统 A 拿到令牌用公钥验签、校验过期时间解析出用户 ID完成“免密登录”。用户接着访问业务系统 B同样未登录。系统 B 重定向到认证中心但用户此时在认证中心已经处于“已登录”状态认证中心自己留一个轻量会话或 Cookie。认证中心不再要求用户输入密码直接给系统 B 签发一个 JWT。系统 B 同样验签、解析、放行。注意第 7 步的时候认证中心自己也需要一个轻量会话记录“这个人刚登录过”。很多文章把 JWT SSO 说成“完全不碰 Session”这是不对的——只是认证中心内部的会话不再承担跨系统共享的职责它只负责判断“用户刚才来过没有”。整体上业务系统是彻底无状态的认证中心则保留极有限的本地状态这个分工实际跑起来很舒服。3. 实操从认证中心到业务系统的落地3.1 认证中心签发令牌的接口设计认证中心的核心接口就两个登录页接口和令牌签发接口。令牌签发接口内部逻辑很集中校验用户身份、生成 JWT、返回给前端。用伪代码表达大致是def issue_token(user_id, issauthcenter, expire_seconds7200): now int(time.time()) payload { iss: iss, sub: str(user_id), iat: now, nbf: now, exp: now expire_seconds, jti: uuid.uuid4().hex } token jwt.encode(payload, PRIVATE_KEY, algorithmRS256) return token这段代码有几个容易被忽略的细节。iat和nbf设置成同一个时间点是为了防止有些 JWT 库默认按 0 处理nbf导致业务侧校验时认为令牌还没生效。jti每次签发都要唯一后面做吊销黑名单、审计排查都靠它是不能省的字段。签发用的私钥不放在代码仓库里而是通过环境变量或者独立的密钥管理服务加载这一点要当成硬性纪律执行。3.2 业务侧验签与用户身份还原业务系统拿到令牌后做的事情本质上就是认证中心签发逻辑的“反操作”。核心代码如下def verify_token(token): try: claims jwt.decode( token, PUBLIC_KEY, algorithms[RS256], issuerauthcenter, leeway30 ) return claims except jwt.ExpiredSignatureError: return None except jwt.InvalidTokenError: return None验签时指定issuer是很多人会漏的一步。如果不校验签发者理论上任何一台持有配对的私钥/公钥的服务器签的令牌都能通过这在多环境测试、预发、生产共存的公司里特别容易出事故。另外切记algorithms参数要明确写死不要让库自动选择算法否则存在算法混淆攻击的可能。业务侧验签过后把sub字段解析出来作为用户 ID之后所有业务逻辑都基于这个 ID 来做。至于用户的其他信息比如昵称、角色、权限列表要么在验签后查业务库要么放在解密的单独接口里获取不要把所有信息硬塞进 JWT否则令牌会膨胀到半屏都显示不完每次请求传来传去既费带宽也容易穿透日志。3.3 刷新机制与续期工程实现无状态 SSO 最大的体验难题是“令牌过期了用户还在用系统”。2 小时的令牌说失效就失效总不能让用户卡在页面中间再登一次。业界的标准做法是引入刷新令牌Refresh Token辅助续期。我的做法是双令牌机制登录时认证中心同时签发一个短期的 Access Token 和一个长期的 Refresh Token。Access Token 有效期 2 小时只管访问Refresh Token 有效期 7 天只允许去认证中心的刷新接口换取新的 Access Token。刷新接口收到 Refresh Token 后验签确认有效就给用户发新的 Access Token顺带把 Refresh Token 也一起轮换掉降低被重放的风险。实现上要注意一个细节Refresh Token 需要认证中心“有状态”地管理至少得记一个吊销状态表。这看起来又回到 Session 思路了但这是有意的取舍——把“长期凭证”控制在认证中心一个入口上业务系统始终不碰它整体无状态的好处照样保留。我们线上甚至还额外限制 Refresh Token 的用途只能调用刷新接口不允许访问任何业务资源这样即使泄露攻击者能拿到的也只是“临时续期的能力”拿不到稳定的身份凭证。3.4 登出与吊销的现实做法无状态令牌天生难吊销这是绕不开的短板。登录时签出去一张 2 小时有效的令牌用户点“退出登录”后只要令牌没到exp理论上拿它去访问系统依然有效。对内部系统来说这个窗口可以容忍但对安全要求高的场景必须想办法“主动作废”。现实的做法有两个方向。方向一是维护一个基于jti的黑名单把退登令牌的jti丢进 Redis设上和exp相同的过期时间业务侧验签后多查一次黑名单。这等于给无状态方案加了少量状态但查的字段很小、Redis 压力可控。方向二是缩短令牌寿命主动降低“退登后依然有效”的时间窗口。我们实际是两者结合常规令牌 2 小时寿命不设黑名单高权限操作比如后台管理签 15 分钟短令牌同时进黑名单保障关键操作的安全强度。4. 实战踩坑与排查实录4.1 服务器时间不同步引发的偶发失效上线第一个月运维反馈“有个别用户反馈登录状态突然失效刷新一下又好了”。事后排查发现业务系统某一台服务器的时间比认证中心慢了将近 20 秒令牌签发时间比本地时间还“靠后”验签时系统认为令牌还未生效直接拒绝。这种情况就是nbf字段在起作用。当时我们的验签代码里已经加了 30 秒 leeway按理能兜住但那台机器偏差波动到 40 多秒时又出了漏子。解决方式分两层根因上把所有服务器统一纳入 NTP 时间同步树一个例行巡检项防御上把 leeway 从 30 秒提到 60 秒。同时也建议在做压测、扩缩容这类操作时顺手查一下新机器的系统时间新拉的容器如果基于未同步时间的镜像启动很容易埋雷。4.2 前端存储令牌的安全红线业务系统是前后端分离前端拿到 JWT 之后往哪里放localStorage 看似方便可一旦站点存在任意 XSS 漏洞攻击者一句localStorage.getItem(token)就能把令牌拿走。Cookie 也没有好到哪去如果没打HttpOnly同样能被脚本读取。我们当时的策略是区分场景纯浏览器端应用把 Access Token 放在内存变量里页面刷新前重新静默刷新拿新令牌牺牲一点“刷新后状态保持”的体验换 XSS 泄漏面的收敛需要长期保持登录的端用HttpOnly Secure SameSiteLax的 Cookie 承载刷新令牌让脚本根本读不到。跨域通信的场景下后端还要显式配置CORS白名单不要把Access-Control-Allow-Origin设成通配加Authorization头否则跨域站点可以直接拿着用户的 Cookie 发起请求。4.3 私钥轮换引发大面积验签失败有次我们做例行密钥轮换换了认证中心的私钥但忘了同步更新各业务系统的公钥。结果上线后十分钟内大批用户登录状态集体失效报错日志里全是InvalidTokenError。好在发布系统有回滚按钮第一时间把认证中心切回旧密钥系统恢复但教训是永久的。从那以后我们定了两条规矩第一密钥轮换必须是一个“先加后切”的过程先把新公钥发给各业务系统并加入信任列表确认所有系统都支持新旧公钥并行验签后再认证中心切换签名私钥第二JWT 库支持 kidKey ID头字段把每个版本的密钥挂上独立编号业务侧看到新 kid 就知道该用新公钥验签。这两条组合下来密钥轮换的安全事故再没出现过。4.4 本地调试 JWT 的思路调试 JWT 的小工具值得说说。本地联调时经常要验证某个令牌是不是过期了、某个字段写没写对。我推荐直接用标准库自带的方法打印出 Payload比如 Python 里jwt.decode(token, options{verify_signature: False})可以先把内容原样打出来人工确认。但要提醒这只是调试手段线上千万不能把验签关掉这属于一旦放开就全线失守的低级错误。另一个调试技巧是在开发环境生成一个永不失效的测试令牌专供联调用。具体做法是签一个 10 年有效期的令牌放到团队的共享配置里前后端联调时省得每次都要走一遍完整登录流程。当然这个操作只准出现在 dev 环境测试和预发环境验签必须和线上一致否则环境差异会把问题掩盖到最后一刻才爆发。5. 安全加固与方案边界5.1 不可妥协的加固清单无状态 SSO 少了一层“会话存储安全闸门”的兜底很多安全工作需要前置。下面这几条是我现在做任何 JWT 项目都会规规矩矩写进检查单的传输必须走 HTTPS令牌绝不通过明文 HTTP 传送。签名算法显式指定不依赖 JWT 库的自动选择。验签必须校验iss、aud并设置合理的 leeway。sub用内部用户 ID不直接使用手机号、邮箱这类可枚举信息。Payload 不携带任何敏感个人信息。私钥单独存放、定期轮换轮换走“先加后切”流程。对关键操作结合短令牌、黑名单、二次认证等手段做额外兜底。这七条不是理论堆砌全是项目里或轻或重付出过代价换来的。比如“验签校验 issuer”这步前面提过我们差点因为环境混用出事“sub 不用手机号”这条也是因为早期版本把手机号写进令牌里日志系统一打印令牌等于把用户手机号泄露了个干净。5.2 什么样的场景不适合无状态 SSOJWT 无状态方案虽好但不是万能模板。场景里如果出现下面几种信号建议谨慎第一允许用户主动注销后“立刻”在所有系统失效是硬性需求。前面说过无状态令牌天然有剩余寿命哪怕加了黑名单也只是追认式的补救做不到随时立刻吊销。第二用户量不大、系统集中在同一机房Session Redis 的方案不仅简单排障也直观没必要为了“无状态”而刻意引入 JWT。第三业务系统有大量长连接、离线任务需要频繁刷新身份的令牌的自动续期逻辑会变成不小的复杂度倒不如沿用有状态的会话保持。我们选型时真正的标准是“业务系统的扩容是否会被会话存储卡脖子”。如果答案是不会有状态的成熟方案往往更省心。最后再分享两个实际操作体验一个是日志。JWT 令牌别整串打进日志我早期在某个系统里看日志排查问题时发现 Access Token 被业务日志原样记录吓得立刻把日志脱敏规则加上。令牌和密码一样属于敏感信息日志打印时必须省略或者只留后四位。另一个是团队协作上的做 JWT SSO 不仅是认证中心一个项目的事建议主动给业务系统团队写一份接入文档把验签、解析、签发者校验的代码模板直接发出去。我们当时就是因为模板齐全新系统接入耗时从最初的一周慢慢降到半天。无状态 SSO 这套东西技术细节固然多但真正让它顺畅跑起来的往往是工程规范和执行纪律。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →