Add DefaultReactiveOAuth2UserService

Issue: gh-4807
This commit is contained in:
Rob Winch 2018-05-02 11:14:02 -05:00
parent b613b2d253
commit 3220e9560a
3 changed files with 393 additions and 0 deletions

View File

@ -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.
* <p>
* 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()}.
* <p>
* <b>NOTE:</b> Attribute names are <b>not</b> 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<OAuth2UserRequest, OAuth2User> {
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<OAuth2User> 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<Map<String, Object>> typeReference = new ParameterizedTypeReference<Map<String, Object>>() {
};
Mono<Map<String, Object>> 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<GrantedAuthority> 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<UserInfoErrorResponse> 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<Map<String, String>> typeReference =
new ParameterizedTypeReference<Map<String, String>>() {};
// Other error?
return httpResponse
.bodyToMono(typeReference)
.map(body -> new UserInfoErrorResponse(ErrorObject.parse(new JSONObject(body))));
}
}

View File

@ -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 <R> The type of OAuth 2.0 User Request
* @param <U> The type of OAuth 2.0 User
*/
public interface ReactiveOAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {
/**
* 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<U> loadUser(R userRequest) throws OAuth2AuthenticationException;
}

View File

@ -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));
}
}