Spring Security OAuth2 帶有用于代碼交換的證明密鑰 (PKCE) 的授權碼流

Spring Security OAuth2 帶有用于代碼交換的證明密鑰 (PKCE) 的授權碼流

1_z-yDtw4IMrLeyjsdLfpRgQ.png

概述

OAuth2依據是否能持有客戶端密鑰,將客戶端分為兩種類型:公共客戶端保密客戶端

保密客戶端在服務器上運行,在前面介紹OAuth2文章中Spring Boot創建的應用程序是保密客戶端類型的示例。首先它們在服務器上運行,并且通常位于具有其他保護措施防火墻或網關的后面。

公共客戶端的代碼一般會以某種形式暴露給最終用戶,要么是在瀏覽器中下載執行,要么是直接在用戶的設備上運行。例如原生應用是直接在最終用戶的設備(計算機或者移動設備)上運行的應用。這類應用在使用OAuth2協議時,我們無法保證為此應用頒發的客戶端密鑰能安全的存儲,因為這些應用程序在運行之前會完全下載到設備上,反編譯應用程序將完全顯示客戶端密鑰。

同樣存在此安全問題還有單頁應用(SPA),瀏覽器本身是一個不安全的環境,一旦你加載JavaScript應用程序,瀏覽器將會下載整個源代碼以便運行它,整個源代碼,包括其中的任何 客戶端密鑰,都將可見。如果你構建一個擁有100000名用戶的應用程序,那么很可能這些用戶中的一部分將感染惡意軟件或病毒,并泄漏客戶端密鑰。

你可能會想,“如果我通過將客戶端密鑰拆分為幾個部分進行混淆呢?”這不可否認會為你爭取點時間,但真正有決心的人仍可能會弄清楚。

為了規避這種安全風險,最好使用代碼交換證明密鑰(PKCE)。

Proof Key for Code Exchange

PKCE 有自己獨立的規范。它使應用程序能夠在公共客戶端中使用授權碼流程。

PKCE.drawio.png
  1. 用戶在客戶端請求資源。

  2. 客戶端創建并記錄名為 code_verifier 的秘密信息,然后客戶端根據 code_verifier 計算出 code_challenge,它的值可以是 code_verifier,也可以是 code_verifier 的 SHA-256 散列,但是應該優先考慮使用密碼散列,因為它能防止驗證器本身遭到截獲。

  3. 客戶端將 code_challenge 以及可選的 code_challenge_method(一個關鍵字,表 示原文或者 SHA-256 散列)與常規的授權請求參數一起發送給授權服務器。

  4. 授權服務器將用戶重定向到登錄頁面。

  5. 用戶使進行身份驗證,并且可能會看到一個同意頁面,其中列出了 授權服務器將授予客戶端的權限。

  6. 授權服務器將 code_challenge 和 code_challenge_method(如果有 的話)記錄下來。授權服務器會將這些信息與頒發的授權碼關聯起來,并攜帶code重定向回客戶端。

  7. 客戶端接收到授權碼之后,攜帶之前生成的 code_verifier 執行令牌請求。

  8. 授權服務器根據code_verifier計算出 code_challenge,并檢查是否與最初提交的code_challenge一致。

  9. 授權服務器向客戶端發送令牌。

  10. 客戶端向受保護資源發送令牌。

  11. 受保護資源向客戶端返回資源。

使用Spring Authorization Server搭建授權服務器

本節我們將使用Spring Authorization Server搭建一個授權服務器,并注冊一個客戶端使之支持PKCE。

maven

<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);//客戶端認證模式為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) //僅支持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)
                   .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.客戶端認證模式指定為none;3.requireProofKey()設置為true,此客戶端僅支持PKCE。

其余配置我這里就不一一說明,可以參考之前文章


接下來,我們創建一個Spring Security的配置類,指定Form表單認證和設置用戶名密碼:

@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客戶端

本節中我們使用Spring Security創建一個客戶端,此客戶端通過PKCE授權碼流向授權服務器請求授權,并將獲取的access_token發送到資源服務。

maven

<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 ->
                        //便于測試,將權限開放
                        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(withDefaults())啟用OAuth2客戶端。并創建一個WebClient實例用于向資源服務器執行HTTP請求。OAuth2AuthorizedClientManager這是協調OAuth2授權碼請求的高級控制器類,不過授權碼流程并不是由它控制,可以查看它所管理的Provider實現類AuthorizationCodeOAuth2AuthorizedClientProvider中并沒有涉及相關授權碼流程代碼邏輯,對于Spring Security授權碼模式涉及核心接口流程我會放在之后的文章統一介紹。回到OAuth2AuthorizedClientManager類中,我們可以看到同時還指定了refreshToken(),它實現了刷新token邏輯,將在請求資源服務過程中access_token過期后將刷新token,前提是refresh_token沒有過期,否則你將重新執行OAuth2授權碼流程。


接下來,我們創建一個Controller類,使用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搭建一個資源服務器。

maven

<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,用于獲取公鑰信息驗證token令牌:

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/article必須擁有message.read權限才能訪問,并配置資源服務使用JWT身份驗證。


之后我們將創建Controller類,作為受保護端點:

@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"]

結論

在Spring Security目前版本中保密客戶端的 PKCE 已經成為默認行為。在保密客戶端授權碼模式中同樣可以使用PKCE。

與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容