1. 概述
在本教程中,我們將學習 JSON Web 簽名 (JWS) 以及如何使用 JSON Web 密鑰 (JWK) 規範在配置為 Spring Security OAuth2 的應用程序中實現它。
請記住,儘管 Spring 正在努力將所有 Spring Security OAuth 功能遷移到 Spring Security 框架,但本指南仍然是一個很好的起點,可以幫助您理解這些規範的基本概念,並在任何框架中實施它們時派上用場。
首先,我們將嘗試理解基本概念,例如 JWS 和 JWK 的含義、目的以及如何輕鬆配置資源服務器以使用此 OAuth 解決方案。
然後,我們將深入研究,通過分析 OAuth2 Boot 在幕後所做的事情以及設置授權服務器以使用 JWK,對規範進行詳細分析。
2. 理解 JWS 和 JWK 的全局概念
在開始之前,重要的是要正確理解一些基本概念。建議您首先閲讀我們的 OAuth 和 JWT 文章,因為這些主題不屬於本教程的範圍。
JWS 是由 IETF 創建的規範,描述了用於驗證數據完整性的不同加密機制,具體是指在 JSON Web Token (JWT) 中的數據。它定義了一種包含所需信息的 JSON 結構。
它在廣泛使用的 JWT 規範中是一個關鍵組成部分,因為聲明需要被簽名或加密,才能被認為是有效安全的。
在第一種情況下,JWT 被表示為 JWS。如果它被加密,JWT 將編碼為 JSON Web Encryption (JWE) 結構。
在與 OAuth 協作時,最常見的場景是處理僅簽名 JWT。這是因為我們通常不需要“隱藏”信息,而是要驗證數據的完整性。
當然,無論我們處理的是簽名 JWT 還是加密 JWT,都需要正式的指南,以便高效地傳輸公鑰。
這就是 JWK 的目的,它是一個由 IETF 定義的 JSON 結構,代表一個加密密鑰。
許多身份驗證提供程序提供“JWK Set”端點,這也定義在規範中。通過它,其他應用程序可以找到公鑰以處理 JWT。
例如,資源服務器使用 JWT 中的 kid(Key Id)字段來查找 JWK 集中正確的密鑰。
2.1. 使用 JWK 實現解決方案
通常,如果我們的應用程序需要以安全的方式提供資源,例如通過使用標準安全協議,如 OAuth 2.0,則需要遵循以下步驟:
- 在授權服務器上註冊客户端——無論是在我們自己的服務中,還是在 Okta、Facebook 或 Github 等知名提供商中
- 這些客户端將從授權服務器請求訪問令牌,並遵循我們可能配置的任何 OAuth 策略
- 它們將通過呈現令牌(在本例中為 JWT)來訪問資源,並將其發送到資源服務器
- 資源服務器必須驗證令牌是否被篡改,通過檢查其簽名,並驗證其聲明
- 最後,我們的資源服務器檢索資源,並確保客户端擁有正確的權限
3. JWK 與資源服務器配置
稍後,我們將學習如何設置自己的授權服務器,該服務器將提供 JWT 以及“JWK 集合”端點。
在此之前,我們將專注於最簡單——也是最常見的場景,即指向現有授權服務器。
我們只需要指示服務如何驗證它接收到的訪問令牌,例如,它應該使用哪個公鑰來驗證 JWT 的簽名。
我們將使用 Spring Security OAuth 的 Autoconfig 功能以簡單而乾淨的方式實現,僅使用應用程序屬性。
3.1. Maven 依賴
我們需要將 OAuth2 自定義配置依賴添加到 Spring 應用的 pom 文件中:
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.1.6.RELEASE</version>
</dependency>如往常一樣,我們可以通過 Maven Central 檢查最新版本的 Artifact。
請注意,這個依賴項不由 Spring Boot 管理,因此我們需要指定其版本。
它應該與我們正在使用的 Spring Boot 版本相匹配。
3.2. 配置資源服務器
接下來,我們需要在應用程序中使用 @EnableResourceServer 標註來啓用資源服務器功能:
@SpringBootApplication
@EnableResourceServer
public class ResourceServerApplication {
public static void main(String[] args) {
SpringApplication.run(ResourceServerApplication.class, args);
}
}現在我們需要指示我們的應用程序如何獲取用於驗證接收到的 JWT 作為 Bearer 令牌時所簽名數據的公鑰。
OAuth2 Boot 提供不同的策略來驗證令牌。
正如我們之前所説,大多數授權服務器會暴露一個 URI,其中包含其他服務可以用來驗證簽名數據的集合。
我們將配置本地授權服務器上的 JWK Set 端點,稍後會進一步完善它。
讓我們在 application.properties 中添加以下內容:
security.oauth2.resource.jwk.key-set-uri=
http://localhost:8081/sso-auth-server/.well-known/jwks.json我們將深入分析該主題,並探討其他策略。
注意: 新版本的 Spring Security 5.1 Resource Server 僅支持 JWK 簽名 JWT 作為授權,Spring Boot 也提供了一個非常相似的屬性來配置 JWK Set 端點:
spring.security.oauth2.resourceserver.jwk-set-uri=
http://localhost:8081/sso-auth-server/.well-known/jwks.json3.3. Spring 配置原理
該先前添加的屬性在創建幾個 Spring Bean 方面起作用。
更準確地説,OAuth2 Boot 將創建:
- 一個 JwkTokenStore,僅具有解碼 JWT 和驗證其簽名的功能
- 一個 DefaultTokenServices 實例,用於使用該 TokenStore
4. 授權服務器中的 JWK 集合端點
現在我們將深入探討這一主題,分析 JWK 和 JWS 的關鍵方面,同時配置一個頒發 JWT 並提供其 JWK 集合端點的授權服務器。
請注意,由於 Spring Security 尚未提供配置授權服務器的功能,因此在當前階段,使用 Spring Security OAuth 的能力構建授權服務器是唯一的選擇。儘管它與 Spring Security 資源服務器兼容。
4.1. 啓用授權服務器功能
第一步是配置我們的授權服務器,以便在需要時頒發訪問令牌。
我們還將添加 <em >spring-security-oauth2-autoconfigure</em> 依賴項,與資源服務器類似。
首先,我們將使用 <em >@EnableAuthorizationServer</em> 註解來配置 OAuth2 授權服務器機制:
@Configuration
@EnableAuthorizationServer
public class JwkAuthorizationServerConfiguration {
// ...
}我們將會使用屬性註冊一個 OAuth 2.0 客户端。
security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret有了這些,我們的應用程序在收到請求時,將檢索相應的憑據對應的隨機令牌。
curl bael-client:bael-secret\
@localhost:8081/sso-auth-server/oauth/token \
-d grant_type=client_credentials \
-d scope=any如我們所見,Spring Security OAuth默認情況下會檢索一個隨機字符串值,而不是使用JWT編碼:
"access_token": "af611028-643f-4477-9319-b5aa8dc9408f"4.2. 發行 JWT
我們可以通過在上下文中創建 JwtAccessTokenConverter bean 來輕鬆實現這一點:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
return new JwtAccessTokenConverter();
}並且在 JwtTokenStore 實例中使用它:
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(accessTokenConverter());
}因此,在這些變更之後,我們請求一個新的訪問令牌,這次我們將獲取一個 JWT,該 JWT 以 JWS 編碼,以確保準確性。
我們可以輕鬆識別 JWS;它們的結構由三個字段(標頭、有效負載和簽名)組成,這些字段之間用點分隔:
"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzY29wZSI6WyJhbnkiXSwiZXhwIjoxNTYxOTcy...
.
XKH70VUHeafHLaUPVXZI9E9pbFxrJ35PqBvrymxtvGI"默認情況下,Spring 使用 Message Authentication Code (MAC) 方式對標頭和有效負載進行簽名。
我們可以通過分析眾多 在線 JWT 解碼器/驗證器 來驗證這一點。
如果我們解碼我們獲得的 JWT,我們會看到 alg 屬性的值是 HS256,這表明使用了 HMAC-SHA256 算法對令牌進行了簽名。
為了理解為什麼在這種方法中我們不需要 JWKs,我們需要理解 MAC 散列函數的工作原理。
4.3. 默認對稱簽名
MAC 哈希使用相同的密鑰對消息進行簽名和驗證其完整性,這是一種對稱哈希函數。
因此,出於安全考慮,應用程序不能公開共享其簽名密鑰。
僅供學術研究目的,我們將公開 Spring Security OAuth /oauth/token_key 端點:
security.oauth2.authorization.token-key-access=permitAll()我們將在配置 JwtAccessTokenConverter Bean 時,自定義簽名密鑰值:
converter.setSigningKey("bael");要準確地瞭解正在使用的對稱密鑰。
注意:即使我們不發佈簽名密鑰,設置弱簽名密鑰也可能對字典攻擊構成潛在威脅。
一旦我們知道簽名密鑰,就可以使用之前提到的在線工具手動驗證令牌的完整性。
Spring Security OAuth 庫還配置了一個 /oauth/check_token 端點,用於驗證並檢索解碼後的 JWT。
該端點還配置了 denyAll() 訪問規則,並且應該有意識地進行安全配置。為此,我們可以像之前為令牌密鑰那樣使用 security.oauth2.authorization.check-token-access 屬性。
4.4. 資源服務器配置的替代方案
根據我們的安全需求,我們可能會考慮對最近提到的端點進行適當保護,同時確保它們可供資源服務器訪問。
如果情況如此,我們就可以保持授權服務器不變,並選擇一種替代方案來配置資源服務器。
資源服務器會期望授權服務器已經對端點進行了安全保護,因此,首先我們需要提供客户端憑據,並使用授權服務器中相同的屬性:
security.oauth2.client.client-id=bael-client
security.oauth2.client.client-secret=bael-secret然後我們可以選擇使用 /oauth/check_token 端點(也稱為內窺端點)或從 /oauth/token_key 獲取單個密鑰:
## Single key URI:
security.oauth2.resource.jwt.key-uri=
http://localhost:8081/sso-auth-server/oauth/token_key
## Introspection endpoint:
security.oauth2.resource.token-info-uri=
http://localhost:8081/sso-auth-server/oauth/check_token當然,以下是翻譯後的內容:
或者,我們也可以配置用於驗證令牌的鍵,在資源服務中:
## Verifier Key
security.oauth2.resource.jwt.key-value=bael採用這種方法,將不會與授權服務器進行交互,但由此也意味着在令牌簽名配置方面,靈活性會受到限制。
與密鑰 URI 策略類似,這種方法可能僅適用於非對稱簽名算法。
4.5. 創建 Keystore 文件
我們不能忘記我們的最終目標:提供 JWK Set 端點,就像最知名的提供商那樣。
如果我們要共享密鑰,最好使用非對稱加密(特別是數字簽名算法)對令牌進行簽名。
創建 Keystore 文件是實現這一目標的第一步。
以下是一種簡單的方法:
- 在您方便的 JDK 或 JRE 的 /bin 目錄下打開命令行:
cd $JAVA_HOME/bin- 運行 keytool 命令,並使用相應的參數:
./keytool -genkeypair \
-alias bael-oauth-jwt \
-keyalg RSA \
-keypass bael-pass \
-keystore bael-jwt.jks \
-storepass bael-pass請注意,我們在此使用了 RSA 算法,該算法具有非對稱性。
- 回答交互式問題並生成 keystore 文件
4.6. 將 Keystore 文件添加到我們的應用程序
我們需要將 Keystore 文件添加到我們的項目資源中。
這是一個簡單的任務,但請記住,這是一種二進制文件。這意味着它不能被過濾,否則會損壞。
如果我們使用 Maven,一種替代方案是將文本文件放在一個單獨的文件夾中,並相應地配置 pom.xml:
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
<resource>
<directory>src/main/resources/filtered</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>4.7. 配置 TokenStore
下一步是使用密鑰對配置我們的 TokenStore,即用於簽名令牌的私鑰和用於驗證完整性的公鑰。
我們將使用 classpath 中的 keystore 文件創建 KeyPair 實例,並使用我們在創建 .jks 文件時使用的參數。
ClassPathResource ksFile =
new ClassPathResource("bael-jwt.jks");
KeyStoreKeyFactory ksFactory =
new KeyStoreKeyFactory(ksFile, "bael-pass".toCharArray());
KeyPair keyPair = ksFactory.getKeyPair("bael-oauth-jwt");我們將在我們的 JwtAccessTokenConverter Bean 中進行配置,移除任何其他配置:
converter.setKeyPair(keyPair);我們可以重新請求和解碼 JWT 以檢查 alg 參數是否發生了更改。
如果我們查看 Token Key 端點,我們會看到從 keystore 獲得的公鑰。
它很容易通過 PEM “封裝邊界” 頭部識別出來,即以 “—–BEGIN PUBLIC KEY—–” 開頭且以 “.” 結尾的字符串。
4.8. JWK 集合端點依賴
Spring Security OAuth 庫本身不包含對 JWK 的支持。
因此,我們需要將 nimbus-jose-jwt 依賴項添加到我們的項目中,該依賴項提供了一些基本的 JWK 實現:
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>7.3</version>
</dependency>請記住,我們可以使用 Maven 中央倉庫搜索引擎 來檢查庫的最新版本。
4.9. 創建 JWK 集合端點
讓我們首先使用我們先前配置的 KeyPair 實例創建一個 JWKSet Bean:
@Bean
public JWKSet jwkSet() {
RSAKey.Builder builder = new RSAKey.Builder((RSAPublicKey) keyPair().getPublic())
.keyUse(KeyUse.SIGNATURE)
.algorithm(JWSAlgorithm.RS256)
.keyID("bael-key-id");
return new JWKSet(builder.build());
}現在創建端點相當簡單:
@RestController
public class JwkSetRestController {
@Autowired
private JWKSet jwkSet;
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> keys() {
return this.jwkSet.toJSONObject();
}
}我們在配置的 JWKSet 實例中定義的 Key Id 字段,會轉換為 kid 參數。
這個 kid 是一個密鑰的任意別名,通常由 Resource Server 用於 從集合中選擇正確的條目,因為相同的密鑰應 包含在 JWT Header 中。
現在我們面臨一個新的問題;由於 Spring Security OAuth 不支持 JWK,所頒發的 JWT 將不會包含 kid Header。
讓我們尋找一個解決方案來解決這個問題。
4.10. 為 JWT 頭部添加 kid 值
我們將創建一個新的、擴展了我們之前使用的 JwtAccessTokenConverter 的類,該類允許向 JWT 中添加頭部條目:
public class JwtCustomHeadersAccessTokenConverter
extends JwtAccessTokenConverter {
// ...
}首先,我們需要:
- 配置父類,如我們之前所做的那樣,設置我們已配置的 KeyPair
- 獲取一個使用來自密鑰庫的私鑰的 Signer 對象
- 當然,我們想要添加的自定義頭部集合
讓我們根據以下內容配置構造函數:
private Map<String, String> customHeaders = new HashMap<>();
final RsaSigner signer;
public JwtCustomHeadersAccessTokenConverter(
Map<String, String> customHeaders,
KeyPair keyPair) {
super();
super.setKeyPair(keyPair);
this.signer = new RsaSigner((RSAPrivateKey) keyPair.getPrivate());
this.customHeaders = customHeaders;
}現在我們將覆蓋 encode 方法。我們的實現將與父類相同,唯一的區別是我們將在創建 String令牌時也傳遞自定義標題。
private JsonParser objectMapper = JsonParserFactory.create();
@Override
protected String encode(OAuth2AccessToken accessToken,
OAuth2Authentication authentication) {
String content;
try {
content = this.objectMapper
.formatMap(getAccessTokenConverter()
.convertAccessToken(accessToken, authentication));
} catch (Exception ex) {
throw new IllegalStateException(
"Cannot convert access token to JSON", ex);
}
String token = JwtHelper.encode(
content,
this.signer,
this.customHeaders).getEncoded();
return token;
}讓我們現在使用這個類,在創建 JwtAccessTokenConverter 豆時:
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
Map<String, String> customHeaders =
Collections.singletonMap("kid", "bael-key-id");
return new JwtCustomHeadersAccessTokenConverter(
customHeaders,
keyPair());
}我們已經準備就緒。請務必將 Resource Server 的屬性恢復到初始狀態。我們需要僅使用在教程開篇設置的 key-set-uri 屬性。
我們可以請求 Access Token,檢查其 kid 值,並使用它來請求資源。
一旦獲取了公鑰,Resource Server 會將其內部存儲,並將其映射到 Key Id 以供後續請求使用。
5. 結論
我們在這份全面的 JWT、JWS 和 JWK 指南中學習了很多內容。不僅涵蓋了 Spring 相關的配置,還涵蓋了通用的 Security 概念,並通過一個實際示例觀察它們的應用。
我們已經看到了 Resource Server 的基本配置,該配置使用 JWK Set 端點處理 JWT。
最後,我們通過設置一個高效地暴露 JWK Set 端點的 Authorization Server,擴展了基本的 Spring Security OAuth 功能。