ARTICLE DETAIL

资讯详情

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

Spring Cloud Gateway + OAuth2 + JWT:微服务统一认证授权方案

Spring Cloud Gateway + OAuth2 + JWT:微服务统一认证授权方案 简介这套资源面向熟悉Spring Cloud与微服务开发的Java工程师聚焦于网关整合OAuth 2.0与JWT实现统一认证授权帮助解决分布式场景下令牌签发、网关鉴权与资源服务保护等关键问题。压缩包共270个文件约340KB以204个xml配置、56个java源码及3个yml文件为主涵盖依赖管理、Spring配置、网关全局过滤器、授权服务器配置、令牌服务实现、自定义用户信息转换器等核心模块并附带SQL初始化脚本与说明文档目录结构清晰、源码与配置分层明确便于直接导入工程参照学习。资源已适配Nacos注册中心配套登录控制器和资源服务测试入口按描述修改Redis与JDBC连接即可运行演示。目前已有7161人学习下载适合需要快速搭建网关认证授权链路的中高级Java开发者可作为实际项目落地的参考模板。 我在微服务项目里负责过十几个子服务的接口接入大部分精力不是花在业务逻辑上而是耗在“这个服务怎么又没带 token”“那个服务为什么自己校验了一套和别的系统完全不同的登录状态”这类烂事上。后面把认证和授权统一收敛到 Spring Cloud Gateway 这一层用 OAuth2.0 负责颁发令牌JWT 负责在服务之间传递身份信息整个系统才真正清爽下来。springcloud-gateway-oauth2 这个项目解决的就是这类问题一次登录所有微服务通行。这篇文章会把方案选型、令牌流转细节、可落地的配置代码、以及我实际踩过的坑全部过一遍适合正在做微服务权限设计、想给老系统加统一鉴权的同学参考。1. 先想清楚为什么要把认证授权放在网关这一层很多人一上来就急着找 OAuth2 配置代码结果在业务服务里塞了一堆过滤器最后还是乱的。微服务架构里的认证授权最忌讳的就是“每个服务各做各的”。1.1 微服务各自认证为什么会失控假设你有订单、用户、库存三个服务。如果每个服务都自己校验 token那么 token 算法升级、密钥更换、用户封禁都要在三套代码里同步修改新加入的服务如果漏配校验规则就直接变成一个裸奔入口。这还不算每个服务对用户信息字段的命名差异A 服务叫userIdB 服务叫uidC 服务甚至把用户信息拼在了一个 JSON 字符串里下游对接的时候全靠猜。所以常规做法是所有请求先经过网关网关完成身份认证和令牌校验业务服务只认网关传过来的用户信息不再关心 token 格式和签名算法。认证逻辑收敛到一条链路上审计、配置、升级都有了明确位置。网关在这里不只是做路由转发它是整个微服务系统的“前台保安”。1.2 OAuth2 和 JWT 是怎么互补的OAuth2.0 和 JWT 不是替代关系是分工关系。OAuth2.0 是授权框架负责定义授权服务器、资源服务器、客户端、资源所有者四个角色解决“谁有权限访问什么”的授权问题JWT 是令牌格式负责把用户标识、权限列表、过期时间打包成一个可验签的字符串解决“你的身份怎么证明”。两者组合后的典型链路是用户向授权服务器申请令牌授权服务器返回 JWT 格式的access_token客户端请求网关时带上这个令牌网关验签通过后把用户身份信息传递给下游服务。为什么不用传统的 Session分布式环境下 Session 要么靠粘性会话要么引入共享存储扩容和故障转移时都要额外处理。JWT 把状态存在令牌本身服务之间无共享存储天然适合水平扩容。代价就是令牌一旦签发很难立刻失效所以必须搭配短时效和刷新机制。2. 令牌设计比写配置更值得花时间想的细节配置类网上遍地都是但令牌怎么设计、过期时间怎么定、密钥怎么管才是真正影响生产环境稳定性的东西。2.1 access_token 和 refresh_token 怎么搭配很多教程只演示拿access_token却忽略了生产环境最需要的refresh_token。JWT 是无状态令牌签发之后只要没过期、签名合法基本无法强制让它失效因此实际项目中一定要给它设置较短的有效期。我常用的方案是access_token15 到 30 分钟refresh_token7 到 30 天。refresh_token的唯一用途是换取新的access_token。客户端拿refresh_token去授权服务器的 token 端点换新令牌不需要重新登录。这个机制也说明refresh_token的敏感性远高于access_token不能存在浏览器本地不能出现在请求日志里否则等于把长期登录凭证交了出去。在网关接入时我会把/oauth/token作为白名单放行因为客户端要先用client_id、client_secret或授权码来换令牌业务服务不需要关心刷新逻辑它只认网关验签后的结果。2.2 JWT 里到底该放什么JWT 不是保险箱它只是防篡改内容本身是 Base64URL 编码的任何人解出来都能看到敏感数据绝对不能放。我一般只放这些字段sub用户 IDclient_id来源客户端scope授权范围和 OAuth2 端点权限强相关authorities权限列表控制功能级权限时使用exp、iat过期时间和签发时间jti令牌唯一 ID做黑名单和审计很有用权限列表不建议放太多。十几个权限没问题如果出现几千个令牌体积会迅速膨胀每次请求都带着一大串字符串网络开销和解析开销都上去了。权限数量大的时候我倾向于只放角色或用户 ID下游服务需要细分权限时再去权限中心查询。2.3 签名算法选 RS256 还是 HS256JWT 签名算法有对称和非对称两条路线。HS256 是对称加密签名和验签用同一个密钥配置简单但任何持有这个密钥的服务都能自己签一个合法 token密钥泄露一次就得全部换。RS256 是非对称加密授权服务器持有私钥做签名网关和业务服务只保存公钥做验签公钥泄露不影响安全。在微服务多节点场景下我更推荐 RS256。核心原因不只是安全分级更是密钥更新成本业务服务数量多了以后用 HS256 每次轮换密钥要同步到所有节点而 RS256 只需要轮换授权服务器的私钥其他服务重新获取公钥即可。当然示例代码我经常用 HS256 演示因为配置最短但生产环境建议直接上 RS256。提示JWT 的exp、nbf判定依赖服务器时间。如果节点之间时间不同步会出现“令牌明明没过期却被拒绝”的问题。生产环境务必统一配置时间同步这个坑跟代码无关但排查起来很让人头疼。3. 实操搭一个最小可运行的网关认证授权示例下面用一个经典组合演示Spring Boot 2.7.x Spring Cloud 2021.0.x Spring Security OAuth2。为什么不用最新版因为 Spring Security 的 OAuth2 模块在 Spring Boot 2.7 之后进入维护模式Spring Boot 3 需要使用 Spring Authorization Server 替代配置类名和依赖坐标都变了。我用 2.7 这套版本组合是因为它是目前网上资料最多、最容易照抄复现的方案。3.1 模块划分和关键依赖我拆成三个模块auth-server授权服务负责发 tokengateway-server网关负责路由和 JWT 校验order-service示例业务服务验证 token 解析后的用户信息能不能正常拿到。auth-server的 Maven 关键依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.security.oauth.boot/groupId artifactIdspring-security-oauth2-autoconfigure/artifactId version2.6.8/version /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-jwt/artifactId version1.1.1.RELEASE/version /dependencygateway-server的关键依赖是spring-cloud-starter-gateway和spring-boot-starter-oauth2-resource-server。注意 Spring Cloud Gateway 基于 WebFlux所以网关里的安全配置和传统 Spring MVC 项目不一样。3.2 授权服务器签发 JWT 的核心配置授权服务器这边最关键的是配置AuthorizationServerConfigurerAdapter注册一个客户端再指定 token 存储方式和签名密钥。下面是最小可运行配置Configuration EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient(web-app) .secret({noop}web-secret) .scopes(read, write) .authorizedGrantTypes(password, refresh_token) .accessTokenValiditySeconds(1800) .refreshTokenValiditySeconds(604800); } Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints.tokenStore(tokenStore()) .accessTokenConverter(accessTokenConverter()); } Bean public JwtTokenStore tokenStore() { return new JwtTokenStore(accessTokenConverter()); } Bean public JwtAccessTokenConverter accessTokenConverter() { JwtAccessTokenConverter converter new JwtAccessTokenConverter(); converter.setSigningKey(demo-secret-key); return converter; } }accessTokenValiditySeconds(1800)是 30 分钟refreshTokenValiditySeconds(604800)是 7 天。示例里用 HS256 的对称密钥做演示所有服务共用同一个demo-secret-key生产环境换成 RSA 密钥对之后授权服务只保留私钥网关和业务服务配置公钥。3.3 网关校验 token 并透传用户身份网关的安全配置用 WebFlux 风格。下面这段配置会启用 JWT 资源服务器自动校验请求头里的Authorization: Bearer tokenConfiguration EnableWebFluxSecurity public class GatewaySecurityConfig { Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http .csrf().disable() .authorizeExchange() .pathMatchers(/oauth/token, /oauth/authorize, /actuator/health).permitAll() .anyExchange().authenticated() .and() .oauth2ResourceServer() .jwt(); return http.build(); } }注意/oauth/token需要放行因为客户端还没拿到 token不能要求它先带 token 才能换 token。真实项目里一般还会放行登录页、验证码接口、静态资源等但不要放得太宽后面我会讲这个坑。网关拿到 token 并验签通过之后Spring Security 会把认证信息放到exchange.getPrincipal()里。我写一个全局过滤器把 JWT 里的用户信息提取出来放到请求头再转发给下游服务Component public class AuthHeaderGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { return exchange.getPrincipal() .flatMap(principal - { if (principal instanceof JwtAuthenticationToken jwtAuthenticationToken) { Jwt jwt jwtAuthenticationToken.getToken(); ServerHttpRequest request exchange.getRequest().mutate() .header(X-User-Id, jwt.getSubject()) .header(X-Authorities, String.join(,, jwt.getClaimAsStringList(authorities))) .build(); ServerWebExchange mutatedExchange exchange.mutate().request(request).build(); return chain.filter(mutatedExchange); } return chain.filter(exchange); }); } Override public int getOrder() { return -100; } }这个过滤器的顺序设置成-100保证优先于普通路由过滤器执行。下游服务只需要读取X-User-Id和X-Authorities这两个 header不需要再解析 JWT。网关的路由配置也很简单以转发到order-service为例spring: cloud: gateway: routes: - id: order-service uri: http://localhost:8082 predicates: - Path/api/order/** filters: - StripPrefix1StripPrefix1会把请求路径里的/api去掉也就是/api/order/list转发到order-service后变成/order/list。这里最容易出问题后面排查部分会讲。3.4 业务服务只信验证后的信息order-service有两种接入方式。一种是继续配置oauth2ResourceServer用公钥验签二次校验 token另一种是只信任网关传过来的 header写一个很薄的前置过滤器。演示项目里我选择配置资源服务器因为更安全一点Configuration EnableWebSecurity public class ResourceServerConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf().disable() .authorizeRequests() .antMatchers(/order/**).hasAuthority(SCOPE_read) .anyRequest().authenticated() .and() .oauth2ResourceServer() .jwt(); return http.build(); } }在网关已经校验过一次 token 的前提下业务服务再做一次验签会有一些性能损耗但能防止某个业务服务被绕过网关直接暴露到公网时没有认证保护。如果业务服务确定只在内网访问可以采用更轻量的方案只校验网关透传过来的X-Gateway-Version头。整个链路调通后用两个 curl 命令就能验证# 1. 获取 token curl -X POST http://localhost:8080/oauth/token \ -d usernameadminpassword123456grant_typepasswordclient_idweb-appclient_secretweb-secret # 2. 带 token 走网关访问订单服务 curl -H Authorization: Bearer access_token http://localhost:8080/api/order/list第一次请求返回的 JSON 里有一个access_token字段把它填到第二次请求的access_token位置能正常返回订单数据就说明链路通了。4. 常见问题与排查技巧这套方案在本地跑通很容易但一到生产环境各种奇怪问题就冒出来了。我整理了一个速查表和几个真实踩坑记录。4.1 问题速查表问题现象可能原因排查和解决思路返回401 invalid_token网关和授权服务器密钥不一致或 token 已经过期先用 jwt.io 在线解析 token检查exp时间再对比签名密钥是否一致返回401 Full authentication required放行规则配置不正确或过滤器顺序有问题检查SecurityWebFilterChain里的permitAll路径确认 Spring Boot 2.7 的自动配置没有覆盖你的规则网关转发后出现502 Bad Gateway路由配置的StripPrefix或转发地址写错看网关日志里实际转发的 host 和 path用 curl 直接请求下游服务验证业务服务一直拿不到用户信息网关没有透传 header或 header 名称和下游服务不一致在业务服务里打日志打印所有请求头确认X-User-Id是否存在页面跨域报错前端直连网关但没有统一配置 CORS在网关层统一加 CORS 过滤器不要在业务服务各配一遍明明没过期却报 token 过期服务器时间不同步JWT 的exp判定受时钟偏差影响统一配置时间同步或者校验时设置一个小的时钟偏移量4.2 我踩过的三个坑第一个坑是网关过滤器里直接抛异常。一开始我在全局过滤器里发现 token 过期就直接throw new RuntimeException结果网关返回的是一个默认错误页前端根本没办法统一处理错误格式。后来改成了ServerHttpResponse的writeWith直接输出 JSON 格式的错误信息前端才能统一解析。网关这一层必须对异常兜底不能依赖下游服务返回错误。第二个坑是白名单放得太宽。早期我把/oauth/**整段放行本意是让授权服务器的端点都走网关转发结果授权服务器内部的 token 撤销端点、用户信息端点全暴露在网关外面了。后来的原则是能精确到具体路径就绝不写通配符每个放行路径都要问一句“这个接口能匿名访问吗为什么”。第三个坑是刷新 token 并发使用。客户端在 access_token 快过期时多个请求同时用同一个 refresh_token 去换新 token刷新端点的 token 校验可能只允许 refresh_token 使用一次产生竞争条件。我现在习惯在网关层做刷新端点的单用户并发控制或者让客户端 SDK 在刷新时加分布式锁避免一个 refresh_token 被并发消费。注意JWT 的无状态特性决定了它没法真正“吊销”。用户封禁、密码修改后已经签发的 token 在过期前依然有效。如果业务上必须立刻失效需要在网关加一层 Redis 黑名单令牌签发时写入白名单或黑名单网关每次校验时先查一下 Redis。这个方案会牺牲一些性能但能换来可控性。5. 落地时我会坚持的几条经验项目做完之后我把认证相关的代码从 6 个业务服务里全部删掉每个服务只保留一个很小的 header 校验逻辑整个系统的权限可控性和可维护性都提升了一个档次。这个过程里有几条经验我认为比具体配置更重要。5.1 授权和资源校验的边界要分清OAuth2 的授权服务器负责发令牌网关和资源服务器负责校验令牌这是两套独立能力。不要把授权服务器的逻辑混在网关里也不要在每个业务服务里各自维护一份客户端配置。正因为边界清晰后面我们把授权服务器从老版本迁移到 Spring Authorization Server 时网关和业务服务的改动才足够小。5.2 权限模型不要全塞进 JWT角色、菜单、按钮级别权限如果全塞进 JWT令牌会越来越大而且权限变化要等令牌过期才能生效。实际项目里我个人更倾向的方案是JWT 里只放用户 ID 和登录态信息权限数据由网关或业务服务在请求时从权限中心拉取并缓存。用户改了角色最多缓存一两分钟就生效不用等令牌过期。5.3 一个小技巧内网请求头做简单的调用来源标识最后分享一个很便宜但很实用的习惯在网关转发内网请求时给请求统一加一个X-Gateway-Version头值固定成一个内部约定的字符串。业务服务侧做一个很薄的前置过滤器只接收带这个头且值匹配的请求。这样即使某个服务不小心被暴露到了公网外部请求因为没有网关签名头也会被直接拒掉。这个成本极低排查问题时却能快速定位“请求到底有没有经过网关”。一开始我们觉得多此一举后来有一次运维误把业务服务端口映射到了公网全靠这个头挡住了裸奔风险。本文还有配套的精品资源点击获取
返回列表