Spring Security OAuth2 帶有用于代碼交換的證明密鑰 (PKCE) 的授權碼流
概述
OAuth2依據是否能持有客戶端密鑰,將客戶端分為兩種類型:公共客戶端和保密客戶端。
保密客戶端在服務器上運行,在前面介紹OAuth2文章中Spring Boot創建的應用程序是保密客戶端類型的示例。首先它們在服務器上運行,并且通常位于具有其他保護措施防火墻或網關的后面。
公共客戶端的代碼一般會以某種形式暴露給最終用戶,要么是在瀏覽器中下載執行,要么是直接在用戶的設備上運行。例如原生應用是直接在最終用戶的設備(計算機或者移動設備)上運行的應用。這類應用在使用OAuth2協議時,我們無法保證為此應用頒發的客戶端密鑰能安全的存儲,因為這些應用程序在運行之前會完全下載到設備上,反編譯應用程序將完全顯示客戶端密鑰。
同樣存在此安全問題還有單頁應用(SPA),瀏覽器本身是一個不安全的環境,一旦你加載JavaScript應用程序,瀏覽器將會下載整個源代碼以便運行它,整個源代碼,包括其中的任何 客戶端密鑰,都將可見。如果你構建一個擁有100000名用戶的應用程序,那么很可能這些用戶中的一部分將感染惡意軟件或病毒,并泄漏客戶端密鑰。
你可能會想,“如果我通過將客戶端密鑰拆分為幾個部分進行混淆呢?”這不可否認會為你爭取點時間,但真正有決心的人仍可能會弄清楚。
為了規避這種安全風險,最好使用代碼交換證明密鑰(PKCE)。
Proof Key for Code Exchange
PKCE 有自己獨立的規范。它使應用程序能夠在公共客戶端中使用授權碼流程。
用戶在客戶端請求資源。
客戶端創建并記錄名為 code_verifier 的秘密信息,然后客戶端根據 code_verifier 計算出 code_challenge,它的值可以是 code_verifier,也可以是 code_verifier 的 SHA-256 散列,但是應該優先考慮使用密碼散列,因為它能防止驗證器本身遭到截獲。
客戶端將 code_challenge 以及可選的 code_challenge_method(一個關鍵字,表 示原文或者 SHA-256 散列)與常規的授權請求參數一起發送給授權服務器。
授權服務器將用戶重定向到登錄頁面。
用戶使進行身份驗證,并且可能會看到一個同意頁面,其中列出了 授權服務器將授予客戶端的權限。
授權服務器將 code_challenge 和 code_challenge_method(如果有 的話)記錄下來。授權服務器會將這些信息與頒發的授權碼關聯起來,并攜帶code重定向回客戶端。
客戶端接收到授權碼之后,攜帶之前生成的 code_verifier 執行令牌請求。
授權服務器根據code_verifier計算出 code_challenge,并檢查是否與最初提交的code_challenge一致。
授權服務器向客戶端發送令牌。
客戶端向受保護資源發送令牌。
受保護資源向客戶端返回資源。
使用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 上獲得。