parent
b613b2d253
commit
3220e9560a
|
@ -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))));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue