ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Cloud微服务安全认证:网关统一JWT校验与令牌设计实践

Spring Cloud微服务安全认证:网关统一JWT校验与令牌设计实践 这几年接手过不少Spring Cloud微服务项目基本每次评审都会被问同一个问题认证怎么做业务拆分已经不稀奇真正让系统变得难搞的往往是这些横切面的事。你拆了十几个服务结果每个服务都要知道“你是谁”“你能干什么”如果还在各自为战迟早把系统做成筛子。这篇文章要聊的就是我在Spring Cloud微服务架构下做安全认证的一些实操经验包括方案取舍、网关统一校验、JWT密钥与过期设计以及我踩过的坑。适合正在做微服务改造或者刚意识到“认证要统一收口”的同学参考。1. 微服务让安全认证从“单点问题”变成系统工程1.1 单体应用时代的安全认证有多简单以前写单体应用认证是最不需要思考的一件事。用户登录后把用户对象扔进Session服务器内存里存一份sessionId浏览器Cookie里带一份下一个请求来了拿Cookie换Session再从Session里取用户信息。所有接口都在同一个进程里谁登录了、有没有权限直接查内存就行根本不存在“服务之间怎么共享登录态”的问题。这种模型的本质是有状态认证结果留在服务器本地请求只在当前进程内处理状态也只在当前进程内可见。单体应用时代这么干没什么毛病因为所有代码都在一个仓库里数据都在一个数据库里安全边界非常清晰。1.2 服务拆分后认证立刻崩了一旦把单体拆成十几个微服务问题马上来了。用户第一次请求登录服务登录服务往自己的Session里写了用户信息第二次请求去了订单服务订单服务一看Session里什么都没有直接返回401。你不可能让用户在访问每个服务前都登录一次那就只能用Session共享方案要么在网关层统一做session复制要么把Session抽出来放到Redis里。Session复制在节点少的时候还能将就节点一多同步带宽爆炸而且K8s环境下Pod随时重建复制的Session说没就没。Redis集中式Session能解决一部分问题但整个系统被强行绑定到一个状态存储上所有请求都要去读一次Redis性能上来了分布式Session的维护成本也跟着上来了。更麻烦的是服务间调用。用户请求经过网关转发到订单服务订单服务要调库存服务库存服务又要调支付服务。如果每个服务都从头验一遍用户身份整个调用链的延迟会很难看如果每个服务都不验那内部攻击、横向移动、越权访问这类问题分分钟就能要了系统的命。1.3 核心思路认证与授权分离统一收口做了几个项目之后我慢慢形成一个习惯先区分“认证”和“授权”再谈方案。认证是确认“你是谁”授权是确认“你能做什么”这两件事必须分开处理。在微服务架构里我的通行做法是认证统一收口在网关层由一个独立的认证中心负责签发身份凭证网关对进来的每个外部请求做统一校验确认通过后才放进业务链路。授权则留给各个业务服务自己判断每个服务根据自己领域的规则决定某个用户能不能操作某个资源。这样做的逻辑并不复杂。网关是整个外部流量的唯一入口在这里一次性完成验签下游服务就不用再重复解析令牌但下游服务之间调用的时候必须区分“用户发起的请求”和“服务主动发起的调用”这两类请求的信任级别完全不同。关于这一点后面实操部分我会详细展开。2. 方案对比Session、JWT与OAuth2怎么选才不返工2.1 Session共享为什么被冷落Session方案本身没错错的是在微服务里硬套单体思维。很多团队一开始都会想单体里用Session没问题那我把Session放到Redis里所有服务共享不就行了吗理论上是可以的。Spring Session Redis能让所有服务读到同一个Session网关校验完身份后把userId透传给业务服务业务服务从Redis里拉用户详情。但这种方案把用户在线状态集中到了一个存储点上Redis一旦抖动整个系统都跟着登录态一起抖。而且Session天然是会话级的用户登录后只要有活跃请求服务端就要不断维护过期时间这对无状态化部署是个不小的负担。2.2 JWT无状态是优点也是风险后来JWT开始流行因为它是自包含的把用户ID、角色、过期时间这些信息签个名打进令牌里服务端不需要存状态拿到JWT就能解析出用户信息验签通过就信任。无状态让JWT特别适合微服务任何一个服务只要拿到公钥就能独立完成身份校验不需要每次请求都反向去认证中心确认一次。但JWT的优点也是它的短板。无状态意味着服务端没法主动让一个令牌提前失效除非引入额外的吊销机制。用户点了“退出登录”如果什么都不做那个JWT在过期之前还能继续使用。另一个问题是JWT载荷会越来越大你把用户所有角色、菜单、租户信息都塞进去每次请求都带着一坨数据在网络上跑带宽和解析时间都在白白损耗。2.3 OAuth2解决的是什么问题OAuth2经常被拿来和JWT对比但它俩根本不是同一维度的东西。OAuth2是授权协议规定的是第三方应用怎么在用户授权后拿到访问令牌JWT是令牌格式解决的是令牌里放什么、怎么验。一个用OAuth2协议做的授权服务器完全可以选择用JWT格式签发令牌也可以选择用不透明令牌。如果项目的用户体系只在内部系统之间用没有第三方应用接入也没有多端需要独立授权盲目上一套完整的OAuth2授权服务器反而是过度设计。真正需要OAuth2的场景是有手机App、有Web端、有第三方开发者、有跨系统SSO你需要一个独立的授权链路把“颁发令牌”这件事和企业内部账号体系解耦。我把这几个方案放在一起对比过实际效果如下方案状态存放扩展性主要痛点适用场景Session 共享存储Redis等集中存储一般存储依赖重无状态化困难传统单体、会话型应用JWT自校验令牌自身携带好吊销麻烦、载荷膨胀前后端分离、微服务内部完整OAuth2授权服务器授权服务器集中管理好实现复杂协议学习成本高多端、多租户、第三方接入折中方案认证中心JWTRedis吊销名单轻量Redis只存黑名单好需要自己封装刷新逻辑大多数内部微服务项目我大多数项目最后都走了折中路线独立的认证中心负责登录和发令牌令牌用JWT格式网关统一验签Redis用来做吊销名单和刷新令牌的存储。这样既拿到JWT无状态验签的性能优势又能让“退出登录”真正生效。3. 落地实操Spring Cloud Gateway统一认证链路搭建3.1 网关的资源服务器配置Spring Cloud Gateway基于WebFlux天然支持Spring Security的Reactive链路。在工程里引入OAuth2资源服务器依赖后我习惯直接配置一个SecurityWebFilterChain把网关变成整个系统的身份闸门。Configuration public class GatewaySecurityConfig { Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http .csrf(ServerHttpSecurity.CsrfSpec::disable) .authorizeExchange(exchanges - exchanges .pathMatchers(/auth/login, /auth/refresh, /actuator/health).permitAll() .anyExchange().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2.jwt(Customizer.withDefaults())); return http.build(); } }这段配置的意思是登录、刷新令牌、健康检查这几个端口直接放行其余所有请求都必须携带有效Bearer令牌。Spring Boot会自动配置JwtDecoder通过jwk-set-uri从认证中心拉取公钥每次请求过来都会验签。再配合配置中心的统一配置spring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://auth-service:8080/.well-known/jwks.json网关本身不需要保存任何密钥只需要知道认证中心的公钥地址。这样一旦认证中心轮换密钥网关侧不需要重启自动从JWKS端点上拉取最新公钥。3.2 身份头透传与防伪造网关验签通过后下一步是把用户身份传递到后面的业务服务。我常用方式是在一个GlobalFilter里把令牌里的核心信息解析出来写进几个约定好的请求头。这里有个非常容易被忽视的细节必须先把请求头里同名的字段删掉再加上服务端解析出来的值。因为请求是客户端发起的客户端完全可以自己伪造一个X-User-Id头。网关如果不先清掉再写入用户伪装身份轻轻松松。Component Order(-100) public class UserContextFilter implements GlobalFilter { private final JwtDecoder jwtDecoder; public UserContextFilter(JwtDecoder jwtDecoder) { this.jwtDecoder jwtDecoder; } Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token resolveToken(exchange.getRequest()); if (token null) { return chain.filter(exchange); } Jwt jwt jwtDecoder.decode(token); String uid jwt.getClaimAsString(uid); String username jwt.getClaimAsString(username); ServerWebExchange mutated exchange.mutate() .request(request - request.headers(headers - { headers.remove(X-User-Id); headers.remove(X-Username); headers.add(X-User-Id, uid); headers.add(X-Username, username); })) .build(); return chain.filter(mutated); } private String resolveToken(ServerHttpRequest request) { String auth request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (auth ! null auth.startsWith(Bearer )) { return auth.substring(7); } return null; } }不要看这段代码短它承载了整条认证链路的身份分发职责。我把头部命名约定为X-User-Id、X-Username并在全部服务之间沿用同一套命名后续排查就特别顺利。记得把外部传入的X-User-Id头清掉只信任从JWT里解析出来的值。3.3 网关后面的服务怎么避免重复认证光有网关还不够。网关能守住外部流量但服务A调用服务B这个场景不会经过网关。如果服务B不校验那网关后面就是一片不设防的内网。所以我在实践里定了两条基本纪律第一条内部服务调用的场景下游服务要能识别调用方是“用户”还是“服务”。如果服务A是在代用户发起请求那它的调用链里必须带上用户令牌或者至少带上网关已经解析好的用户ID头下游才能做真正的数据权限校验。如果服务A只是自己拉取配置、同步数据那它就应该使用独立的服务账号身份而不是冒充某个用户。第二条下游服务不能只信HTTP头至少要保证调用链路都被管控住。最简单的做法是把所有业务服务部署在内部网络外部流量只经过网关。稍微严格一点的团队会用mTLS做服务间双向认证再严格一点的会引入服务网格让ServiceMesh层把身份关系管起来。对于Feign调用我通常在配置里加一个拦截器把当前请求上下文中的用户头透传到下游。大致思路是这样public class UserContextFeignInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { template.header(X-User-Id, UserContextHolder.getUserId()); template.header(X-Username, UserContextHolder.getUsername()); } }UserContextHolder从当前请求的ThreadLocal里读取网关传入的头这个头在入口处已经被清掉重写过所以整个调用链上的身份信息是可信的。当然这只在服务间调用只发生于受控内网的前提下成立如果网络边界本身不干净还是要上更重的方案。4. 令牌设计密钥、过期时间与吊销机制4.1 签名算法与密钥保管令牌签名的算法选择我一般分两种情况。如果认证中心和服务数量都不多或者大家共享密钥方便可以用HS256对称算法。认证中心用同一个密钥签名网关和服务用同一个密钥验签。对称算法的优点是简单缺点是密钥一旦泄露谁来都能伪造令牌。如果系统里有多个独立团队维护的服务或者打算把认证能力开放给非Java技术栈我更倾向RS256非对称算法。认证中心持有私钥负责签发令牌各服务只持有公钥负责验签。私钥永远不会离开认证中心即使某个业务服务被攻破攻击者也拿不到签发令牌的能力。有个细节是不要硬编码密钥。我见过有人直接把密钥写在application.yml里推上Git仓库这在内部项目可能没人管但只要代码库权限稍微放宽密钥就等于公开了。正确做法是放到配置中心并用KMS或加密配置插件做解密环境变量里只放引用标识。如果你的认证中心用OAuth2协议建议把公钥暴露成JWKS格式比如/.well-known/jwks.json并给每个密钥加一个kid。网关验签时根据JWT头里的kid自动选择对应公钥轮换的时候新老密钥并存一段时间就能做到优雅切换。4.2 Access Token和Refresh Token的过期时间怎么定JWT无状态所以过期时间要在签发时就定死。太长了风险高太短了用户频繁重新登录体验差。我的经验值是Access Token 15到30分钟Refresh Token 7到30天具体看业务敏感程度。Access Token短是为了把令牌泄露的窗口期压小。就算某个令牌被抓包15分钟后它就自然失效。Refresh Token长是为了用户在合理时间内不需要重新输入密码。但Refresh Token如果泄露风险同样大所以它必须保存在更安全的位置并且只能由认证中心校验。刷新流程简单总结就是前端发现Access Token过期携带Refresh Token请求认证中心的刷新端点。认证中心校验Refresh Token是否有效。签发新的Access Token同时换发一个新的Refresh Token。旧的Refresh Token立即失效。第4步非常关键这叫Refresh Token轮换。不轮换的话Refresh Token只要泄露一次就能被反复使用安全性和永不过期没有区别。轮换之后如果旧的Refresh Token被重放认证中心应该能发觉异常。4.3 登出、拉黑与实时吊销JWT无状态带来的最大问题是登出。用户明明点了退出登录但服务端拿那个还没过期的JWT一点办法都没有。我的解决办法是维护一张“吊销名单”把需要作废的令牌的jti存进Redis设置过期时间等于令牌剩余有效期。验签链路从单纯的“验签通过即信任”变成两步先解析JWT拿到jti。查Redis吊销名单如果jti存在直接拒绝如果不存在再校验签名和过期时间。这步查询虽然有额外开销但我只存储被吊销的令牌而不是所有令牌Redis里的数据量非常有限。Telegraf可以本地缓存比如用一个Caffeine缓存存最近校验过的jti吊销时反查缓存删掉对应条目能大幅减少对Redis的query压力。登出接口里做的事大致是PostMapping(/logout) public ResultVoid logout(RequestHeader(Authorization) String authHeader) { String token authHeader.replace(Bearer , ); Jwt jwt jwtDecoder.decode(token); stringRedisTemplate.opsForValue().set( blacklist:jti: jwt.getId(), 1, Duration.between(Instant.now(), jwt.getExpiresAt()) ); return Result.success(); }注意Redis TTL一定要设成令牌剩余有效期不要用固定时间。如果令牌还有1小时过期那黑名单里的key也要1小时后自己消失否则Redis里会堆一堆永不过期的垃圾key。5. 扩展接入非Java服务与AI Agent链路5.1 认证中心独立成服务后的部署思路在Spring Cloud Alibaba体系下我习惯把认证中心做成独立服务注册到Nacos网关和业务服务都通过服务名访问它。认证中心只管三件事登录、发放令牌、提供JWKS公钥端点。用户信息、角色权限可以走统一账号服务认证中心通过Feign调账号服务取数据。这样做的好处是认证中心不会跟具体业务纠缠在一起将来接入了新的业务域不需要改认证中心的代码各服务通过统一的公钥验证即可。它天然成了整个微服务体系的信任根也是密钥轮换、审计日志、安全策略集中管理的地方。5.2 Python等非Java应用如何融入微服务体系不一定全是Java。很多团队会有Python写的算法服务、数据处理服务它们也要校验用户身份。只要令牌格式是标准的JWT非Java服务完全可以直接集成。Python里用PyJWT就能做验签from jwt import PyJWKClient jwks_client PyJWKClient(http://auth-service:8080/.well-known/jwks.json) signing_key jwks_client.get_signing_key_from_jwt(token) payload jwt.decode(token, signing_key.key, algorithms[RS256], audienceyour-service)这段代码的意思是Python服务启动时不需要保存任何公钥每当收到JWT先根据JWT头里的kid去认证中心拉取对应公钥然后验签。这样它和Java服务一样密钥轮换时也无需重启服务天然支持多语言团队协作。我在实际项目里还见过一种做法Python服务不直接验签而是把token转发给认证中心做一个远程校验。这种方式会引入同步调用每次请求都要多一跳而且认证中心会成为性能瓶颈。除非有特殊安全合规要求我还是建议非Java服务直接用PyJWT本地验签然后取载荷里的用户信息。5.3 AI Agent多工具调用中的身份保持最近Spring AI在Java社区越来越热不少人已经在Spring Cloud微服务架构里塞进AI Agent。Agent为了完成一个任务通常会连续调用多个工具服务这个链路很容易把用户身份丢掉。比如用户让Agent“查一下我最近的订单并统计金额”Agent会先调订单服务再调支付服务。如果Agent内部调用工具时没有把当前用户上下文带过去后面的服务就不知道这次查询是哪个用户发起的数据权限直接失效。我的实践是给Agent的每个工具调用都显式传递用户上下文。Spring AI的ToolCallback里可以拿到ToolContext在Agent入口处把uid、username、tenantId封装进去每个服务调用插件都从上下文取用户信息而不是另起炉灶。ToolContext toolContext new ToolContext(Map.of( uid, UserContextHolder.getUserId(), tenantId, UserContextHolder.getTenantId() ));一句话总结这类场景的安全重点AI Agent可以聪明但不能绕过权限模型。它替用户做的事仍然要等价于用户自己去做否则一个能自由调工具的Agent就成了权限提权的后门。6. 高频问题与排查实录6.1 排查认证问题的思路清单遇到认证问题我一般按固定顺序排查而不是到处瞎猜先看网关日志里的请求落到了什么状态401还是200。再看客户端发出的Authorization头格式是不是Bearer开头很多问题其实就是前端把token存错了位置。接着把token放到jwt.io这类工具里解析看JWT头里的alg和kid是不是预期值然后确认签名密钥是否匹配。最后查一下Redis吊销名单和过期时间排除“登出后token还在用”的情况。这套顺序看起来简单但能覆盖我遇到过的绝大部分问题。干净的错误日志和标准化的令牌结构能让排查效率提升一大截。6.2 常见问题速查表现象原因解决思路网关返回401但认证中心登录接口正常网关拿到的公钥和签发私钥不匹配检查JWKS端点返回的公钥id确认和JWT头的kid一致刷新后依然提示未授权Refresh Token没做轮换旧token被重用换发新Refresh Token并让旧token失效用户退出登录后token还能访问吊销名单未接入或Redis TTL过长登出时把jti写入黑名单TTL设成token剩余有效期服务A调服务B报权限不足内部调用没有透传用户上下文给Feign加拦截器从当前请求头取用户信息传下去客户端伪造X-User-Id成功服务直接信任外部传入的身份头网关和业务服务必须清理外部身份头只信任解析后的JWT内容密钥轮换后服务验签失败各服务缓存了旧公钥没有走JWKS动态刷新用JWKS端点按kid解析公钥不要缓存永久公钥文件请求偶发401时间不确定签发服务器与验签服务器时钟漂移所有节点统一NTP时钟并在JWT验证时允许小范围时钟偏移6.3 来自实践的几条小建议最后说几个我自己踩坑后沉淀下来的习惯。第一个习惯是清理所有外部身份头。网关层必须把X-User-Id、X-Username这类字段从头到尾视为不可信输入先删除再从JWT解析重写。后端服务也一样禁止直接读取客户端传入的用户头。第二个习惯是日志里始终打印用户ID和traceId。安全认证问题排查最怕连是谁的请求都找不到。我在网关和各个服务里统一加了请求ID并在日志上下文里带上userId这样一条调用链从头到尾都能用traceId串起来。第三个习惯是不要让token在网络上裸奔。不管用JWT还是OAuth2全链路HTTPS都是底线不是加了HTTPS就万事大吉但里面任何一点没加捕获token的成本也会大幅降低。安全认证是个长链路每一环都值得认真对待而不是等到上线被人爬了一遍接口清单之后才着急去补。我做了几个微服务架构项目后最深的体会是安全认证不是一个独立的组件能搞定的它需要网关、认证中心、业务服务、日志监控一起配合。把身份来源统一收口把令牌生命周期管清楚把服务间调用的信任边界划明白这套体系才算真正立得住。
返回列表