From 3220e9560a9e27c4db28087dfb26d89e0ed1919c Mon Sep 17 00:00:00 2001 From: Rob Winch Date: Wed, 2 May 2018 11:14:02 -0500 Subject: [PATCH] Add DefaultReactiveOAuth2UserService Issue: gh-4807 --- .../DefaultReactiveOAuth2UserService.java | 157 +++++++++++++++ .../userinfo/ReactiveOAuth2UserService.java | 50 +++++ ...DefaultReactiveOAuth2UserServiceTests.java | 186 ++++++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/ReactiveOAuth2UserService.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java new file mode 100644 index 0000000000..90a79a7bf8 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserService.java @@ -0,0 +1,157 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.security.oauth2.client.userinfo; + +import java.net.UnknownHostException; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +import com.nimbusds.oauth2.sdk.ErrorObject; +import com.nimbusds.openid.connect.sdk.UserInfoErrorResponse; + +import net.minidev.json.JSONObject; +import reactor.core.publisher.Mono; + +/** + * An implementation of an {@link ReactiveOAuth2UserService} that supports standard OAuth 2.0 Provider's. + *

+ * For standard OAuth 2.0 Provider's, the attribute name used to access the user's name + * from the UserInfo response is required and therefore must be available via + * {@link org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails.UserInfoEndpoint#getUserNameAttributeName() UserInfoEndpoint.getUserNameAttributeName()}. + *

+ * NOTE: Attribute names are not standardized between providers and therefore will vary. + * Please consult the provider's API documentation for the set of supported user attribute names. + * + * @author Rob Winch + * @since 5.1 + * @see ReactiveOAuth2UserService + * @see OAuth2UserRequest + * @see OAuth2User + * @see DefaultOAuth2User + */ +public class DefaultReactiveOAuth2UserService implements ReactiveOAuth2UserService { + private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response"; + private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri"; + private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute"; + + private WebClient webClient = WebClient.create(); + + @Override + public Mono loadUser(OAuth2UserRequest userRequest) + throws OAuth2AuthenticationException { + return Mono.defer(() -> { + Assert.notNull(userRequest, "userRequest cannot be null"); + + String userInfoUri = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUri(); + if (!StringUtils.hasText( + userInfoUri)) { + OAuth2Error oauth2Error = new OAuth2Error( + MISSING_USER_INFO_URI_ERROR_CODE, + "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + + userRequest.getClientRegistration().getRegistrationId(), + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint() + .getUserNameAttributeName(); + if (!StringUtils.hasText(userNameAttributeName)) { + OAuth2Error oauth2Error = new OAuth2Error( + MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE, + "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + + userRequest.getClientRegistration().getRegistrationId(), + null); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + + ParameterizedTypeReference> typeReference = new ParameterizedTypeReference>() { + }; + + Mono> userAttributes = this.webClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, + "Bearer " + userRequest.getAccessToken().getTokenValue()) + .retrieve() + .onStatus(s -> s != HttpStatus.OK, response -> { + return parse(response).map(userInfoErrorResponse -> { + String description = userInfoErrorResponse.getErrorObject().getDescription(); + OAuth2Error oauth2Error = new OAuth2Error( + INVALID_USER_INFO_RESPONSE_ERROR_CODE, description, + null); + throw new OAuth2AuthenticationException(oauth2Error, + oauth2Error.toString()); + }); + }) + .bodyToMono(typeReference); + + return userAttributes.map(attrs -> { + GrantedAuthority authority = new OAuth2UserAuthority(attrs); + Set authorities = new HashSet<>(); + authorities.add(authority); + + return new DefaultOAuth2User(authorities, attrs, userNameAttributeName); + }) + .onErrorMap(UnknownHostException.class, t -> new AuthenticationServiceException("Unable to access the userInfoEndpoint " + userInfoUri, t)) + .onErrorMap(t -> !(t instanceof AuthenticationServiceException), t -> { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE, "An error occurred reading the UserInfo Success response: " + t.getMessage(), null); + return new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), t); + }); + }); + } + + /** + * Sets the {@link WebClient} used for retrieving the user endpoint + * @param webClient the client to use + */ + public void setWebClient(WebClient webClient) { + Assert.notNull(webClient, "webClient cannot be null"); + this.webClient = webClient; + } + + private static Mono parse(ClientResponse httpResponse) { + + String wwwAuth = httpResponse.headers().asHttpHeaders().getFirst(HttpHeaders.WWW_AUTHENTICATE); + + if (!StringUtils.isEmpty(wwwAuth)) { + // Bearer token error? + return Mono.fromCallable(() -> UserInfoErrorResponse.parse(wwwAuth)); + } + + ParameterizedTypeReference> typeReference = + new ParameterizedTypeReference>() {}; + // Other error? + return httpResponse + .bodyToMono(typeReference) + .map(body -> new UserInfoErrorResponse(ErrorObject.parse(new JSONObject(body)))); + } +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/ReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/ReactiveOAuth2UserService.java new file mode 100644 index 0000000000..d1fd147433 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/userinfo/ReactiveOAuth2UserService.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.security.oauth2.client.userinfo; + +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import reactor.core.publisher.Mono; + +/** + * Implementations of this interface are responsible for obtaining the user attributes + * of the End-User (Resource Owner) from the UserInfo Endpoint + * using the {@link OAuth2UserRequest#getAccessToken() Access Token} + * granted to the {@link OAuth2UserRequest#getClientRegistration() Client} + * and returning an {@link AuthenticatedPrincipal} in the form of an {@link OAuth2User}. + * + * @author Rob Winch + * @since 5.1 + * @see OAuth2UserRequest + * @see OAuth2User + * @see AuthenticatedPrincipal + * + * @param The type of OAuth 2.0 User Request + * @param The type of OAuth 2.0 User + */ +public interface ReactiveOAuth2UserService { + + /** + * Returns an {@link OAuth2User} after obtaining the user attributes of the End-User from the UserInfo Endpoint. + * + * @param userRequest the user request + * @return an {@link OAuth2User} + * @throws OAuth2AuthenticationException if an error occurs while attempting to obtain the user attributes from the UserInfo Endpoint + */ + Mono loadUser(R userRequest) throws OAuth2AuthenticationException; + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java new file mode 100644 index 0000000000..70090f3d89 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/userinfo/DefaultReactiveOAuth2UserServiceTests.java @@ -0,0 +1,186 @@ +/* + * Copyright 2002-2018 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 + * + * http://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.security.oauth2.client.userinfo; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.*; + +/** + * @author Rob Winch + * @since 5.1 + */ +public class DefaultReactiveOAuth2UserServiceTests { + private ClientRegistration.Builder clientRegistration; + + private DefaultReactiveOAuth2UserService userService = new DefaultReactiveOAuth2UserService(); + + private OAuth2AccessToken accessToken = new OAuth2AccessToken( + OAuth2AccessToken.TokenType.BEARER, "access-token", Instant.now(), Instant.now().plus(Duration.ofDays(1))); + + private MockWebServer server; + + @Before + public void setup() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + + String userInfoUri = this.server.url("/user").toString(); + + this.clientRegistration = ClientRegistration.withRegistrationId("github") + .redirectUriTemplate("{baseUrl}/{action}/oauth2/code/{registrationId}") + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .scope("read:user") + .authorizationUri("https://github.com/login/oauth/authorize") + .tokenUri("https://github.com/login/oauth/access_token") + .userInfoUri(userInfoUri) + .userNameAttributeName("user-name") + .clientName("GitHub") + .clientId("clientId") + .clientSecret("clientSecret"); + } + + @After + public void cleanup() throws Exception { + this.server.shutdown(); + } + + @Test + public void loadUserWhenUserRequestIsNullThenThrowIllegalArgumentException() { + OAuth2UserRequest request = null; + StepVerifier.create(this.userService.loadUser(request)) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + public void loadUserWhenUserInfoUriIsNullThenThrowOAuth2AuthenticationException() { + this.clientRegistration.userInfoUri(null); + + StepVerifier.create(this.userService.loadUser(oauth2UserRequest())) + .expectErrorSatisfies(t -> assertThat(t) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("missing_user_info_uri") + ) + .verify(); + } + + @Test + public void loadUserWhenUserNameAttributeNameIsNullThenThrowOAuth2AuthenticationException() { + this.clientRegistration.userNameAttributeName(null); + + StepVerifier.create(this.userService.loadUser(oauth2UserRequest())) + .expectErrorSatisfies(t -> assertThat(t) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("missing_user_name_attribute") + ) + .verify(); + } + + @Test + public void loadUserWhenUserInfoSuccessResponseThenReturnUser() throws Exception { + String userInfoResponse = "{\n" + + " \"user-name\": \"user1\",\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n" + + "}\n"; + enqueueApplicationJsonBody(userInfoResponse); + + OAuth2User user = this.userService.loadUser(oauth2UserRequest()).block(); + + assertThat(user.getName()).isEqualTo("user1"); + assertThat(user.getAttributes().size()).isEqualTo(6); + assertThat(user.getAttributes().get("user-name")).isEqualTo("user1"); + assertThat(user.getAttributes().get("first-name")).isEqualTo("first"); + assertThat(user.getAttributes().get("last-name")).isEqualTo("last"); + assertThat(user.getAttributes().get("middle-name")).isEqualTo("middle"); + assertThat(user.getAttributes().get("address")).isEqualTo("address"); + assertThat(user.getAttributes().get("email")).isEqualTo("user1@example.com"); + + assertThat(user.getAuthorities().size()).isEqualTo(1); + assertThat(user.getAuthorities().iterator().next()).isInstanceOf(OAuth2UserAuthority.class); + OAuth2UserAuthority userAuthority = (OAuth2UserAuthority) user.getAuthorities().iterator().next(); + assertThat(userAuthority.getAuthority()).isEqualTo("ROLE_USER"); + assertThat(userAuthority.getAttributes()).isEqualTo(user.getAttributes()); + } + + @Test + public void loadUserWhenUserInfoSuccessResponseInvalidThenThrowOAuth2AuthenticationException() throws Exception { + String userInfoResponse = "{\n" + + " \"user-name\": \"user1\",\n" + + " \"first-name\": \"first\",\n" + + " \"last-name\": \"last\",\n" + + " \"middle-name\": \"middle\",\n" + + " \"address\": \"address\",\n" + + " \"email\": \"user1@example.com\"\n"; + // "}\n"; // Make the JSON invalid/malformed + enqueueApplicationJsonBody(userInfoResponse); + + assertThatThrownBy(() -> this.userService.loadUser(oauth2UserRequest()).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("invalid_user_info_response"); + } + + @Test + public void loadUserWhenUserInfoErrorResponseThenThrowOAuth2AuthenticationException() throws Exception { + this.server.enqueue(new MockResponse().setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).setResponseCode(500).setBody("{}")); + + assertThatThrownBy(() -> this.userService.loadUser(oauth2UserRequest()).block()) + .isInstanceOf(OAuth2AuthenticationException.class) + .hasMessageContaining("invalid_user_info_response"); + } + + @Test + public void loadUserWhenUserInfoUriInvalidThenThrowAuthenticationServiceException() throws Exception { + this.clientRegistration.userInfoUri("http://invalid-provider.com/user"); + assertThatThrownBy(() -> this.userService.loadUser(oauth2UserRequest()).block()) + .isInstanceOf(AuthenticationServiceException.class); + } + + private OAuth2UserRequest oauth2UserRequest() { + return new OAuth2UserRequest(this.clientRegistration.build(), this.accessToken); + } + + private void enqueueApplicationJsonBody(String json) { + + this.server.enqueue(new MockResponse() + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .setBody(json)); + } +}