diff --git a/spring-cloud-openfeign-core/pom.xml b/spring-cloud-openfeign-core/pom.xml index edab0071..cdb4d588 100644 --- a/spring-cloud-openfeign-core/pom.xml +++ b/spring-cloud-openfeign-core/pom.xml @@ -119,6 +119,11 @@ okhttp true + + org.springframework.security.oauth.boot + spring-security-oauth2-autoconfigure + true + org.springframework.boot spring-boot-autoconfigure-processor diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java index f48ecee5..93e8ca6e 100644 --- a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignAutoConfiguration.java @@ -27,6 +27,7 @@ import javax.annotation.PreDestroy; import feign.Client; import feign.Feign; +import feign.RequestInterceptor; import feign.httpclient.ApacheHttpClient; import feign.okhttp.OkHttpClient; import okhttp3.ConnectionPool; @@ -39,6 +40,7 @@ import org.apache.http.conn.HttpClientConnectionManager; import org.apache.http.impl.client.CloseableHttpClient; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -48,11 +50,14 @@ import org.springframework.cloud.commons.httpclient.ApacheHttpClientConnectionMa import org.springframework.cloud.commons.httpclient.ApacheHttpClientFactory; import org.springframework.cloud.commons.httpclient.OkHttpClientConnectionPoolFactory; import org.springframework.cloud.commons.httpclient.OkHttpClientFactory; +import org.springframework.cloud.openfeign.security.OAuth2FeignRequestInterceptor; import org.springframework.cloud.openfeign.support.DefaultGzipDecoderConfiguration; import org.springframework.cloud.openfeign.support.FeignHttpClientProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.security.oauth2.client.OAuth2ClientContext; +import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails; /** * @author Spencer Gibb @@ -209,4 +214,19 @@ public class FeignAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(OAuth2ClientContext.class) + @ConditionalOnProperty("feign.oauth2.enabled") + protected static class Oauth2FeignConfiguration { + + @Bean + @ConditionalOnMissingBean(OAuth2FeignRequestInterceptor.class) + @ConditionalOnBean({ OAuth2ClientContext.class, OAuth2ProtectedResourceDetails.class }) + public RequestInterceptor oauth2FeignRequestInterceptor(OAuth2ClientContext oAuth2ClientContext, + OAuth2ProtectedResourceDetails resource) { + return new OAuth2FeignRequestInterceptor(oAuth2ClientContext, resource); + } + + } + } diff --git a/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/security/OAuth2FeignRequestInterceptor.java b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/security/OAuth2FeignRequestInterceptor.java new file mode 100644 index 00000000..0389e7cf --- /dev/null +++ b/spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/security/OAuth2FeignRequestInterceptor.java @@ -0,0 +1,182 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.security; + +import java.util.Arrays; + +import feign.RequestInterceptor; +import feign.RequestTemplate; + +import org.springframework.security.oauth2.client.OAuth2ClientContext; +import org.springframework.security.oauth2.client.http.AccessTokenRequiredException; +import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails; +import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException; +import org.springframework.security.oauth2.client.token.AccessTokenProvider; +import org.springframework.security.oauth2.client.token.AccessTokenProviderChain; +import org.springframework.security.oauth2.client.token.AccessTokenRequest; +import org.springframework.security.oauth2.client.token.grant.client.ClientCredentialsAccessTokenProvider; +import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider; +import org.springframework.security.oauth2.client.token.grant.implicit.ImplicitAccessTokenProvider; +import org.springframework.security.oauth2.client.token.grant.password.ResourceOwnerPasswordAccessTokenProvider; +import org.springframework.security.oauth2.common.OAuth2AccessToken; + +/** + * Pre-defined custom RequestInterceptor for Feign Requests. It uses the + * {@link OAuth2ClientContext OAuth2ClientContext} provided from the environment and + * construct a new header on the request before it is made by Feign. + * + * @author Joao Pedro Evangelista + * @author Tim Ysewyn + * @since 3.0.0 + */ +public class OAuth2FeignRequestInterceptor implements RequestInterceptor { + + /** + * The name of the token. + */ + public static final String BEARER = "Bearer"; + + /** + * The name of the header. + */ + public static final String AUTHORIZATION = "Authorization"; + + private final OAuth2ClientContext oAuth2ClientContext; + + private final OAuth2ProtectedResourceDetails resource; + + private final String tokenType; + + private final String header; + + private AccessTokenProvider accessTokenProvider = new AccessTokenProviderChain(Arrays.asList( + new AuthorizationCodeAccessTokenProvider(), new ImplicitAccessTokenProvider(), + new ResourceOwnerPasswordAccessTokenProvider(), new ClientCredentialsAccessTokenProvider())); + + /** + * Default constructor which uses the provided OAuth2ClientContext and Bearer tokens + * within Authorization header. + * @param oAuth2ClientContext provided context + * @param resource type of resource to be accessed + */ + public OAuth2FeignRequestInterceptor(OAuth2ClientContext oAuth2ClientContext, + OAuth2ProtectedResourceDetails resource) { + this(oAuth2ClientContext, resource, BEARER, AUTHORIZATION); + } + + /** + * Fully customizable constructor for changing token type and header name, in cases of + * Bearer and Authorization is not the default such as "bearer", "authorization". + * @param oAuth2ClientContext current oAuth2 Context + * @param resource type of resource to be accessed + * @param tokenType type of token e.g. "token", "Bearer" + * @param header name of the header e.g. "Authorization", "authorization" + */ + public OAuth2FeignRequestInterceptor(OAuth2ClientContext oAuth2ClientContext, + OAuth2ProtectedResourceDetails resource, String tokenType, String header) { + this.oAuth2ClientContext = oAuth2ClientContext; + this.resource = resource; + this.tokenType = tokenType; + this.header = header; + } + + /** + * Create a template with the header of provided name and extracted extract. + * + * @see RequestInterceptor#apply(RequestTemplate) + */ + @Override + public void apply(RequestTemplate template) { + template.header(header); // Clears out the header, no "clear" method available. + template.header(header, extract(tokenType)); + } + + /** + * Extracts the token extract id the access token exists or returning an empty extract + * if there is no one on the context it may occasionally causes Unauthorized response + * since the token extract is empty. + * @param tokenType type name of token + * @return token value from context if it exists otherwise empty String + */ + protected String extract(String tokenType) { + OAuth2AccessToken accessToken = getToken(); + return String.format("%s %s", tokenType, accessToken.getValue()); + } + + /** + * Extract the access token within the request or try to acquire a new one by + * delegating it to {@link #acquireAccessToken()}. + * @return valid token + */ + public OAuth2AccessToken getToken() { + + OAuth2AccessToken accessToken = oAuth2ClientContext.getAccessToken(); + if (accessToken == null || accessToken.isExpired()) { + try { + accessToken = acquireAccessToken(); + } + catch (UserRedirectRequiredException e) { + oAuth2ClientContext.setAccessToken(null); + String stateKey = e.getStateKey(); + if (stateKey != null) { + Object stateToPreserve = e.getStateToPreserve(); + if (stateToPreserve == null) { + stateToPreserve = "NONE"; + } + oAuth2ClientContext.setPreservedState(stateKey, stateToPreserve); + } + throw e; + } + } + return accessToken; + } + + /** + * Try to acquire the token using a access token provider. + * @return valid access token + * @throws UserRedirectRequiredException in case the user needs to be redirected to an + * approval page or login page + */ + protected OAuth2AccessToken acquireAccessToken() throws UserRedirectRequiredException { + AccessTokenRequest tokenRequest = oAuth2ClientContext.getAccessTokenRequest(); + if (tokenRequest == null) { + throw new AccessTokenRequiredException( + "Cannot find valid context on request for resource '" + resource.getId() + "'.", resource); + } + String stateKey = tokenRequest.getStateKey(); + if (stateKey != null) { + tokenRequest.setPreservedState(oAuth2ClientContext.removePreservedState(stateKey)); + } + OAuth2AccessToken existingToken = oAuth2ClientContext.getAccessToken(); + if (existingToken != null) { + oAuth2ClientContext.setAccessToken(existingToken); + } + OAuth2AccessToken obtainableAccessToken; + obtainableAccessToken = accessTokenProvider.obtainAccessToken(resource, tokenRequest); + if (obtainableAccessToken == null || obtainableAccessToken.getValue() == null) { + throw new IllegalStateException( + " Access token provider returned a null token, which is illegal according to the contract."); + } + oAuth2ClientContext.setAccessToken(obtainableAccessToken); + return obtainableAccessToken; + } + + public void setAccessTokenProvider(AccessTokenProvider accessTokenProvider) { + this.accessTokenProvider = accessTokenProvider; + } + +} diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockAccessTokenProvider.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockAccessTokenProvider.java new file mode 100644 index 00000000..3f8e4e2d --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockAccessTokenProvider.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.security; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails; +import org.springframework.security.oauth2.client.resource.UserApprovalRequiredException; +import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException; +import org.springframework.security.oauth2.client.token.AccessTokenProvider; +import org.springframework.security.oauth2.client.token.AccessTokenRequest; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2RefreshToken; + +/** + * Mocks the access token provider + * + * @author Mihhail Verhovtsov + */ +public class MockAccessTokenProvider implements AccessTokenProvider { + + private OAuth2AccessToken token; + + public MockAccessTokenProvider(OAuth2AccessToken token) { + this.token = token; + } + + @Override + public OAuth2AccessToken obtainAccessToken(OAuth2ProtectedResourceDetails oAuth2ProtectedResourceDetails, + AccessTokenRequest accessTokenRequest) + throws UserRedirectRequiredException, UserApprovalRequiredException, AccessDeniedException { + return token; + } + + @Override + public boolean supportsResource(OAuth2ProtectedResourceDetails oAuth2ProtectedResourceDetails) { + return true; + } + + @Override + public OAuth2AccessToken refreshAccessToken(OAuth2ProtectedResourceDetails oAuth2ProtectedResourceDetails, + OAuth2RefreshToken oAuth2RefreshToken, AccessTokenRequest accessTokenRequest) + throws UserRedirectRequiredException { + return null; + } + + @Override + public boolean supportsRefresh(OAuth2ProtectedResourceDetails oAuth2ProtectedResourceDetails) { + return false; + } + +} diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockOAuth2AccessToken.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockOAuth2AccessToken.java new file mode 100644 index 00000000..da272ff2 --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockOAuth2AccessToken.java @@ -0,0 +1,79 @@ +/* + * Copyright 2015-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.security; + +import java.util.Date; +import java.util.Map; +import java.util.Set; + +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2RefreshToken; + +/** + * Mocks the OAuth2 access token + * + * @author Mihhail Verhovtsov + */ +public class MockOAuth2AccessToken implements OAuth2AccessToken { + + private String value; + + public MockOAuth2AccessToken(String value) { + this.value = value; + } + + @Override + public Map getAdditionalInformation() { + return null; + } + + @Override + public Set getScope() { + return null; + } + + @Override + public OAuth2RefreshToken getRefreshToken() { + return null; + } + + @Override + public String getTokenType() { + return null; + } + + @Override + public boolean isExpired() { + return false; + } + + @Override + public Date getExpiration() { + return null; + } + + @Override + public int getExpiresIn() { + return 0; + } + + @Override + public String getValue() { + return value; + } + +} diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockOAuth2ClientContext.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockOAuth2ClientContext.java new file mode 100644 index 00000000..237642a9 --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/MockOAuth2ClientContext.java @@ -0,0 +1,67 @@ +/* + * Copyright 2015-2015 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.security; + +import java.util.HashMap; + +import org.springframework.security.oauth2.client.OAuth2ClientContext; +import org.springframework.security.oauth2.client.token.AccessTokenRequest; +import org.springframework.security.oauth2.client.token.DefaultAccessTokenRequest; +import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; + +/** + * Mocks the current client context + * + * @author João Pedro Evangelista + */ +final class MockOAuth2ClientContext implements OAuth2ClientContext { + + private final String value; + + MockOAuth2ClientContext(String value) { + this.value = value; + } + + @Override + public OAuth2AccessToken getAccessToken() { + return new DefaultOAuth2AccessToken(value); + } + + @Override + public void setAccessToken(OAuth2AccessToken accessToken) { + + } + + @Override + public AccessTokenRequest getAccessTokenRequest() { + DefaultAccessTokenRequest tokenRequest = new DefaultAccessTokenRequest(new HashMap()); + tokenRequest.setExistingToken(new DefaultOAuth2AccessToken(value)); + return tokenRequest; + } + + @Override + public void setPreservedState(String stateKey, Object preservedState) { + + } + + @Override + public Object removePreservedState(String stateKey) { + return null; + } + +} diff --git a/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/OAuth2FeignRequestInterceptorTests.java b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/OAuth2FeignRequestInterceptorTests.java new file mode 100644 index 00000000..b0c06f41 --- /dev/null +++ b/spring-cloud-openfeign-core/src/test/java/org/springframework/cloud/openfeign/security/OAuth2FeignRequestInterceptorTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2015-2019 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.openfeign.security; + +import java.util.Collection; +import java.util.Map; + +import feign.Request.HttpMethod; +import feign.RequestTemplate; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.security.oauth2.client.DefaultOAuth2ClientContext; +import org.springframework.security.oauth2.client.OAuth2ClientContext; +import org.springframework.security.oauth2.client.resource.BaseOAuth2ProtectedResourceDetails; +import org.springframework.security.oauth2.client.resource.OAuth2AccessDeniedException; +import org.springframework.security.oauth2.client.token.AccessTokenRequest; +import org.springframework.security.oauth2.common.OAuth2AccessToken; + +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author João Pedro Evangelista + * @author Tim Ysewyn + */ +public class OAuth2FeignRequestInterceptorTests { + + private OAuth2FeignRequestInterceptor oAuth2FeignRequestInterceptor; + + private RequestTemplate requestTemplate; + + @Before + public void setUp() { + oAuth2FeignRequestInterceptor = new OAuth2FeignRequestInterceptor(new MockOAuth2ClientContext("Fancy"), + new BaseOAuth2ProtectedResourceDetails()); + requestTemplate = new RequestTemplate().method(HttpMethod.GET); + } + + @Test + public void applyAuthorizationHeader() { + oAuth2FeignRequestInterceptor.apply(requestTemplate); + Map> headers = requestTemplate.headers(); + Assert.assertTrue("RequestTemplate must have a Authorization header", headers.containsKey("Authorization")); + Assert.assertThat("Authorization must have a extract of Fancy", headers.get("Authorization"), + contains("Bearer Fancy")); + } + + @Test(expected = OAuth2AccessDeniedException.class) + public void tryToAcquireToken() { + oAuth2FeignRequestInterceptor = new OAuth2FeignRequestInterceptor(new DefaultOAuth2ClientContext(), + new BaseOAuth2ProtectedResourceDetails()); + OAuth2AccessToken oAuth2AccessToken = oAuth2FeignRequestInterceptor.getToken(); + Assert.assertTrue(oAuth2AccessToken.getValue() + " Must be null", oAuth2AccessToken.getValue() == null); + } + + @Test + public void configureAccessTokenProvider() { + OAuth2AccessToken mockedToken = new MockOAuth2AccessToken("MOCKED_TOKEN"); + oAuth2FeignRequestInterceptor.setAccessTokenProvider(new MockAccessTokenProvider(mockedToken)); + Assert.assertEquals("Should return same mocked token instance", mockedToken, + oAuth2FeignRequestInterceptor.acquireAccessToken()); + } + + @Test + public void applyAuthorizationHeaderOnlyOnce() { + OAuth2ClientContext oAuth2ClientContext = mock(OAuth2ClientContext.class); + when(oAuth2ClientContext.getAccessToken()).thenReturn(new MockOAuth2AccessToken("MOCKED_TOKEN")); + + OAuth2FeignRequestInterceptor oAuth2FeignRequestInterceptor = new OAuth2FeignRequestInterceptor( + oAuth2ClientContext, new BaseOAuth2ProtectedResourceDetails()); + + oAuth2FeignRequestInterceptor.apply(requestTemplate); + + // First idempotent call failed, retry mechanism kicks in, and token has expired + // in the meantime + + OAuth2AccessToken expiredAccessToken = mock(OAuth2AccessToken.class); + when(expiredAccessToken.isExpired()).thenReturn(true); + when(oAuth2ClientContext.getAccessToken()).thenReturn(expiredAccessToken); + AccessTokenRequest accessTokenRequest = mock(AccessTokenRequest.class); + when(oAuth2ClientContext.getAccessTokenRequest()).thenReturn(accessTokenRequest); + OAuth2AccessToken newToken = new MockOAuth2AccessToken("Fancy"); + oAuth2FeignRequestInterceptor.setAccessTokenProvider(new MockAccessTokenProvider(newToken)); + + oAuth2FeignRequestInterceptor.apply(requestTemplate); + + Map> headers = requestTemplate.headers(); + Assert.assertTrue("RequestTemplate must have a Authorization header", headers.containsKey("Authorization")); + Assert.assertThat("Authorization must have a extract of Fancy", headers.get("Authorization"), hasSize(1)); + Assert.assertThat("Authorization must have a extract of Fancy", headers.get("Authorization"), + contains("Bearer Fancy")); + } + +} diff --git a/spring-cloud-openfeign-dependencies/pom.xml b/spring-cloud-openfeign-dependencies/pom.xml index aa0ddac1..dc948b01 100644 --- a/spring-cloud-openfeign-dependencies/pom.xml +++ b/spring-cloud-openfeign-dependencies/pom.xml @@ -17,9 +17,15 @@ 10.10.1 3.8.0 + 2.1.2.RELEASE + + org.springframework.security.oauth.boot + spring-security-oauth2-autoconfigure + ${spring-security-oauth2-autoconfigure.version} + org.springframework.cloud spring-cloud-openfeign-core