
去年把一个单体电商后台拆成十多个微服务时第一周几乎没写业务代码全在折腾“登录态”。单体时代一个Tomcat里放HttpSession用户登录后随手setAttribute就完事。拆成微服务后第一个问题就来了订单服务在A机器购物车服务在B机器用户明明在网关登录了请求转发到下游时每个服务都在问“你是谁”。这个问题的标准答案就是Spring Cloud体系里常说的OAuth2认证授权方案。这篇内容我结合自己从单体迁移到微服务的实操经历把分布式权限校验的完整思路、Spring Cloud下的落地姿势、以及那些文档里不会写的坑一次性讲清楚。适合正在做Spring Cloud微服务化改造或者被session共享、token校验问题折磨过的后端同学。1. Session在微服务里为什么撑不住从共享会话到无状态令牌的转变1.1 单体时代的Session机制回顾先花一分钟回顾单体应用里的登录态是怎么工作的。用户提交用户名密码服务端校验通过后生成一个随机的sessionId把这个sessionId通过Cookie写到浏览器同时在服务端内存里存一份session数据。后续请求带着Cookie过来服务端根据sessionId找到对应的session对象就知道用户是谁、有什么权限。这套机制在单个应用实例下很好使因为这背后的核心假设是“所有请求都会打到同一台机器上”。内存中的Session是进程内数据天然只能被本进程访问。但微服务架构把这个假设彻底打破了。服务被拆成多个独立进程部署网关层做请求转发时同一个用户的连续请求可能落在不同服务、不同机器上。你当然可以把Session数据抽到Redis里做共享但这只是把问题从应用层搬到了存储层后面会讲为什么这条路也是越走越窄。1.2 微服务下Session共享的四种尝试与失效原因我在项目里确实试过几种Session共享的方案只能说各有各的心酸方案基本思路真实体验Nginx IP Hash粘滞会话让同一IP的请求固定打到同一实例IP变化就失效且负载均衡策略被绑死Spring Session RedisSession数据统一存Redis每次请求一次Redis读取Session里塞序列化对象还容易踩版本坑服务间手动传用户ID网关透传Header简单粗暴但没法校验下游谁都能伪装基于Token的无状态认证服务端签名、客户端持有最终选择了这条路粘滞会话看似简单但移动网络下用户的出口IP可能频繁变化而且它把服务实例和用户绑定死了和微服务“无状态、可水平扩展”的目标是冲突的。Spring Session Redis方案在初期可用但Session机制本身是“有状态”的服务端必须记住每个会话内存泄漏风险、序列化问题、Redis缓存击穿都会找上门。1.3 从“会话”到“令牌”的思路转变后来看了不少关于分布式组件设计理念的资料慢慢想明白了一个核心点分布式的关键是让状态“各回各家”而不是把状态集中到一个地方硬扛。Session方案是“服务端保存状态”而Token方案是“状态由客户端持有服务端只负责验证真伪”。这就好比以前进小区要保安登记你的访客信息服务端存状态现在你手上有了业主门禁卡客户端持有令牌保安只查卡的真伪就能放行。JWTJSON Web Token这种令牌之所以适合微服务是因为它把用户身份、角色权限这些信息直接编码进令牌本身服务端拿到后验签通过就能信任里面的内容不需要查库、不需要查会话。分布式权限校验的本质就是让认证状态从“服务端会话”转移到“客户端令牌”再加上一套可靠的签名校验机制。2. OAuth2四角色与授权码流程认证中心到底在做什么2.1 四个角色的职责划分OAuth2不是一个具体的库而是一套授权框架协议它把整个认证授权过程拆成四个角色资源所有者Resource Owner就是用户本人拥有数据的所有权客户端Client想访问用户数据的应用比如前端门户网站、管理后台、移动端App授权服务器Authorization Server负责认证用户身份、颁发令牌资源服务器Resource Server托管受保护资源的服务在Spring Cloud里就是你的各种微服务这四个角色在Spring Cloud架构里对应关系很清晰网关和前端是客户端独立的认证服务是授权服务器业务微服务是资源服务器。用户是资源所有者。这里有一个常见误区很多人以为OAuth2只是“第三方登录用的协议”。实际上第三方登录只是OAuth2授权码模式的一种应用场景。在微服务内部OAuth2解决的是“用户身份校验和权限发放”的标准化问题。2.2 授权码模式为什么是B端应用的首选OAuth2协议定义了四种授权模式授权码模式、简化模式、密码模式、客户端凭证模式。我在项目里分别踩过之后就悟了授权码模式最安全、最完整的流程适合有后端参与的Web应用。用户登录时先跳转授权服务器登录成功后授权服务器返回一个短期有效的授权码客户端拿授权码再去换真正的访问令牌。授权码走后端通道令牌不会暴露在浏览器地址栏安全性最高。简化模式针对纯前端应用设计的令牌直接通过URL片段返回浏览器安全性差一些现在基本被PKCE流程取代。密码模式用户直接把用户名密码交给客户端客户端拿密码去换令牌。这在自家内部系统里看起来方便但用户名密码会经过客户端如果客户端不可信密码就泄露了。而且这种模式下客户端能拿到用户的原始凭据违背了“最少权限”原则。我一律不建议用。客户端凭证模式没有用户参与适合服务间通信。比如定时任务服务调用用户服务用这种模式签一个代表服务身份的令牌。B端管理系统通常有自己的前端和后端推荐直接用授权码模式。哪怕是你自己公司的内部后台也应该让用户访问认证中心的登录页而不是在业务系统页面里收集密码。2.3 Token类型不透明Token与JWT之争OAuth2本身并没有规定令牌长什么样它可以是任意的随机字符串也就是不透明Token。Spring Cloud实现里不透明Token通常意味着认证服务器把令牌信息存在Redis或数据库里资源服务器拿到Token后要回调认证服务器或查Redis确认令牌是否有效。JWT则是一种结构化的令牌由Header、Payload、Signature三部分组成用户身份和权限信息直接放在Payload里资源服务器拿到后本地验签就行不需要回调认证中心。对比一下两条路对比项不透明TokenJWT验证方式需要查存储/回调认证中心本地验签即可携带信息只是个随机串查库才有信息自包含用户信息撤销能力删存储即失效支持即时撤销天生难以撤销需配合黑名单性能每次校验多一次IO无额外IO令牌长度短较长Header可能很大我的取舍是业务系统的登录令牌用JWT但记住一个原则——JWT可以不自包含太多业务信息但必须能通过签名验证完整性。权限信息适合放角色编码不适合放大对象。用户昵称头像之类频繁变化的信息也别放进去否则改了资料要等令牌过期才能生效曾经在这里吃过亏。3. 认证中心落地实现JWT签发、刷新令牌与登出设计3.1 技术选型Spring Authorization Server早期Spring Cloud项目里搭认证服务很多人用Spring Security OAuth2那个老项目但那套东西已经进入维护状态官方不再推荐新项目使用。现在正确的选择是Spring Authorization Server这是Spring官方推出的现代版OAuth2授权服务器实现。项目结构上认证服务是一个独立的微服务只干两件事认证用户身份、颁发和刷新令牌。它不参与任何业务逻辑数据模型上只需要用户表、客户端表记录有哪些系统接入、令牌记录表可选的刷新令牌存储。依赖引入用Spring Boot 3.x的话核心就这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-oauth2-authorization-server/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency3.2 授权服务器配置要点配置授权服务器最核心的是注册一个SecurityFilterChain的Bean把授权服务器的相关端点和管理规则配好同时需要一个RegisteredClientRepository这个仓库用来管理有哪些客户端应用接入了认证中心。每个接入认证中心的系统都要在这里登记一份客户端信息包括clientId、clientSecret、授权方式authorization_code、回调地址redirect_uri、允许的scope。clientSecret就好比这个应用的“独立密码”不要硬编码在配置里放到配置中心的加密存储里。关键配置项里容易踩坑的有两个。一个是redirect_uri必须和实际回调地址完全一致包括协议、域名、端口、路径Spring Authorization Server校验这个非常严格少一个斜杠都匹配不上。另一个是授权码的有效期默认只有几分钟前端回调逻辑慢一点就过期了建议根据实际情况调到10分钟左右。3.3 JWT结构拆解与Claims设计JWT的三段式结构第一段是Header声明签名算法第二段是Payload放各种声明Claims第三段是Signature用私钥对前两段做签名。认证中心用RSA私钥给JWT签名资源服务器用对应的公钥验签。这里我强烈建议非对称加密因为你不可能让每个微服务都持有私钥。私钥只属于认证中心公钥可以大大方方地给所有资源服务器。Claims放什么内容决定了下游服务能拿到什么信息。我实际项目里放这些{ iss: auth-service, sub: 10001, aud: gateway, exp: 1710000000, iat: 1709996400, jti: 8f4a3b..., uid: 10001, roles: [admin, order_manager], client_id: portal-web }sub用户唯一标识uid用户业务ID方便下游服务直接使用不用再解析subroles角色编码列表下游做权限判断用jti令牌唯一ID登出黑名单要用iat/exp签发时间和过期时间一个教训Payload里千万别放过多没用的信息。JWT是Base64编码的不算加密任何人都能解码看到内容敏感数据放进去等于裸奔。而且要控制令牌体积Gateway网关和每个服务都要在Header里转发这个令牌网络开销是实打实的。3.4 刷新令牌与登出设计访问令牌Access Token我建议有效期设短一些比如30分钟到2小时这样即使令牌泄露损失窗口也小。但用户不可能每30分钟就重新登录一次所以需要刷新令牌Refresh Token上场。刷新令牌是长期凭证有效期可以设置7天到30天存储在认证中心的数据库或Redis里用户访问令牌过期后客户端拿刷新令牌来换新的访问令牌用户无感知。刷新令牌的存储有个细节一定要记录刷新令牌的签发设备或客户端信息发现异常时可以精确吊销。我遇到过一个问题用户在不同设备登录刷新令牌混用导致一个设备登出后另一个设备也跟着失效。后来的做法是一个客户端会话对应一个刷新令牌关联userId和clientId使用时校验归属。登出设计上JWT的问题是签发后服务端没有记录无法主动使其失效。因此我用Redis维护了一个黑名单key是jtivalue是过期时间。网关校验JWT时先查一下这个jti在不在黑名单里。用户登出时把当前令牌的jti写进黑名单过期时间设成和令牌exp一致这样黑名单不会无限膨胀。4. 网关统一鉴权把校验逻辑收敛到一个入口4.1 为什么校验一定放在网关层微服务可能有一二十个如果让每个服务都自己去解析JWT、验证签名、查询黑名单会有一堆重复代码哪天签名公钥要轮换还得挨个服务改配置。就算不考虑维护成本多个服务的校验标准很容易出现偏差同一个令牌在这个服务能过在另一个服务被拒排查起来极其痛苦。网关层做统一鉴权的价值就是“集中管控”网关是流量的总入口所有请求先在这里过一次安检合法请求放行并附带上用户身份信息给下游非法请求直接拦下。下游服务默认信任网关传过来的用户身份即可不需要重复解析JWT。在Spring Cloud Gateway里做JWT校验有两种常见方式一种是自定义GlobalFilter逻辑完全可控另一种是引入spring-boot-starter-oauth2-resource-server用Spring Security的WebFlux安全配置来做。我实际项目中选了自定义GlobalFilter原因有两个一是网关的职责足够单一不需要引入整套Security上下文二是代码量很少出了问题好排查。4.2 白名单与路径匹配设计有些路径是必须公开的比如认证中心的登录页、OAuth2的token端点、健康检查、静态资源。网关里维护一个PathPattern数组匹配到的直接放行不经过JWT校验。private static final String[] WHITE_LIST { /auth/login, /auth/token, /auth/.well-known/**, /actuator/health, /webjars/** };这里有个容易被忽略的坑/auth/.well-known/**这种公共配置路径忘了加白名单会导致认证中心自身的JWK公钥端点被网关拦掉。Spring Authorization Server默认会发布一个/.well-known/oauth-authorization-server端点里面包含了公钥信息如果网关把这个路径拦了所有资源的验签公钥都拉不下来整个链路直接瘫痪。白名单匹配不是简单的字符串匹配路径可能携带参数、可能有多级路径所以我用Spring的PathMatcher或者AntPathMatcher做模式匹配不要用String.startsWith()去判断很容易误伤。4.3 网关JWT校验的核心逻辑网关过滤器的执行流程很简单请求进来 → 判断路径是否在白名单 → 从Header取出Authorization → 解析JWT → 验签 → 查黑名单 → 通过后把用户信息放进请求头 → 转发下游。核心校验逻辑的伪代码Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); if (isWhiteListed(path)) { return chain.filter(exchange); } String authorization request.getHeaders().getFirst(Authorization); if (authorization null || !authorization.startsWith(Bearer )) { return unauthorized(exchange, 缺少访问令牌); } String token authorization.substring(7); try { JwsClaims jws Jwts.parserBuilder() .setSigningKey(publicKey) .build() .parseClaimsJws(token); Claims claims jws.getBody(); if (blacklistService.isBlacklisted(claims.get(jti))) { return unauthorized(exchange, 令牌已注销); } ServerHttpRequest mutatedRequest request.mutate() .header(X-User-Id, claims.get(uid).toString()) .header(X-User-Roles, claims.get(roles).toString()) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (ExpiredJwtException e) { return unauthorized(exchange, 令牌已过期); } catch (JwtException e) { return unauthorized(exchange, 令牌无效); } }用户信息怎么传给下游是设计关键。我选择在请求头里加X-User-Id和X-User-Roles下游服务直接从Header里取不用再解析JWT。这里有个安全前提下游服务不能对外暴露端口流量必须经过网关。如果下游服务可以直接被访问别人伪造一个X-User-Id请求头进来就冒充用户了。4.4 校验失败与令牌过期的处理策略令牌过期是实际项目里最频繁遇到的情况。用户操作到一半令牌过期如果直接返回401前端只能让用户重新登录体验很差。所以要先检查必要的JWT解析逻辑区分“令牌过期”和“令牌无效”两种情况。按照标准OAuth2规范资源服务器校验失败时返回WWW-Authenticate响应头里面带errorinvalid_token。前端收到这个响应后应当拿着刷新令牌去认证中心换新令牌然后重放请求。在网关返回值的设计上实际实现时统一的JSON错误结构体非常关键前端只用判断code字段就行{ code: 40101, message: 访问令牌已过期, data: null }40101表示令牌过期40102表示令牌无效40103表示未登录。不同的code对应前端不同的处理策略这样前后端沟通成本会低很多。5. 下游服务的令牌传播与内部鉴权堵住绕过网关的路5.1 网络边界是安全的第一道屏障网关层做了统一鉴权之后很多人觉得万事大吉实际上还有个大漏洞如果业务服务的端口直接暴露在网络里别人绕过网关直接请求服务照样能访问数据。我之前在排查一个安全扫描问题时发现开发环境的用户服务8848端口裸奔谁都能直接调接口。对策分两层。网络层面用防火墙/安全组规则限制业务服务的端口只对网关的IP开放或者部署在Kubernetes集群里用NetworkPolicy控制。应用层面每个服务都应该配上资源服务器的公钥对直接进来的请求做二次JWT校验发现没有合法令牌就直接拒绝不能默认信任请求头里的X-User-Id。这里必须说明下游服务自己解析JWT是有意义的但它和网关校验的侧重点不同。网关校验是“这个令牌合法且未过期”下游服务是“这个用户是否有权限操作这个资源”。网关解决认证下游解决授权两者各司其职。5.2 服务间调用的令牌传播Feign拦截器的标准写法业务场景通常是这样的用户下单请求到达订单服务订单服务需要调用库存服务扣减库存这时要把用户身份传递下去。Spring Cloud项目里服务间调用大多用OpenFeign可以写一个RequestInterceptor统一把上游请求的Header透传下去。Bean public RequestInterceptor userHeaderInterceptor() { return requestTemplate - { RequestAttributes attributes RequestContextHolder.getRequestAttributes(); if (attributes instanceof ServletRequestAttributes servletAttributes) { HttpServletRequest request servletAttributes.getRequest(); String userId request.getHeader(X-User-Id); String userRoles request.getHeader(X-User-Roles); if (userId ! null) { requestTemplate.header(X-User-Id, userId); requestTemplate.header(X-User-Roles, userRoles); } } }; }使用RequestContextHolder存在一个隐患在异步线程比如Async线程池、Reactor链路里拿不到当前请求对象。如果在订单服务里开了异步线程去调库存服务透传会静默失败下游就以为调用方没登录。解决方案是把用户信息作为参数传递或者用线程上下文ThreadLocal传递在进入异步线程前先把用户信息快照进去。5.3 内部服务间的角色权限校验网关校验通过后只能说明请求是某个合法用户发出的这只能算认证闭环。用户有没有权限调用这个接口还需要做权限匹配也就是方法级别的鉴权。最简单灵活的做法是使用注解在Controller方法上声明需要的角色用一个AOP切面统一拦截从X-User-Roles请求头里解析角色列表做包含关系判断CheckPermission(roles {order_manager}) PostMapping(/orders/cancel) public ResultVoid cancelOrder(RequestBody CancelOrderRequest request) { return orderService.cancel(request); }AOP切面里把逻辑写清楚从RequestContextHolder拿到请求头解析角色判断是否包含目标角色。这个方案的好处是非常透明不用引入额外的权限框架几百行代码够用。项目复杂度上来了再考虑Spring Security方法级安全或更完整的权限框架。一个容易犯的错误角色判断用equals精确匹配。实际上角色是有层级关系的比如admin应该拥有所有权限。我在AOP里专门处理了这个逻辑维护一个角色优先级映射低级别角色不能访问高级别接口admin角色直接放行。6. 三个线上问题的排查链路密钥、时钟与缓存6.1 坑一网关验签报错认证中心一切正常现象认证中心能正常签发令牌Postman里直接调认证中心接口也能通但请求经过网关时一直401日志显示验证签名失败。排查链路第一步先把认证中心签发的JWT复制到jwt.io去解析确认签名算法和claims字段都正常证明签名生成没有问题。第二步检查网关配的公钥和认证中心的私钥是否匹配。这一步我是在两个服务里各打印了公钥的Base64编码做比对发现完全不一致。根因网关配置里的公钥是从配置中心拉取的但配置中心里的值是在环境搭建初期放的测试密钥对后面认证中心正式环境的RSA密钥对重新生成过配置中心的公钥没有同步更新。修复把认证中心新的公钥更新到配置中心网关刷新配置后恢复。之后的改进是加了一个启动自检认证中心和网关启动时都会向配置中心拉取密钥指纹指纹不一致直接报错启动失败问题能提前暴露在发布阶段而不是用户访问时才发现。6.2 坑二多实例部署下令牌时而有效时而无效现象认证中心做了多实例部署后用户登录拿到的令牌在部分请求下有效部分请求下无效表现为间歇性401。而且和用户请求打到了认证中心的哪个实例有明显关联。排查链路先看的日志发现A实例签发的令牌在B实例验签时失败B实例签发的令牌在A实例验签时也失败。这就说明不同实例持有的签名私钥不一样。根因认证中心部署时每个实例的配置文件里各自生成了新的RSA密钥对没有统一从配置中心读取。分布式环境下任何有状态的数据都必须集中管理密钥对这种全局性配置更不能各用各的。修复把RSA私钥统一放在配置中心所有认证中心实例启动时读取同一份私钥同时保留一个私钥版本号方便将来做密钥轮换。这次的排查经历让我意识到分布式架构中对状态的一致性要求比功能本身更重要。你写了再好的鉴权逻辑只要密钥在不同实例之间不一致整个体系就崩了。6.3 坑三令牌明明没过期却频繁提示登录过期现象用户反馈每隔半小时左右就被迫重新登录但访问令牌配置的有效期明明是2小时。排查链路先看前端日志发现前端刷新令牌的请求成功了但新拿到的访问令牌似乎旧令牌还是同一个。再看网关日志发现Redis黑名单里多了很多jti记录。根因登出接口的实现里没有只把当前令牌的jti加入黑名单而是用userId作为key把该用户的所有jti记录都拉出来写进了黑名单。问题出在刷新令牌也会触发“签发新令牌”的钩子这个钩子误把旧令牌当作“已登出”的状态写进了黑名单。结果就是刷新令牌换了新令牌之后旧令牌的jti进了黑名单而某些下游请求如果用的是旧令牌就被误判为注销。修复把黑名单的写入范围缩小为“仅用户主动登出的那个jti”同时刷新令牌换新时不把旧访问令牌自动加入黑名单让旧令牌自然过期。另外黑名单查询的过期时间要和令牌的exp对齐否则黑名单本身可能成为Redis内存增长的隐患。说实话这个坑排查了很久因为现象太有迷惑性了看上去像是令牌过期时间配置错了实际上问题出在业务逻辑对黑名单语义的混乱理解。在分布式权限设计里术语的定义必须精确登出是用户主动行为刷新是自动续期两者的处理路径不能混在一起。6.4 排查思路总结从现象到根因的三板斧三次线上问题排查下来我沉淀了一套流程现在遇到类似的鉴权问题都按这个顺序查先检查密钥一致性——认证中心和所有资源服务器是否持有匹配的密钥对密钥指纹比对是最快的手段。再检查时间体系——服务器之间的系统时间是否一致JWT的iat和exp都和当前时间对比如果某个实例的时钟漂移几十秒令牌就可能被判为未生效或已过期。最后检查缓存状态——Redis里是否存在脏数据黑名单有没有误写入刷新令牌和访问令牌的关联关系是否被错误操作。大部分分布式令牌问题都逃不出这三类。遇到401不要急着改代码先把这三样查一遍经常能省下半天排查时间。我现在的做法是在测试环境就配置好告警认证服务签发的令牌和网关验签的令牌总量做对比偏差超过阈值就报警。线上JWT验签失败率的监控曲线拉出来一旦异常能立刻定位到是网关问题还是认证中心问题。这套监控在项目上线的第一个月就帮我抓住了两次即将酿成故障的苗头。分布式权限校验做到这里基本算闭环了认证中心负责发令牌网关负责验令牌下游服务通过网关透传的用户信息做权限判断服务间调用通过拦截器传播身份。最后的叮嘱是权限安全不是配置一次就完事的东西密钥轮换、黑名单清理、令牌有效期调整这些都需要日常运维持续关注。