授权代码流带有代码交换的证明密钥(PKCE)
#安全 #java #oauth2 #springsecurity

Image description

概述

oauth2根据是否可以持有客户端密钥将客户分为两种类型:公共客户机密客户

机密客户端在服务器上运行,而Spring Boot在上一个OAuth2文章中创建的应用程序是机密客户端类型的示例。首先,它们在服务器上运行,通常在防火墙或网关后面带有其他保护。

公共客户端的代码通常以某种形式接触到最终用户,在浏览器中下载和执行,或直接在用户的设备上运行。例如,A 本机应用程序是直接在最终用户设备(计算机或移动设备)上运行的应用程序。当此类型的应用程序使用OAUTH2协议时,我们不能保证可以安全存储为此应用程序发布的客户端密钥,因为这些应用程序将在运行前将这些应用程序完全下载到设备上,并且对应用程序进行分解将完全显示客户端密钥。

使用单页应用程序存在相同的安全问题(SPA),浏览器本身就是一个不安全的环境,一旦加载JavaScript应用程序,浏览器将下载整个源代码以运行以运行它的整个源代码(包括任何客户端秘密)将被可见。如果您使用100,000个用户构建应用程序,那么一些用户中的某些用户可能会获取恶意软件或病毒并泄漏客户端密钥。

您可能会在想:“如果我将其分为部分,该怎么办?”这无疑会给您带来一段时间,但是一个真正有决心的人可能仍然会弄清楚。

为了规避这种安全风险,最好将证明密钥用于代码交换(PKCE)。

代码交换的证明键

pkce有自己的独立specification。它使应用程序可以使用公共客户端的授权代码流。

Image description

  1. 用户请求客户端的资源。
  2. 客户端创建并记录名为code_verifier的秘密信息,然后客户根据Code_verifier计算Code_challenge。它的值可以是Code_verier或Code_verifier的SHA-256哈希,但是应该优选加密哈希,因为它可以防止身份验证者本身被拦截。
  3. 客户端将Code_challenge和可选Code_challenge_method(代表原始文本或SHA-256 HASH的关键字)以及正常授权请求参数一起发送到授权服务器。
  4. 授权服务器将用户重定向到登录页面。
  5. 用户已经过身份验证,可能会看到同意页面列出授权服务器将授予客户的权限。
  6. 授权服务器记录code_challenge和code_challenge_method(如果有)。授权服务器将将此信息与已发行的授权代码相关联,并使用代码将其重定向到客户端。
  7. 收到授权代码后,客户端携带先前生成的code_verifier执行令牌请求。
  8. 授权服务器根据Code_verifier计算Code_challenge,并检查它是否与最初提交的Code_challenge一致。
  9. 授权服务器将令牌发送给客户端。
  10. 客户使用令牌请求受保护的资源。
  11. 受保护的资源将资源返回到客户端。

ðâ€注意:如果您不想阅读直到最后,您可以在这里查看source code

使用Spring授权服务器构建授权服务器ð

在本节中,我们将使用Spring Authorization Server构建授权服务器并注册客户以支持PKCE。

小牛

<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文件,并指定授权服务器端口为8080:

server:
  port: 8080

之后,我们将创建一个OAuth2ServerConfig配置类,在此类中,我们将创建OAuth2授权服务所需的特定bean:

@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();
}

@Bean
public RegisteredClientRepository registeredClientRepository() {
  RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("relive-client")
    .clientAuthenticationMethods(s -> {
      s.add(ClientAuthenticationMethod.NONE);//Client authentication mode is none
    })
    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
    .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-pkce")
    .scope("message.read")
    .clientSettings(ClientSettings.builder()
                    .requireAuthorizationConsent(true)
                    .requireProofKey(true) //Only PKCE is supported
                    .build())
    .tokenSettings(TokenSettings.builder()
                   .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // Generate JWT token
                   .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
                   .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                   .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                   .reuseRefreshTokens(true)
                   .build())
    .build();

  return new InMemoryRegisteredClientRepository(registeredClient);
}

@Bean
public ProviderSettings providerSettings() {
  return ProviderSettings.builder()
    .issuer("http://127.0.0.1:8080")
    .build();
}

@Bean
public JWKSource<SecurityContext> jwkSource() {
  RSAKey rsaKey = Jwks.generateRsa();
  JWKSet jwkSet = new JWKSet(rsaKey);
  return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}

static class Jwks {

  private Jwks() {
  }

  public static RSAKey generateRsa() {
    KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
    return new RSAKey.Builder(publicKey)
      .privateKey(privateKey)
      .keyID(UUID.randomUUID().toString())
      .build();
  }
}

static class KeyGeneratorUtils {

  private KeyGeneratorUtils() {
  }

  static KeyPair generateRsaKey() {
    KeyPair keyPair;
    try {
      KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
      keyPairGenerator.initialize(2048);
      keyPair = keyPairGenerator.generateKeyPair();
    } catch (Exception ex) {
      throw new IllegalStateException(ex);
    }
    return keyPair;
  }
}

创建RegisteredClient客户端注册类时:

  1. 我们没有定义client_secret

2.客户端身份验证模式被指定为无。

3. requireproofkey()设置为true,此客户仅支持pkce。

我不会在此处解释其余的配置,您可以参考previous article

接下来,我们创建一个弹簧安全配置类,指定表单表单身份验证并设置用户名和密码:

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        authorizeRequests.anyRequest().authenticated()
                )
                .formLogin(withDefaults());
        return http.build();
    }

    @Bean
    UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }

    @Bean
    PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

到目前为止,我们已经配置了一个简单的授权服务器。 ð

OAuth2.0客户端ð

在本节中,我们使用Spring Security创建一个客户端,该客户端通过PKCE授权代码流从授权服务器请求授权,并将获得的access_token发送到资源服务。

小牛

<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-client</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-webflux</artifactId>
  <version>5.3.9</version>
</dependency>
<dependency>
  <groupId>io.projectreactor.netty</groupId>
  <artifactId>reactor-netty</artifactId>
  <version>1.0.9</version>
</dependency>

配置

首先,我们将在application.yml中配置客户端信息,并将服务端口号指定为8070:

server:
  port: 8070
  servlet:
    session:
      cookie:
        name: CLIENT-SESSION

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-pkce:
            provider: client-provider
            client-id: relive-client
            client-secret: relive-client
            authorization-grant-type: authorization_code
            client-authentication-method: none
            redirect-uri: "http://127.0.0.1:8070/login/oauth2/code/{registrationId}"
            scope: message.read
            client-name: messaging-client-pkce
        provider:
          client-provider:
            authorization-uri: http://127.0.0.1:8080/oauth2/authorize
            token-uri: http://127.0.0.1:8080/oauth2/token

接下来,我们创建Spring Security配置类以启用OAuth2客户端。

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorizeRequests ->
                        //It is convenient for us to test, and the client service does not add authentication
                        authorizeRequests.anyRequest().permitAll()
                )
                .oauth2Client(withDefaults());
        return http.build();
    }

    @Bean
    WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
        ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
        return WebClient.builder()
                .filter(oauth2Client)
                .build();
    }

    @Bean
    OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
                                                          OAuth2AuthorizedClientRepository authorizedClientRepository) {

        OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
                .builder()
                .authorizationCode()
                .refreshToken()
                .build();
        DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }
}

在上面的配置类中,我们通过 oauth2client(wresdeDefaults()) oauth2 client 。并创建一个WebClient实例,以对资源服务器执行HTTP请求。 OAuth2AuthorizedClientManager是一个高级控制器类,可协调OAuth2授权代码请求,但是授权代码过程不受IT控制。 AuthorizationCodeOAuth2AuthorizedClientProvider类中没有相关的授权代码过程逻辑,对于Spring Secrity授权代码模式的核心接口过程,我将在以后的文章中介绍它。

回到OAuth2AuthorizedClientManager类,我们可以同时指定 refreshtoken(),它实现了刷新令牌逻辑,并且在此过程中的access_token在此过程中会刷新令牌请求资源服务的前提是Refresh_token尚未过期,否则您将再次通过OAuth2授权代码流。

接下来,我们创建一个控制器类,并使用WebClient请求资源服务:

@RestController
public class PkceClientController {

    @Autowired
    private WebClient webClient;

    @GetMapping(value = "/client/test")
    public List getArticles(@RegisteredOAuth2AuthorizedClient("messaging-client-pkce") OAuth2AuthorizedClient authorizedClient) {
        return this.webClient
                .get()
                .uri("http://127.0.0.1:8090/resource/article")
                .attributes(oauth2AuthorizedClient(authorizedClient))
                .retrieve()
                .bodyToMono(List.class)
                .block();
    }
}

资源服务器ð

在本节中,我们将使用Spring Security构建资源服务器。

小牛

<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配置资源服务器服务端口8070,并指定授权服务器JWK URI,该服务器用于获取公共密钥信息并验证令牌:

server:
  port: 8090

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://127.0.0.1:8080/oauth2/jwks

下一个配置Spring Security配置类,以指定对受保护端点的访问:

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain defaultSecurityFilter(HttpSecurity http) throws Exception {
        http.requestMatchers()
                .antMatchers("/resource/article")
                .and()
                .authorizeHttpRequests((authorize) -> authorize
                        .antMatchers("/resource/article")
                        .hasAuthority("SCOPE_message.read")
                        .mvcMatchers()
                )
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }
}

在上面的配置类中,指定/resource/actits 必须具有消息访问权限,并且资源服务被配置为使用JWT Authentication。

之后,我们将创建控制器类,创建受保护的测试终点:

@RestController
public class ArticleRestController {

    @GetMapping("/resource/article")
    public List<String> article() {
        return Arrays.asList("article1", "article2", "article3");
    }
}

访问资源列表

启动所有服务后,在浏览器中输入http://127.0.0.1:8070/client/test,通过授权服务器身份验证后,您将在页面上看到以下输出信息:

["article1","article2","article3"]

结论

机密客户端PKCE已成为当前版本的Spring Security的默认行为。 PKCE也可以在安全客户端授权代码模式中使用。

本文中使用的源代码可在GitHub上找到。

感谢您的阅读! ð