将Spring Security OAuth2授权服务JWK与Consul 配置中心结合使用

概述

在前文中介绍了OAuth2授权服务简单的实现密钥轮换,与其不同,本文将通过Consul实现我们的目的。
Consul KV Store提供了一个分层的KV存储,能够存储分布式键值,我们将利用Consul KV Store使资源服务器发现授权服务器的公钥,授权服务器将密钥通过HTTP API更新到KV Store。

先决条件
需要安装Consul软件,为此,您可以按照以下步骤操作。

  1. 下载Consul软件(https://developer.hashicorp.com/consul/downloads)
  2. 接下来解压缩下载的软件包
  3. 将可执行文件(如果要在Windows系统中安装)放在要启动Consul的文件夹下
  4. 接下来启动命令提示符(cmd),并进入consul.exe所在路径下
  5. 通过键入consul命令检查Consul是否可用
  6. 最后,我们将通过执行此命令来运行Consul, consul agent -dev

注意:consul agent -dev仅建议在开发模式中使用。

授权服务实现

本节中我们使用Spring Authorization Server搭建OAuth2授权服务,并将此服务注册到Consul。

Maven依赖

        <dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-consul-discovery</artifactId><version>3.1.0</version></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-consul-config</artifactId><version>3.1.0</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId><version>2.6.7</version></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-authorization-server</artifactId><version>0.3.1</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><version>2.6.7</version></dependency>

配置

首先在application.yml中添加Consul配置,如您想了解具体配置参数解释可以参考https://docs.spring.io/spring-cloud-consul/docs/current/reference/html/appendix.html

spring:config:import: optional:consul:127.0.0.1:8500application:name: authorization-servercloud:consul:scheme: httphost: 127.0.0.1port: 8500discovery:instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}health-check-path: /actuator/healthprefer-agent-address: truehostname: ${spring.application.name}catalog-services-watch-timeout: 5health-check-timeout: 15sderegister: trueheartbeat:enabled: truehealth-check-critical-timeout: 10sconfig:enabled: trueformat: YAMLname: appsdata-key: dataprefix: configprofileSeparator: "::"

接下来我们将创建AuthorizationServerConfig配置类,用于配置OAuth2授权服务所需Bean,首先我们向授权服务注册一个OAuth2客户端:

    @Beanpublic RegisteredClientRepository registeredClientRepository() {RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()).clientId("relive-client").clientSecret("{noop}relive-client").clientAuthenticationMethods(s -> {s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);}).authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE).authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN).redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code").scope("message.read").clientSettings(ClientSettings.builder().requireAuthorizationConsent(true)//requireAuthorizationConsent:是否需要授权统同意.requireProofKey(false) //requireProofKey:是否仅支持PKCE.build()).tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) //自包含令牌,使用JWT格式.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256).accessTokenTimeToLive(Duration.ofSeconds(30 * 60)).refreshTokenTimeToLive(Duration.ofSeconds(60 * 60)).reuseRefreshTokens(true) //是否重用refreshToken.build()).build();return new InMemoryRegisteredClientRepository(registeredClient);}

OAuth2客户端主要信息如下,以下信息最终将于客户端服务保持一致。

  • clientId: relive-client
  • clientSecret: relive-client
  • redirectUri: http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code
  • scope: message.read

使用Spring Authorization Server提供的授权服务默认配置,并将未认证的授权请求重定向到登录页面:

    @Bean@Order(Ordered.HIGHEST_PRECEDENCE)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);return http.exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))).build();}

自定义ConsulConfigRotateJWKSource实体类实现JWKSource,并通过ConsulClient操作KV Store更新JWK。

public class ConsulConfigRotateJWKSource<C extends SecurityContext> implements JWKSource<C> {private ObjectMapper objectMapper = new ObjectMapper();private final JWKSource<C> failoverJWKSource;private final ConsulClient consulClient;private final JWKSetCache jwkSetCache;private final JWKGenerator<? extends JWK> jwkGenerator;private KeyIDStrategy keyIDStrategy = this::generateKeyId;private String path = "/config/apps/data";public ConsulConfigRotateJWKSource(ConsulClient consulClient) {this(consulClient, null, null, null);}public ConsulConfigRotateJWKSource(ConsulClient consulClient, long lifespan, long refreshTime, TimeUnit timeUnit) {this(consulClient, new DefaultJWKSetCache(lifespan, refreshTime, timeUnit), null, null);}//...省略@Overridepublic List<JWK> get(JWKSelector jwkSelector, C context) throws KeySourceException {JWKSet jwkSet = this.jwkSetCache.get();if (this.jwkSetCache.requiresRefresh() || jwkSet == null) {try {synchronized (this) {jwkSet = this.jwkSetCache.get();if (this.jwkSetCache.requiresRefresh() || jwkSet == null) {jwkSet = this.updateJWKSet(jwkSet);}}} catch (Exception e) {List<JWK> failoverMatches = this.failover(e, jwkSelector, context);if (failoverMatches != null) {return failoverMatches;}if (jwkSet == null) {throw e;}}}List<JWK> jwks = jwkSelector.select(jwkSet);if (!jwks.isEmpty()) {return jwks;} else {return Collections.emptyList();}}private JWKSet updateJWKSet(JWKSet jwkSet)throws ConsulConfigKeySourceException {JWK jwk;try {jwkGenerator.keyID(this.keyIDStrategy.generateKeyID());jwk = jwkGenerator.generate();} catch (JOSEException e) {throw new ConsulConfigKeySourceException("Couldn't generate JWK:" + e.getMessage(), e);}List<JWK> jwks = new ArrayList<>();jwks.add(jwk);if (jwkSet != null) {List<JWK> keys = jwkSet.getKeys();List<JWK> updateJwks = new ArrayList<>(keys);jwks.addAll(updateJwks);}JWKSet result = new JWKSet(jwks);try {consulClient.setKVValue(path, objectMapper.writeValueAsString(Collections.singletonMap("jwks", result.toString())));} catch (JsonProcessingException e) {throw new ConsulConfigKeySourceException("JWK cannot convert JSON:" + e.getMessage(), e);}jwkSetCache.put(result);return result;}//...省略
}

如果您以看过Spring Security OAuth2实现简单的密钥轮换及配置资源服务器JWK缓存,那么你会对于上述代码不在陌生。

ConsulConfigRotateJWKSource遵循以下步骤:

  • 首先从JWKSetCache缓存中获取JWKSet(JWKSet仅包含未过期JWK),默认实现为DefaultJWKSetCache,在DefaultJWKSetCache包含两个重要属性,lifespan为缓存JWKSet时间,refreshTime为刷新时间。

  • 如果JWKSet不为空或不需要刷新密钥,则通过JWKSelector从指定的 JWKS 中选择与配置的条件匹配的JWK。

  • 否则,执行updateJWKSet(JWKSet jwkSet)生成新的密钥对添加进缓存,并更新到Consul KV Store。

注意:path属性与spring.cloud.consul.config保持一致。

避免客户端发送使用以前颁发的密钥签名的 JWT 造成验证失败潜在问题,在令牌完全过期之前,我们需要在一段时间内保持两个密钥。所以授权服务在签发JWT令牌时,
由于某一段时间存在多个密钥,我们需要指定最新密钥用于生成JWT,以下方式中我们在生成JWT前获取最新密钥的kid

    @Beanpublic OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(JWKSource<SecurityContext> jwkSource) {return (context) -> {if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType()) ||OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {JWKSelector jwkSelector = new JWKSelector(new JWKMatcher.Builder().build());List<JWK> jwks;try {jwks = jwkSource.get(jwkSelector, null);} catch (KeySourceException e) {throw new IllegalStateException("Failed to select the JWK(s) -> " + e.getMessage(), e);}String kid = jwks.stream().map(JWK::getKeyID).max(String::compareTo).orElseThrow(() -> new IllegalArgumentException("kid not found"));context.getHeaders().keyId(kid);}};}

本示例中JWK的kid使用时间戳定义,因此通过获取最大值kid放入Header中,在生成JWT时将使用最大值kid对应的JWK生成JWT。

最后让我们配置Form表单认证方式保护我们的授权服务,并设置用户名和密码:

    @BeanSecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()).formLogin(Customizer.withDefaults());return http.build();}@BeanUserDetailsService userDetailsService() {UserDetails userDetails = User.withUsername("admin").password("{noop}password").roles("ADMIN").build();return new InMemoryUserDetailsManager(userDetails);}@BeanPasswordEncoder passwordEncoder() {return PasswordEncoderFactories.createDelegatingPasswordEncoder();}

资源服务

本节中我们使用Spring Security构建OAuth2资源服务器,
并且我们将从Consul KV Store 中获取公钥以取代JWK Set Uri 配置。

Maven依赖

        <dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-consul-discovery</artifactId><version>3.1.0</version></dependency><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-consul-config</artifactId><version>3.1.0</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><version>2.6.7</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId><version>2.6.7</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId><version>2.6.7</version></dependency>

配置

首先我们还是从application.yml配置开始,添加Consul配置,此处spring.cloud.consul.config配置与授权服务保持一致。

server:port: 8090spring:config:import: optional:consul:127.0.0.1:8500application:name: resource-servercloud:consul:host: 127.0.0.1port: 8500discovery:instance-id: ${spring.application.name}:${spring.cloud.client.ip-address}health-check-path: /actuator/healthprefer-agent-address: truehostname: ${spring.application.name}catalog-services-watch-timeout: 5health-check-timeout: 15sderegister: trueheartbeat:enabled: truehealth-check-critical-timeout: 10sconfig:enabled: trueformat: YAMLprefix: configname: appsdata-key: dataprofileSeparator: "::"

接下来我们将自定义ConsulJWKSet实体类取代默认配置,在ConsulJWKSet中获取Consul KV Store中公钥。


public class ConsulJWKSet<C extends SecurityContext> implements JWKSource<C> {@Value("${jwks:}")private String key;private final JWKSource<C> failoverJWKSource;public ConsulJWKSet() {this(null);}public ConsulJWKSet(JWKSource<C> failoverJWKSource) {this.failoverJWKSource = failoverJWKSource;}@Overridepublic List<JWK> get(JWKSelector jwkSelector, C context) throws KeySourceException {JWKSet jwkSet = null;if (StringUtils.hasText(key)) {try {jwkSet = this.parseJWKSet();} catch (Exception e) {List<JWK> failoverMatches = this.failover(e, jwkSelector, context);if (failoverMatches != null) {return failoverMatches;}throw e;}List<JWK> matches = jwkSelector.select(jwkSet);if (!matches.isEmpty()) {return matches;}}return null;}private JWKSet parseJWKSet() {try {return JWKSet.parse(this.key);} catch (ParseException ex) {throw new IllegalArgumentException(ex);}}//...省略
}

利用@RefreshScope刷新机制实现公钥的动态加载:

    @Bean@RefreshScopepublic JWKSource<SecurityContext> jwkSource() {return new ConsulJWKSet<>();}

使用ConsulJWKSet声明JwtDecoder覆盖自动配置中JwtDecoder

    @BeanJwtDecoder jwtDecoder(final JWKSource<SecurityContext> jwkSource) {ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();jwtProcessor.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource));jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> {});return new NimbusJwtDecoder(jwtProcessor);}

之后我们将使用Spring Security 支持的JWT形式的 OAuth 2.0 保护测试接口,此处定义/resource/article必须拥有message.read权限才能授权访问:

    @BeanSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.authorizeHttpRequests((authorize) -> authorize.antMatchers("/resource/article").hasAuthority("SCOPE_message.read").anyRequest().authenticated()).oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);return http.build();}

最后,我们提供一个测试接口以供客户端调用:


@RestController
public class ArticleController {@GetMapping("/resource/article")public Map<String, Object> getArticle(@AuthenticationPrincipal Jwt jwt) {Map<String, Object> result = new HashMap<>();result.put("principal", jwt.getClaims());result.put("article", Arrays.asList("article1", "article2", "article3"));return result;}
}

测试

首先说明,本示例中OAuth2客户端服务与之前文章中的介绍并没有额外改动,所以在本文中将不单独介绍OAuth2客户端服务搭建,可以通过文末源码获取。

我们将服务全部启动后,浏览器访问http://127.0.0.1:8070/client/article,请求将重定向到授权服务登录页面,在我们键入用户名和密码(admin/password)后,最终响应结果将展现在页面上。

如何验证密钥是否轮换

本示例中密钥轮换时间设置为5分钟。

  1. 首先我们通过浏览器访问客户端服务,完成认证和授权后页面将展示响应结果。
  2. 记录此时Consul KV Store中公钥信息。
  3. 5分钟后,我们打开新页面(建议打开无痕页面,避免使用之前请求中JSESSIONID),重新请求。
  4. 将此时Consul KV Store中公钥信息与之前比较,此时已经新增了一个公钥。
  5. 首次请求页面我们会发现依然可以正常访问。
  6. 密钥有效期本示例中设置为15分钟,待15分钟后,KV Store中已经移除首次存储的公钥。

结论

与往常一样,本文中使用的源代码可在 GitHub 上获得。

将Spring Security OAuth2授权服务JWK与Consul 配置中心结合使用相关推荐

  1. Spring Security Oauth2 授权服务开发

    2019独角兽企业重金招聘Python工程师标准>>> 集成开发环境 ·开发工具:Eclipse/Myeclipse/IntelliJ IDEA 任选其一 ·运行环境:jdk1.7及 ...

  2. Spring Security OAuth2 微服务认证中心自定义授权模式扩展以及常见登录认证场景下的应用实战

    本文源码地址 后端:https://gitee.com/youlaitech/youlai-mall/tree/v2.0.1 前端:https://gitee.com/youlaiorg/mall-a ...

  3. Spring Security Oauth2 授权码模式下 自定义登录、授权页面

    主要说明:基于若依springcloud微服务框架的2.1版本 嫌弃缩进不舒服的,直接访问我的博客站点: http://binarydance.top//aticle_view.html?aticle ...

  4. Spring Security OAuth2 授权码模式 (Authorization Code)

    前言 Spring Security OAuth2 授权码模式 (Authorization Code) 应该是授权登录的一个行业标准 整体流程 首先在平台注册获取CLIENT_ID和CLIENT_S ...

  5. Spring Cloud构建微服务架构:分布式配置中心【Dalston版】

    Spring Cloud Config是Spring Cloud团队创建的一个全新项目,用来为分布式系统中的基础设施和微服务应用提供集中化的外部配置支持,它分为服务端与客户端两个部分.其中服务端也称为 ...

  6. Spring Security OAuth2 授权失败(401)

    Spring Cloud架构中采用Spring Security OAuth2作为权限控制,关于OAuth2详细介绍可以参考 http://www.ruanyifeng.com/blog/2014/0 ...

  7. spring security oauth2 授权服务器负载均衡解决方案

    研究了好几天的授权服务对资源服务是如何实现负载均衡的 真的是丈二和尚摸不着头脑,研究了几天今天终于找到了一篇文章 真的是翻:烂了 奈何自己太菜 上一下资源服务的yml配置(oauth-server是注 ...

  8. Spring Cloud构建微服务架构:分布式配置中心(加密解密)

    最近正好想发一篇关于配置中心加密的细节内容,结果发现基础的加密解密居然漏了,所以在这个入门系列中补充一下.后面再更新一下,使用配置中心的一些经验和教训. 在微服务架构中,我们通常都会采用DevOps的 ...

  9. Spring Security + OAuth2.0

    授权服务器 授权服务器中有4个端点.说明如下: Authorize Endpoint :授权端点,进行授权. Token Endpoint :令牌端点,经过授权拿到对应的Token. lntrospe ...

最新文章

  1. Windows 下端口被占用
  2. Android SDK目录结构
  3. Navicat设置unique报错
  4. 右下角文字如何写_如何提取任意小程序的小程序路径
  5. 设计一个4*4魔方程序,让魔方的各行值的和等于各列值的和,并且等于两对角线的和,例如以下魔方,各行各列及两对角线值的和都是64.
  6. MySQL的几个character_set变量的说明
  7. 面试题目整理--逻辑
  8. 【1024开发者节】:2019科大讯飞声博会会议记录——AI+女性,AI+5G
  9. 河北省农村居民家庭平均每百户家用计算机拥有量,2013-2015年全国居民家庭平均每百户计算机拥有量统计...
  10. 海量数据的常见处理算法
  11. 腾讯云 + picgo图床功能
  12. 仰睇天路,俯促鸣弦。神仪妩媚,举止详妍
  13. 2021强校北师大附中招信息学奥赛方向的科技特长生认定标准
  14. java基本类型的小把戏
  15. 5分钟教你制作独一无二的卡通头像,新手做自媒体,不敢真人露脸
  16. Java-opts变量没生效,使用JAVA_OPTS env变量运行java无效
  17. 51Nod 1677 treecnt
  18. 通过GlobalMapper获取的地形模型是否适用于BIM模型
  19. 在联网状态下,有很多网页或者应用无法联网问题,如360安全卫士, Smartscreen筛选器无法访问, 部分网页无法访问等问题的解决方法
  20. 模拟鼠标移动程序实现——解决域控制器策略强制电脑锁屏问题

热门文章

  1. PHP+Mysql—仓储管理系统网站(前端+后端、整套源码)
  2. maven下载pom,但是不下来jar 和PKIX path building failed
  3. c语言编程中负1什么意思,c语言中1e是什么意思?
  4. C/C++ 1e-6
  5. gPTP(802.1as)研究
  6. Arduino 温度传感器NTC温度AD对应值映射改进
  7. 小鼠Lgr5味觉干细胞分化由来的味觉类组织具有味觉全能性
  8. 爬取豆瓣短评制作词云
  9. Python爬虫入门之豆瓣短评爬取
  10. 佛祖保佑 永无BUG