1. 引言
在本教程中,我們將演示如何將 JWT(JSON Web Token)聲明映射到 Spring Security 的 權限。
2. 背景
當一個配置正確的基於 Spring Security 的應用程序接收到請求時,它會經歷一系列步驟,其本質是旨在實現兩個目標:
- 驗證請求,以便應用程序知道是誰訪問它
- 決定是否可以執行相關的操作
對於使用 JWT 作為主要安全機制的應用程序,授權方面包括:
- 從 JWT 負載中提取聲明值,通常是 scope 或 scp 聲明
- 將這些聲明映射到一組 GrantedAuthority 對象
一旦安全引擎設置好這些權限,它就可以評估當前請求是否存在任何訪問限制,並決定是否可以繼續。
3. Default Mapping
Out-of-the-box, Spring uses a straightforward strategy to convert claims into GrantedAuthority instances. Firstly, it extracts the scope or scp claim and splits it into a list of strings. Next, for each string, it creates a new SimpleGrantedAuthority using the prefix SCOPE_ followed by the scope value.
To illustrate this strategy, let’s create a simple endpoint that allows us to inspect some key properties of the Authentication instance made available to the application:
@RestController
@RequestMapping("/user")
public class UserRestController {
@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
Collection<String> authorities = principal.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
Map<String,Object> info = new HashMap<>();
info.put("name", principal.getName());
info.put("authorities", authorities);
info.put("tokenAttributes", principal.getTokenAttributes());
if ( principal instanceof AccountToken ) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}
}
Here, we use a JwtAuthenticationToken argument because we know that, when using JWT-based authentication, this will be the actual Authentication implementation created by Spring Security. We create the result extracting from its name property, the available GrantedAuthority instances, and the JWT’s original attributes.
Now, let’s assume we invoke this endpoint passing and encoded-and-signed JWT containing this payload:
{
"aud": "api://f84f66ca-591f-4504-960a-3abc21006b45",
"iss": "https://sts.windows.net/2e9fde3a-38ec-44f9-8bcd-c184dc1e8033/",
"iat": 1648512013,
"nbf": 1648512013,
"exp": 1648516868,
"email": "[email protected]",
"family_name": "Sevestre",
"given_name": "Philippe",
"name": "Philippe Sevestre",
"scp": "profile.read",
"sub": "eXWysuqIJmK1yDywH3gArS98PVO1SV67BLt-dvmQ-pM",
... more claims omitted
}
The response should look like a JSON object with three properties:
{
"tokenAttributes": {
// ... token claims omitted
},
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"SCOPE_profile",
"SCOPE_email",
"SCOPE_openid"
]
}
We can use those scopes to restrict access to certain parts of our applications by creating a SecurityFilterChain:
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
// @formatter:off
return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
.jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
// @formatter:on
}
Notice that we’ve intentionally avoided using WebSecurityConfigureAdapter. As described, this class will be deprecated in Spring Security version 5.7, so it’s better to start moving to the new approach as soon as possible.
Alternatively, we could use method-level annotations and an SpEL expression to achieve the same result:
@GetMapping("/authorities")
@PreAuthorize("hasAuthority('SCOPE_profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... same code as before
}
Finally, for more complex scenarios, we can also resort to accessing directly the current JwtAuthenticationToken from which we have direct access to all GrantedAuthorities
4. 定製 SCOPE_ 前綴
作為改變 Spring Security 的默認 claim 映射行為的第一個例子,讓我們看看如何將 SCOPE_ 前綴更改為其他值。 如文檔所述,有兩類參與此任務:
- JwtAuthenticationConverter:將原始 JWT 轉換為 AbstractAuthenticationToken
- JwtGrantedAuthoritiesConverter:從原始 JWT 中提取 GrantedAuthority 實例的集合。
內部,JwtAuthenticationConverter 使用 JwtGrantedAuthoritiesConverter 來填充 JwtAuthenticationToken 中包含 GrantedAuthority 對象以及其他屬性。
最簡單的方法是提供我們自己的 JwtAuthenticationConverter bean,配置為使用 JwtGrantedAuthoritiesConverter 配置為我們自己的選擇:
@Configuration
@EnableConfigurationProperties(JwtMappingProperties.class)
@EnableMethodSecurity
public class SecurityConfig {
// ... 字段和構造函數已省略
@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix().trim());
}
return converter;
}
@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());
return converter;
}
這裏,JwtMappingProperties 只是一個 @ConfigurationProperties 類,我們將使用它來外部化映射屬性。 雖然本節中未顯示,但我們將使用構造函數注入來初始化 mappingProps 字段,並使用從任何配置的 PropertySource 實例初始化,從而給我們足夠的靈活性來在部署時更改其值。
此 @Configuration 類有兩個 @Bean 方法:jwtGrantedAuthoritiesConverter() 創建所需的 Converter,該 Converter 創建 GrantedAuthority 集合。 在這種情況下,我們正在使用配置屬性中前綴設置為配置的默認 JwtGrantedAuthoritiesConverter。
接下來,我們有 customJwtAuthenticationConverter(),其中我們構造 JwtAuthenticationConverter,配置為使用自定義的 Converter。 從那裏,Spring Security 將將其作為其標準自動配置過程中的一部分拾取,並替換默認的。
現在,一旦我們將 baeldung.jwt.mapping.authorities-prefix 屬性設置為某個值,MY_SCOPE,例如,並調用/user/authorities,我們將看到定製的權限:
{
"tokenAttributes": {
// ... token claims omitted
},
"name": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}
5. 使用自定義前綴在安全構造中
請注意,通過更改管理者的前綴,我們會影響任何依賴於它們的名稱的授權規則。例如,如果我們更改前綴為 MY_PREFIX_ ,那麼任何 @PreAuthorize 表達式,如果假設使用默認前綴,將不再起作用。同樣適用於基於 HttpSecurity 的授權構造。
解決此問題很簡單。首先,讓我們向我們的 @Configuration 類中添加一個 @Bean 方法,該方法返回配置的前綴。由於此配置是可選的,因此我們必須確保如果未提供任何前綴,則返回默認值:
@Bean
public String jwtGrantedAuthoritiesPrefix() {
return mappingProps.getAuthoritiesPrefix() != null ?
mappingProps.getAuthoritiesPrefix() :
"SCOPE_";
}
現在,我們可以使用此 Bean,使用 @<bean-name> 語法在 SpEL 表達式中。 這樣使用前綴 Bean 與 @PreAuthorize 的方式如下:
@GetMapping("/authorities")
@PreAuthorize("hasAuthority(@jwtGrantedAuthoritiesPrefix + 'profile.read')")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... method implementation omitted
}
我們也可以使用類似的方法來定義 SecurityFilterChain:
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
return http.authorizeHttpRequests(auth -> {
auth.requestMatchers("/user/**")
.hasAuthority(mappingProps.getAuthoritiesPrefix() + "profile");
})
// ... other customizations omitted
.build();
}
6. 自定義 Principal 的姓名
有時,標準 sub 聲明,Spring 映射到 Authentication 的 name 屬性的值可能不太實用。 Keycloak 生成的 JWT 是一個很好的例子:
{
// ... 其他聲明已省略
"sub": "0047af40-473a-4dd3-bc46-07c3fe2b69a5",
"scope": "openid profile email",
"email_verified": true,
"name": "User Primo",
"preferred_username": "user1",
"given_name": "User",
"family_name": "Primo"
}
在這種情況下,sub 帶有內部標識符,但我們可以看到 preferred_username 聲明具有更友好的值。 我們可以通過將 JwtAuthenticationConverter 的行為設置為所需聲明名稱的 principalClaimName 屬性輕鬆修改:
@Bean
public JwtAuthenticationConverter customJwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter());
if (StringUtils.hasText(mappingProps.getPrincipalClaimName())) {
converter.setPrincipalClaimName(mappingProps.getPrincipalClaimName());
}
return converter;
}
現在,如果我們將 baeldung.jwt.mapping.authorities-prefix 屬性設置為“preferred_username”,則 /user/authorities 的結果將相應地更改:
{
"tokenAttributes": {
// ... token claims omitted
},
"name": "user1",
"authorities": [
"MY_SCOPE_profile",
"MY_SCOPE_email",
"MY_SCOPE_openid"
]
}
7. 作用名稱映射
有時,我們需要將JWT中收到的作用名稱映射到內部名稱。例如,當同一個應用程序需要與由不同授權服務器生成的令牌進行交互,具體取決於其部署的環境時,就會出現這種情況。
我們可能會被誘惑來擴展JwtGrantedAuthoritiesConverter,但由於這是一個最終類,因此我們無法使用這種方法。相反,我們必須編寫自己的Converter類並將其注入到JwtAuthorizationConverter中。這個增強的映射器,MappingJwtGrantedAuthoritiesConverter,實現了Converter<Jwt, Collection<GrantedAuthority>> 並且看起來與原始版本非常相似:
public class MappingJwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
private static Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES = Arrays.asList("scope", "scp");
private Map<String,String> scopes;
private String authoritiesClaimName = null;
private String authorityPrefix = "SCOPE_";
// ... constructor and setters omitted
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<String> tokenScopes = parseScopesClaim(jwt);
if (tokenScopes.isEmpty()) {
return Collections.emptyList();
}
return tokenScopes.stream()
.map(s -> scopes.getOrDefault(s, s))
.map(s -> this.authorityPrefix + s)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toCollection(HashSet::new));
}
protected Collection<String> parseScopesClaim(Jwt jwt) {
// ... parse logic omitted
}
}
在此,該類的關鍵在於映射步驟,其中我們使用提供的scopes映射來將原始作用名稱轉換為映射後的作用名稱。 此外,任何沒有映射可用的傳入作用名稱都將保持不變。
最後,我們在@Configuration的jwtGrantedAuthoritiesConverter()方法中使用此增強的轉換器:
@Bean
public Converter<Jwt, Collection<GrantedAuthority>> jwtGrantedAuthoritiesConverter() {
MappingJwtGrantedAuthoritiesConverter converter = new MappingJwtGrantedAuthoritiesConverter(mappingProps.getScopes());
if (StringUtils.hasText(mappingProps.getAuthoritiesPrefix())) {
converter.setAuthorityPrefix(mappingProps.getAuthoritiesPrefix());
}
if (StringUtils.hasText(mappingProps.getAuthoritiesClaimName())) {
converter.setAuthoritiesClaimName(mappingProps.getAuthoritiesClaimName());
}
return converter;
}
8. 使用自定義 JwtAuthenticationConverter
在這種場景中,我們將完全控制 JwtAuthenticationToken 生成過程。我們可以使用這種方法返回一個包含來自數據庫的額外數據的擴展版本。
有兩種可能的替代標準 JwtAuthenticationConverter 的方法。第一種,我們之前在各個部分中使用的,是創建一個返回我們自定義轉換器 @Bean 方法。但是,這意味着我們的自定義版本必須擴展 Spring 的 JwtAuthenticationConverter,以便配置自動裝載過程可以拾取它。
第二種選擇是使用基於 HttpSecurity 的 DSL 方法,其中我們可以提供我們的自定義轉換器。我們將使用 oauth2ResourceServer 自定製器,它允許我們插入任何實現更通用的接口 Converter<Jwt, AbstractAuthorizationToken> 的轉換器:
@Bean
SecurityFilterChain customJwtSecurityChain(HttpSecurity http) throws Exception {
// @formatter:off
return http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
.jwtAuthenticationConverter(customJwtAuthenticationConverter(accountService)))).build();
// @formatter:on
}
我們的 CustomJwtAuthenticationConverter 使用 AccountService (在線可用)來根據 principalClaimValue 主體聲明值檢索 Account 對象。然後它使用它來創建一個帶有額外訪問器方法的 CustomJwtAuthenticationToken:
public class CustomJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
// ...private fields and construtor omitted
@Override
public AbstractAuthenticationToken convert(Jwt source) {
Collection<GrantedAuthority> authorities = jwtGrantedAuthoritiesConverter.convert(source);
String principalClaimValue = source.getClaimAsString(this.principalClaimName);
Account acc = accountService.findAccountByPrincipal(principalClaimValue);
return new AccountToken(source, authorities, principalClaimValue, acc);
}
}
現在,讓我們修改我們的 /user/authorities 處理程序以使用我們的增強的 Authentication:
@GetMapping("/authorities")
public Map<String,Object> getPrincipalInfo(JwtAuthenticationToken principal) {
// ... create result map as before (omitted)
if (principal instanceof AccountToken) {
info.put( "account", ((AccountToken)principal).getAccount());
}
return info;
}
這種方法的優點是,我們可以現在在應用程序的其他部分輕鬆使用我們的增強的身份驗證對象。例如,我們可以直接從內置變量 authentication 中訪問帳户信息,使用 SpEL 表達式:
@GetMapping("/account/{accountNumber}")
@PreAuthorize("authentication.account.accountNumber == #accountNumber")
public Account getAccountById(@PathVariable("accountNumber") String accountNumber, AccountToken authentication) {
return authentication.getAccount();
}
在這裏,@PreAuthorize 表達式強制 accountNumber 變量路徑中傳遞的值屬於用户。 這種方法在與 Spring Data JPA 結合使用時特別有用,如官方文檔所述: https://docs.spring.io/spring-security/reference/servlet/integrations/data.html。
9. 測試技巧
迄今為止提供的示例假設我們已經擁有一個運行正常的身份提供者(IdP),該IdP會頒發基於JWT的訪問令牌。使用我們之前已經涵蓋的嵌入式Keycloak服務器是一個不錯的選擇。有關更多配置説明,請參閲我們的使用Keycloak的快速指南。請注意,這些説明涵蓋了如何註冊OAuth客户端。對於實際測試,Postman是一個良好的工具,它支持授權碼流程。這裏的重要細節是如何正確配置有效重定向URI參數。由於Postman是一個桌面應用程序,它使用一個位於https://oauth.pstmn.io/v1/callback的輔助站點來捕獲授權碼。因此,在測試過程中,我們必須確保擁有互聯網連接。如果無法做到,則可以使用不太安全的密碼授權流程。
無論選擇哪個IdP和客户端,我們都必須配置我們的資源服務器,以便它可以正確驗證收到的JWT。
對於標準OIDC提供程序,這意味着為spring.security.oauth2.resourceserver.jwt.issuer-uri屬性提供一個合適的價值。Spring將使用該文檔從那裏獲取所有配置詳細信息.well-known/openid-configuration。
在我們的情況下,我們的Keycloak領域發器URI是http://localhost:8083/auth/realms/baeldung。我們可以將瀏覽器指向以檢索完整文檔http://localhost:8083/auth/realms/baeldung/.well-known/openid-configuration。
10. 結論
在本文中,我們展示了多種自定義 Spring Security 從 JWT 聲明映射權限的方法。