如何使用 Spring Security 为 client_credentials 工作流向 Feign 客户端提供 OAuth2 令牌
Posted
技术标签:
【中文标题】如何使用 Spring Security 为 client_credentials 工作流向 Feign 客户端提供 OAuth2 令牌【英文标题】:How to provide an OAuth2 token to a Feign client using Spring Security for the client_credentials workflow 【发布时间】:2021-05-17 14:11:28 【问题描述】:概述
我正在尝试编写一个访问公共 REST API 的程序。为了让我能够使用它,我需要提供一个 OAuth2 令牌。
我的应用使用 Spring Boot 2.4.2 和 Spring Cloud 版本 2020.0.1。应用程序本身每 24 小时调用一次 REST API,下载数据并将其存储在数据库中。不同的微服务在其他时间使用这些数据,并且需要每天刷新数据。
我的方法是使用 OpenFeign 声明使用 REST API 的 REST 客户端并为其提供 OAuth2 令牌。这是一个很常见的问题,所以我假设机器到机器 client_credentials
的工作流程有据可查。
确实,我确实找到了一个使用 OpenFeign 执行此操作的简单示例 - 此处:https://github.com/netshoes/sample-feign-oauth2-interceptor/blob/master/src/main/java/com/sample/feign/oauth2/interceptor/OrderFeignClientConfiguration.java
TL;DR:尝试编写需要 OAuth2 令牌(client_credentials 授权类型)的机器对机器微服务。
问题
这是我的第一次尝试,但不幸的是,在新的 Spring Security 版本中,我似乎无法实例化 OAuth2FeignRequestInterceptor
,我可能遇到了包问题。然后我继续研究 Spring Security 和新的 OAuth2 重写的文档,可以在这里找到:https://docs.spring.io/spring-security/site/docs/5.1.2.RELEASE/reference/htmlsingle/#oauth2client。
方法
我的方法是使用RequestInterceptor
,它通过添加授权承载标头将当前的 OAuth2 令牌注入到 OpenFeign 客户端的请求中。我的假设是我可以使用 Spring Security OAuth2 层或多或少地自动检索它。
使用文档,我尝试为我的拦截器提供一个 OAuth2RegisteredClient
的 bean,以及一个 OAuth2AccessToken
类型的 bean - 两者都不起作用。我的最后一次尝试看起来像这样,可以看作是一种冰雹玛丽,一种方法:
@Bean
public OAuth2AccessToken apiAccessToken(
@RegisteredOAuth2AuthorizedClient("MY_AWESOME_PROVIDER") OAuth2AuthorizedClient authorizedClient)
return authorizedClient.getAccessToken();
这不起作用,因为RegisteredOAuth2AuthorizedClient
需要用户会话,以免它是null
。我还看到 *** 上的其他人尝试了相同的方法,但他们实际上是在 Controller
(=> Resolving OAuth2AuthorizedClient as a Spring bean) 中做到的
我还尝试了一些我在这里找到的方法:
Feign and Spring Security 5 - Client Credentials(提供的答案使用 Spring Boot 2.2.4 - 因此不再相关) Alternative For OAuth2FeignRequestInterceptor as it is deprecated NOW 另一位绅士正在寻找OAuth2FeignRequestInterceptor
的替代品
OAuth2FeignRequestInterceptor class deprecated in Spring Boot 2.3 - 这里的解决方案再次需要一个活跃的用户会话
https://github.com/jgrandja/spring-security-oauth-5-2-migrate 这个 Github repo 不时弹出,我研究了它,但我认为它与我的问题无关 - 也许我错过了什么?据我了解,这个示例应用程序有多个使用多个范围的提供程序 - 但仍然是一个触发登录的用户,因此通过 Spring Security 自动生成 OAuth2 令牌。 (也出现在这个问题中:Migrating from Spring Boot Oauth2 to Spring Security 5)[1]
https://github.com/spring-cloud/spring-cloud-openfeign/issues/417 -> 目前没有替代 OAuth2FeignRequestInterceptor
我的假设是我可以以某种方式使用 Spring Security 5 来解决这个问题,但我根本不知道如何实际做到这一点。在我看来,我发现的大多数教程和代码示例实际上都需要用户会话,或者在 Spring Security 5 中已经过时。
我似乎确实遗漏了一些东西,我希望有人能指出我正确的方向,关于如何实现这一点的教程或书面文档。
深入示例
我尝试提供OAuth2AuthorizedClientManager
,如本例所示 (https://github.com/jgrandja/spring-security-oauth-5-2-migrate)。
为此,我按照示例代码注册了OAuth2AuthorizedClientManager
:
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository)
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.clientCredentials()
.password()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
并将其提供给我的RequestInterceptor
,如下所示:
@Bean
public RequestInterceptor requestInterceptor(OAuth2AuthorizedClientManager clientManager)
return new OAuthRequestInterceptor(clientManager);
最后我写了拦截器,看起来像这样:
private String getAccessToken()
OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(appClientId)
// .principal(appClientId) // if this is not set, I receive "principal cannot be null" (or empty)
.build();
return Optional.ofNullable(authorizedClientManager)
.map(clientManager -> clientManager.authorize(request))
.map(OAuth2AuthorizedClient::getAccessToken)
.map(AbstractOAuth2Token::getTokenValue)
.orElseThrow(OAuth2AccessTokenRetrievalException::failureToRetrieve);
@Override
public void apply(RequestTemplate template)
log.debug("FeignClientInterceptor -> apply CALLED");
String token = getAccessToken();
if (token != null)
String bearerString = String.format("%s %s", BEARER, token);
template.header(HttpHeaders.AUTHORIZATION, bearerString);
log.debug("set the template header to this bearer string: ", bearerString);
else
log.error("No bearer string.");
当我运行代码时,我可以在控制台中看到“FeignClientInterceptor -> apply called”输出,然后是一个异常:
Caused by: java.lang.IllegalArgumentException: servletRequest cannot be null
我的假设是我收到了这个,因为我没有活跃的用户会话。因此,在我看来,我绝对需要一个来解决这个问题——我在机器对机器的通信中没有这个问题。
这是一个常见的用例,所以我确定我一定是在某些时候犯了错误。
用过的包
也许我的包裹弄错了?
implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.boot:spring-boot-starter-jooq'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
【问题讨论】:
我的直接解决方案是降级到2.2.2
,并使用OAuth2FeignRequestInterceptor
。像一个绝对的魅力一样工作。
【参考方案1】:
所以。我在空闲时间玩你的解决方案。并找到了简单的解决方案:
只需将SecurityContextHolder.getContext().authentication
原则添加到您的代码OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest.withClientRegistrationId(appClientId).build();
应该是这样的:
val request = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak") // <-- here your registered client from application.yaml
.principal(SecurityContextHolder.getContext().authentication)
.build()
使用过的包:
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
application.yaml
:
spring:
security:
oauth2:
client:
registration:
keycloak: # <--- It's your custom client. I am using keycloak
client-id: $SECURITY_CLIENT_ID
client-secret: $SECURITY_CLIENT_SECRET
authorization-grant-type: client_credentials
scope: openid # your scopes
provider:
keycloak: # <--- Here Registered my custom provider
authorization-uri: $SECURITY_HOST/auth/realms/$YOUR_REALM/protocol/openid-connect/authorize
token-uri: $SECURITY_HOST/auth/realms/$YOUR_REALM/protocol/openid-connect/token
feign:
compression:
request:
enabled: true
mime-types: application/json
response:
enabled: true
client.config.default:
connectTimeout: 1000
readTimeout: 60000
decode404: false
loggerLevel: $LOG_LEVEL_FEIGN:basic
SecurityConfiguration
:
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
class SecurityConfiguration() : WebSecurityConfigurerAdapter()
@Throws(Exception::class)
override fun configure(http: HttpSecurity)
// @formatter:off
http
.authorizeRequests authorizeRequests ->
authorizeRequests
.antMatchers(HttpMethod.GET, "/test").permitAll() // Here my public endpoint which do logic with secured client enpoint
.anyRequest().authenticated()
.cors().configurationSource(corsConfigurationSource()).and()
.csrf().disable()
.cors().disable()
.httpBasic().disable()
.formLogin().disable()
.logout().disable()
.oauth2Client()
// @formatter:on
@Bean
fun authorizedClientManager(
clientRegistration: ClientRegistrationRepository?,
authorizedClient: OAuth2AuthorizedClientRepository?
): OAuth2AuthorizedClientManager?
val authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
.builder()
.clientCredentials()
.build()
val authorizedClientManager = DefaultOAuth2AuthorizedClientManager(clientRegistration, authorizedClient)
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider)
return authorizedClientManager
FeignClientConfiguration
:
private val logger = KotlinLogging.logger
class FeignClientConfiguration(private val authorizedClientManager: OAuth2AuthorizedClientManager)
@Bean
fun requestInterceptor(): RequestInterceptor = RequestInterceptor template ->
if (template.headers()["Authorization"].isNullOrEmpty())
val accessToken = getAccessToken()
logger.debug "ACCESS TOKEN TYPE: $accessToken?.tokenType?.value"
logger.debug "ACCESS TOKEN: $accessToken?.tokenValue"
template.header("Authorization", "Bearer $accessToken?.tokenValue")
private fun getAccessToken(): OAuth2AccessToken?
val request = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak") // <- Here you load your registered client
.principal(SecurityContextHolder.getContext().authentication)
.build()
return authorizedClientManager.authorize(request)?.accessToken
TestClient
:
@FeignClient(
name = "test",
url = "http://localhost:8080",
configuration = [FeignClientConfiguration::class]
)
interface TestClient
@GetMapping("/test")
fun test(): ResponseEntity<Void> // Here my secured resource server endpoint. Expect 204 status
【讨论】:
我正在尝试在资源服务器中使用您的代码,用于资源服务器到资源服务器的通信并且 OAuth2AuthorizeRequest 类不可用,有什么方法可以在资源服务器中使用它【参考方案2】:根据文档需要使用 AuthorizedClientServiceOAuth2AuthorizedClientManager 而不是 DefaultOAuth2AuthorizedClientManager
在 HttpServletRequest 的上下文之外操作时,请改用 AuthorizedClientServiceOAuth2AuthorizedClientManager。
【讨论】:
以上是关于如何使用 Spring Security 为 client_credentials 工作流向 Feign 客户端提供 OAuth2 令牌的主要内容,如果未能解决你的问题,请参考以下文章
如何在 Spring Security 中将令牌转换为身份验证?
如何使用 Spring MVC 和 Spring Security 为资源处理程序启用 HTTP 缓存
Spring Security:部署时的 ClassNotFoundException 或 IllegalArgumentException
Grails Spring Security:如何允许密码为空?
如何使用 Spring Boot Security 修复 Spring Boot Webflux 应用程序中的“名称为 requestMappingHandlerAdapter 的 bean 定义无效