Browse Source

SONAR-22088 Fix GitLab auth when group sync is disabled

copy_of_master
Aurelien Poscia 2 weeks ago
parent
commit
d42e73cd8a

+ 1
- 0
server/sonar-auth-gitlab/build.gradle View File

@@ -21,6 +21,7 @@ dependencies {
testImplementation 'com.squareup.okhttp3:mockwebserver'
testImplementation 'com.squareup.okhttp3:okhttp'
testImplementation 'junit:junit'
testImplementation 'com.tngtech.java:junit-dataprovider'
testImplementation 'org.assertj:assertj-core'
testImplementation 'org.mockito:mockito-core'
}

+ 42
- 28
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java View File

@@ -20,16 +20,17 @@
package org.sonar.auth.gitlab;

import com.github.scribejava.core.builder.ServiceBuilder;
import com.github.scribejava.core.builder.ServiceBuilderOAuth20;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.model.OAuthConstants;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.stream.Stream;
import javax.inject.Inject;
import org.sonar.api.server.authentication.Display;
import org.sonar.api.server.authentication.OAuth2IdentityProvider;
import org.sonar.api.server.authentication.UnauthorizedException;
@@ -41,17 +42,24 @@ import static java.util.stream.Collectors.toSet;

public class GitLabIdentityProvider implements OAuth2IdentityProvider {

public static final String API_SCOPE = "api";
public static final String READ_USER_SCOPE = "read_user";
public static final String KEY = "gitlab";
private final GitLabSettings gitLabSettings;
private final ScribeGitLabOauth2Api scribeApi;
private final GitLabRestClient gitLabRestClient;
private final ScribeFactory scribeFactory;

@Inject
public GitLabIdentityProvider(GitLabSettings gitLabSettings, GitLabRestClient gitLabRestClient, ScribeGitLabOauth2Api scribeApi) {
this(gitLabSettings, gitLabRestClient, scribeApi, new ScribeFactory());
}

@VisibleForTesting
GitLabIdentityProvider(GitLabSettings gitLabSettings, GitLabRestClient gitLabRestClient, ScribeGitLabOauth2Api scribeApi,
ScribeFactory scribeFactory) {
this.gitLabSettings = gitLabSettings;
this.scribeApi = scribeApi;
this.gitLabRestClient = gitLabRestClient;
this.scribeFactory = scribeFactory;
}

@Override
@@ -85,23 +93,18 @@ public class GitLabIdentityProvider implements OAuth2IdentityProvider {
@Override
public void init(InitContext context) {
String state = context.generateCsrfState();
OAuth20Service scribe = newScribeBuilder(context).build(scribeApi);
String url = scribe.getAuthorizationUrl(state);
context.redirectTo(url);
}

private ServiceBuilderOAuth20 newScribeBuilder(OAuth2Context context) {
checkState(isEnabled(), "GitLab authentication is disabled");
return new ServiceBuilder(gitLabSettings.applicationId())
.apiSecret(gitLabSettings.secret())
.defaultScope(gitLabSettings.syncUserGroups() ? API_SCOPE : READ_USER_SCOPE)
.callback(context.getCallbackUrl());
try (OAuth20Service scribe = scribeFactory.newScribe(gitLabSettings, context.getCallbackUrl(), scribeApi)) {
String url = scribe.getAuthorizationUrl(state);
context.redirectTo(url);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}

@Override
public void callback(CallbackContext context) {
try {
onCallback(context);
try (OAuth20Service scribe = scribeFactory.newScribe(gitLabSettings, context.getCallbackUrl(), scribeApi)) {
onCallback(context, scribe);
} catch (IOException | ExecutionException e) {
throw new IllegalStateException(e);
} catch (InterruptedException e) {
@@ -110,12 +113,10 @@ public class GitLabIdentityProvider implements OAuth2IdentityProvider {
}
}

private void onCallback(CallbackContext context) throws InterruptedException, ExecutionException, IOException {
private void onCallback(CallbackContext context, OAuth20Service scribe) throws InterruptedException, ExecutionException, IOException {
HttpRequest request = context.getHttpRequest();
OAuth20Service scribe = newScribeBuilder(context).build(scribeApi);
String code = request.getParameter(OAuthConstants.CODE);
OAuth2AccessToken accessToken = scribe.getAccessToken(code);

GsonUser user = gitLabRestClient.getUser(scribe, accessToken);

UserIdentity.Builder builder = UserIdentity.builder()
@@ -124,22 +125,20 @@ public class GitLabIdentityProvider implements OAuth2IdentityProvider {
.setName(user.getName())
.setEmail(user.getEmail());


Set<String> userGroups = getGroups(scribe, accessToken);

if (!gitLabSettings.allowedGroups().isEmpty()) {
validateUserInAllowedGroups(userGroups, gitLabSettings.allowedGroups());
}

if (gitLabSettings.syncUserGroups()) {
Set<String> userGroups = getGroups(scribe, accessToken);
validateUserInAllowedGroups(userGroups, gitLabSettings.allowedGroups());
builder.setGroups(userGroups);
}

context.authenticate(builder.build());
context.redirectToRequestedPage();
}

private static void validateUserInAllowedGroups(Set<String> userGroups, Set<String> allowedGroups) {
private void validateUserInAllowedGroups(Set<String> userGroups, Set<String> allowedGroups) {
if (gitLabSettings.allowedGroups().isEmpty()) {
return;
}

boolean allowedUser = userGroups.stream()
.anyMatch(userGroup -> isAllowedGroup(userGroup, allowedGroups));

@@ -160,4 +159,19 @@ public class GitLabIdentityProvider implements OAuth2IdentityProvider {
.collect(toSet());
}

static class ScribeFactory {

private static final String API_SCOPE = "api";
private static final String READ_USER_SCOPE = "read_user";

OAuth20Service newScribe(GitLabSettings gitLabSettings, String callbackUrl, ScribeGitLabOauth2Api scribeApi) {
checkState(gitLabSettings.isEnabled(), "GitLab authentication is disabled");
return new ServiceBuilder(gitLabSettings.applicationId())
.apiSecret(gitLabSettings.secret())
.defaultScope(gitLabSettings.syncUserGroups() ? API_SCOPE : READ_USER_SCOPE)
.callback(callbackUrl)
.build(scribeApi);
}
}

}

+ 205
- 41
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabIdentityProviderTest.java View File

@@ -19,25 +19,85 @@
*/
package org.sonar.auth.gitlab;

import org.assertj.core.api.Assertions;
import com.github.scribejava.core.model.OAuth2AccessToken;
import com.github.scribejava.core.model.OAuthConstants;
import com.github.scribejava.core.oauth.OAuth20Service;
import com.tngtech.java.junit.dataprovider.DataProvider;
import com.tngtech.java.junit.dataprovider.DataProviderRunner;
import com.tngtech.java.junit.dataprovider.UseDataProvider;
import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.sonar.api.server.authentication.Display;
import org.sonar.api.server.authentication.OAuth2IdentityProvider;
import org.sonar.api.server.authentication.UnauthorizedException;
import org.sonar.api.server.authentication.UserIdentity;

import static java.util.stream.Collectors.toSet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.MockitoAnnotations.openMocks;

@RunWith(DataProviderRunner.class)
public class GitLabIdentityProviderTest {

private static final String OAUTH_CODE = "code fdsojfsjodfg";
private static final String AUTHORIZATION_URL = "AUTHORIZATION_URL";
private static final String CALLBACK_URL = "CALLBACK_URL";
private static final String STATE = "State request";

@Mock
private GitLabRestClient gitLabRestClient;
@Mock
private GitLabSettings gitLabSettings;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private GitLabIdentityProvider.ScribeFactory scribeFactory;
@Mock
private OAuth2IdentityProvider.InitContext initContext;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private OAuth2IdentityProvider.CallbackContext callbackContext;
@Mock
private OAuth20Service scribe;
@Mock
private ScribeGitLabOauth2Api scribeApi;
@Mock
private OAuth2AccessToken accessToken;

private GitLabIdentityProvider gitLabIdentityProvider;

@Before
public void setup() throws IOException, ExecutionException, InterruptedException {
openMocks(this);
gitLabIdentityProvider = new GitLabIdentityProvider(gitLabSettings, gitLabRestClient, scribeApi, scribeFactory);

when(initContext.generateCsrfState()).thenReturn(STATE);
when(initContext.getCallbackUrl()).thenReturn(CALLBACK_URL);

when(callbackContext.getCallbackUrl()).thenReturn(CALLBACK_URL);
when(callbackContext.getHttpRequest().getParameter(OAuthConstants.CODE)).thenReturn(OAUTH_CODE);

when(scribeFactory.newScribe(gitLabSettings, CALLBACK_URL, scribeApi)).thenReturn(scribe);
when(scribe.getAccessToken(OAUTH_CODE)).thenReturn(accessToken);
when(scribe.getAuthorizationUrl(STATE)).thenReturn(AUTHORIZATION_URL);
}

@Test
public void test_identity_provider() {
GitLabSettings gitLabSettings = mock(GitLabSettings.class);
when(gitLabSettings.isEnabled()).thenReturn(true);
when(gitLabSettings.allowUsersToSignUp()).thenReturn(true);
GitLabIdentityProvider gitLabIdentityProvider = new GitLabIdentityProvider(gitLabSettings, new GitLabRestClient(gitLabSettings),
new ScribeGitLabOauth2Api(gitLabSettings));

assertThat(gitLabIdentityProvider.getKey()).isEqualTo("gitlab");
assertThat(gitLabIdentityProvider.getName()).isEqualTo("GitLab");
@@ -49,61 +109,165 @@ public class GitLabIdentityProviderTest {
}

@Test
public void test_init() {
GitLabSettings gitLabSettings = mock(GitLabSettings.class);
when(gitLabSettings.isEnabled()).thenReturn(true);
when(gitLabSettings.allowUsersToSignUp()).thenReturn(true);
when(gitLabSettings.applicationId()).thenReturn("123");
when(gitLabSettings.secret()).thenReturn("456");
when(gitLabSettings.url()).thenReturn("http://server");
when(gitLabSettings.syncUserGroups()).thenReturn(true);
GitLabIdentityProvider gitLabIdentityProvider = new GitLabIdentityProvider(gitLabSettings, new GitLabRestClient(gitLabSettings),
new ScribeGitLabOauth2Api(gitLabSettings));
public void init_whenSuccessful_redirectsToUrl() {
gitLabIdentityProvider.init(initContext);

verify(initContext).generateCsrfState();
verify(initContext).redirectTo(AUTHORIZATION_URL);
}

@Test
public void init_whenErrorWhileBuildingScribe_shouldReThrow() {
IllegalStateException exception = new IllegalStateException("GitLab authentication is disabled");
when(scribeFactory.newScribe(any(), any(), any())).thenThrow(exception);

OAuth2IdentityProvider.InitContext initContext = mock(OAuth2IdentityProvider.InitContext.class);
when(initContext.getCallbackUrl()).thenReturn("http://server/callback");

gitLabIdentityProvider.init(initContext);
assertThatIllegalStateException()
.isThrownBy(() -> gitLabIdentityProvider.init(initContext))
.isEqualTo(exception);
}

@Test
public void onCallback_withGroupSyncDisabledAndNoAllowedGroups_redirectsToRequestedPage() {
GsonUser gsonUser = mockGsonUser();

gitLabIdentityProvider.callback(callbackContext);

verify(initContext).redirectTo("http://server/oauth/authorize?response_type=code&client_id=123&redirect_uri=http%3A%2F%2Fserver%2Fcallback&scope=api");
verifyAuthenticateIsCalledWithExpectedIdentity(callbackContext, gsonUser, Set.of());
verify(callbackContext).redirectToRequestedPage();
verify(gitLabRestClient, never()).getGroups(any(), any());
}

@Test
public void test_init_without_sync() {
GitLabSettings gitLabSettings = mock(GitLabSettings.class);
when(gitLabSettings.isEnabled()).thenReturn(true);
when(gitLabSettings.allowUsersToSignUp()).thenReturn(true);
when(gitLabSettings.applicationId()).thenReturn("123");
when(gitLabSettings.secret()).thenReturn("456");
when(gitLabSettings.url()).thenReturn("http://server");
public void onCallback_withGroupSyncDisabledAndAllowedGroups_redirectsToRequestedPage() {
when(gitLabSettings.syncUserGroups()).thenReturn(false);
GitLabIdentityProvider gitLabIdentityProvider = new GitLabIdentityProvider(gitLabSettings, new GitLabRestClient(gitLabSettings),
new ScribeGitLabOauth2Api(gitLabSettings));

OAuth2IdentityProvider.InitContext initContext = mock(OAuth2IdentityProvider.InitContext.class);
when(initContext.getCallbackUrl()).thenReturn("http://server/callback");
GsonUser gsonUser = mockGsonUser();

gitLabIdentityProvider.init(initContext);
gitLabIdentityProvider.callback(callbackContext);

verify(initContext).redirectTo("http://server/oauth/authorize?response_type=code&client_id=123&redirect_uri=http%3A%2F%2Fserver%2Fcallback&scope=read_user");
verifyAuthenticateIsCalledWithExpectedIdentity(callbackContext, gsonUser, Set.of());
verify(callbackContext).redirectToRequestedPage();
verify(gitLabRestClient, never()).getGroups(any(), any());
}

@Test
public void fail_to_init() {
GitLabSettings gitLabSettings = mock(GitLabSettings.class);
@UseDataProvider("allowedGroups")
public void onCallback_withGroupSyncAndAllowedGroupsMatching_redirectsToRequestedPage(Set<String> allowedGroups) {
when(gitLabSettings.syncUserGroups()).thenReturn(true);
when(gitLabSettings.allowedGroups()).thenReturn(allowedGroups);

GsonUser gsonUser = mockGsonUser();
Set<GsonGroup> gsonGroups = mockGitlabGroups();

gitLabIdentityProvider.callback(callbackContext);

verifyAuthenticateIsCalledWithExpectedIdentity(callbackContext, gsonUser, gsonGroups);
verify(callbackContext).redirectToRequestedPage();
}

@DataProvider
public static Object[][] allowedGroups() {
return new Object[][]{
{Set.of()},
{Set.of("path")}
};
}

@Test
public void onCallback_withGroupSyncAndAllowedGroupsNotMatching_shouldThrow() {
when(gitLabSettings.syncUserGroups()).thenReturn(true);
when(gitLabSettings.allowedGroups()).thenReturn(Set.of("path2"));

mockGsonUser();
mockGitlabGroups();

assertThatExceptionOfType(UnauthorizedException.class)
.isThrownBy(() -> gitLabIdentityProvider.callback(callbackContext))
.withMessage("You are not allowed to authenticate");
}

@Test
public void onCallback_ifScribeFactoryFails_shouldThrow() {
IllegalStateException exception = new IllegalStateException("message");
when(scribeFactory.newScribe(any(), any(), any())).thenThrow(exception);

assertThatIllegalStateException()
.isThrownBy(() -> gitLabIdentityProvider.callback(callbackContext))
.isEqualTo(exception);
}

private Set<GsonGroup> mockGitlabGroups() {
GsonGroup gsonGroup = mock(GsonGroup.class);
when(gsonGroup.getFullPath()).thenReturn("path/to/group");
GsonGroup gsonGroup2 = mock(GsonGroup.class);
when(gsonGroup2.getFullPath()).thenReturn("path/to/group2");
when(gitLabRestClient.getGroups(scribe, accessToken)).thenReturn(List.of(gsonGroup, gsonGroup2));
return Set.of(gsonGroup, gsonGroup2);
}

private static void verifyAuthenticateIsCalledWithExpectedIdentity(OAuth2IdentityProvider.CallbackContext callbackContext,
GsonUser gsonUser, Set<GsonGroup> gsonGroups) {
ArgumentCaptor<UserIdentity> userIdentityCaptor = ArgumentCaptor.forClass(UserIdentity.class);
verify(callbackContext).authenticate(userIdentityCaptor.capture());

UserIdentity actualIdentity = userIdentityCaptor.getValue();

assertThat(actualIdentity.getProviderId()).asLong().isEqualTo(gsonUser.getId());
assertThat(actualIdentity.getProviderLogin()).isEqualTo(gsonUser.getUsername());
assertThat(actualIdentity.getName()).isEqualTo(gsonUser.getName());
assertThat(actualIdentity.getEmail()).isEqualTo(gsonUser.getEmail());
assertThat(actualIdentity.getGroups()).isEqualTo(gsonGroups.stream().map(GsonGroup::getFullPath).collect(toSet()));
}

private GsonUser mockGsonUser() {
GsonUser gsonUser = mock();
when(gsonUser.getId()).thenReturn(432423L);
when(gsonUser.getUsername()).thenReturn("userName");
when(gsonUser.getName()).thenReturn("name");
when(gsonUser.getEmail()).thenReturn("toto@gitlab.com");
when(gitLabRestClient.getUser(scribe, accessToken)).thenReturn(gsonUser);
return gsonUser;
}

@Test
public void newScribe_whenGitLabAuthIsDisabled_throws() {
when(gitLabSettings.isEnabled()).thenReturn(false);
when(gitLabSettings.allowUsersToSignUp()).thenReturn(true);
when(gitLabSettings.applicationId()).thenReturn("123");
when(gitLabSettings.secret()).thenReturn("456");
when(gitLabSettings.url()).thenReturn("http://server");
GitLabIdentityProvider gitLabIdentityProvider = new GitLabIdentityProvider(gitLabSettings, new GitLabRestClient(gitLabSettings),

assertThatIllegalStateException()
.isThrownBy(() -> new GitLabIdentityProvider.ScribeFactory().newScribe(gitLabSettings, CALLBACK_URL, new ScribeGitLabOauth2Api(gitLabSettings)))
.withMessage("GitLab authentication is disabled");
}

@Test
@UseDataProvider("groupsSyncToScope")
public void newScribe_whenGitLabSettingsValid_shouldUseCorrectScopeDependingOnGroupSync(boolean groupSyncEnabled, String expectedScope) {
setupGitlabSettingsWithGroupSync(groupSyncEnabled);


OAuth20Service realScribe = new GitLabIdentityProvider.ScribeFactory().newScribe(gitLabSettings, CALLBACK_URL,
new ScribeGitLabOauth2Api(gitLabSettings));

OAuth2IdentityProvider.InitContext initContext = mock(OAuth2IdentityProvider.InitContext.class);
when(initContext.getCallbackUrl()).thenReturn("http://server/callback");
assertThat(realScribe).isNotNull();
assertThat(realScribe.getCallback()).isEqualTo(CALLBACK_URL);
assertThat(realScribe.getApiSecret()).isEqualTo(gitLabSettings.secret());
assertThat(realScribe.getDefaultScope()).isEqualTo(expectedScope);
}

Assertions.assertThatThrownBy(() -> gitLabIdentityProvider.init(initContext))
.hasMessage("GitLab authentication is disabled")
.isInstanceOf(IllegalStateException.class);
@DataProvider
public static Object[][] groupsSyncToScope() {
return new Object[][]{
{false, "read_user"},
{true, "api"}
};
}

private void setupGitlabSettingsWithGroupSync(boolean enableGroupSync) {
when(gitLabSettings.isEnabled()).thenReturn(true);
when(gitLabSettings.applicationId()).thenReturn("123");
when(gitLabSettings.secret()).thenReturn("456");
when(gitLabSettings.syncUserGroups()).thenReturn(enableGroupSync);
}
}

+ 18
- 2
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/IntegrationTest.java View File

@@ -72,7 +72,8 @@ public class IntegrationTest {
.setProperty(GITLAB_AUTH_URL, gitLabUrl)
.setProperty(GITLAB_AUTH_APPLICATION_ID, "123")
.setProperty(GITLAB_AUTH_SECRET, "456")
.setProperty(GITLAB_AUTH_ALLOWED_GROUPS, "group1,group2");
.setProperty(GITLAB_AUTH_ALLOWED_GROUPS, "group1,group2")
.setProperty(GITLAB_AUTH_SYNC_USER_GROUPS, "true");
}

@Test
@@ -96,7 +97,7 @@ public class IntegrationTest {
}

@Test
public void callback_whenNotAllowedUser_shouldThrow() {
public void callback_whenGroupNotAllowedAndGroupSyncEnabled_shouldThrow() {
OAuth2IdentityProvider.CallbackContext callbackContext = mockCallbackContext();

mockAccessTokenResponse();
@@ -108,6 +109,21 @@ public class IntegrationTest {
.hasMessage("You are not allowed to authenticate");
}

@Test
public void callback_whenGroupNotAllowedAndGroupSyncDisabled_shouldThrow() {
mapSettings.setProperty(GITLAB_AUTH_SYNC_USER_GROUPS, "false");
OAuth2IdentityProvider.CallbackContext callbackContext = mockCallbackContext();

mockAccessTokenResponse();
mockUserResponse();
mockSingleGroupReponse("wrong-group");

gitLabIdentityProvider.callback(callbackContext);

verify(callbackContext).authenticate(any());
verify(callbackContext).redirectToRequestedPage();
}

@Test
public void callback_whenAllowedUserBySubgroupMembership_shouldAuthenticate() {
OAuth2IdentityProvider.CallbackContext callbackContext = mockCallbackContext();

+ 1
- 1
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -1598,7 +1598,7 @@ settings.authentication.gitlab.form.secret.description=Secret provided by GitLab
settings.authentication.gitlab.form.synchronizeGroups.name=Synchronize user groups
settings.authentication.gitlab.form.synchronizeGroups.description=For each GitLab group they belong to, the user will be associated to a group with the same name (if it exists) in SonarQube. If enabled, the GitLab OAuth 2 application will need to provide the api scope.
settings.authentication.gitlab.form.allowedGroups.name=Allowed groups
settings.authentication.gitlab.form.allowedGroups.description.JIT=Only members of these groups (and sub-groups) will be allowed to authenticate. Please enter the group slug as it appears in the GitLab URL, for instance `my-gitlab-group`. ⚠︎ if not set and `Allow users to sign up` is enabled, any user from GitLab will be able to login to this SonarQube instance.
settings.authentication.gitlab.form.allowedGroups.description.JIT=Only members of these groups (and sub-groups) will be allowed to authenticate. Enter the group slug as it appears in the GitLab URL, for instance `my-gitlab-group`. ⚠︎ When you turn on `Allow users to sign up`, make sure to also turn on group synchronization and provide a list of allowed groups. Otherwise, any GitLab user will be able to log in to this SonarQube instance.
settings.authentication.gitlab.form.allowedGroups.description.AUTO_PROVISIONING=Only members of these groups (and sub-groups) will be provisioned. Please enter the group slug as it appears in the GitLab URL, for instance `my-gitlab-group`.
settings.authentication.gitlab.form.allowUsersToSignUp.name=Allow users to sign up
settings.authentication.gitlab.form.allowUsersToSignUp.description=Allow new users to authenticate. When set to disabled, only existing users will be able to authenticate to the server.

Loading…
Cancel
Save