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

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

1_z-yDtw4IMrLeyjsdLfpRgQ.png

概述

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

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

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

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

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

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

Proof Key for Code Exchange

PKCE 有自己獨立的規(guī)范。它使應(yīng)用程序能夠在公共客戶端中使用授權(quán)碼流程。

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

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

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

  4. 授權(quán)服務(wù)器將用戶重定向到登錄頁面。

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

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

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

  8. 授權(quán)服務(wù)器根據(jù)code_verifier計算出 code_challenge,并檢查是否與最初提交的code_challenge一致。

  9. 授權(quán)服務(wù)器向客戶端發(fā)送令牌。

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

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

使用Spring Authorization Server搭建授權(quán)服務(wù)器

本節(jié)我們將使用Spring Authorization Server搭建一個授權(quán)服務(wù)器,并注冊一個客戶端使之支持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>

配置

首先很簡單,我們將創(chuàng)建application.yml文件,并指定授權(quán)服務(wù)器端口為8080:

server:
  port: 8080


之后我們將創(chuàng)建一個OAuth2ServerConfig配置類,并在此類中我們將創(chuàng)建OAuth2授權(quán)服務(wù)所需特定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);//客戶端認(rèn)證模式為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;
  }
}

請注意在創(chuàng)建RegisteredClient注冊客戶端類中,1.我們沒有定義client_secret;2.客戶端認(rèn)證模式指定為none;3.requireProofKey()設(shè)置為true,此客戶端僅支持PKCE。

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


接下來,我們創(chuàng)建一個Spring Security的配置類,指定Form表單認(rèn)證和設(shè)置用戶名密碼:

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

至此我們就已經(jīng)配置好了一個簡單的授權(quán)服務(wù)器。

OAuth2客戶端

本節(jié)中我們使用Spring Security創(chuàng)建一個客戶端,此客戶端通過PKCE授權(quán)碼流向授權(quán)服務(wù)器請求授權(quán),并將獲取的access_token發(fā)送到資源服務(wù)。

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中配置客戶端信息,并指定服務(wù)端口號為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


接下來,我們創(chuàng)建Spring Security配置類,啟用OAuth2客戶端。

@Configuration
public class SecurityConfig {

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


接下來,我們創(chuàng)建一個Controller類,使用WebClient請求資源服務(wù):

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

資源服務(wù)器

本節(jié)中,我們將使用Spring Security搭建一個資源服務(wù)器。

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配置資源服務(wù)器服務(wù)端口8070,并指定授權(quán)服務(wù)器jwk uri,用于獲取公鑰信息驗證token令牌:

server:
  port: 8090

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

接下來配置Spring Security配置類,指定受保護端點訪問權(quán)限:

@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權(quán)限才能訪問,并配置資源服務(wù)使用JWT身份驗證。


之后我們將創(chuàng)建Controller類,作為受保護端點:

@RestController
public class ArticleRestController {

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

訪問資源列表

啟動所有服務(wù)后,在瀏覽器中輸入 http://127.0.0.1:8070/client/test ,通過授權(quán)服務(wù)器認(rèn)證后,您將在頁面中看到以下輸出信息:

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

結(jié)論

在Spring Security目前版本中保密客戶端的 PKCE 已經(jīng)成為默認(rèn)行為。在保密客戶端授權(quán)碼模式中同樣可以使用PKCE。

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容