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