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

概述
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)碼流程。

用戶在客戶端請求資源。
客戶端創(chuàng)建并記錄名為 code_verifier 的秘密信息,然后客戶端根據(jù) code_verifier 計算出 code_challenge,它的值可以是 code_verifier,也可以是 code_verifier 的 SHA-256 散列,但是應(yīng)該優(yōu)先考慮使用密碼散列,因為它能防止驗證器本身遭到截獲。
客戶端將 code_challenge 以及可選的 code_challenge_method(一個關(guān)鍵字,表 示原文或者 SHA-256 散列)與常規(guī)的授權(quán)請求參數(shù)一起發(fā)送給授權(quán)服務(wù)器。
授權(quán)服務(wù)器將用戶重定向到登錄頁面。
用戶使進行身份驗證,并且可能會看到一個同意頁面,其中列出了 授權(quán)服務(wù)器將授予客戶端的權(quán)限。
授權(quán)服務(wù)器將 code_challenge 和 code_challenge_method(如果有 的話)記錄下來。授權(quán)服務(wù)器會將這些信息與頒發(fā)的授權(quán)碼關(guān)聯(lián)起來,并攜帶code重定向回客戶端。
客戶端接收到授權(quán)碼之后,攜帶之前生成的 code_verifier 執(zhí)行令牌請求。
授權(quán)服務(wù)器根據(jù)code_verifier計算出 code_challenge,并檢查是否與最初提交的code_challenge一致。
授權(quán)服務(wù)器向客戶端發(fā)送令牌。
客戶端向受保護資源發(fā)送令牌。
受保護資源向客戶端返回資源。
使用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 上獲得。