diff --git a/pom.xml b/pom.xml index 6cacae4f0..bf0c5539c 100644 --- a/pom.xml +++ b/pom.xml @@ -216,6 +216,11 @@ ${jackson.version} + + + org.springframework.security + spring-security-oauth2-client + diff --git a/src/main/java/io/codeka/gaia/config/security/oauth2/OAuth2ClientSecurityConfig.java b/src/main/java/io/codeka/gaia/config/security/oauth2/OAuth2ClientSecurityConfig.java new file mode 100644 index 000000000..298cd9232 --- /dev/null +++ b/src/main/java/io/codeka/gaia/config/security/oauth2/OAuth2ClientSecurityConfig.java @@ -0,0 +1,42 @@ +package io.codeka.gaia.config.security.oauth2; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.ClientsConfiguredCondition; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; + +@Configuration +@Order(70) +@Conditional(ClientsConfiguredCondition.class) +public class OAuth2ClientSecurityConfig extends WebSecurityConfigurerAdapter { + + private OAuth2SuccessHandler oAuth2SuccessHandler; + + @Autowired + public OAuth2ClientSecurityConfig(OAuth2SuccessHandler oAuth2SuccessHandler) { + this.oAuth2SuccessHandler = oAuth2SuccessHandler; + } + + @Override + protected void configure(HttpSecurity http) throws Exception { + var requestMatcher = new OrRequestMatcher( + // connection to oauth2 client + new AntPathRequestMatcher("/oauth2/authorization/*"), + // oauth2 client callback + new AntPathRequestMatcher("/login/oauth2/code/*") + ); + http + .requestMatcher(requestMatcher) + .authorizeRequests() + .anyRequest().permitAll() + .and() + .oauth2Login() + .loginPage("/login") + .successHandler(oAuth2SuccessHandler).permitAll(); + } +} diff --git a/src/main/java/io/codeka/gaia/config/security/oauth2/OAuth2SuccessHandler.java b/src/main/java/io/codeka/gaia/config/security/oauth2/OAuth2SuccessHandler.java new file mode 100644 index 000000000..fc1efd635 --- /dev/null +++ b/src/main/java/io/codeka/gaia/config/security/oauth2/OAuth2SuccessHandler.java @@ -0,0 +1,58 @@ +package io.codeka.gaia.config.security.oauth2; + +import io.codeka.gaia.config.security.SuccessHandler; +import io.codeka.gaia.registries.RegistryOAuth2Provider; +import io.codeka.gaia.teams.OAuth2User; +import io.codeka.gaia.teams.User; +import io.codeka.gaia.teams.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.ClientsConfiguredCondition; +import org.springframework.context.annotation.Conditional; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.stereotype.Component; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; + +@Component +@Conditional(ClientsConfiguredCondition.class) +public class OAuth2SuccessHandler extends SuccessHandler { + + private List registryOAuth2Providers; + private OAuth2AuthorizedClientService oAuth2AuthorizedClientService; + + @Autowired + public OAuth2SuccessHandler(UserRepository userRepository, List registryOAuth2Providers, + OAuth2AuthorizedClientService oAuth2AuthorizedClientService) { + super(userRepository); + this.registryOAuth2Providers = registryOAuth2Providers; + this.oAuth2AuthorizedClientService = oAuth2AuthorizedClientService; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + // get user if exist, otherwise create a new one + var user = userRepository.findById(authentication.getName()) + .orElse(new User(authentication.getName(), null)); + // get oauth2 data + user.setOAuth2User(getOAuth2User((OAuth2AuthenticationToken) authentication)); + userRepository.save(user); + redirect(request, response); + } + + private OAuth2User getOAuth2User(OAuth2AuthenticationToken authentication) { + var client = oAuth2AuthorizedClientService.loadAuthorizedClient( + authentication.getAuthorizedClientRegistrationId(), + authentication.getName()); + var user = (DefaultOAuth2User) authentication.getPrincipal(); + return registryOAuth2Providers.stream() + .filter(p -> p.isAssignableFor(client.getClientRegistration().getRegistrationId())) + .map(p -> p.getOAuth2User(user, client)) + .findFirst().orElse(null); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2fdcb4393..91afb00fb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -18,3 +18,20 @@ gaia.dockerDaemonUrl=unix:///var/run/docker.sock terraform.releases.url=https://releases.hashicorp.com/terraform/ terraform.releases.version.min=0.11.13 + +## oauth2 for gitlab +#spring.security.oauth2.client.registration.gitlab.client-id= +#spring.security.oauth2.client.registration.gitlab.client-secret= +#spring.security.oauth2.client.registration.gitlab.authorization-grant-type=authorization_code +#spring.security.oauth2.client.registration.gitlab.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} +#spring.security.oauth2.client.provider.gitlab.authorization-uri=https://gitlab.com/oauth/authorize +#spring.security.oauth2.client.provider.gitlab.token-uri=https://gitlab.com/oauth/token +#spring.security.oauth2.client.provider.gitlab.user-info-uri=https://gitlab.com/api/v4/user +#spring.security.oauth2.client.provider.gitlab.user-name-attribute=username + +## oauth2 for github +#spring.security.oauth2.client.registration.github.client-id= +#spring.security.oauth2.client.registration.github.client-secret= +#spring.security.oauth2.client.registration.github.authorization-grant-type=authorization_code +#spring.security.oauth2.client.registration.github.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} +#spring.security.oauth2.client.provider.github.user-name-attribute=login diff --git a/src/test/java/io/codeka/gaia/config/security/oauth2/OAuth2ClientSecurityConfigIT.java b/src/test/java/io/codeka/gaia/config/security/oauth2/OAuth2ClientSecurityConfigIT.java new file mode 100644 index 000000000..f2ca79f7b --- /dev/null +++ b/src/test/java/io/codeka/gaia/config/security/oauth2/OAuth2ClientSecurityConfigIT.java @@ -0,0 +1,61 @@ +package io.codeka.gaia.config.security.oauth2; + +import io.codeka.gaia.test.MongoContainer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@DirtiesContext +@Testcontainers +class OAuth2ClientSecurityConfigIT { + + @Container + private static MongoContainer mongoContainer = new MongoContainer(); + + @Nested + @SpringBootTest + class OAuth2ClientSecurityConfigNotLoadedTest { + @Test + void oauth2ClientSecurityConfig_shouldNotBeInstantiated( + @Autowired(required = false) OAuth2ClientSecurityConfig oauth2ClientSecurityConfig) { + assertNull(oauth2ClientSecurityConfig); + } + + @Test + void oAuth2SuccessHandler_shouldNotBeInstantiated( + @Autowired(required = false) OAuth2SuccessHandler oAuth2SuccessHandler) { + assertNull(oAuth2SuccessHandler); + } + } + + @Nested + @SpringBootTest(properties = { + "spring.security.oauth2.client.registration.test_oauth2_client.client-id=ID", + "spring.security.oauth2.client.registration.test_oauth2_client.client-secret=SECRET", + "spring.security.oauth2.client.registration.test_oauth2_client.authorization-grant-type=authorization_code", + "spring.security.oauth2.client.registration.test_oauth2_client.redirect-uri=REDIRECT_URI", + "spring.security.oauth2.client.provider.test_oauth2_client.authorization-uri=AUTHORIZATION_URI", + "spring.security.oauth2.client.provider.test_oauth2_client.token-uri=TOKEN_URI", + "spring.security.oauth2.client.provider.test_oauth2_client.user-info-uri=USER_INFO_URI", + }) + class OAuth2ClientSecurityConfigLoadedTest { + @Test + void oauth2ClientSecurityConfig_shouldBeInstantiated( + @Autowired(required = false) OAuth2ClientSecurityConfig oauth2ClientSecurityConfig) { + assertNotNull(oauth2ClientSecurityConfig); + } + + @Test + void oAuth2SuccessHandler_shouldBeInstantiated( + @Autowired(required = false) OAuth2SuccessHandler oAuth2SuccessHandler) { + assertNotNull(oAuth2SuccessHandler); + } + } +} \ No newline at end of file diff --git a/src/test/java/io/codeka/gaia/config/security/oauth2/OAuth2SuccessHandlerTest.java b/src/test/java/io/codeka/gaia/config/security/oauth2/OAuth2SuccessHandlerTest.java new file mode 100644 index 000000000..0c229d2e9 --- /dev/null +++ b/src/test/java/io/codeka/gaia/config/security/oauth2/OAuth2SuccessHandlerTest.java @@ -0,0 +1,148 @@ +package io.codeka.gaia.config.security.oauth2; + +import io.codeka.gaia.registries.RegistryOAuth2Provider; +import io.codeka.gaia.teams.OAuth2User; +import io.codeka.gaia.teams.User; +import io.codeka.gaia.teams.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.web.savedrequest.DefaultSavedRequest; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OAuth2SuccessHandlerTest { + + @Mock + UserRepository userRepository; + + @Mock + OAuth2AuthorizedClientService oAuth2AuthorizedClientService; + + @Mock + HttpServletRequest request; + + @Mock + HttpServletResponse response; + + @Mock + OAuth2AuthenticationToken authentication; + + @Mock + HttpSession httpSession; + + private OAuth2SuccessHandler oAuth2SuccessHandler; + private List registryOAuth2Providers; + + @BeforeEach + void setup() { + registryOAuth2Providers = new ArrayList<>(); + oAuth2SuccessHandler = new OAuth2SuccessHandler(userRepository, registryOAuth2Providers, oAuth2AuthorizedClientService); + + when(authentication.getName()).thenReturn("django"); + when(request.getSession()).thenReturn(httpSession); + } + + @Test + void onAuthenticationSuccess_shouldCreateUser_whenNotExists() throws IOException { + // when + when(userRepository.findById(anyString())).thenReturn(Optional.empty()); + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + var captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertThat(captor.getValue()).isNotNull() + .hasFieldOrPropertyWithValue("username", "django") + .hasFieldOrPropertyWithValue("team", null); + } + + @Test + void onAuthenticationSuccess_shouldUpdateUser_whenExists() throws IOException { + // given + var user = new User("calvin", null); + + // when + when(userRepository.findById(anyString())).thenReturn(Optional.of(user)); + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + verify(userRepository).save(user); + } + + @Test + void onAuthenticationSuccess_shouldFillOAuth2User() throws IOException { + // given + var user = new User("frankie", null); + var oauth2User = new OAuth2User("tarantino", "unchained", null); + var client = mock(OAuth2AuthorizedClient.class); + var registration = ClientRegistration + .withRegistrationId("test_registration_id") + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientId("test_client_id") + .redirectUriTemplate("test_uri_template") + .authorizationUri("test_authorization_uri") + .tokenUri("test_token_uri") + .build(); + var principal = mock(DefaultOAuth2User.class); + var oauth2Provider = mock(RegistryOAuth2Provider.class); + registryOAuth2Providers.add(oauth2Provider); + + // when + when(userRepository.findById(anyString())).thenReturn(Optional.of(user)); + when(oAuth2AuthorizedClientService.loadAuthorizedClient(any(), anyString())).thenReturn(client); + when(oauth2Provider.isAssignableFor(anyString())).thenReturn(true); + when(oauth2Provider.getOAuth2User(principal, client)).thenReturn(oauth2User); + when(client.getClientRegistration()).thenReturn(registration); + when(authentication.getPrincipal()).thenReturn(principal); + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + assertThat(user).hasFieldOrPropertyWithValue("oAuth2User", oauth2User); + } + + @Test + void onAuthenticationSuccess_shouldRedirectToHomePage() throws IOException { + // when + when(userRepository.findById(anyString())).thenReturn(Optional.empty()); + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + verify(response).sendRedirect("/"); + } + + @Test + void onAuthenticationSuccess_shouldRedirectToAskedPage_whenSpecified() throws IOException { + // given + var savedRequest = mock(DefaultSavedRequest.class); + + // when + when(userRepository.findById(anyString())).thenReturn(Optional.empty()); + when(httpSession.getAttribute("SPRING_SECURITY_SAVED_REQUEST")).thenReturn(savedRequest); + when(savedRequest.getRequestURI()).thenReturn("/test_url"); + oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); + + // then + verify(response).sendRedirect("/test_url"); + } +} \ No newline at end of file