Spring Security OAuth2 內(nèi)省協(xié)議與 JWT 結(jié)合使用指南
概述
我們已經(jīng)熟悉兩種用于授權(quán)服務(wù)器和受保護(hù)資源之間傳遞信息的方法:JWT(JSON Web Token)和令牌內(nèi)省。
但實(shí)際上,將它們結(jié)合起來使用也可以得到很好的效果。尤其在受保護(hù)資源要接受來自多個(gè)授權(quán)服務(wù)器的令牌的情況下特別有用。受保護(hù)資源可以先解析 JWT,弄清楚
令牌頒發(fā)自哪一個(gè)授權(quán)服務(wù)器,然后向?qū)?yīng)的授權(quán)服務(wù)器發(fā)送內(nèi)省請(qǐng)求以獲取詳細(xì)信息。
這篇文章將介紹如何實(shí)現(xiàn)Spring Security 5設(shè)置資源服務(wù)器實(shí)現(xiàn)內(nèi)省協(xié)議與JWT的結(jié)合使用,讓我們開始實(shí)踐吧!
授權(quán)服務(wù)器
在本節(jié)中我們將使用 Spring Authorization Server 搭建授權(quán)服務(wù)器,訪問令牌格式為
JWT(JSON Web Token)。
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指定服務(wù)端口:
server:
port: 8080
接下來我們創(chuàng)建AuthorizationServerConfig配置類,在此類中我們將創(chuàng)建授權(quán)服務(wù)所需Bean。下面我們將為授權(quán)服務(wù)器創(chuàng)建一個(gè)OAuth2客戶端,RegisteredClient
包含客戶端信息,它將由RegisteredClientRepository管理。
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("relive-client")
.clientSecret("{noop}relive-client")
.clientAuthenticationMethods(s -> {
s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
})
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
.scope("message.read")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(false)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
.accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
.refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
.reuseRefreshTokens(false)
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
上述由RegisteredClient定義的OAuth2 客戶端參數(shù)信息說明如下:
- id: 唯一標(biāo)識(shí)
- clientId: 客戶端標(biāo)識(shí)符
- clientSecret: 客戶端秘密
-
clientAuthenticationMethods: 客戶端可能使用的身份驗(yàn)證方法。支持的值為
client_secret_basic、client_secret_post、private_key_jwt、client_secret_jwt和none -
authorizationGrantTypes: 客戶端可以使用的授權(quán)類型。支持的值為
authorization_code、implicit、password、client_credentials、refresh_token和urn:ietf:params:oauth:grant-type:jwt-bearer - redirectUris: 客戶端已注冊(cè)重定向 URI
- scopes: 允許客戶端請(qǐng)求的范圍
-
clientSettings: 客戶端的自定義設(shè)置
- requireAuthorizationConsent: 是否需要授權(quán)統(tǒng)同意
- requireProofKey: 當(dāng)參數(shù)為true時(shí),該客戶端支持PCKE
-
tokenSettings: OAuth2 令牌的自定義設(shè)置
- accessTokenFormat: 訪問令牌格式,支持OAuth2TokenFormat.SELF_CONTAINED(自包含的令牌使用受保護(hù)的、有時(shí)間限制的數(shù)據(jù)結(jié)構(gòu),例如JWT);OAuth2TokenFormat.REFERENCE(不透明令牌)
- accessTokenTimeToLive: access_token有效期
- refreshTokenTimeToLive: refresh_token有效期
- reuseRefreshTokens: 是否重用刷新令牌。當(dāng)參數(shù)為true時(shí),刷新令牌后不會(huì)重新生成新的refreshToken
ProviderSettings包含OAuth2授權(quán)服務(wù)器的配置設(shè)置。它指定了協(xié)議端點(diǎn)的URI以及發(fā)行人標(biāo)識(shí)。此處issuer在下文將由受保護(hù)資源解析用于區(qū)分授權(quán)服務(wù)器。
@Bean
public ProviderSettings providerSettings() {
return ProviderSettings.builder()
.issuer("http://127.0.0.1:8080")
.build();
}
我們將通過OAuth2AuthorizationServerConfiguration將OAuth2默認(rèn)安全配置應(yīng)用于HttpSecurity,同時(shí)對(duì)于未認(rèn)證請(qǐng)求重定向到登錄頁(yè)面。
@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();
}
授權(quán)服務(wù)器需要其用于JWT令牌的簽名密鑰,讓我們生成一個(gè)的 RSA 密鑰:
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
最后我們將定義Spring Security安全配置類,定義Form表單認(rèn)證方式保護(hù)我們的授權(quán)服務(wù)。
@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();
}
資源服務(wù)器
本節(jié)中我們使用 Spring Security 5 設(shè)置OAuth2 受保護(hù)資源服務(wù)。通過自定義實(shí)現(xiàn)AuthenticationManagerResolver將 JWT 與內(nèi)省協(xié)議結(jié)合使用。
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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.3</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>9.43.1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.3</version>
</dependency>
配置
首先通過application.yml配置數(shù)據(jù)庫(kù)連接和服務(wù)端口。
server:
port: 8090
spring:
application:
name: auth-server
datasource:
druid:
db-type: mysql
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/resourceserver-introspection?createDatabaseIfNotExist=true&useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: <<username>> # update user
password: <<password>> # update password
以往我們配置受保護(hù)資源服務(wù)通常會(huì)在application.yml中指定 spring.security.resourceserver.jwt或spring.security.resourceserver.opaquetoken配置,
Spring Security 會(huì)使用JwtAuthenticationProvider或OpaqueTokenAuthenticationProvider 驗(yàn)證access_token 。
本節(jié)中我們將根據(jù)AuthenticationManagerResolver獲取驗(yàn)證access_token規(guī)則。由于issuer伴隨著已簽署的JWT,因此可以使用JwtIssuerAuthenticationManagerResolver完成。
我們將創(chuàng)建 AuthenticationManagerResolver的實(shí)現(xiàn)IntrospectiveIssuerJwtAuthenticationManagerResolver 作為參數(shù)構(gòu)造 JwtIssuerAuthenticationManagerResolver 。
public class IntrospectiveIssuerJwtAuthenticationManagerResolver implements AuthenticationManagerResolver<String> {
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();
private final OAuth2IntrospectionService introspectionService;
private final OpaqueTokenIntrospectorSupport opaqueTokenIntrospectorSupport;
public IntrospectiveIssuerJwtAuthenticationManagerResolver(OAuth2IntrospectionService introspectionService,
OpaqueTokenIntrospectorSupport opaqueTokenIntrospectorSupport) {
Assert.notNull(introspectionService, "introspectionService can be not null");
Assert.notNull(opaqueTokenIntrospectorSupport, "opaqueTokenIntrospectorSupport can be not null");
this.introspectionService = introspectionService;
this.opaqueTokenIntrospectorSupport = opaqueTokenIntrospectorSupport;
}
@Override
public AuthenticationManager resolve(String issuer) {
OAuth2Introspection oAuth2Introspection = this.introspectionService.loadIntrospection(issuer);
if (oAuth2Introspection != null) {
AuthenticationManager authenticationManager = this.authenticationManagers.computeIfAbsent(issuer,
(k) -> {
log.debug("Constructing AuthenticationManager");
OpaqueTokenIntrospector opaqueTokenIntrospector = this.opaqueTokenIntrospectorSupport.fromOAuth2Introspection(oAuth2Introspection);
return new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector)::authenticate;
});
log.debug(LogMessage.format("Resolved AuthenticationManager for issuer '%s'", issuer).toString());
return authenticationManager;
} else {
log.debug("Did not resolve AuthenticationManager since issuer is not trusted");
}
return null;
}
}
OAuth2IntrospectionService管理OAuth2Introspection并負(fù)責(zé)持久化。在 OAuth2Introspection 中包含了issuer,clientId,clientSecret,introspectionUri屬性信息。
OpaqueTokenIntrospectorSupport負(fù)責(zé)根據(jù) OAuth2Introspection 創(chuàng)建 OpaqueTokenIntrospector,用于 OAuth 2.0 令牌的內(nèi)省和驗(yàn)證。 OpaqueTokenIntrospector此接口的實(shí)現(xiàn)將向 OAuth 2.0 內(nèi)省端點(diǎn)發(fā)出請(qǐng)求以驗(yàn)證令牌并返回其屬性。在使用令牌內(nèi)省會(huì)導(dǎo)致 OAuth 2.0 系統(tǒng)內(nèi)的網(wǎng)絡(luò)流量增加,
為了解決這個(gè)問題,我們可以允許受保護(hù)資源緩存給定令牌的內(nèi)省請(qǐng)求結(jié)果。我們將創(chuàng)建 OpaqueTokenIntrospector 的緩存實(shí)現(xiàn) CachingOpaqueTokenIntrospector。建議設(shè)置短于令牌生命周期的緩存有效期,以便降低令牌被撤回但緩存還有效的可能性。
public class CachingOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
private final Cache cache;
private final OpaqueTokenIntrospector introspector;
public CachingOpaqueTokenIntrospector(Cache cache, OpaqueTokenIntrospector introspector) {
this.cache = cache;
this.introspector = introspector;
}
@Override
public OAuth2AuthenticatedPrincipal introspect(String token) {
try {
return this.cache.get(token,
() -> this.introspector.introspect(token));
} catch (Cache.ValueRetrievalException ex) {
throw new OAuth2IntrospectionException("Did not validate token from cache.");
} catch (OAuth2IntrospectionException e) {
if (e instanceof BadOpaqueTokenException) {
throw (BadOpaqueTokenException) e;
}
throw new OAuth2IntrospectionException(e.getMessage());
} catch (Exception ex) {
log.error("Token introspection failed.", ex);
throw new OAuth2IntrospectionException("Token introspection failed.");
}
}
}
接下來我們創(chuàng)建 OAuth2IntrospectiveResourceServerAuthorizationConfigurer 繼承 AbstractHttpConfigurer,實(shí)現(xiàn)我們的定制化配置。
public class OAuth2IntrospectiveResourceServerAuthorizationConfigurer extends AbstractHttpConfigurer<OAuth2IntrospectiveResourceServerAuthorizationConfigurer, HttpSecurity> {
//...
@Override
public void init(HttpSecurity http) throws Exception {
this.validateConfiguration();
ApplicationContext applicationContext = http.getSharedObject(ApplicationContext.class);
if (this.authenticationManagerResolver == null) {
OAuth2IntrospectionService oAuth2IntrospectionService = applicationContext.getBean(OAuth2IntrospectionService.class);
OpaqueTokenIntrospectorSupport opaqueTokenIntrospectorSupport = this.getOpaqueTokenIntrospectorSupport(applicationContext);
IntrospectiveIssuerJwtAuthenticationManagerResolver introspectiveIssuerJwtAuthenticationManagerResolver =
new IntrospectiveIssuerJwtAuthenticationManagerResolver(oAuth2IntrospectionService, opaqueTokenIntrospectorSupport);
this.authenticationManagerResolver = introspectiveIssuerJwtAuthenticationManagerResolver;
}
JwtIssuerAuthenticationManagerResolver jwtIssuerAuthenticationManagerResolver =
new JwtIssuerAuthenticationManagerResolver(this.authenticationManagerResolver);
http.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(jwtIssuerAuthenticationManagerResolver)
);
}
//...
}
最后定義Spring Security安全配置類,通過http.apply()加載定制化配置OAuth2IntrospectiveResourceServerAuthorizationConfigurer。同時(shí)定義
保護(hù)端點(diǎn) /resource/article 權(quán)限為 message.read 。
@Configuration(proxyBeanMethods = false)
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.mvcMatchers("/resource/article").hasAuthority("SCOPE_message.read")
.anyRequest().authenticated()
)
.apply(new OAuth2IntrospectiveResourceServerAuthorizationConfigurer())
.opaqueTokenIntrospectorSupport();
return http.build();
}
}
篇幅限制本節(jié)中涉及代碼都取自片段,源碼附在文末 鏈接 中。
測(cè)試
Spring Security 構(gòu)造 OAuth2.0 客戶端服務(wù)流程文中并沒有介紹,如果您對(duì)此有疑問,可以參考以前文章 或從文末 鏈接 中獲取源碼。
我們將服務(wù)啟動(dòng)后,瀏覽器訪問 http://127.0.0.1:8070/client/test,通過認(rèn)證(用戶名密碼為admin/password)并同意授權(quán)后,您將看到如下最終結(jié)果:
{
"sub": "admin",
"articles": ["Effective Java", "Spring In Action"]
}
結(jié)論
與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。