Explorar o código

Add DefaultReactiveOAuth2UserService

Issue: gh-4807
Rob Winch %!s(int64=7) %!d(string=hai) anos
pai
achega
3220e9560a

+ 157 - 0
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.
+ * <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))));
+	}
+}

+ 50 - 0
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 <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;
+
+}

+ 186 - 0
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));
+	}
+}