From f7a2a41241fad1a80ae6a41635bffa4f21c516af Mon Sep 17 00:00:00 2001 From: Rob Winch Date: Wed, 6 Jun 2018 13:43:48 -0500 Subject: [PATCH] Add OidcReactiveOAuth2UserService Issue: gh-5330 --- .../OidcReactiveOAuth2UserService.java | 95 +++++++++++ .../OidcReactiveOAuth2UserServiceTests.java | 157 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java create mode 100644 oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java new file mode 100644 index 0000000000..222a1e396d --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserService.java @@ -0,0 +1,95 @@ +/* + * 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.oidc.userinfo; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultReactiveOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import reactor.core.publisher.Mono; + +/** + * An implementation of an {@link ReactiveOAuth2UserService} that supports OpenID Connect 1.0 Provider's. + * + * @author Rob Winch + * @since 5.1 + * @see ReactiveOAuth2UserService + * @see OidcUserRequest + * @see OidcUser + * @see DefaultOidcUser + * @see OidcUserInfo + */ +public class OidcReactiveOAuth2UserService implements + ReactiveOAuth2UserService { + + private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response"; + + private ReactiveOAuth2UserService oauth2UserService = new DefaultReactiveOAuth2UserService(); + + @Override + public Mono loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { + Assert.notNull(userRequest, "userRequest cannot be null"); + return getUserInfo(userRequest) + .map(userInfo -> new OidcUserAuthority(userRequest.getIdToken(), userInfo)) + .defaultIfEmpty(new OidcUserAuthority(userRequest.getIdToken(), null)) + .map(authority -> { + OidcUserInfo userInfo = authority.getUserInfo(); + Set authorities = new HashSet<>(); + authorities.add(authority); + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + if (StringUtils.hasText(userNameAttributeName)) { + return new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo, userNameAttributeName); + } else { + return new DefaultOidcUser(authorities, userRequest.getIdToken(), userInfo); + } + }); + } + + private Mono getUserInfo(OidcUserRequest userRequest) { + if (!OidcUserRequestUtils.shouldRetrieveUserInfo(userRequest)) { + return Mono.empty(); + } + return this.oauth2UserService.loadUser(userRequest) + .map(OAuth2User::getAttributes) + .map(OidcUserInfo::new) + .doOnNext(userInfo -> { + String subject = userInfo.getSubject(); + if (subject == null || !subject.equals(userRequest.getIdToken().getSubject())) { + OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE); + throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString()); + } + }); + } + + public void setOauth2UserService(ReactiveOAuth2UserService oauth2UserService) { + Assert.notNull(oauth2UserService, "oauth2UserService cannot be null"); + this.oauth2UserService = oauth2UserService; + } +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.java new file mode 100644 index 0000000000..6da77ea8a3 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/oidc/userinfo/OidcReactiveOAuth2UserServiceTests.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.oidc.userinfo; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.ReactiveOAuth2UserService; +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.oidc.IdTokenClaimNames; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.StandardClaimNames; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * @author Rob Winch + * @since 5.1 + */ +@RunWith(MockitoJUnitRunner.class) +public class OidcReactiveOAuth2UserServiceTests { + @Mock + private ReactiveOAuth2UserService oauth2UserService; + + private ClientRegistration.Builder registration = ClientRegistration.withRegistrationId("id") + .clientAuthenticationMethod(ClientAuthenticationMethod.BASIC) + .authorizationUri("https://example.com/oauth2/authorize") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .userInfoUri("https://example.com/users/me") + .clientId("client-id") + .clientName("client-name") + .clientSecret("client-secret") + .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}") + .scope("user") + .tokenUri("https://example.com/oauth/access_token"); + + private OidcIdToken idToken = new OidcIdToken("token123", Instant.now(), + Instant.now().plusSeconds(3600), Collections + .singletonMap(IdTokenClaimNames.SUB, "sub123")); + + private OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", + Instant.now(), + Instant.now().plus(Duration.ofDays(1)), + Collections.singleton("user")); + + private OidcReactiveOAuth2UserService userService = new OidcReactiveOAuth2UserService(); + + @Before + public void setup() { + this.userService.setOauth2UserService(this.oauth2UserService); + } + + @Test + public void loadUserWhenUserInfoUriNullThenUserInfoNotRetrieved() { + this.registration.userInfoUri(null); + + OidcUser user = this.userService.loadUser(userRequest()).block(); + + assertThat(user.getUserInfo()).isNull(); + } + + @Test + public void loadUserWhenOAuth2UserEmptyThenNullUserInfo() { + when(this.oauth2UserService.loadUser(any())).thenReturn(Mono.empty()); + + OidcUser user = this.userService.loadUser(userRequest()).block(); + + assertThat(user.getUserInfo()).isNull(); + } + + @Test + public void loadUserWhenOAuth2UserSubjectNullThenOAuth2AuthenticationException() { + OAuth2User oauth2User = new DefaultOAuth2User(AuthorityUtils.createAuthorityList("ROLE_USER"), Collections.singletonMap("user", "rob"), "user"); + when(this.oauth2UserService.loadUser(any())).thenReturn(Mono.just(oauth2User)); + + assertThatCode(() -> this.userService.loadUser(userRequest()).block()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void loadUserWhenOAuth2UserSubjectNotEqualThenOAuth2AuthenticationException() { + Map attributes = new HashMap<>(); + attributes.put(StandardClaimNames.SUB, "not-equal"); + attributes.put("user", "rob"); + OAuth2User oauth2User = new DefaultOAuth2User(AuthorityUtils.createAuthorityList("ROLE_USER"), + attributes, "user"); + when(this.oauth2UserService.loadUser(any())).thenReturn(Mono.just(oauth2User)); + + assertThatCode(() -> this.userService.loadUser(userRequest()).block()) + .isInstanceOf(OAuth2AuthenticationException.class); + } + + @Test + public void loadUserWhenOAuth2UserThenUserInfoNotNull() { + Map attributes = new HashMap<>(); + attributes.put(StandardClaimNames.SUB, "sub123"); + attributes.put("user", "rob"); + OAuth2User oauth2User = new DefaultOAuth2User(AuthorityUtils.createAuthorityList("ROLE_USER"), + attributes, "user"); + when(this.oauth2UserService.loadUser(any())).thenReturn(Mono.just(oauth2User)); + + assertThat(this.userService.loadUser(userRequest()).block().getUserInfo()).isNotNull(); + } + + @Test + public void loadUserWhenOAuth2UserAndUser() { + this.registration.userNameAttributeName("user"); + Map attributes = new HashMap<>(); + attributes.put(StandardClaimNames.SUB, "sub123"); + attributes.put("user", "rob"); + OAuth2User oauth2User = new DefaultOAuth2User(AuthorityUtils.createAuthorityList("ROLE_USER"), + attributes, "user"); + when(this.oauth2UserService.loadUser(any())).thenReturn(Mono.just(oauth2User)); + + assertThat(this.userService.loadUser(userRequest()).block().getName()).isEqualTo("rob"); + } + + private OidcUserRequest userRequest() { + return new OidcUserRequest(this.registration.build(), this.accessToken, this.idToken); + } +}