]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12460 Support GitLab Authentication
authorJulien Lancelot <julien.lancelot@sonarsource.com>
Tue, 17 Sep 2019 10:21:53 +0000 (12:21 +0200)
committerSonarTech <sonartech@sonarsource.com>
Mon, 23 Sep 2019 18:21:07 +0000 (20:21 +0200)
21 files changed:
build.gradle
server/sonar-auth-gitlab/build.gradle [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabModule.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabSettings.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonGroup.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonUser.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/ScribeGitLabOauth2Api.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/package-info.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabIdentityProviderTest.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabModuleTest.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabSettingsTest.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonGroupTest.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonUserTest.java [new file with mode: 0644]
server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/IntegrationTest.java [new file with mode: 0644]
server/sonar-web/public/images/gitlab-icon-rgb.svg [new file with mode: 0644]
server/sonar-webserver/build.gradle
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
settings.gradle
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 4f7028367951ba1801a5387a99362ba4f1259538..2aab6bee5c9b62c7a0eee71fbfb7c957897be070 100644 (file)
@@ -166,6 +166,8 @@ subprojects {
       dependency 'com.fasterxml.jackson.core:jackson-databind:2.9.9.2'
       dependency 'com.eclipsesource.minimal-json:minimal-json:0.9.5'
       dependency 'com.github.kevinsawicki:http-request:5.4.1'
+      dependency 'com.github.scribejava:scribejava-core:6.8.1'
+      dependency 'com.github.scribejava:scribejava-apis:6.8.1'      
       dependency 'com.googlecode.java-diff-utils:diffutils:1.2'
       dependency('com.googlecode.json-simple:json-simple:1.1.1') {
         exclude 'junit:junit'
diff --git a/server/sonar-auth-gitlab/build.gradle b/server/sonar-auth-gitlab/build.gradle
new file mode 100644 (file)
index 0000000..f30f083
--- /dev/null
@@ -0,0 +1,25 @@
+description = 'SonarQube :: Authentication :: GitLab'
+
+configurations {
+    testCompile.extendsFrom compileOnly
+}
+
+dependencies {
+    // please keep the list ordered
+
+    compile 'com.github.scribejava:scribejava-apis'
+    compile 'com.github.scribejava:scribejava-core'
+    compile 'com.google.code.gson:gson'
+
+    compileOnly 'com.google.code.findbugs:jsr305'
+    compileOnly 'com.squareup.okhttp3:okhttp'
+    compileOnly 'javax.servlet:javax.servlet-api'
+    compileOnly project(':sonar-core')
+    compileOnly project(':sonar-ws')
+
+    testCompile 'com.squareup.okhttp3:mockwebserver'
+    testCompile 'com.squareup.okhttp3:okhttp'
+    testCompile 'junit:junit'
+    testCompile 'org.assertj:assertj-core'
+    testCompile 'org.mockito:mockito-core'
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java
new file mode 100644 (file)
index 0000000..5921bac
--- /dev/null
@@ -0,0 +1,135 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import com.github.scribejava.core.builder.ServiceBuilder;
+import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.github.scribejava.core.model.OAuthConstants;
+import com.github.scribejava.core.oauth.OAuth20Service;
+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.servlet.http.HttpServletRequest;
+import org.sonar.api.server.authentication.Display;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.stream.Collectors.toSet;
+
+public class GitLabIdentityProvider implements OAuth2IdentityProvider {
+
+  private final GitLabSettings gitLabSettings;
+  private final ScribeGitLabOauth2Api scribeApi;
+  private final GitLabRestClient gitLabRestClient;
+
+  public GitLabIdentityProvider(GitLabSettings gitLabSettings, GitLabRestClient gitLabRestClient, ScribeGitLabOauth2Api scribeApi) {
+    this.gitLabSettings = gitLabSettings;
+    this.scribeApi = scribeApi;
+    this.gitLabRestClient = gitLabRestClient;
+  }
+
+  @Override
+  public String getKey() {
+    return "gitlab";
+  }
+
+  @Override
+  public String getName() {
+    return "GitLab";
+  }
+
+  @Override
+  public Display getDisplay() {
+    return Display.builder()
+      .setIconPath("/images/gitlab-icon-rgb.svg").setBackgroundColor("#6a4fbb").build();
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return gitLabSettings.isEnabled();
+  }
+
+  @Override
+  public boolean allowsUsersToSignUp() {
+    return gitLabSettings.allowUsersToSignUp();
+  }
+
+  @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 ServiceBuilder newScribeBuilder(OAuth2Context context) {
+    checkState(isEnabled(), "GitLab authentication is disabled");
+    return new ServiceBuilder(gitLabSettings.applicationId())
+      .apiSecret(gitLabSettings.secret())
+      .callback(context.getCallbackUrl());
+  }
+
+  @Override
+  public void callback(CallbackContext context) {
+    try {
+      onCallback(context);
+    } catch (IOException | ExecutionException e) {
+      throw new IllegalStateException(e);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private void onCallback(CallbackContext context) throws InterruptedException, ExecutionException, IOException {
+    HttpServletRequest request = context.getRequest();
+    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()
+      .setProviderId(Long.toString(user.getId()))
+      .setProviderLogin(user.getUsername())
+      .setName(user.getName())
+      .setEmail(user.getEmail());
+
+    if (gitLabSettings.syncUserGroups()) {
+      builder.setGroups(getGroups(scribe, accessToken));
+    }
+
+    context.authenticate(builder.build());
+    context.redirectToRequestedPage();
+  }
+
+  private Set<String> getGroups(OAuth20Service scribe, OAuth2AccessToken accessToken) {
+    List<GsonGroup> groups = gitLabRestClient.getGroups(scribe, accessToken);
+    return Stream.of(groups)
+      .flatMap(Collection::stream)
+      .map(GsonGroup::getFullPath)
+      .collect(toSet());
+  }
+
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabModule.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabModule.java
new file mode 100644 (file)
index 0000000..884dd17
--- /dev/null
@@ -0,0 +1,41 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import java.util.List;
+import org.sonar.api.config.PropertyDefinition;
+import org.sonar.core.platform.Module;
+
+import static org.sonar.auth.gitlab.GitLabSettings.definitions;
+
+public class GitLabModule extends Module {
+
+  @Override
+  protected void configureModule() {
+    add(
+      GitLabIdentityProvider.class,
+      GitLabRestClient.class,
+      GitLabSettings.class,
+      ScribeGitLabOauth2Api.class);
+    List<PropertyDefinition> definitions = definitions();
+    add(definitions.toArray(new Object[definitions.size()]));
+  }
+
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java
new file mode 100644 (file)
index 0000000..9abfcbe
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.github.scribejava.core.model.OAuthRequest;
+import com.github.scribejava.core.model.Response;
+import com.github.scribejava.core.model.Verb;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static java.lang.String.format;
+
+public class GitLabRestClient {
+
+  private static final int DEFAULT_PAGE_SIZE = 100;
+  private static final Pattern NEXT_LINK_PATTERN = Pattern.compile(".*<(.*)>; rel=\"next\"");
+
+  private static final String API_SUFFIX = "/api/v4";
+
+  private final GitLabSettings settings;
+
+  public GitLabRestClient(GitLabSettings settings) {
+    this.settings = settings;
+  }
+
+  GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) {
+    try (Response response = executeRequest(settings.url() + API_SUFFIX + "/user", scribe, accessToken)) {
+      String responseBody = response.getBody();
+      return GsonUser.parse(responseBody);
+    } catch (IOException e) {
+      throw new IllegalStateException("Failed to get gitlab user", e);
+    }
+  }
+
+  List<GsonGroup> getGroups(OAuth20Service scribe, OAuth2AccessToken accessToken) {
+    return executePaginatedQuery(settings.url() + API_SUFFIX + "/groups", scribe, accessToken, GsonGroup::parse);
+  }
+
+  private static Response executeRequest(String requestUrl, OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException {
+    OAuthRequest request = new OAuthRequest(Verb.GET, requestUrl);
+    scribe.signRequest(accessToken, request);
+    try {
+      Response response = scribe.execute(request);
+      if (!response.isSuccessful()) {
+        throw unexpectedResponseCode(requestUrl, response);
+      }
+      return response;
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new IllegalStateException(e);
+    } catch (ExecutionException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private static <E> List<E> executePaginatedQuery(String query, OAuth20Service scribe, OAuth2AccessToken accessToken, Function<String, List<E>> function) {
+    List<E> result = new ArrayList<>();
+    readNextPage(result, scribe, accessToken, query + "?per_page=" + DEFAULT_PAGE_SIZE, function);
+    return result;
+  }
+
+  private static <E> void readNextPage(List<E> result, OAuth20Service scribe, OAuth2AccessToken accessToken, String nextEndPoint, Function<String, List<E>> function) {
+    try (Response nextResponse = executeRequest(nextEndPoint, scribe, accessToken)) {
+      String content = nextResponse.getBody();
+      if (content == null) {
+        return;
+      }
+      result.addAll(function.apply(content));
+      readNextEndPoint(nextResponse).ifPresent(newNextEndPoint -> readNextPage(result, scribe, accessToken, newNextEndPoint, function));
+    } catch (IOException e) {
+      throw new IllegalStateException(format("Failed to get %s", nextEndPoint), e);
+    }
+  }
+
+  private static Optional<String> readNextEndPoint(Response response) {
+    String link = response.getHeader("Link");
+    if (link == null || link.isEmpty() || !link.contains("rel=\"next\"")) {
+      return Optional.empty();
+    }
+    Matcher nextLinkMatcher = NEXT_LINK_PATTERN.matcher(link);
+    if (!nextLinkMatcher.find()) {
+      return Optional.empty();
+    }
+    return Optional.of(nextLinkMatcher.group(1));
+  }
+
+  private static IllegalStateException unexpectedResponseCode(String requestUrl, Response response) throws IOException {
+    return new IllegalStateException(format("Fail to execute request '%s'. HTTP code: %s, response: %s", requestUrl, response.getCode(), response.getBody()));
+  }
+
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabSettings.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabSettings.java
new file mode 100644 (file)
index 0000000..7eb6c23
--- /dev/null
@@ -0,0 +1,127 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import java.util.Arrays;
+import java.util.List;
+import org.sonar.api.CoreProperties;
+import org.sonar.api.PropertyType;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.config.PropertyDefinition;
+
+import static java.lang.String.valueOf;
+import static org.sonar.api.PropertyType.BOOLEAN;
+
+public class GitLabSettings {
+
+  static final String GITLAB_AUTH_ENABLED = "sonar.auth.gitlab.enabled";
+  static final String GITLAB_AUTH_URL = "sonar.auth.gitlab.url";
+  static final String GITLAB_AUTH_APPLICATION_ID = "sonar.auth.gitlab.applicationId";
+  static final String GITLAB_AUTH_SECRET = "sonar.auth.gitlab.secret";
+  static final String GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP = "sonar.auth.gitlab.allowUsersToSignUp";
+  static final String GITLAB_AUTH_SYNC_USER_GROUPS = "sonar.auth.gitlab.groupsSync";
+
+  private static final String CATEGORY = CoreProperties.CATEGORY_SECURITY;
+  private static final String SUBCATEGORY = "gitlab";
+
+  private final Configuration configuration;
+
+  public GitLabSettings(Configuration configuration) {
+    this.configuration = configuration;
+  }
+
+  public String url() {
+    return configuration.get(GITLAB_AUTH_URL).orElse(null);
+  }
+
+  public String applicationId() {
+    return configuration.get(GITLAB_AUTH_APPLICATION_ID).orElse(null);
+  }
+
+  public String secret() {
+    return configuration.get(GITLAB_AUTH_SECRET).orElse(null);
+  }
+
+  public boolean isEnabled() {
+    return configuration.getBoolean(GITLAB_AUTH_ENABLED).orElse(false) && applicationId() != null && secret() != null;
+  }
+
+  public boolean allowUsersToSignUp() {
+    return configuration.getBoolean(GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP).orElse(false);
+  }
+
+  public boolean syncUserGroups() {
+    return configuration.getBoolean(GITLAB_AUTH_SYNC_USER_GROUPS).orElse(false);
+  }
+
+  static List<PropertyDefinition> definitions() {
+    return Arrays.asList(
+      PropertyDefinition.builder(GITLAB_AUTH_ENABLED)
+        .name("Enabled")
+        .description("Enable Gitlab users to login. Value is ignored if URL, Application ID, and Secret are not set.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(BOOLEAN)
+        .defaultValue(valueOf(false))
+        .index(1)
+        .build(),
+      PropertyDefinition.builder(GITLAB_AUTH_URL)
+        .name("GitLab URL")
+        .description("URL to access GitLab.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .defaultValue("https://gitlab.com")
+        .index(2)
+        .build(),
+      PropertyDefinition.builder(GITLAB_AUTH_APPLICATION_ID)
+        .name("Application ID")
+        .description("Application ID provided by GitLab when registering the application.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .index(3)
+        .build(),
+      PropertyDefinition.builder(GITLAB_AUTH_SECRET)
+        .name("Secret")
+        .description("Secret provided by GitLab when registering the application.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .index(4)
+        .build(),
+      PropertyDefinition.builder(GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP)
+        .name("Allow users to sign-up")
+        .description("Allow new users to authenticate. When set to 'false', only existing users will be able to authenticate to the server.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(BOOLEAN)
+        .defaultValue(valueOf(true))
+        .index(5)
+        .build(),
+      PropertyDefinition.builder(GITLAB_AUTH_SYNC_USER_GROUPS)
+        .deprecatedKey("sonar.auth.gitlab.sync_user_groups")
+        .name("Synchronize user groups")
+        .description("For each GitLab group he belongs to, the user will be associated to a group with the same name (if it exists) in SonarQube.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(PropertyType.BOOLEAN)
+        .defaultValue(valueOf(false))
+        .index(6)
+        .build());
+  }
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonGroup.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonGroup.java
new file mode 100644 (file)
index 0000000..b1e6c88
--- /dev/null
@@ -0,0 +1,55 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import java.lang.reflect.Type;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Lite representation of JSON response of GET https://docs.gitlab.com/ee/api/groups.html
+ */
+public class GsonGroup {
+
+  private String full_path;
+
+  public GsonGroup() {
+    // http://stackoverflow.com/a/18645370/229031
+    this("");
+  }
+
+  GsonGroup(String full_path) {
+    this.full_path = full_path;
+  }
+
+  String getFullPath() {
+    return full_path;
+  }
+
+  static List<GsonGroup> parse(String json) {
+    Type collectionType = new TypeToken<Collection<GsonGroup>>() {
+    }.getType();
+    Gson gson = new Gson();
+    return gson.fromJson(json, collectionType);
+  }
+
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonUser.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GsonUser.java
new file mode 100644 (file)
index 0000000..b7d4e0e
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import com.google.gson.Gson;
+
+/**
+ * Lite representation of JSON response of GET https://gitlab.com/api/v4/user
+ */
+public class GsonUser {
+  private long id;
+  private String username;
+  private String name;
+  private String email;
+
+  public long getId() {
+    return id;
+  }
+
+  public String getUsername() {
+    return username;
+  }
+
+  public String getName() {
+    return name;
+  }
+
+  public String getEmail() {
+    return email;
+  }
+
+  public static GsonUser parse(String json) {
+    Gson gson = new Gson();
+    return gson.fromJson(json, GsonUser.class);
+  }
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/ScribeGitLabOauth2Api.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/ScribeGitLabOauth2Api.java
new file mode 100644 (file)
index 0000000..0b15a06
--- /dev/null
@@ -0,0 +1,42 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import com.github.scribejava.core.builder.api.DefaultApi20;
+
+public class ScribeGitLabOauth2Api extends DefaultApi20 {
+
+  private final GitLabSettings settings;
+
+  public ScribeGitLabOauth2Api(GitLabSettings settings) {
+    this.settings = settings;
+  }
+
+  @Override
+  public String getAccessTokenEndpoint() {
+    return settings.url() + "/oauth/token";
+  }
+
+  @Override
+  protected String getAuthorizationBaseUrl() {
+    return settings.url() + "/oauth/authorize";
+  }
+
+}
diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/package-info.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/package-info.java
new file mode 100644 (file)
index 0000000..31e176a
--- /dev/null
@@ -0,0 +1,23 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+@ParametersAreNonnullByDefault
+package org.sonar.auth.gitlab;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabIdentityProviderTest.java b/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabIdentityProviderTest.java
new file mode 100644 (file)
index 0000000..9558f8a
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.server.authentication.Display;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class GitLabIdentityProviderTest {
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  @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");
+    Display display = gitLabIdentityProvider.getDisplay();
+    assertThat(display.getIconPath()).isEqualTo("/images/gitlab-icon-rgb.svg");
+    assertThat(display.getBackgroundColor()).isEqualTo("#6a4fbb");
+    assertThat(gitLabIdentityProvider.isEnabled()).isTrue();
+    assertThat(gitLabIdentityProvider.allowsUsersToSignUp()).isTrue();
+  }
+
+  @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");
+    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");
+
+    gitLabIdentityProvider.init(initContext);
+
+    verify(initContext).redirectTo("http://server/oauth/authorize?response_type=code&client_id=123&redirect_uri=http%3A%2F%2Fserver%2Fcallback");
+  }
+
+  @Test
+  public void fail_to_init() {
+    GitLabSettings gitLabSettings = mock(GitLabSettings.class);
+    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),
+      new ScribeGitLabOauth2Api(gitLabSettings));
+
+    OAuth2IdentityProvider.InitContext initContext = mock(OAuth2IdentityProvider.InitContext.class);
+    when(initContext.getCallbackUrl()).thenReturn("http://server/callback");
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage("GitLab authentication is disabled");
+
+    gitLabIdentityProvider.init(initContext);
+  }
+}
diff --git a/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabModuleTest.java b/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabModuleTest.java
new file mode 100644 (file)
index 0000000..b905f7e
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import org.junit.Test;
+import org.sonar.core.platform.ComponentContainer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER;
+
+public class GitLabModuleTest {
+
+  @Test
+  public void verify_count_of_added_components() {
+    ComponentContainer container = new ComponentContainer();
+    new GitLabModule().configure(container);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 10);
+  }
+
+}
diff --git a/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabSettingsTest.java b/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GitLabSettingsTest.java
new file mode 100644 (file)
index 0000000..b73fe18
--- /dev/null
@@ -0,0 +1,76 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_APPLICATION_ID;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_ENABLED;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_SECRET;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_SYNC_USER_GROUPS;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_URL;
+
+public class GitLabSettingsTest {
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  private MapSettings settings;
+  private GitLabSettings config;
+
+  @Before
+  public void prepare() {
+    settings = new MapSettings(new PropertyDefinitions(GitLabSettings.definitions()));
+    config = new GitLabSettings(settings.asConfig());
+  }
+
+  @Test
+  public void test_settings() {
+    assertThat(config.url()).isEqualTo("https://gitlab.com");
+    settings.setProperty(GITLAB_AUTH_URL, "https://gitlab.com/api");
+    assertThat(config.url()).isEqualTo("https://gitlab.com/api");
+
+    assertThat(config.isEnabled()).isFalse();
+    settings.setProperty(GITLAB_AUTH_ENABLED, "true");
+    assertThat(config.isEnabled()).isFalse();
+    settings.setProperty(GITLAB_AUTH_APPLICATION_ID, "1234");
+    assertThat(config.isEnabled()).isFalse();
+    settings.setProperty(GITLAB_AUTH_SECRET, "5678");
+    assertThat(config.isEnabled()).isTrue();
+
+    assertThat(config.applicationId()).isEqualTo("1234");
+    assertThat(config.secret()).isEqualTo("5678");
+
+    assertThat(config.allowUsersToSignUp()).isTrue();
+    settings.setProperty(GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP, "false");
+    assertThat(config.allowUsersToSignUp()).isFalse();
+
+    assertThat(config.syncUserGroups()).isFalse();
+    settings.setProperty(GITLAB_AUTH_SYNC_USER_GROUPS, true);
+    assertThat(config.syncUserGroups()).isTrue();
+  }
+}
diff --git a/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonGroupTest.java b/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonGroupTest.java
new file mode 100644 (file)
index 0000000..a1605e1
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import java.util.List;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonGroupTest {
+
+  @Test
+  public void test_parse() {
+    List<GsonGroup> groups = GsonGroup.parse("[{\n" +
+      "\"id\": 123456789,\n" +
+      "\"web_url\": \"https://gitlab.com/groups/my-awesome-group/my-project\",\n" +
+      "\"name\": \"my-project\",\n" +
+      "\"path\": \"my-project\",\n" +
+      "\"description\": \"\",\n" +
+      "\"visibility\": \"private\",\n" +
+      "\"lfs_enabled\": true,\n" +
+      "\"avatar_url\": null,\n" +
+      "\"request_access_enabled\": false,\n" +
+      "\"full_name\": \"my-awesome-group / my-project\",\n" +
+      "\"full_path\": \"my-awesome-group/my-project\",\n" +
+      "\"parent_id\": 987654321,\n" +
+      "\"ldap_cn\": null,\n" +
+      "\"ldap_access\": null\n" +
+      "}]");
+
+    assertThat(groups).isNotNull();
+    assertThat(groups.size()).isEqualTo(1);
+    assertThat(groups.get(0).getFullPath()).isEqualTo("my-awesome-group/my-project");
+  }
+}
diff --git a/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonUserTest.java b/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/GsonUserTest.java
new file mode 100644 (file)
index 0000000..79743f6
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonUserTest {
+
+  @Test
+  public void test_parse() {
+    GsonUser gsonUser = GsonUser.parse("{\n" +
+      "\"id\": 4418804,\n" +
+      "\"name\": \"Pierre Guillot\",\n" +
+      "\"username\": \"pierre-guillot-sonarsource\",\n" +
+      "\"state\": \"active\",\n" +
+      "\"avatar_url\": \"https://secure.gravatar.com/avatar/fe075537af1b94fd1cea160e5359e178?s=80&d=identicon\",\n" +
+      "\"web_url\": \"https://gitlab.com/pierre-guillot-sonarsource\",\n" +
+      "\"created_at\": \"2019-08-06T08:36:09.031Z\",\n" +
+      "\"bio\": null,\n" +
+      "\"location\": null,\n" +
+      "\"public_email\": \"\",\n" +
+      "\"skype\": \"\",\n" +
+      "\"linkedin\": \"\",\n" +
+      "\"twitter\": \"\",\n" +
+      "\"website_url\": \"\",\n" +
+      "\"organization\": null,\n" +
+      "\"last_sign_in_at\": \"2019-08-19T11:53:15.041Z\",\n" +
+      "\"confirmed_at\": \"2019-08-06T08:36:08.246Z\",\n" +
+      "\"last_activity_on\": \"2019-08-23\",\n" +
+      "\"email\": \"pierre.guillot@sonarsource.com\",\n" +
+      "\"theme_id\": 1,\n" +
+      "\"color_scheme_id\": 1,\n" +
+      "\"projects_limit\": 100000,\n" +
+      "\"current_sign_in_at\": \"2019-08-23T09:27:42.853Z\",\n" +
+      "\"identities\": [\n" +
+      "{\n" +
+      "\"provider\": \"github\",\n" +
+      "\"extern_uid\": \"50145663\",\n" +
+      "\"saml_provider_id\": null\n" +
+      "}\n" +
+      "],\n" +
+      "\"can_create_group\": true,\n" +
+      "\"can_create_project\": true,\n" +
+      "\"two_factor_enabled\": false,\n" +
+      "\"external\": false,\n" +
+      "\"private_profile\": false,\n" +
+      "\"shared_runners_minutes_limit\": 50000,\n" +
+      "\"extra_shared_runners_minutes_limit\": null\n" +
+      "}");
+
+    assertThat(gsonUser).isNotNull();
+    assertThat(gsonUser.getUsername()).isEqualTo("pierre-guillot-sonarsource");
+    assertThat(gsonUser.getName()).isEqualTo("Pierre Guillot");
+    assertThat(gsonUser.getEmail()).isEqualTo("pierre.guillot@sonarsource.com");
+  }
+}
diff --git a/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/IntegrationTest.java b/server/sonar-auth-gitlab/src/test/java/org/sonar/auth/gitlab/IntegrationTest.java
new file mode 100644 (file)
index 0000000..158d845
--- /dev/null
@@ -0,0 +1,192 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2019 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+package org.sonar.auth.gitlab;
+
+import javax.servlet.http.HttpServletRequest;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_APPLICATION_ID;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_ENABLED;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_SECRET;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_SYNC_USER_GROUPS;
+import static org.sonar.auth.gitlab.GitLabSettings.GITLAB_AUTH_URL;
+
+public class IntegrationTest {
+
+  private static final String ANY_CODE_VALUE = "ANY_CODE";
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  @Rule
+  public MockWebServer gitlab = new MockWebServer();
+
+  private MapSettings mapSettings = new MapSettings();
+
+  private GitLabSettings gitLabSettings = new GitLabSettings(mapSettings.asConfig());
+
+  private String gitLabUrl;
+
+  private GitLabIdentityProvider gitLabIdentityProvider = new GitLabIdentityProvider(gitLabSettings,
+    new GitLabRestClient(gitLabSettings),
+    new ScribeGitLabOauth2Api(gitLabSettings));
+
+  @Before
+  public void setUp() {
+    this.gitLabUrl = format("http://%s:%d", gitlab.getHostName(), gitlab.getPort());
+    mapSettings
+      .setProperty(GITLAB_AUTH_ENABLED, "true")
+      .setProperty(GITLAB_AUTH_ALLOW_USERS_TO_SIGNUP, "true")
+      .setProperty(GITLAB_AUTH_URL, gitLabUrl)
+      .setProperty(GITLAB_AUTH_APPLICATION_ID, "123")
+      .setProperty(GITLAB_AUTH_SECRET, "456");
+  }
+
+  @Test
+  public void authenticate_user() {
+    OAuth2IdentityProvider.CallbackContext callbackContext = Mockito.mock(OAuth2IdentityProvider.CallbackContext.class);
+    when(callbackContext.getCallbackUrl()).thenReturn("http://server/callback");
+
+    HttpServletRequest httpServletRequest = Mockito.mock(HttpServletRequest.class);
+    when(httpServletRequest.getParameter("code")).thenReturn(ANY_CODE_VALUE);
+    when(callbackContext.getRequest()).thenReturn(httpServletRequest);
+
+    gitlab.enqueue(new MockResponse().setBody(
+      "{\n" + " \"access_token\": \"de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54\",\n" + " \"token_type\": \"bearer\",\n" + " \"expires_in\": 7200,\n"
+        + " \"refresh_token\": \"8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1\"\n" + "}"));
+    // response of /user
+    gitlab.enqueue(new MockResponse().setBody("{\"id\": 123, \"username\":\"toto\", \"name\":\"Toto Toto\",\"email\":\"toto@toto.com\"}"));
+
+    gitLabIdentityProvider.callback(callbackContext);
+
+    ArgumentCaptor<UserIdentity> argument = ArgumentCaptor.forClass(UserIdentity.class);
+    verify(callbackContext).authenticate(argument.capture());
+    assertThat(argument.getValue()).isNotNull();
+    assertThat(argument.getValue().getLogin()).isNull();
+    assertThat(argument.getValue().getProviderId()).isEqualTo("123");
+    assertThat(argument.getValue().getProviderLogin()).isEqualTo("toto");
+    assertThat(argument.getValue().getName()).isEqualTo("Toto Toto");
+    assertThat(argument.getValue().getEmail()).isEqualTo("toto@toto.com");
+    verify(callbackContext).redirectToRequestedPage();
+  }
+
+  @Test
+  public void synchronize_groups() {
+    mapSettings.setProperty(GITLAB_AUTH_SYNC_USER_GROUPS, "true");
+    OAuth2IdentityProvider.CallbackContext callbackContext = Mockito.mock(OAuth2IdentityProvider.CallbackContext.class);
+    when(callbackContext.getCallbackUrl()).thenReturn("http://server/callback");
+
+    HttpServletRequest httpServletRequest = Mockito.mock(HttpServletRequest.class);
+    when(httpServletRequest.getParameter("code")).thenReturn(ANY_CODE_VALUE);
+    when(callbackContext.getRequest()).thenReturn(httpServletRequest);
+
+    gitlab.enqueue(new MockResponse().setBody(
+      "{\n" + " \"access_token\": \"de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54\",\n" + " \"token_type\": \"bearer\",\n" + " \"expires_in\": 7200,\n"
+        + " \"refresh_token\": \"8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1\"\n" + "}"));
+    // response of /user
+    gitlab.enqueue(new MockResponse().setBody("{\"id\": 123, \"username\": \"username\", \"name\": \"name\"}"));
+    // response of /groups
+    gitlab.enqueue(new MockResponse().setBody("[{\"full_path\": \"group1\"}, {\"full_path\": \"group2\"}]"));
+
+    gitLabIdentityProvider.callback(callbackContext);
+
+    ArgumentCaptor<UserIdentity> captor = ArgumentCaptor.forClass(UserIdentity.class);
+    verify(callbackContext).authenticate(captor.capture());
+    UserIdentity value = captor.getValue();
+    assertThat(value.getGroups()).contains("group1", "group2");
+  }
+
+  @Test
+  public void synchronize_groups_on_many_pages() {
+    mapSettings.setProperty(GITLAB_AUTH_SYNC_USER_GROUPS, "true");
+    OAuth2IdentityProvider.CallbackContext callbackContext = Mockito.mock(OAuth2IdentityProvider.CallbackContext.class);
+    when(callbackContext.getCallbackUrl()).thenReturn("http://server/callback");
+
+    HttpServletRequest httpServletRequest = Mockito.mock(HttpServletRequest.class);
+    when(httpServletRequest.getParameter("code")).thenReturn(ANY_CODE_VALUE);
+    when(callbackContext.getRequest()).thenReturn(httpServletRequest);
+
+    gitlab.enqueue(new MockResponse().setBody(
+      "{\n" + " \"access_token\": \"de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54\",\n" + " \"token_type\": \"bearer\",\n" + " \"expires_in\": 7200,\n"
+        + " \"refresh_token\": \"8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1\"\n" + "}"));
+    // response of /user
+    gitlab.enqueue(new MockResponse().setBody("{\"id\": 123, \"username\": \"username\", \"name\": \"name\"}"));
+    // response of /groups, first page
+    gitlab.enqueue(new MockResponse()
+      .setBody("[{\"full_path\": \"group1\"}, {\"full_path\": \"group2\"}]")
+      .setHeader("Link", format(" <%s/groups?per_page=100&page=2>; rel=\"next\"," +
+        "  <%s/groups?per_page=100&&page=3>; rel=\"last\"," +
+        "  <%s/groups?per_page=100&&page=1>; rel=\"first\"", gitLabUrl, gitLabUrl, gitLabUrl)));
+    // response of /groups, page 2
+    gitlab.enqueue(new MockResponse()
+      .setBody("[{\"full_path\": \"group3\"}, {\"full_path\": \"group4\"}]")
+      .setHeader("Link", format("<%s/groups?per_page=100&page=3>; rel=\"next\"," +
+        "  <%s/groups?per_page=100&&page=3>; rel=\"last\"," +
+        "  <%s/groups?per_page=100&&page=1>; rel=\"first\"", gitLabUrl, gitLabUrl, gitLabUrl)));
+    // response of /groups, page 3
+    gitlab.enqueue(new MockResponse()
+      .setBody("[{\"full_path\": \"group5\"}, {\"full_path\": \"group6\"}]")
+      .setHeader("Link", format("<%s/groups?per_page=100&&page=3>; rel=\"last\"," +
+        "  <%s/groups?per_page=100&&page=1>; rel=\"first\"", gitLabUrl, gitLabUrl)));
+
+    gitLabIdentityProvider.callback(callbackContext);
+
+    ArgumentCaptor<UserIdentity> captor = ArgumentCaptor.forClass(UserIdentity.class);
+    verify(callbackContext).authenticate(captor.capture());
+    UserIdentity value = captor.getValue();
+    assertThat(value.getGroups()).contains("group1", "group2", "group3", "group4", "group5", "group6");
+  }
+
+  @Test
+  public void fail_to_authenticate() {
+    OAuth2IdentityProvider.CallbackContext callbackContext = Mockito.mock(OAuth2IdentityProvider.CallbackContext.class);
+    when(callbackContext.getCallbackUrl()).thenReturn("http://server/callback");
+
+    HttpServletRequest httpServletRequest = Mockito.mock(HttpServletRequest.class);
+    when(httpServletRequest.getParameter("code")).thenReturn(ANY_CODE_VALUE);
+    when(callbackContext.getRequest()).thenReturn(httpServletRequest);
+
+    gitlab.enqueue(new MockResponse().setBody(
+      "{\n" + " \"access_token\": \"de6780bc506a0446309bd9362820ba8aed28aa506c71eedbe1c5c4f9dd350e54\",\n" + " \"token_type\": \"bearer\",\n" + " \"expires_in\": 7200,\n"
+        + " \"refresh_token\": \"8257e65c97202ed1726cf9571600918f3bffb2544b26e00a61df9897668c33a1\"\n" + "}"));
+    gitlab.enqueue(new MockResponse().setResponseCode(404).setBody("empty"));
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage("Fail to execute request '" + gitLabSettings.url() + "/api/v4/user'. HTTP code: 404, response: empty");
+
+    gitLabIdentityProvider.callback(callbackContext);
+  }
+
+}
diff --git a/server/sonar-web/public/images/gitlab-icon-rgb.svg b/server/sonar-web/public/images/gitlab-icon-rgb.svg
new file mode 100644 (file)
index 0000000..e54552c
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" data-name="logo art" width="342.95477391269446" height="318.78894492590746" style=""><rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill="none" stroke="none"/><defs><style>.cls-1{fill:#fc6d26;}.cls-2{fill:#e24329;}.cls-3{fill:#fca326;}</style></defs><title>gitlab-icon-rgb</title><g class="currentLayer" style=""><title>Layer 1</title><g id="g44" class=""><path id="path46" class="cls-1" d="M339.56196038246156,180.72447212219237 l-18.91,-58.12 L283.2319603824615,7.32447212219239 a6.47,6.47 0 0 0 -12.27,0 L233.54196038246153,122.53447212219237 H109.21196038246154 L71.79196038246155,7.32447212219239 a6.46,6.46 0 0 0 -12.26,0 L22.17196038246155,122.53447212219237 l-18.91,58.19 a12.88,12.88 0 0 0 4.66,14.39 L171.39196038246155,313.8944721221924 L334.83196038246155,195.1144721221924 a12.9,12.9 0 0 0 4.73,-14.39 "/></g><g id="g48" class=""><path id="path50" class="cls-2" d="M171.39196038246155,313.8044721221924 h0 l62.16,-191.28 H109.26196038246155 L171.39196038246155,313.8044721221924 z"/></g><g id="g56" class=""><path id="path58" class="cls-1" d="M171.39196038246155,313.8044721221924 L109.21196038246154,122.52447212219238 h-87 L171.39196038246155,313.8044721221924 z"/></g><g id="g64" class=""><path id="path66" class="cls-3" d="M22.141960382461548,122.58447212219238 h0 l-18.91,58.12 a12.88,12.88 0 0 0 4.66,14.39 L171.39196038246155,313.8944721221924 L22.141960382461548,122.58447212219238 z"/></g><g id="g72" class=""><path id="path74" class="cls-2" d="M22.17196038246155,122.58447212219238 h87.11 L71.79196038246155,7.384472122192392 a6.47,6.47 0 0 0 -12.27,0 l-37.35,115.2 z"/></g><g id="g76" class=""><path id="path78" class="cls-1" d="M171.39196038246155,313.8044721221924 l62.16,-191.28 H320.69196038246156 L171.39196038246155,313.8044721221924 z"/></g><g id="g80" class=""><path id="path82" class="cls-3" d="M320.63196038246156,122.58447212219238 h0 l18.91,58.12 a12.85,12.85 0 0 1 -4.66,14.39 L171.39196038246155,313.8044721221924 l149.2,-191.22 z"/></g><g id="g84" class=""><path id="path86" class="cls-2" d="M320.6719603824615,122.58447212219238 h-87.1 l37.42,-115.2 a6.46,6.46 0 0 1 12.26,0 l37.42,115.2 z"/></g></g></svg>
\ No newline at end of file
index 4fc1039102ffac7340e0f724efbc909746f0b389..87b08f8c225936cec661a2ce4bd4f1814e14e3f0 100644 (file)
@@ -12,6 +12,7 @@ dependencies {
   compile 'com.google.guava:guava'
   compile 'org.apache.tomcat.embed:tomcat-embed-core'
   compile project(':sonar-core')
+  compile project(':server:sonar-auth-gitlab')
   compile project(':server:sonar-ce-task-projectanalysis')
   compile project(':server:sonar-process')
   compile project(':server:sonar-webserver-core')
index c00cf3852c9b83f8d671f312754c16db912d607b..da9712f4f48df6967b4b47e6d398338eb3dd02b3 100644 (file)
@@ -28,6 +28,7 @@ import org.sonar.api.resources.ResourceTypes;
 import org.sonar.api.rules.AnnotationRuleParser;
 import org.sonar.api.rules.XMLRuleParser;
 import org.sonar.api.server.rule.RulesDefinitionXmlLoader;
+import org.sonar.auth.gitlab.GitLabModule;
 import org.sonar.ce.task.projectanalysis.notification.ReportAnalysisFailureNotificationModule;
 import org.sonar.ce.task.projectanalysis.taskprocessor.ReportTaskProcessor;
 import org.sonar.core.component.DefaultResourceTypes;
@@ -349,6 +350,7 @@ public class PlatformLevel4 extends PlatformLevel {
       // authentication
       AuthenticationModule.class,
       AuthenticationWsModule.class,
+      GitLabModule.class,
 
       // users
       UserSessionFactoryImpl.class,
index ddd0333158763943ce71b7ab5be08eafb71f1288..6f681f2228b496ea24d226dfcca541d7da804dbd 100644 (file)
@@ -2,6 +2,7 @@ rootProject.name = 'sonarqube'
 
 include 'plugins:sonar-xoo-plugin'
 
+include 'server:sonar-auth-gitlab'
 include 'server:sonar-ce'
 include 'server:sonar-ce-common'
 include 'server:sonar-ce-task'
index bd95abe8e39e362264ea6c31fbec69b1a6764c2a..58f460c0fd00bdbd520e6c38d3927d7e151d5ef6 100644 (file)
@@ -892,6 +892,7 @@ property.category.general.subProjects=Sub-projects
 property.category.organizations=Organizations
 property.category.security=Security
 property.category.security.encryption=Encryption
+property.category.security.gitlab=Gitlab
 property.category.java=Java
 property.category.differentialViews=New Code
 property.category.codeCoverage=Code Coverage