我们团队上一个项目从单体拆成微服务时,第一周线上事故不是数据库慢查询,而是用户登录掉得稀里哗啦。A 服务认一种 Token 格式,B 服务认另一种,用户在一个服务里改了密码,另一个服务还拿着旧身份继续干活。最头疼的是,这套系统里同时有 Java、Go、Python 三种语言的后端,每个服务对“你是谁”的理解都不一样,安全认证这个本该最核心的底座,反而成了最先崩的地方。
后来我们花了几轮迭代,把整个认证与权限管理重新梳理了一遍,核心思路是:认证走统一入口,授权走策略引擎,会话状态统一托管,各语言服务只需要遵循同一套协议。这篇文章就是那次重构的完整复盘。里面会讲清楚我们为什么这样选型、网关到底该管什么不该管什么、JWT 和集中式会话怎么搭配、多语言客户端接入时那些容易踩的坑,以及密钥轮换、审计、账号生命周期这类日常运营里最容易被忽略的安全死角。如果你所在的团队也正处于微服务改造阶段,或者正在被多语言服务间的登录态问题折磨,这篇文章应该能给你一份可以直接落地的参考。
1. 多语言微服务的认证为什么容易变成一团乱麻
1.1 症状:每个微服务都在自己解析登录态
微服务拆分之后,最容易出现的问题就是“谁都在管认证”。订单服务在代码里写了一个拦截器,用 Base64 解一下用户标识;支付服务自己维护了一个内存里的 Session Map;用户服务稍微正规一点,引入了安全框架,但只对自身请求生效。再加上多语言技术栈,同一个用户请求经过三个服务,会被解析出三种不同的身份表示:有的服务认识用户 ID,有的服务认识用户名,有的服务只认一个自造的随机字符串。
这个状态的本质问题是:认证结果没有被标准化。微服务架构里,服务之间通过 HTTP 或消息队列通信,每个请求到达业务服务之前,都应该已经有一个“经过验证的身份上下文”。但如果每个服务都自己执行一遍解析逻辑,必然导致行为不一致。而且这种不一致往往是在线上才暴露出来的——某个服务升级了依赖库,签名算法多校验了一项,其他服务没有同步升级,跨服务调用就断掉了。
我在实际项目里见过更隐蔽的版本:两个服务用的都是 JWT,但一个要求 Token 里必须包含角色字段,另一个不要求。于是认证中心给 A 服务签发的 Token,B 服务解析时因为缺少字段直接报错。表面上看起来是 JWT 的问题,实际上是服务之间没有约定统一的 Token 契约。
1.2 把用户身份塞进 JWT:为什么越用越别扭
很多团队第一反应是“用 JWT 就好了,无状态、多语言友好”。JWT 确实优势明显:本身是标准格式,任何语言都有解析库,Payload 自包含,不需要回查会话存储。但它有两条容易被忽略的特性,恰好会在微服务场景里埋雷。
第一,Payload 只是 Base64URL 编码,不是加密的。任何人拿到 Token 都能解码看到里面所有字段。有些团队把手机号、邮箱、甚至密码哈希往里面塞,这等于把敏感信息明文暴露在每一跳请求里。第二,JWT 一旦签发,在过期之前无法主动失效。用户改了密码、管理员封禁了账号、用户主动登出,只要 Token 还在有效期内,它依然能用。这在单体应用里也许能忍,在微服务架构里就是安全大坑:登出接口返回了成功,但旧 Token 还能继续调订单接口下单。
但这不意味着要把 JWT 一棍子打死。正确做法是让 JWT 回归它的本质——短期的、可替换的访问凭证。身份持久化的活,交给服务端会话来管;权限判断的活,交给策略引擎去管。JWT 只负责证明“这个请求在五分钟前经历过一次可靠认证”。
1.3 问题本质:认证、授权、会话状态被搅在一起
梳理到最后我们发现,所谓认证混乱,本质上不是某个库选错了,而是把三个不同职责的概念搅成了一锅粥。认证(Authentication)回答的是“你是谁”,授权(Authorization)回答的是“你能干什么”,会话状态(Session State)回答的是“你这次登录还有效吗”。单体时代这三个东西经常被一个登录模块包办了,拆成微服务之后必须拆开。
我们最终定的边界是:认证中心负责验证身份、签发短期访问凭证、管理刷新凭证和会话状态;网关负责统一的凭证校验和身份注入;业务服务原则上不再关心“用户是否登录”,只关心“当前用户是否有权限做这件事”。这个模型在多语言环境里尤为重要——身份校验逻辑只需实现一遍,各语言服务只需要按约定读取可信头信息,或者调用一次统一的鉴权 SDK。后面所有的方案设计,都是围绕着这个边界展开的。
2. 认证方案选型:短期 Token、刷新 Token 与黑名单怎么搭配
2.1 为什么访问 Token 要缩短到 15 分钟
最初我们用的是 24 小时有效的长期 JWT,理由是“减少刷新频率,用户体验好”。结果第一次安全评审就被否了:一个 Token 有效期 24 小时,意味着如果它泄露到日志或者第三方手里,攻击者有一整天时间可以任意使用。而且 24 小时的有效期导致登出形同虚设——用户点登出之后,旧 Token 还能用一天。
后来我们把访问 Token 的有效期压到了 15 分钟。这个数字不是拍脑袋定的,而是权衡了两点:一是泄露后的风险窗口要足够短,15 分钟即使被滥用,造成的损失可控;二是不需要频繁到影响用户体验,正常操作下用户基本感知不到 15 分钟这个周期,因为后台可以用刷新 Token 静默续期。对安全性要求更高的场景,比如管理后台或支付操作,可以把访问 Token 压到 2~5 分钟。
这里有个容易忽略的点:访问 Token 存哪。别往 LocalStorage 里塞,XSS 一打全没了。我们当时的方案是放在内存变量里,每次页面刷新后通过刷新 Token 换新,同时尽量缩短刷新 Token 的持久化时间。安全没有银弹,只能一层层把风险窗口缩小。
2.2 刷新 Token 的生命周期设计与轮换
访问 Token 短命,就必须有刷新 Token 来维持登录态。我们给刷新 Token 设了 7 天的有效期,并且规定:每次刷新必须返回一个新的刷新 Token,旧的立即作废。这个设计叫做刷新令牌轮换,它的意义在于如果刷新 Token 泄露,攻击者每用一次,真正用户下一次刷新就会收到一个已被使用过的 Token,我们就能立刻感知并触发告警。
刷新 Token 的存储也很有讲究。我们没有简单地把刷新 Token 当作一个不透明字符串直接存 Redis,而是存了一个会话记录:会话 ID、用户 ID、设备信息、IP、登录时间、最后一次活跃时间。刷新 Token 本体用哈希算法加工后再落库,即使缓存被拖库,攻击者也拿不到可直接使用的原始凭证。
对应地,刷新接口会校验设备和 IP 指纹。如果发现同一会话的 IP 突然跨地区跳动,我们会让本次刷新返回一个标记,要求用户重新登录。这个功能上线之后,确实拦截过几起账号被盗的尝试——攻击者拿到 Token 却换了网络环境,指纹对不上。
2.3 注册黑名单只存最小信息:一个容易被忽略的存储设计
JWT 无法主动失效,所以我们引入了一个黑名单机制:登出或刷新轮换时,把旧的访问 Token 的唯一标识 JTI(JWT ID)放进缓存,标记为失效。这里有一个性能陷阱:如果直接把整个 Token 字符串当作 Key,那 Redis 里会堆满几百字节的冗余数据,而且 Key 的命名也乱。
我们实际只存 JTI 和两个字段:用户 ID、过期时间。Key 格式统一为deny:{jti},Value 存用户 ID,TTL 设置为该 Token 剩余的有效期。这样 Token 一过期,黑名单记录自动消失,不会无限积压。判断逻辑就是一句话:网关收到请求,先查一下deny:{jti}是否存在,存在直接拒绝。
但这个方案有一个短板:每次请求都要查一次 Redis。于是我们做了一层本地缓存,把最近五分钟内加入黑名单的 JTI 放到网关进程内存里。毕竟正常情况下,被吊销的 Token 只占请求总数很小的比例,本地缓存命中率非常高。实测 QPS 上来之后,鉴权接口的 P99 延迟基本没变化。
2.4 会话版本号:比黑名单更优雅的主动失效方案
黑名单能解决登出问题,但解决不了“用户被封禁后所有会话立即失效”的需求。如果管理员封禁一个账号,难道要遍历这个用户的所有 Token 逐个拉黑?不现实。
我们的做法是给每个用户维护一个会话版本号,存在缓存里,同时把版本号写进签发的访问 Token 中。网关校验的时候除了验签名和过期时间,还会比对 Token 里的版本号和缓存里的当前版本号。账号被封禁或密码被修改时,我们只需要把这个版本号加一,该用户名下所有已签发的 Token 立刻全部失效。这个方案比黑名单更根本,因为不需要逐一记住哪些 Token 需要拒绝,而是从源头上让所有旧 Token 都变成无效签名。
黑名单和版本号可以并存:黑名单负责处理单点登出和令牌轮换,版本号负责处理全量失效。前者精准,后者痛快,各有适用场景。
3. 网关侧校验逻辑拆解:哪些活该网关干,哪些必须留给业务服务
3.1 网关该做的三件事:验签、查过期、注入身份
网关是微服务流量的第一道关口,也是做统一认证最理想的位置。我们的网关处理了三个任务。
第一,验签。用认证中心下发的公钥对 JWT 做签名验证,确认这个 Token 确实是认证中心签发的,没有被篡改过。第二,查有效期和黑名单。过期、黑名单命中的请求直接返回 401,根本不会进到后面的业务服务。第三,把可信的用户身份注入到请求头里,转发给下游服务。比如X-User-Id、X-User-Session-Id,如果 Token 里带了必要的权限上下文,也可以注入X-User-Roles之类的头。
这样做的最大好处是业务服务不需要关心签名算法,也不需要依赖 JWT 库。尤其多语言场景下,Node.js、Go、Python、Java 各自解析 JWT 的行为细节不一致,网关统一处理后,下游所有服务只需要读请求头。用哪种语言实现业务,都不会受到签名校验差异的干扰。
3.2 网关不该做的:业务级权限判断
网关做了统一鉴权之后,很容易被业务团队当做一个委以重任的“权限中心”:把接口权限配置都丢给网关管,网关按照 URL 匹配规则判断用户能不能访问某个 API。
初期这样没什么大问题,但业务复杂度上来之后就会失控。真实的权限判断经常依赖资源上下文的:用户能不能删除某个订单,不仅要看角色,还要看这个订单是不是他本人的、订单状态是不是允许删除、有没有超过售后时限。这些信息只有业务服务才知道,网关强行通过 URL 规则去判断,要么规则膨胀到无法维护,要么判断过程需要回查一堆业务数据,把网关的性能拖垮。
我们定的规矩是:网关只做身份认证和粗粒度访问控制,业务服务做细粒度资源授权。什么叫粗粒度?比如“这个接口需要登录才能访问”、“这个接口只有管理员角色能进”。这可以在网关用简单的角色集合匹配实现,成本很低。什么叫细粒度?比如“只能操作自己的数据”、“同一个订单只能被所属店铺的员工修改”,这一类必须由业务服务根据自己的领域逻辑去判断。
3.3 头信息伪造漏洞:为什么必须清掉 X-User-Id
把身份放在请求头里转发,有一个经典安全漏洞:客户端伪造头。某些不严谨的实现里,网关看到请求里已经带了X-User-Id,就直接信任并转发给下游。攻击者只需要在请求里手动加一个X-User-Id: 10001,就能伪装成其他用户。
我们的处理是在网关做身份注入之前,先无条件删除请求里的所有保留头字段,再从 Token 里解析出可信身份重新写一遍。这个逻辑看起来很简单,但架不住确实有人漏掉。我记得有一次排查线上越权问题,最后定位到原因就是某个内部服务之间调用时,上游代码手动塞了一个X-User-Id头,下游服务误以为是网关注入的可信身份,直接放行了。从那以后,我们在网关和所有内部 SDK 里加了一条铁律:这些头只能由网关注入,任何服务代码不得自行设置。
3.4 网关挂了,登录还能用吗
网关成为单点之后,我们立刻要面对一个问题:如果网关宕机,所有需要认证的接口都进不来,整个系统直接瘫痪。这个问题没有完美的解法,只有取舍。我们当时做了两层设计。
第一层,网关集群至少部署三个节点,前面再加负载均衡,避免单台故障导致不可用。第二层,如果整个网关集群都出了问题,认证中心本身还有一个直连通道,只对内部核心服务开放。极端情况下,核心服务可以通过这个通道直接调用认证中心校验 Token,跳过网关这一跳。这个降级方案平时不启用,只作为故障演练的保留科目,每季度会真刀真枪地演练一次。认证这种基础设施,平时不觉得重要,真挂了就是全站事故,所以冗余和降级预案必须在设计阶段就做进去。
4. 权限模型从角色到策略的演进:RBAC 不够用了怎么办
4.1 经典 RBAC 在微服务里的三个局限
权限管理的传统方案是 RBAC(基于角色的访问控制):用户分配角色,角色绑定权限。这个模型的优势是直观、好理解,但微服务化之后它的三个局限会越来越明显。
首先,角色的粒度太粗。一个“运营”角色在用户管理模块可能有查看权限,但在订单导出模块可能是完全禁止,用 RBAC 就得为每个模块拆一堆细碎角色,角色数量爆炸。其次,数据范围很难描述。“运营可以查看订单”这句话没说明是查看全部订单还是只看自己负责的区域的订单,RBAC 里想要表达这种数据级权限,就得靠业务代码里到处写 if,分散且容易漏。最后,多服务之间共享权限数据很困难。角色和权限的映射关系放在哪个服务里维护?如果每个服务各维护一套,用户名下的权限在服务 A 是role=admin,在服务 B 是permission=order:delete,根本对不上。
4.2 策略与数据过滤的职能切分
我们在实践里逐渐调整出一个分层思路:网关做动作级策略判断,业务服务做数据级范围过滤。
动作级策略长什么样?比如“创建订单”、“删除评论”、“查看报表”,这些可以抽象成资源加动作的二元组:order:create、comment:delete、report:view。用户能执行哪些动作,由统一的策略服务决定。策略服务返回的是一组动作清单,网关或业务服务直接检查即可。
数据级范围过滤则留在业务服务里。比如“查看订单”这个动作已经被放行,但用户能看哪些订单?这需要业务服务根据用户 ID、店铺归属、地区等条件去查询过滤。我们把这类逻辑收敛到一个统一的查询注解或工具方法里,避免每个接口自己写一坨WHERE user_id = ?。
这个切分的好处是:动作级权限可以集中管理、集中变更,实时生效;数据级权限贴合业务,灵活但不过度扩散。两者叠加,就覆盖了大部分微服务场景的权限需求。
4.3 集中式策略服务与本地缓存的配合
策略服务本身也是一个微服务,对外提供“查询某用户的动作权限清单”和“校验某用户是否有某动作权限”两个接口。但它不能成为每个请求的必经瓶颈,否则策略服务一抖动,所有业务接口都跟着遭殃。我们的做法是:策略服务启动时把权限配置发布到网关和主要业务服务的本地缓存,更新时通过消息通道推送一个版本号,各服务收到通知后刷新本地缓存。
本地缓存的好处显而易见:鉴权零网络开销。坏处是策略变更不是百分之百实时生效,可能有一两秒的延迟。对于权限变更这种低频操作,短暂延迟完全可以接受。我们甚至专门把同步策略配置的接口做成了“最终一致”的标准——不追求强一致,追求业务可用。
如果本地缓存已经刷新,但消息推送丢了怎么办?所以我们给每个服务的缓存设置了 5 分钟的兜底过期时间,确保即使推送链路出问题,缓存也会在短时间内自动回收重建。权限数据一致性的要求,完全没有必要强到数据库事务那种级别。
4.4 服务间调用身份校验:不能只靠内网隔离
微服务内部通信常常默认“内网就是安全的”,这个假设在复杂环境里经不起推敲。一旦某个服务被攻破,攻击者可能直接以内网身份调用其他服务。我们在实践里给服务间调用加了两层保护。
第一层,来源身份令牌。每个服务启动时向认证中心申请一个服务账号,拿到一张用于服务间调用的凭证。调用别人时,在请求头里带上这个凭证,接收方校验签名和来源服务 ID,确认“调用方的确是它声称的那个服务”。第二层,最小调用权限。服务 A 调用服务 B 时,只能调用 B 暴露给它的那一组接口,而不是 B 的所有接口。我们用网关给服务间调用也做一层路由过滤,防止服务被当跳板。
这一块一开始被人说是“过度设计”,直到有次攻防演练中,一个测试账号绕过了前端直接调内部接口,结果被服务间鉴权拦住了,大家才意识到这层保护的价值。内网信任这种观念,在微服务架构里一定要尽早放弃。
5. 多语言客户端接入实践:各自实现还是统一封装
5.1 先定 Token 契约和 JWKS 公钥分发
多语言项目的最大敌人是“每个人理解的 JWT 都不一样”。Java 同学认为sub字段存用户 ID,Go 同学觉得user_id更直接,Python 同学可能两个都不看,直接用旧的 Session ID。最后我们先把一个契约文档定死,所有服务必须遵守。
这个契约包括几个核心约定:JWT 必须用 RS256 或 ES256 这类非对称算法,不用 HS256,这样各服务只需持有公钥,私钥永远只在认证中心手里;Payload 里的字段统一用sub存用户 ID,jti存 Token 唯一标识,ver存会话版本号,scope存动作权限;过期时间统一按 UTC 时间计算,杜绝时区差异导致的过期判断错乱。
公钥分发我们使用了 JWKS(JSON Web Key Set)标准格式,认证中心提供一个公钥端点,各服务定期拉取,根据 Token 头里的kid字段选择对应公钥验签。这套机制的好处是密钥轮换时,新旧公钥可以同时挂在 JWKS 里,旧 Token 在有效期内依然可以验签,新 Token 用新公钥验签,平滑过渡。
5.2 多语言实现的常见偏差:Base64URL、时区和大小写
即使约定好了契约,多语言实现的偏差依然防不胜防。我们踩过的坑包括但不限于这些。
第一个坑是 Base64URL 填充字符。JWT 用到的是 URL 安全的 Base64 编码,标准库在编码时会把=填充符去掉,但有些语言的库在解码时对缺失的填充符很敏感,导致同一个 Token 在 Go 里解析正常,到 Python 里就报错。解决办法是要求所有服务统一使用支持自动补全的解析库,而不是各自手写解码逻辑。
第二个坑是时间精度。有的库对exp字段的判断精确到秒,有的会加一个默认的 30 秒容差。如果认证中心签发的 Token 正好在临界点,就会出现一个服务认为 Token 还活着,另一个服务认为已经过期。我们的统一约定是解析库必须显式设置leeway=30s,并且把时钟校准服务部署下去,避免各机器的系统时间差导致误伤。
第三个坑是 Bearer 前缀的大小写和空格处理。别小看这个细节,我们确实遇到过某个服务用字符串分割Authorization头时,只处理了Bearer一种写法,前端实际发的是bearer,直接返回 401。这些问题在设计阶段很难发现,基本都要等到联调才炸出来,所以多语言服务的认证联调测试一定要覆盖这些边界。
5.3 SDK 薄封装:把容易做错的事统一收口
契约定了,公钥分发方式定了,但每个服务还是各自写校验逻辑的话,迟早出偏差。我们采取的做法是提供一套薄封装 SDK,每种主流语言一套,内部逻辑一模一样。
这里的关键词是“薄”。SDK 只做四件事:拉取 JWKS、验签、解析 Token 得到身份、提供权限校验函数。它不介入业务逻辑,不写任何业务字段进去,也不做复杂的权限规则引擎。这样 SDK 的维护成本很低,但把最容易做错的部分统一收口了。
SDK 的接口设计也尽量同构。比如 Java 版本是AuthContext.requireUserId(),Go 版本是auth.RequireUserID(),Python 版本是auth.require_user_id()。底层实现不同,但调用方式几乎一致。这样即使团队里有人从 Go 服务调到 Java 服务,认知负担也小很多。更重要的是,后续安全团队做审计时,不需要读多语言的业务代码,只看 SDK 的公共逻辑即可。
5.4 离线校验与在线校验:什么时候该回源
网关和服务使用 SDK 时,通常会选择离线校验模式:只靠公钥验签和本地缓存的黑名单、版本号做判断,不调用远程服务。这种方式性能最好,但存在一个盲区:如果认证中心在签发 Token 之后立即吊销了某个会话,离线校验的服务可能无法第一时间感知,最多等缓存过期。
我们采用了一个折中方案:默认离线校验,但对高风险操作强制在线校验。比如修改密码、解绑手机号、大额支付这类操作,客户端在调用 SDK 时会带一个require_online_auth标记,SDK 发现这个标记后,会去认证中心做一次实时会话检查。中低风险接口完全走离线,既保证了绝大多数请求的低延迟,又让关键操作不会被吊销延迟影响。这个设计本质上是在性能和安全之间做了分级处理,而不是非此即彼。
6. 安全加固与运营:密钥轮换、审计追踪与账号生命周期
6.1 私钥的生成、托管与轮换流程
认证中心的私钥是整个系统的定海神针,一旦泄露,攻击者可以给任何用户签发合法 Token。我们在实践里把私钥管理纳入了一个很严格的流程。私钥由专门的密钥托管服务生成,私钥本身不落盘到认证中心普通文件目录,而是由进程启动时从托管服务加载到内存,内存中用完即释放相关引用。
密钥轮换是日常运营里最容易忽视的环节。我们的轮换周期是 90 天一次强制轮换,遵循一套固定流程:先生成新密钥对,把新公钥追加到 JWKS 端点,用一个新kid标识,保持旧公钥继续可用 24 小时;认证中心开始用新私钥签发 Token;等待 24 小时后,确认线上已经没有旧kid的 Token 在途,再移除旧公钥。整个过程客户端无感知,不会出现 Token 突然全部验证失败的情况。
这里要特别提醒:轮换一定要有灰度期,千万别改了私钥就立刻删旧公钥。我见过一个团队做了密钥轮换之后,旧 Token 在新公钥下验签失败,导致所有存量用户全部被踢下线,在线用户量直接断崖。这个锅不该由密钥轮换背,完全是流程缺失导致的。
6.2 审计日志:不只是记流水账
安全审计和合规要求让我们必须把认证相关操作全部记录在案。但审计日志不是越大越全越好,全量记录所有请求只会让存储爆炸,关键时刻反而找不到重点。我们的审计范围收敛到了四类事件:登录成功与失败、刷新 Token 成功与失败、登出、权限变更(角色/策略调整)、高危操作调用。
每条审计日志固定字段包括:事件类型、用户 ID、会话 ID、客户端 IP、User-Agent、目标资源、动作、结果(成功/失败)、失败原因、时间戳。其中失败原因特别重要,不能只记一个failed,要区分是密码错误、Token 过期、签名无效、黑名单命中还是权限不足。有了这些字段,我们才能做下面的异常分析。
审计日志的另一个要求是不要只写进业务数据库的普通表。我们采用了一种典型的日志管道方案:认证中心把审计事件以异步消息方式发到日志采集系统,最终落到独立的日志存储中,跟业务数据物理隔离。这样即使业务库被拖走,审计证据还在,且日志系统本身有权限管控,只有安全运维角色能查。
6.3 账号生命周期同步与缓存清理
账号生命周期管理是安全体系里最容易被业务团队忽略的一块,但它往往决定安全底线的下限。员工离职了,账号立刻应该被禁用;用户注销了,所有 Token、刷新会话、缓存中的角色和策略信息都必须同步清理。
这里有一个隐蔽的问题:用户角色和权限信息被缓存到了各服务的本地缓存里,账号即使被禁用,本地缓存可能还能撑几分钟,期间业务服务依然认为该用户是正常状态。我们用一个“全量失效”事件来解决:账号状态变更时,事件不仅更新用户库,还把消息推到所有服务的缓存刷新通道。各服务收到消息后,删掉该用户在本地的权限缓存,同时认证中心把该用户的会话版本号加一,名下所有 Token 立即失效。
清缓存和版本号加一这两件事必须同时执行,否则会出现“Token 已经被吊销,但某个服务本地还是能查到角色缓存”的中间态。我们把这两个操作放在同一个消息处理流程里,保证要么都执行,要么都回滚重试。虽然这种最终一致性可能有一两秒的窗口,但对于账号生命周期管理已经完全够用。
7. 一条完整的登出与吊销链路:从用户点击到 Token 失效
登出是一个看起来很简单、但链路里全是细节的功能。我在实践里把整条链路拆成了六个步骤,每步都要明确执行什么、出错了怎么兜底。
用户点击登出之后,前端调用认证中心的登出接口。认证中心收到请求,先校验访问 Token 是否仍然有效,拿到用户 ID 和会话 ID。然后执行三步:会话版本号加一,该会话下所有访问 Token 全部失效;把当前访问 Token 的 JTI 放进黑名单,TTL 设为剩余有效期;删除刷新 Token 对应的会话记录,让后续刷新请求拿到 401。
这几个操作都完成后,如果需要通知其他服务(比如让网关同步清一次该用户的本地相关缓存),会发一个异步事件,但不等事件完成就直接返回前端登出成功。前端随后清掉本地内存里的访问 Token 和刷新 Token,跳转登录页。再往后,即使用户的旧 Token 被截获拿去调用接口,网关验签时要么发现黑名单命中、要么发现版本号不匹配,直接拒绝。整套链路,从用户点击到全服务生效,核心耗时取决于缓存写入延迟,基本可以做到秒级。
我们针对性做过一个故障演练:登出后 10 秒,用旧 Token 在另一个网络环境发起请求,网关返回 401;再用刷新 Token 去刷新,也返回 401。这个结果是我们想要的。最怕的情况是刷新 Token 已经轮换过,但旧的刷新 Token 还能用,所以我们强制校验每次刷新必须带原始会话记录,轮换过的旧刷新 Token 即使语法正确也无法命中会话。
监控指标上,我们盯三个数:认证接口成功率、网关 401 分布、鉴权链路 P99 延迟。401 分布特别有诊断价值——如果某服务突然出现大量“签名不匹配”,大概率是代码里混入了旧公钥;如果出现“会话版本号不匹配”,可能是账号被批量重置;如果是全国范围的“Token 过期”,说明客户端时钟整体有问题,或者访问 Token 有效期设置不合理。各种 401 原因的比例变化,往往比抽象的系统健康度指标更能快速暴露问题。
最后分享一个我特别强调的原则:认证和授权一定要分开,登录态和权限判断是两回事。很多微服务团队把这两个概念搅在一起,导致 Token 里塞满了自定义权限字段、网关里写满了 URL 规则,最后变成一锅粥。先把边界划清楚,再去选型、定协议、部署网关和策略服务,这套体系才能真正稳得住。至于技术选型,无论是 JWT 还是集中式 Session,无论是 RBAC 还是策略引擎,核心都不在那套工具本身,而在于你有没有把身份、会话、权限这三个层次想清楚。链路长、服务多、语言杂的时候,最值钱的反而是那些看起来枯燥的约定和流程。