diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/InMemoryReactiveOAuth2AuthorizedClientService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/InMemoryReactiveOAuth2AuthorizedClientService.java new file mode 100644 index 0000000000..df616df02f --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/InMemoryReactiveOAuth2AuthorizedClientService.java @@ -0,0 +1,90 @@ +/* + * 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; + +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.util.Assert; + +import reactor.core.publisher.Mono; + +/** + * An {@link OAuth2AuthorizedClientService} that stores + * {@link OAuth2AuthorizedClient Authorized Client(s)} in-memory. + * + * @author Rob Winch + * @since 5.1 + * @see OAuth2AuthorizedClientService + * @see OAuth2AuthorizedClient + * @see ClientRegistration + * @see Authentication + */ +public final class InMemoryReactiveOAuth2AuthorizedClientService implements ReactiveOAuth2AuthorizedClientService { + private final Map authorizedClients = new ConcurrentHashMap<>(); + private final ReactiveClientRegistrationRepository clientRegistrationRepository; + + /** + * Constructs an {@code InMemoryOAuth2AuthorizedClientService} using the provided parameters. + * + * @param clientRegistrationRepository the repository of client registrations + */ + public InMemoryReactiveOAuth2AuthorizedClientService(ReactiveClientRegistrationRepository clientRegistrationRepository) { + Assert.notNull(clientRegistrationRepository, "clientRegistrationRepository cannot be null"); + this.clientRegistrationRepository = clientRegistrationRepository; + } + + @Override + public Mono loadAuthorizedClient(String clientRegistrationId, String principalName) { + Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty"); + Assert.hasText(principalName, "principalName cannot be empty"); + return (Mono) getIdentifier(clientRegistrationId, principalName) + .flatMap(identifier -> Mono.justOrEmpty(this.authorizedClients.get(identifier))); + } + + @Override + public Mono saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, Authentication principal) { + Assert.notNull(authorizedClient, "authorizedClient cannot be null"); + Assert.notNull(principal, "principal cannot be null"); + return Mono.fromRunnable(() -> { + String identifier = this.getIdentifier(authorizedClient.getClientRegistration(), principal.getName()); + this.authorizedClients.put(identifier, authorizedClient); + }); + } + + @Override + public Mono removeAuthorizedClient(String clientRegistrationId, String principalName) { + Assert.hasText(clientRegistrationId, "clientRegistrationId cannot be empty"); + Assert.hasText(principalName, "principalName cannot be empty"); + return this.getIdentifier(clientRegistrationId, principalName) + .doOnNext(identifier -> this.authorizedClients.remove(identifier)) + .then(Mono.empty()); + } + + private Mono getIdentifier(String clientRegistrationId, String principalName) { + return this.clientRegistrationRepository.findByRegistrationId(clientRegistrationId) + .map(registration -> getIdentifier(registration, principalName)); + } + + private String getIdentifier(ClientRegistration registration, String principalName) { + String identifier = "[" + registration.getRegistrationId() + "][" + principalName + "]"; + return Base64.getEncoder().encodeToString(identifier.getBytes()); + } +} diff --git a/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ReactiveOAuth2AuthorizedClientService.java b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ReactiveOAuth2AuthorizedClientService.java new file mode 100644 index 0000000000..2f7c66eba7 --- /dev/null +++ b/oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/ReactiveOAuth2AuthorizedClientService.java @@ -0,0 +1,73 @@ +/* + * 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; + +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.OAuth2AccessToken; + +import reactor.core.publisher.Mono; + +/** + * Implementations of this interface are responsible for the management + * of {@link OAuth2AuthorizedClient Authorized Client(s)}, which provide the purpose + * of associating an {@link OAuth2AuthorizedClient#getAccessToken() Access Token} credential + * to a {@link OAuth2AuthorizedClient#getClientRegistration() Client} and Resource Owner, + * who is the {@link OAuth2AuthorizedClient#getPrincipalName() Principal} + * that originally granted the authorization. + * + * @author Rob Winch + * @since 5.1 + * @see OAuth2AuthorizedClient + * @see ClientRegistration + * @see Authentication + * @see OAuth2AccessToken + */ +public interface ReactiveOAuth2AuthorizedClientService { + + /** + * Returns the {@link OAuth2AuthorizedClient} associated to the + * provided client registration identifier and End-User's {@code Principal} name + * or {@code null} if not available. + * + * @param clientRegistrationId the identifier for the client's registration + * @param principalName the name of the End-User {@code Principal} (Resource Owner) + * @param a type of OAuth2AuthorizedClient + * @return the {@link OAuth2AuthorizedClient} or {@code null} if not available + */ + Mono loadAuthorizedClient(String clientRegistrationId, + String principalName); + + /** + * Saves the {@link OAuth2AuthorizedClient} associating it to + * the provided End-User {@link Authentication} (Resource Owner). + * + * @param authorizedClient the authorized client + * @param principal the End-User {@link Authentication} (Resource Owner) + */ + Mono saveAuthorizedClient(OAuth2AuthorizedClient authorizedClient, + Authentication principal); + + /** + * Removes the {@link OAuth2AuthorizedClient} associated to the + * provided client registration identifier and End-User's {@code Principal} name. + * + * @param clientRegistrationId the identifier for the client's registration + * @param principalName the name of the End-User {@code Principal} (Resource Owner) + */ + Mono removeAuthorizedClient(String clientRegistrationId, String principalName); + +} diff --git a/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/InMemoryReactiveOAuth2AuthorizedClientServiceTests.java b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/InMemoryReactiveOAuth2AuthorizedClientServiceTests.java new file mode 100644 index 0000000000..6828059792 --- /dev/null +++ b/oauth2/oauth2-client/src/test/java/org/springframework/security/oauth2/client/InMemoryReactiveOAuth2AuthorizedClientServiceTests.java @@ -0,0 +1,211 @@ +/* + * 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; + +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.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.when; + +/** + * @author Rob Winch + * @since 5.1 + */ +@RunWith(MockitoJUnitRunner.class) +public class InMemoryReactiveOAuth2AuthorizedClientServiceTests { + @Mock + private ReactiveClientRegistrationRepository clientRegistrationRepository; + + private InMemoryReactiveOAuth2AuthorizedClientService authorizedClientService; + + private String clientRegistrationId = "github"; + + private String principalName = "username"; + + private Authentication principal = new TestingAuthenticationToken(this.principalName, "notused"); + + OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, + "token", + Instant.now(), + Instant.now().plus(Duration.ofDays(1))); + + private ClientRegistration clientRegistration = ClientRegistration.withRegistrationId(this.clientRegistrationId) + .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("https://api.github.com/user") + .userNameAttributeName("id") + .clientName("GitHub") + .clientId("clientId") + .clientSecret("clientSecret") + .build(); + + @Before + public void setup() { + this.authorizedClientService = new InMemoryReactiveOAuth2AuthorizedClientService( + this.clientRegistrationRepository); + } + + @Test + public void constructorNullClientRegistrationRepositoryThenThrowsIllegalArgumentException() { + this.clientRegistrationRepository = null; + assertThatThrownBy(() -> new InMemoryReactiveOAuth2AuthorizedClientService(this.clientRegistrationRepository)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void loadAuthorizedClientWhenClientRegistrationIdNullThenIllegalArgumentException() { + this.clientRegistrationId = null; + assertThatThrownBy(() -> this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void loadAuthorizedClientWhenClientRegistrationIdEmptyThenIllegalArgumentException() { + this.clientRegistrationId = ""; + assertThatThrownBy(() -> this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void loadAuthorizedClientWhenPrincipalNameNullThenIllegalArgumentException() { + this.principalName = null; + assertThatThrownBy(() -> this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void loadAuthorizedClientWhenPrincipalNameEmptyThenIllegalArgumentException() { + this.principalName = ""; + assertThatThrownBy(() -> this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void loadAuthorizedClientWhenClientRegistrationIdNotFoundThenEmpty() { + when(this.clientRegistrationRepository.findByRegistrationId(this.clientRegistrationId)) + .thenReturn(Mono.empty()); + StepVerifier + .create(this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .verifyComplete(); + } + + @Test + public void loadAuthorizedClientWhenClientRegistrationFoundAndNotAuthorizedClientThenEmpty() { + when(this.clientRegistrationRepository.findByRegistrationId(this.clientRegistrationId)).thenReturn(Mono.just(this.clientRegistration)); + StepVerifier + .create(this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .verifyComplete(); + } + + @Test + public void loadAuthorizedClientWhenClientRegistrationFoundThenFound() { + when(this.clientRegistrationRepository.findByRegistrationId(this.clientRegistrationId)).thenReturn(Mono.just(this.clientRegistration)); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, this.principalName, this.accessToken); + Mono saveAndLoad = this.authorizedClientService.saveAuthorizedClient(authorizedClient, this.principal) + .then(this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)); + + StepVerifier.create(saveAndLoad) + .expectNext(authorizedClient) + .verifyComplete(); + } + + @Test + public void saveAuthorizedClientWhenAuthorizedClientNullThenIllegalArgumentException() { + OAuth2AuthorizedClient authorizedClient = null; + assertThatThrownBy(() -> this.authorizedClientService.saveAuthorizedClient(authorizedClient, this.principal)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void saveAuthorizedClientWhenPrincipalNullThenIllegalArgumentException() { + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, this.principalName, this.accessToken); + this.principal = null; + assertThatThrownBy(() -> this.authorizedClientService.saveAuthorizedClient(authorizedClient, this.principal)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void removeAuthorizedClientWhenClientRegistrationIdNullThenIllegalArgumentException() { + this.clientRegistrationId = null; + assertThatThrownBy(() -> this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void removeAuthorizedClientWhenClientRegistrationIdEmptyThenIllegalArgumentException() { + this.clientRegistrationId = ""; + assertThatThrownBy(() -> this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void removeAuthorizedClientWhenPrincipalNameNullThenIllegalArgumentException() { + this.principalName = null; + assertThatThrownBy(() -> this.authorizedClientService.removeAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void removeAuthorizedClientWhenPrincipalNameEmptyThenIllegalArgumentException() { + this.principalName = ""; + assertThatThrownBy(() -> this.authorizedClientService.removeAuthorizedClient(this.clientRegistrationId, this.principalName)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void removeAuthorizedClientWhenClientIdThenNoException() { + when(this.clientRegistrationRepository.findByRegistrationId(this.clientRegistrationId)).thenReturn(Mono.empty()); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, this.principalName, this.accessToken); + Mono saveAndDeleteAndLoad = this.authorizedClientService.saveAuthorizedClient(authorizedClient, this.principal) + .then(this.authorizedClientService.removeAuthorizedClient(this.clientRegistrationId, this.principalName)); + + StepVerifier.create(saveAndDeleteAndLoad) + .verifyComplete(); + } + + @Test + public void removeAuthorizedClientWhenClientRegistrationFoundRemovedThenNotFound() { + when(this.clientRegistrationRepository.findByRegistrationId(this.clientRegistrationId)).thenReturn(Mono.just(this.clientRegistration)); + OAuth2AuthorizedClient authorizedClient = new OAuth2AuthorizedClient(this.clientRegistration, this.principalName, this.accessToken); + Mono saveAndDeleteAndLoad = this.authorizedClientService.saveAuthorizedClient(authorizedClient, this.principal) + .then(this.authorizedClientService.removeAuthorizedClient(this.clientRegistrationId, this.principalName)) + .then(this.authorizedClientService.loadAuthorizedClient(this.clientRegistrationId, this.principalName)); + + StepVerifier.create(saveAndDeleteAndLoad) + .verifyComplete(); + } +}