]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-12471 Embed GitHub authentication
authorJulien Lancelot <julien.lancelot@sonarsource.com>
Tue, 24 Sep 2019 09:44:20 +0000 (11:44 +0200)
committerSonarTech <sonartech@sonarsource.com>
Mon, 7 Oct 2019 18:21:06 +0000 (20:21 +0200)
37 files changed:
server/sonar-auth-common/build.gradle [new file with mode: 0644]
server/sonar-auth-common/src/main/java/org/sonar/auth/OAuthRestClient.java [new file with mode: 0644]
server/sonar-auth-common/src/main/java/org/sonar/auth/package-info.java [new file with mode: 0644]
server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java [new file with mode: 0644]
server/sonar-auth-github/build.gradle [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java [new file with mode: 0644]
server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java [new file with mode: 0644]
server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java [new file with mode: 0644]
server/sonar-auth-gitlab/build.gradle
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java
server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java
server/sonar-docs/src/pages/instance-administration/delegated-auth.md
server/sonar-server-common/src/main/java/org/sonar/server/plugins/ServerExtensionInstaller.java
server/sonar-server-common/src/test/java/org/sonar/server/plugins/ServerExtensionInstallerTest.java
server/sonar-web/public/images/github.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-application/build.gradle
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-auth-common/build.gradle b/server/sonar-auth-common/build.gradle
new file mode 100644 (file)
index 0000000..9a66954
--- /dev/null
@@ -0,0 +1,21 @@
+description = 'SonarQube :: Authentication :: Common'
+
+configurations {
+    testCompile.extendsFrom compileOnly
+}
+
+dependencies {
+    // please keep the list ordered
+
+    compile 'com.github.scribejava:scribejava-apis'
+    compile 'com.github.scribejava:scribejava-core'
+
+    compileOnly 'com.google.code.findbugs:jsr305'
+
+    testCompile 'commons-lang:commons-lang'
+    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-common/src/main/java/org/sonar/auth/OAuthRestClient.java b/server/sonar-auth-common/src/main/java/org/sonar/auth/OAuthRestClient.java
new file mode 100644 (file)
index 0000000..f07b9e0
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * 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;
+
+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 OAuthRestClient {
+
+  private static final int DEFAULT_PAGE_SIZE = 100;
+  private static final Pattern NEXT_LINK_PATTERN = Pattern.compile(".*<(.*)>; rel=\"next\"");
+
+  private OAuthRestClient() {
+    // Only static method
+  }
+
+  public 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);
+    }
+  }
+
+  public static <E> List<E> executePaginatedRequest(String request, OAuth20Service scribe, OAuth2AccessToken accessToken, Function<String, List<E>> function) {
+    List<E> result = new ArrayList<>();
+    readPage(result, scribe, accessToken, request + "?per_page=" + DEFAULT_PAGE_SIZE, function);
+    return result;
+  }
+
+  private static <E> void readPage(List<E> result, OAuth20Service scribe, OAuth2AccessToken accessToken, String endPoint, Function<String, List<E>> function) {
+    try (Response nextResponse = executeRequest(endPoint, scribe, accessToken)) {
+      String content = nextResponse.getBody();
+      if (content == null) {
+        return;
+      }
+      result.addAll(function.apply(content));
+      readNextEndPoint(nextResponse).ifPresent(newNextEndPoint -> readPage(result, scribe, accessToken, newNextEndPoint, function));
+    } catch (IOException e) {
+      throw new IllegalStateException(format("Failed to get %s", endPoint), 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-common/src/main/java/org/sonar/auth/package-info.java b/server/sonar-auth-common/src/main/java/org/sonar/auth/package-info.java
new file mode 100644 (file)
index 0000000..00a1668
--- /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;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java b/server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java
new file mode 100644 (file)
index 0000000..7f1826e
--- /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;
+
+import com.github.scribejava.core.builder.ServiceBuilder;
+import com.github.scribejava.core.builder.api.DefaultApi20;
+import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.github.scribejava.core.model.Response;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+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 static java.lang.String.format;
+import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.sonar.auth.OAuthRestClient.executePaginatedRequest;
+import static org.sonar.auth.OAuthRestClient.executeRequest;
+
+public class OAuthRestClientTest {
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  @Rule
+  public MockWebServer mockWebServer = new MockWebServer();
+
+  private OAuth2AccessToken auth2AccessToken = mock(OAuth2AccessToken.class);
+
+  private String serverUrl;
+
+  private OAuth20Service oAuth20Service = new ServiceBuilder("API_KEY")
+    .apiSecret("API_SECRET")
+    .callback("CALLBACK")
+    .build(new TestAPI());
+
+  @Before
+  public void setUp() {
+    this.serverUrl = format("http://%s:%d", mockWebServer.getHostName(), mockWebServer.getPort());
+  }
+
+  @Test
+  public void execute_request() throws IOException {
+    String body = randomAlphanumeric(10);
+    mockWebServer.enqueue(new MockResponse().setBody(body));
+
+    Response response = executeRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken);
+
+    assertThat(response.getBody()).isEqualTo(body);
+  }
+
+  @Test
+  public void fail_to_execute_request() throws IOException {
+    mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("Error!"));
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage(format("Fail to execute request '%s/test'. HTTP code: 404, response: Error!", serverUrl));
+
+    executeRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken);
+  }
+
+  @Test
+  public void execute_paginated_request() {
+    mockWebServer.enqueue(new MockResponse()
+      .setHeader("Link", "<" + serverUrl + "/test?per_page=100&page=2>; rel=\"next\", <" + serverUrl + "/test?per_page=100&page=2>; rel=\"last\"")
+      .setBody("A"));
+    mockWebServer.enqueue(new MockResponse()
+      .setHeader("Link", "<" + serverUrl + "/test?per_page=100&page=1>; rel=\"prev\", <" + serverUrl + "/test?per_page=100&page=1>; rel=\"first\"")
+      .setBody("B"));
+
+    List<String> response = executePaginatedRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken, Arrays::asList);
+
+    assertThat(response).contains("A", "B");
+  }
+
+  @Test
+  public void fail_to_executed_paginated_request() {
+    mockWebServer.enqueue(new MockResponse()
+      .setHeader("Link", "<" + serverUrl + "/test?per_page=100&page=2>; rel=\"next\", <" + serverUrl + "/test?per_page=100&page=2>; rel=\"last\"")
+      .setBody("A"));
+    mockWebServer.enqueue(new MockResponse().setResponseCode(404).setBody("Error!"));
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage(format("Fail to execute request '%s/test?per_page=100&page=2'. HTTP code: 404, response: Error!", serverUrl));
+
+    executePaginatedRequest(serverUrl + "/test", oAuth20Service, auth2AccessToken, Arrays::asList);
+  }
+
+  private class TestAPI extends DefaultApi20 {
+
+    @Override
+    public String getAccessTokenEndpoint() {
+      return serverUrl + "/login/oauth/access_token";
+    }
+
+    @Override
+    protected String getAuthorizationBaseUrl() {
+      return serverUrl + "/login/oauth/authorize";
+    }
+
+  }
+}
diff --git a/server/sonar-auth-github/build.gradle b/server/sonar-auth-github/build.gradle
new file mode 100644 (file)
index 0000000..3bc4be0
--- /dev/null
@@ -0,0 +1,26 @@
+description = 'SonarQube :: Authentication :: GitHub'
+
+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'
+    compile project(':server:sonar-auth-common')
+
+    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-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java
new file mode 100644 (file)
index 0000000..26d2d74
--- /dev/null
@@ -0,0 +1,161 @@
+/*
+ * 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.github;
+
+import com.github.scribejava.core.builder.ServiceBuilder;
+import com.github.scribejava.core.model.OAuth2AccessToken;
+import com.github.scribejava.core.oauth.OAuth20Service;
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+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.UnauthorizedException;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.lang.String.format;
+
+public class GitHubIdentityProvider implements OAuth2IdentityProvider {
+
+  static final String KEY = "github";
+
+  private final GitHubSettings settings;
+  private final UserIdentityFactory userIdentityFactory;
+  private final ScribeGitHubApi scribeApi;
+  private final GitHubRestClient gitHubRestClient;
+
+  public GitHubIdentityProvider(GitHubSettings settings, UserIdentityFactory userIdentityFactory, ScribeGitHubApi scribeApi, GitHubRestClient gitHubRestClient) {
+    this.settings = settings;
+    this.userIdentityFactory = userIdentityFactory;
+    this.scribeApi = scribeApi;
+    this.gitHubRestClient = gitHubRestClient;
+  }
+
+  @Override
+  public String getKey() {
+    return KEY;
+  }
+
+  @Override
+  public String getName() {
+    return "GitHub";
+  }
+
+  @Override
+  public Display getDisplay() {
+    return Display.builder()
+      .setIconPath("/images/github.svg")
+      .setBackgroundColor("#444444")
+      .build();
+  }
+
+  @Override
+  public boolean isEnabled() {
+    return settings.isEnabled();
+  }
+
+  @Override
+  public boolean allowsUsersToSignUp() {
+    return settings.allowUsersToSignUp();
+  }
+
+  @Override
+  public void init(InitContext context) {
+    String state = context.generateCsrfState();
+    OAuth20Service scribe = newScribeBuilder(context)
+      .defaultScope(getScope())
+      .build(scribeApi);
+    String url = scribe.getAuthorizationUrl(state);
+    context.redirectTo(url);
+  }
+
+  String getScope() {
+    return (settings.syncGroups() || isOrganizationMembershipRequired()) ? "user:email,read:org" : "user:email";
+  }
+
+  @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 {
+    context.verifyCsrfState();
+
+    HttpServletRequest request = context.getRequest();
+    OAuth20Service scribe = newScribeBuilder(context).build(scribeApi);
+    String code = request.getParameter("code");
+    OAuth2AccessToken accessToken = scribe.getAccessToken(code);
+
+    GsonUser user = gitHubRestClient.getUser(scribe, accessToken);
+    check(scribe, accessToken, user);
+
+    final String email;
+    if (user.getEmail() == null) {
+      // if the user has not specified a public email address in their profile
+      email = gitHubRestClient.getEmail(scribe, accessToken);
+    } else {
+      email = user.getEmail();
+    }
+
+    UserIdentity userIdentity = userIdentityFactory.create(user, email,
+      settings.syncGroups() ? gitHubRestClient.getTeams(scribe, accessToken) : null);
+    context.authenticate(userIdentity);
+    context.redirectToRequestedPage();
+  }
+
+  boolean isOrganizationMembershipRequired() {
+    return settings.organizations().length > 0;
+  }
+
+  private void check(OAuth20Service scribe, OAuth2AccessToken accessToken, GsonUser user) throws InterruptedException, ExecutionException, IOException {
+    if (isUnauthorized(scribe, accessToken, user.getLogin())) {
+      throw new UnauthorizedException(format("'%s' must be a member of at least one organization: '%s'", user.getLogin(), String.join("', '", settings.organizations())));
+    }
+  }
+
+  private boolean isUnauthorized(OAuth20Service scribe, OAuth2AccessToken accessToken, String login) throws IOException, ExecutionException, InterruptedException {
+    return isOrganizationMembershipRequired() && !isOrganizationsMember(scribe, accessToken, login);
+  }
+
+  private boolean isOrganizationsMember(OAuth20Service scribe, OAuth2AccessToken accessToken, String login) throws IOException, ExecutionException, InterruptedException {
+    for (String organization : settings.organizations()) {
+      if (gitHubRestClient.isOrganizationMember(scribe, accessToken, organization, login)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private ServiceBuilder newScribeBuilder(OAuth2IdentityProvider.OAuth2Context context) {
+    checkState(isEnabled(), "GitHub authentication is disabled");
+    return new ServiceBuilder(settings.clientId())
+      .apiSecret(settings.clientSecret())
+      .callback(context.getCallbackUrl());
+  }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java
new file mode 100644 (file)
index 0000000..1f349d1
--- /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.github;
+
+import java.util.List;
+import org.sonar.api.config.PropertyDefinition;
+import org.sonar.core.platform.Module;
+
+import static org.sonar.auth.github.GitHubSettings.definitions;
+
+public class GitHubModule extends Module {
+
+  @Override
+  protected void configureModule() {
+    add(
+      GitHubIdentityProvider.class,
+      GitHubSettings.class,
+      GitHubRestClient.class,
+      UserIdentityFactoryImpl.class,
+      ScribeGitHubApi.class);
+    List<PropertyDefinition> definitions = definitions();
+    add(definitions.toArray(new Object[definitions.size()]));
+  }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java
new file mode 100644 (file)
index 0000000..5c53368
--- /dev/null
@@ -0,0 +1,99 @@
+/*
+ * 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.github;
+
+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.net.HttpURLConnection;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import org.sonar.api.utils.log.Logger;
+import org.sonar.api.utils.log.Loggers;
+
+import static java.lang.String.format;
+import static org.sonar.auth.OAuthRestClient.executePaginatedRequest;
+import static org.sonar.auth.OAuthRestClient.executeRequest;
+
+public class GitHubRestClient {
+
+  private static final Logger LOGGER = Loggers.get(GitHubRestClient.class);
+
+  private final GitHubSettings settings;
+
+  public GitHubRestClient(GitHubSettings settings) {
+    this.settings = settings;
+  }
+
+  GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException {
+    String responseBody = executeRequest(settings.apiURL() + "user", scribe, accessToken).getBody();
+    LOGGER.trace("User response received : {}", responseBody);
+    return GsonUser.parse(responseBody);
+  }
+
+  String getEmail(OAuth20Service scribe, OAuth2AccessToken accessToken) throws IOException {
+    String responseBody = executeRequest(settings.apiURL() + "user/emails", scribe, accessToken).getBody();
+    LOGGER.trace("Emails response received : {}", responseBody);
+    List<GsonEmail> emails = GsonEmail.parse(responseBody);
+    return emails.stream()
+      .filter(email -> email.isPrimary() && email.isVerified())
+      .findFirst()
+      .map(GsonEmail::getEmail)
+      .orElse(null);
+  }
+
+  List<GsonTeam> getTeams(OAuth20Service scribe, OAuth2AccessToken accessToken) {
+    return executePaginatedRequest(settings.apiURL() + "user/teams", scribe, accessToken, GsonTeam::parse);
+  }
+
+  /**
+   * Check to see that login is a member of organization.
+   *
+   * A 204 response code indicates organization membership.  302 and 404 codes are not treated as exceptional,
+   * they indicate various ways in which a login is not a member of the organization.
+   *
+   * @see <a href="https://developer.github.com/v3/orgs/members/#response-if-requester-is-an-organization-member-and-user-is-a-member">GitHub members API</a>
+   */
+  boolean isOrganizationMember(OAuth20Service scribe, OAuth2AccessToken accessToken, String organization, String login)
+    throws IOException, ExecutionException, InterruptedException {
+    String requestUrl = settings.apiURL() + format("orgs/%s/members/%s", organization, login);
+    OAuthRequest request = new OAuthRequest(Verb.GET, requestUrl);
+    scribe.signRequest(accessToken, request);
+
+    Response response = scribe.execute(request);
+    int code = response.getCode();
+    switch (code) {
+      case HttpURLConnection.HTTP_MOVED_TEMP:
+      case HttpURLConnection.HTTP_NOT_FOUND:
+      case HttpURLConnection.HTTP_NO_CONTENT:
+        LOGGER.trace("Orgs response received : {}", code);
+        return code == HttpURLConnection.HTTP_NO_CONTENT;
+      default:
+        throw unexpectedResponseCode(requestUrl, response);
+    }
+  }
+
+  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-github/src/main/java/org/sonar/auth/github/GitHubSettings.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java
new file mode 100644 (file)
index 0000000..7c3f7a2
--- /dev/null
@@ -0,0 +1,191 @@
+/*
+ * 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.github;
+
+import java.util.Arrays;
+import java.util.List;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+import org.sonar.api.config.Configuration;
+import org.sonar.api.config.PropertyDefinition;
+
+import static java.lang.String.format;
+import static java.lang.String.valueOf;
+import static org.sonar.api.PropertyType.BOOLEAN;
+import static org.sonar.api.PropertyType.SINGLE_SELECT_LIST;
+import static org.sonar.api.PropertyType.STRING;
+
+public class GitHubSettings {
+
+  private static final String CLIENT_ID = "sonar.auth.github.clientId.secured";
+  private static final String CLIENT_SECRET = "sonar.auth.github.clientSecret.secured";
+  private static final String ENABLED = "sonar.auth.github.enabled";
+  private static final String ALLOW_USERS_TO_SIGN_UP = "sonar.auth.github.allowUsersToSignUp";
+  private static final String GROUPS_SYNC = "sonar.auth.github.groupsSync";
+  private static final String API_URL = "sonar.auth.github.apiUrl";
+  private static final String WEB_URL = "sonar.auth.github.webUrl";
+
+  static final String LOGIN_STRATEGY = "sonar.auth.github.loginStrategy";
+  static final String LOGIN_STRATEGY_UNIQUE = "Unique";
+  static final String LOGIN_STRATEGY_PROVIDER_ID = "Same as GitHub login";
+  static final String LOGIN_STRATEGY_DEFAULT_VALUE = LOGIN_STRATEGY_UNIQUE;
+
+  private static final String ORGANIZATIONS = "sonar.auth.github.organizations";
+
+  private static final String CATEGORY = "security";
+  private static final String SUBCATEGORY = "github";
+
+  private final Configuration configuration;
+
+  public GitHubSettings(Configuration configuration) {
+    this.configuration = configuration;
+  }
+
+  String clientId() {
+    return configuration.get(CLIENT_ID).orElse("");
+  }
+
+  String clientSecret() {
+    return configuration.get(CLIENT_SECRET).orElse("");
+  }
+
+  boolean isEnabled() {
+    return configuration.getBoolean(ENABLED).orElse(false) && !clientId().isEmpty() && !clientSecret().isEmpty();
+  }
+
+  boolean allowUsersToSignUp() {
+    return configuration.getBoolean(ALLOW_USERS_TO_SIGN_UP).orElse(false);
+  }
+
+  String loginStrategy() {
+    return configuration.get(LOGIN_STRATEGY).orElse("");
+  }
+
+  boolean syncGroups() {
+    return configuration.getBoolean(GROUPS_SYNC).orElse(false);
+  }
+
+  @CheckForNull
+  String webURL() {
+    return urlWithEndingSlash(configuration.get(WEB_URL).orElse(""));
+  }
+
+  @CheckForNull
+  String apiURL() {
+    return urlWithEndingSlash(configuration.get(API_URL).orElse(""));
+  }
+
+  String[] organizations() {
+    return configuration.getStringArray(ORGANIZATIONS);
+  }
+
+  @CheckForNull
+  private static String urlWithEndingSlash(@Nullable String url) {
+    if (url != null && !url.endsWith("/")) {
+      return url + "/";
+    }
+    return url;
+  }
+
+  public static List<PropertyDefinition> definitions() {
+    return Arrays.asList(
+      PropertyDefinition.builder(ENABLED)
+        .name("Enabled")
+        .description("Enable GitHub users to login. Value is ignored if client ID and secret are not defined.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(BOOLEAN)
+        .defaultValue(valueOf(false))
+        .index(1)
+        .build(),
+      PropertyDefinition.builder(CLIENT_ID)
+        .name("Client ID")
+        .description("Client ID provided by GitHub when registering the application.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .index(2)
+        .build(),
+      PropertyDefinition.builder(CLIENT_SECRET)
+        .name("Client Secret")
+        .description("Client password provided by GitHub when registering the application.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .index(3)
+        .build(),
+      PropertyDefinition.builder(ALLOW_USERS_TO_SIGN_UP)
+        .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(4)
+        .build(),
+      PropertyDefinition.builder(LOGIN_STRATEGY)
+        .name("Login generation strategy")
+        .description(format("When the login strategy is set to '%s', the user's login will be auto-generated the first time so that it is unique. " +
+          "When the login strategy is set to '%s', the user's login will be the GitHub login.",
+          LOGIN_STRATEGY_UNIQUE, LOGIN_STRATEGY_PROVIDER_ID))
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(SINGLE_SELECT_LIST)
+        .defaultValue(LOGIN_STRATEGY_DEFAULT_VALUE)
+        .options(LOGIN_STRATEGY_UNIQUE, LOGIN_STRATEGY_PROVIDER_ID)
+        .index(5)
+        .build(),
+      PropertyDefinition.builder(GROUPS_SYNC)
+        .name("Synchronize teams as groups")
+        .description("For each team he belongs to, the user will be associated to a group named 'Organisation/Team' (if it exists) in SonarQube.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(BOOLEAN)
+        .defaultValue(valueOf(false))
+        .index(6)
+        .build(),
+      PropertyDefinition.builder(API_URL)
+        .name("The API url for a GitHub instance.")
+        .description("The API url for a GitHub instance. https://api.github.com/ for github.com, https://github.company.com/api/v3/ when using Github Enterprise")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(STRING)
+        .defaultValue(valueOf("https://api.github.com/"))
+        .index(7)
+        .build(),
+      PropertyDefinition.builder(WEB_URL)
+        .name("The WEB url for a GitHub instance.")
+        .description("The WEB url for a GitHub instance. " +
+          "https://github.com/ for github.com, https://github.company.com/ when using GitHub Enterprise.")
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .type(STRING)
+        .defaultValue(valueOf("https://github.com/"))
+        .index(8)
+        .build(),
+      PropertyDefinition.builder(ORGANIZATIONS)
+        .name("Organizations")
+        .description("Only members of these organizations will be able to authenticate to the server. " +
+          "If a user is a member of any of the organizations listed they will be authenticated.")
+        .multiValues(true)
+        .category(CATEGORY)
+        .subCategory(SUBCATEGORY)
+        .index(9)
+        .build());
+  }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java
new file mode 100644 (file)
index 0000000..662f1f4
--- /dev/null
@@ -0,0 +1,66 @@
+/*
+ * 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.github;
+
+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://api.github.com/user/emails
+ */
+public class GsonEmail {
+
+  private String email;
+  private boolean verified;
+  private boolean primary;
+
+  public GsonEmail() {
+    // http://stackoverflow.com/a/18645370/229031
+    this("", false, false);
+  }
+
+  public GsonEmail(String email, boolean verified, boolean primary) {
+    this.email = email;
+    this.verified = verified;
+    this.primary = primary;
+  }
+
+  public String getEmail() {
+    return email;
+  }
+
+  public boolean isVerified() {
+    return verified;
+  }
+
+  public boolean isPrimary() {
+    return primary;
+  }
+
+  public static List<GsonEmail> parse(String json) {
+    Type collectionType = new TypeToken<Collection<GsonEmail>>() {
+    }.getType();
+    Gson gson = new Gson();
+    return gson.fromJson(json, collectionType);
+  }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java
new file mode 100644 (file)
index 0000000..8c51c82
--- /dev/null
@@ -0,0 +1,77 @@
+/*
+ * 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.github;
+
+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://api.github.com/user/teams
+ */
+public class GsonTeam {
+
+  private String slug;
+  private GsonOrganization organization;
+
+  public GsonTeam() {
+    // http://stackoverflow.com/a/18645370/229031
+    this("", new GsonOrganization());
+  }
+
+  public GsonTeam(String slug, GsonOrganization organization) {
+    this.slug = slug;
+    this.organization = organization;
+  }
+
+  public String getId() {
+    return slug;
+  }
+
+  public String getOrganizationId() {
+    return organization.getLogin();
+  }
+
+  public static List<GsonTeam> parse(String json) {
+    Type collectionType = new TypeToken<Collection<GsonTeam>>() {
+    }.getType();
+    Gson gson = new Gson();
+    return gson.fromJson(json, collectionType);
+  }
+
+  public static class GsonOrganization {
+    private String login;
+
+    public GsonOrganization() {
+      // http://stackoverflow.com/a/18645370/229031
+      this("");
+    }
+
+    public GsonOrganization(String login) {
+      this.login = login;
+    }
+
+    public String getLogin() {
+      return login;
+    }
+  }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java
new file mode 100644 (file)
index 0000000..f113adf
--- /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.github;
+
+import com.google.gson.Gson;
+import javax.annotation.CheckForNull;
+import javax.annotation.Nullable;
+
+/**
+ * Lite representation of JSON response of GET https://api.github.com/user
+ */
+public class GsonUser {
+  private String id;
+  private String login;
+  private String name;
+  private String email;
+
+  public GsonUser() {
+    // even if empty constructor is not required for Gson, it is strongly
+    // recommended:
+    // http://stackoverflow.com/a/18645370/229031
+  }
+
+  public GsonUser(String id, String login, @Nullable String name, @Nullable String email) {
+    this.id = id;
+    this.login = login;
+    this.name = name;
+    this.email = email;
+  }
+
+  public String getId() {
+    return id;
+  }
+
+  public String getLogin() {
+    return login;
+  }
+
+  /**
+   * Name is optional at GitHub
+   */
+  @CheckForNull
+  public String getName() {
+    return name;
+  }
+
+  /**
+   * Name is optional at GitHub
+   */
+  @CheckForNull
+  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-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java
new file mode 100644 (file)
index 0000000..fc5828e
--- /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.github;
+
+import com.github.scribejava.apis.GitHubApi;
+
+public class ScribeGitHubApi extends GitHubApi {
+  private final GitHubSettings settings;
+
+  public ScribeGitHubApi(GitHubSettings settings) {
+    this.settings = settings;
+  }
+
+  @Override
+  public String getAccessTokenEndpoint() {
+    return settings.webURL() + "login/oauth/access_token";
+  }
+
+  @Override
+  protected String getAuthorizationBaseUrl() {
+    return settings.webURL() + "login/oauth/authorize";
+  }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java
new file mode 100644 (file)
index 0000000..4e9085f
--- /dev/null
@@ -0,0 +1,32 @@
+/*
+ * 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.github;
+
+import java.util.List;
+import javax.annotation.Nullable;
+import org.sonar.api.server.authentication.UserIdentity;
+
+/**
+ * Converts GitHub JSON response to {@link UserIdentity}
+ */
+public interface UserIdentityFactory {
+
+  UserIdentity create(GsonUser user, @Nullable String email, @Nullable List<GsonTeam> teams);
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java
new file mode 100644 (file)
index 0000000..a93798a
--- /dev/null
@@ -0,0 +1,54 @@
+/*
+ * 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.github;
+
+import java.util.List;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static org.sonar.auth.github.UserIdentityGenerator.generateLogin;
+import static org.sonar.auth.github.UserIdentityGenerator.generateName;
+
+public class UserIdentityFactoryImpl implements UserIdentityFactory {
+
+  private final GitHubSettings settings;
+
+  public UserIdentityFactoryImpl(GitHubSettings settings) {
+    this.settings = settings;
+  }
+
+  @Override
+  public UserIdentity create(GsonUser user, @Nullable String email, @Nullable List<GsonTeam> teams) {
+    UserIdentity.Builder builder = UserIdentity.builder()
+      .setProviderId(user.getId())
+      .setProviderLogin(user.getLogin())
+      .setLogin(generateLogin(user, settings.loginStrategy()))
+      .setName(generateName(user))
+      .setEmail(email);
+    if (teams != null) {
+      builder.setGroups(teams.stream()
+        .map(team -> team.getOrganizationId() + "/" + team.getId())
+        .collect(Collectors.toSet()));
+    }
+    return builder.build();
+  }
+
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java
new file mode 100644 (file)
index 0000000..4b3dbdf
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * 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.github;
+
+import static java.lang.String.format;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_UNIQUE;
+
+class UserIdentityGenerator {
+
+  private UserIdentityGenerator() {
+    // Only static method
+  }
+
+  static String generateLogin(GsonUser gsonUser, String loginStrategy) {
+    switch (loginStrategy) {
+      case LOGIN_STRATEGY_PROVIDER_ID:
+        return gsonUser.getLogin();
+      case LOGIN_STRATEGY_UNIQUE:
+        return generateUniqueLogin(gsonUser);
+      default:
+        throw new IllegalStateException(format("Login strategy not supported : %s", loginStrategy));
+    }
+  }
+
+  static String generateName(GsonUser gson) {
+    String name = gson.getName();
+    return name == null || name.isEmpty() ? gson.getLogin() : name;
+  }
+
+  private static String generateUniqueLogin(GsonUser gsonUser) {
+    return format("%s@%s", gsonUser.getLogin(), GitHubIdentityProvider.KEY);
+  }
+}
diff --git a/server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java
new file mode 100644 (file)
index 0000000..153f45e
--- /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.github;
+
+import javax.annotation.ParametersAreNonnullByDefault;
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java
new file mode 100644 (file)
index 0000000..ac78fd8
--- /dev/null
@@ -0,0 +1,185 @@
+/*
+ * 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.github;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.internal.MapSettings;
+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;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_DEFAULT_VALUE;
+
+public class GitHubIdentityProviderTest {
+
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  private MapSettings settings = new MapSettings();
+  private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig());
+  private UserIdentityFactoryImpl userIdentityFactory = mock(UserIdentityFactoryImpl.class);
+  private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings);
+  private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings);
+  private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient);
+
+  @Test
+  public void check_fields() {
+    assertThat(underTest.getKey()).isEqualTo("github");
+    assertThat(underTest.getName()).isEqualTo("GitHub");
+    assertThat(underTest.getDisplay().getIconPath()).isEqualTo("/images/github.svg");
+    assertThat(underTest.getDisplay().getBackgroundColor()).isEqualTo("#444444");
+  }
+
+  @Test
+  public void is_enabled() {
+    settings.setProperty("sonar.auth.github.clientId.secured", "id");
+    settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+    settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+    settings.setProperty("sonar.auth.github.enabled", true);
+    assertThat(underTest.isEnabled()).isTrue();
+
+    settings.setProperty("sonar.auth.github.enabled", false);
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void should_allow_users_to_signup() {
+    assertThat(underTest.allowsUsersToSignUp()).as("default").isFalse();
+
+    settings.setProperty("sonar.auth.github.allowUsersToSignUp", true);
+    assertThat(underTest.allowsUsersToSignUp()).isTrue();
+  }
+
+  @Test
+  public void init() {
+    setSettings(true);
+    OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+    when(context.generateCsrfState()).thenReturn("state");
+    when(context.getCallbackUrl()).thenReturn("http://localhost/callback");
+    settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+
+    underTest.init(context);
+
+    verify(context).redirectTo("https://github.com/login/oauth/authorize" +
+      "?response_type=code" +
+      "&client_id=id" +
+      "&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=user%3Aemail" +
+      "&state=state");
+  }
+
+  @Test
+  public void init_when_group_sync() {
+    setSettings(true);
+    settings.setProperty("sonar.auth.github.groupsSync", "true");
+    settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+    OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+    when(context.generateCsrfState()).thenReturn("state");
+    when(context.getCallbackUrl()).thenReturn("http://localhost/callback");
+
+    underTest.init(context);
+
+    verify(context).redirectTo("https://github.com/login/oauth/authorize" +
+      "?response_type=code" +
+      "&client_id=id" +
+      "&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=user%3Aemail%2Cread%3Aorg" +
+      "&state=state");
+  }
+
+  @Test
+  public void init_when_organizations() {
+    setSettings(true);
+    settings.setProperty("sonar.auth.github.organizations", "example");
+    settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+    OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+    when(context.generateCsrfState()).thenReturn("state");
+    when(context.getCallbackUrl()).thenReturn("http://localhost/callback");
+
+    underTest.init(context);
+
+    verify(context).redirectTo("https://github.com/login/oauth/authorize" +
+      "?response_type=code" +
+      "&client_id=id" +
+      "&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback" +
+      "&scope=user%3Aemail%2Cread%3Aorg" +
+      "&state=state");
+  }
+
+  @Test
+  public void fail_to_init_when_disabled() {
+    setSettings(false);
+    OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class);
+
+    thrown.expect(IllegalStateException.class);
+    thrown.expectMessage("GitHub authentication is disabled");
+    underTest.init(context);
+  }
+
+  @Test
+  public void scope_includes_org_when_necessary() {
+    setSettings(false);
+
+    settings.setProperty("sonar.auth.github.groupsSync", false);
+    settings.setProperty("sonar.auth.github.organizations", "");
+    assertThat(underTest.getScope()).isEqualTo("user:email");
+
+    settings.setProperty("sonar.auth.github.groupsSync", true);
+    settings.setProperty("sonar.auth.github.organizations", "");
+    assertThat(underTest.getScope()).isEqualTo("user:email,read:org");
+
+    settings.setProperty("sonar.auth.github.groupsSync", false);
+    settings.setProperty("sonar.auth.github.organizations", "example");
+    assertThat(underTest.getScope()).isEqualTo("user:email,read:org");
+
+    settings.setProperty("sonar.auth.github.groupsSync", true);
+    settings.setProperty("sonar.auth.github.organizations", "example");
+    assertThat(underTest.getScope()).isEqualTo("user:email,read:org");
+  }
+
+  @Test
+  public void organization_membership_required() {
+    setSettings(true);
+    settings.setProperty("sonar.auth.github.organizations", "example");
+    assertThat(underTest.isOrganizationMembershipRequired()).isTrue();
+    settings.setProperty("sonar.auth.github.organizations", "example0, example1");
+    assertThat(underTest.isOrganizationMembershipRequired()).isTrue();
+  }
+
+  @Test
+  public void organization_membership_not_required() {
+    setSettings(true);
+    settings.setProperty("sonar.auth.github.organizations", "");
+    assertThat(underTest.isOrganizationMembershipRequired()).isFalse();
+  }
+
+  private void setSettings(boolean enabled) {
+    if (enabled) {
+      settings.setProperty("sonar.auth.github.clientId.secured", "id");
+      settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+      settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+      settings.setProperty("sonar.auth.github.enabled", true);
+    } else {
+      settings.setProperty("sonar.auth.github.enabled", false);
+    }
+  }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java
new file mode 100644 (file)
index 0000000..0589b7a
--- /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.github;
+
+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 GitHubModuleTest {
+
+  @Test
+  public void verify_count_of_added_components() {
+    ComponentContainer container = new ComponentContainer();
+    new GitHubModule().configure(container);
+    assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 14);
+  }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java
new file mode 100644 (file)
index 0000000..422990b
--- /dev/null
@@ -0,0 +1,162 @@
+/*
+ * 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.github;
+
+import org.junit.Test;
+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.github.GitHubSettings.LOGIN_STRATEGY_DEFAULT_VALUE;
+import static org.sonar.auth.github.GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID;
+
+public class GitHubSettingsTest {
+
+  private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions()));
+
+  private GitHubSettings underTest = new GitHubSettings(settings.asConfig());
+
+  @Test
+  public void is_enabled() {
+    settings.setProperty("sonar.auth.github.clientId.secured", "id");
+    settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+    settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+
+    settings.setProperty("sonar.auth.github.enabled", true);
+    assertThat(underTest.isEnabled()).isTrue();
+
+    settings.setProperty("sonar.auth.github.enabled", false);
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void is_enabled_always_return_false_when_client_id_is_null() {
+    settings.setProperty("sonar.auth.github.enabled", true);
+    settings.setProperty("sonar.auth.github.clientId.secured", (String) null);
+    settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+    settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void is_enabled_always_return_false_when_client_secret_is_null() {
+    settings.setProperty("sonar.auth.github.enabled", true);
+    settings.setProperty("sonar.auth.github.clientId.secured", "id");
+    settings.setProperty("sonar.auth.github.clientSecret.secured", (String) null);
+    settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_DEFAULT_VALUE);
+
+    assertThat(underTest.isEnabled()).isFalse();
+  }
+
+  @Test
+  public void return_client_id() {
+    settings.setProperty("sonar.auth.github.clientId.secured", "id");
+    assertThat(underTest.clientId()).isEqualTo("id");
+  }
+
+  @Test
+  public void return_client_secret() {
+    settings.setProperty("sonar.auth.github.clientSecret.secured", "secret");
+    assertThat(underTest.clientSecret()).isEqualTo("secret");
+  }
+
+  @Test
+  public void return_login_strategy() {
+    settings.setProperty("sonar.auth.github.loginStrategy", LOGIN_STRATEGY_PROVIDER_ID);
+    assertThat(underTest.loginStrategy()).isEqualTo(LOGIN_STRATEGY_PROVIDER_ID);
+  }
+
+  @Test
+  public void allow_users_to_sign_up() {
+    settings.setProperty("sonar.auth.github.allowUsersToSignUp", "true");
+    assertThat(underTest.allowUsersToSignUp()).isTrue();
+
+    settings.setProperty("sonar.auth.github.allowUsersToSignUp", "false");
+    assertThat(underTest.allowUsersToSignUp()).isFalse();
+
+    // default value
+    settings.setProperty("sonar.auth.github.allowUsersToSignUp", (String) null);
+    assertThat(underTest.allowUsersToSignUp()).isTrue();
+  }
+
+  @Test
+  public void sync_groups() {
+    settings.setProperty("sonar.auth.github.groupsSync", "true");
+    assertThat(underTest.syncGroups()).isTrue();
+
+    settings.setProperty("sonar.auth.github.groupsSync", "false");
+    assertThat(underTest.syncGroups()).isFalse();
+
+    // default value
+    settings.setProperty("sonar.auth.github.groupsSync", (String) null);
+    assertThat(underTest.syncGroups()).isFalse();
+  }
+
+  @Test
+  public void apiUrl_must_have_ending_slash() {
+    settings.setProperty("sonar.auth.github.apiUrl", "https://github.com");
+    assertThat(underTest.apiURL()).isEqualTo("https://github.com/");
+
+    settings.setProperty("sonar.auth.github.apiUrl", "https://github.com/");
+    assertThat(underTest.apiURL()).isEqualTo("https://github.com/");
+  }
+
+  @Test
+  public void webUrl_must_have_ending_slash() {
+    settings.setProperty("sonar.auth.github.webUrl", "https://github.com");
+    assertThat(underTest.webURL()).isEqualTo("https://github.com/");
+
+    settings.setProperty("sonar.auth.github.webUrl", "https://github.com/");
+    assertThat(underTest.webURL()).isEqualTo("https://github.com/");
+  }
+
+  @Test
+  public void return_organizations_single() {
+    String setting = "example";
+    settings.setProperty("sonar.auth.github.organizations", setting);
+    String[] expected = new String[] {"example"};
+    String[] actual = underTest.organizations();
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void return_organizations_multiple() {
+    String setting = "example0,example1";
+    settings.setProperty("sonar.auth.github.organizations", setting);
+    String[] expected = new String[] {"example0", "example1"};
+    String[] actual = underTest.organizations();
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void return_organizations_empty_list() {
+    String[] setting = null;
+    settings.setProperty("sonar.auth.github.organizations", setting);
+    String[] expected = new String[] {};
+    String[] actual = underTest.organizations();
+    assertThat(actual).isEqualTo(expected);
+  }
+
+  @Test
+  public void definitions() {
+    assertThat(GitHubSettings.definitions()).hasSize(9);
+  }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java
new file mode 100644 (file)
index 0000000..40d1d1d
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * 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.github;
+
+import java.util.List;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonEmailTest {
+
+  @Test
+  public void parse() {
+    List<GsonEmail> underTest = GsonEmail.parse(
+      "[\n" +
+        "  {\n" +
+        "    \"email\": \"octocat@github.com\",\n" +
+        "    \"verified\": true,\n" +
+        "    \"primary\": true\n" +
+        "  },\n" +
+        "  {\n" +
+        "    \"email\": \"support@github.com\",\n" +
+        "    \"verified\": false,\n" +
+        "    \"primary\": false\n" +
+        "  }\n" +
+        "]");
+    assertThat(underTest).hasSize(2);
+
+    assertThat(underTest.get(0).getEmail()).isEqualTo("octocat@github.com");
+    assertThat(underTest.get(0).isVerified()).isTrue();
+    assertThat(underTest.get(0).isPrimary()).isTrue();
+
+    assertThat(underTest.get(1).getEmail()).isEqualTo("support@github.com");
+    assertThat(underTest.get(1).isVerified()).isFalse();
+    assertThat(underTest.get(1).isPrimary()).isFalse();
+  }
+
+  @Test
+  public void should_have_no_arg_constructor() {
+    assertThat(new GsonEmail().getEmail()).isEqualTo("");
+  }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java
new file mode 100644 (file)
index 0000000..01b346f
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * 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.github;
+
+import java.util.List;
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonTeamTest {
+
+  @Test
+  public void parse_one_team() {
+    List<GsonTeam> underTest = GsonTeam.parse(
+      "[\n" +
+        "  {\n" +
+        "    \"name\": \"Developers\",\n" +
+        "    \"slug\": \"developers\",\n" +
+        "    \"organization\": {\n" +
+        "      \"login\": \"SonarSource\"\n" +
+        "    }\n" +
+        "  }\n" +
+        "]");
+    assertThat(underTest).hasSize(1);
+
+    assertThat(underTest.get(0).getId()).isEqualTo("developers");
+    assertThat(underTest.get(0).getOrganizationId()).isEqualTo("SonarSource");
+  }
+
+  @Test
+  public void parse_two_teams() {
+    List<GsonTeam> underTest = GsonTeam.parse(
+      "[\n" +
+        "  {\n" +
+        "    \"name\": \"Developers\",\n" +
+        "    \"slug\": \"developers\",\n" +
+        "    \"organization\": {\n" +
+        "      \"login\": \"SonarSource\"\n" +
+        "    }\n" +
+        "  },\n" +
+        "  {\n" +
+        "    \"login\": \"SonarSource Developers\",\n" +
+        "    \"organization\": {\n" +
+        "      \"login\": \"SonarQubeCommunity\"\n" +
+        "    }\n" +
+        "  }\n" +
+        "]");
+    assertThat(underTest).hasSize(2);
+  }
+
+  @Test
+  public void should_have_no_arg_constructor() {
+    assertThat(new GsonTeam().getId()).isEqualTo("");
+    assertThat(new GsonTeam.GsonOrganization().getLogin()).isEqualTo("");
+  }
+
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java
new file mode 100644 (file)
index 0000000..7e15cac
--- /dev/null
@@ -0,0 +1,56 @@
+/*
+ * 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.github;
+
+import org.junit.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class GsonUserTest {
+
+  @Test
+  public void parse_json() {
+    GsonUser user = GsonUser.parse(
+      "{\n" +
+        "  \"login\": \"octocat\",\n" +
+        "  \"id\": 1,\n" +
+        "  \"name\": \"monalisa octocat\",\n" +
+        "  \"email\": \"octocat@github.com\"\n" +
+        "}");
+    assertThat(user.getId()).isEqualTo("1");
+    assertThat(user.getLogin()).isEqualTo("octocat");
+    assertThat(user.getName()).isEqualTo("monalisa octocat");
+    assertThat(user.getEmail()).isEqualTo("octocat@github.com");
+  }
+
+  @Test
+  public void name_can_be_null() {
+    GsonUser underTest = GsonUser.parse("{login:octocat, email:octocat@github.com}");
+    assertThat(underTest.getLogin()).isEqualTo("octocat");
+    assertThat(underTest.getName()).isNull();
+  }
+
+  @Test
+  public void email_can_be_null() {
+    GsonUser underTest = GsonUser.parse("{login:octocat}");
+    assertThat(underTest.getLogin()).isEqualTo("octocat");
+    assertThat(underTest.getEmail()).isNull();
+  }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java
new file mode 100644 (file)
index 0000000..e3ef355
--- /dev/null
@@ -0,0 +1,471 @@
+/*
+ * 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.github;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.server.authentication.OAuth2IdentityProvider;
+import org.sonar.api.server.authentication.UnauthorizedException;
+import org.sonar.api.server.authentication.UserIdentity;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class IntegrationTest {
+
+  private static final String CALLBACK_URL = "http://localhost/oauth/callback/github";
+
+  @Rule
+  public MockWebServer github = new MockWebServer();
+
+  // load settings with default values
+  private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions()));
+  private GitHubSettings gitHubSettings = new GitHubSettings(settings.asConfig());
+  private UserIdentityFactoryImpl userIdentityFactory = new UserIdentityFactoryImpl(gitHubSettings);
+  private ScribeGitHubApi scribeApi = new ScribeGitHubApi(gitHubSettings);
+  private GitHubRestClient gitHubRestClient = new GitHubRestClient(gitHubSettings);
+
+  private String gitHubUrl;
+
+  private GitHubIdentityProvider underTest = new GitHubIdentityProvider(gitHubSettings, userIdentityFactory, scribeApi, gitHubRestClient);
+
+  @Before
+  public void enable() {
+    gitHubUrl = format("http://%s:%d", github.getHostName(), github.getPort());
+    settings.setProperty("sonar.auth.github.clientId.secured", "the_id");
+    settings.setProperty("sonar.auth.github.clientSecret.secured", "the_secret");
+    settings.setProperty("sonar.auth.github.enabled", true);
+    settings.setProperty("sonar.auth.github.apiUrl", gitHubUrl);
+    settings.setProperty("sonar.auth.github.webUrl", gitHubUrl);
+  }
+
+  /**
+   * First phase: SonarQube redirects browser to GitHub authentication form, requesting the
+   * minimal access rights ("scope") to get user profile (login, name, email and others).
+   */
+  @Test
+  public void redirect_browser_to_github_authentication_form() throws Exception {
+    DumbInitContext context = new DumbInitContext("the-csrf-state");
+    underTest.init(context);
+
+    assertThat(context.redirectedTo).isEqualTo(
+      gitHubSettings.webURL() +
+        "login/oauth/authorize" +
+        "?response_type=code" +
+        "&client_id=the_id" +
+        "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+        "&scope=" + URLEncoder.encode("user:email", StandardCharsets.UTF_8.name()) +
+        "&state=the-csrf-state");
+  }
+
+  /**
+   * Second phase: GitHub redirects browser to SonarQube at /oauth/callback/github?code={the verifier code}.
+   * This SonarQube web service sends two requests to GitHub:
+   * <ul>
+   *   <li>get an access token</li>
+   *   <li>get the profile of the authenticated user</li>
+   * </ul>
+   */
+  @Test
+  public void callback_on_successful_authentication() throws IOException, InterruptedException {
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
+    assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github");
+    assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
+    assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com");
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+
+    // Verify the requests sent to GitHub
+    RecordedRequest accessTokenGitHubRequest = github.takeRequest();
+    assertThat(accessTokenGitHubRequest.getMethod()).isEqualTo("POST");
+    assertThat(accessTokenGitHubRequest.getPath()).isEqualTo("/login/oauth/access_token");
+    assertThat(accessTokenGitHubRequest.getBody().readUtf8()).isEqualTo(
+      "code=the-verifier-code" +
+        "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+        "&grant_type=authorization_code");
+
+    RecordedRequest profileGitHubRequest = github.takeRequest();
+    assertThat(profileGitHubRequest.getMethod()).isEqualTo("GET");
+    assertThat(profileGitHubRequest.getPath()).isEqualTo("/user");
+  }
+
+  @Test
+  public void should_retrieve_private_primary_verified_email_address() {
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}"));
+    // response of api.github.com/user/emails
+    github.enqueue(new MockResponse().setBody(
+      "[\n" +
+        "  {\n" +
+        "    \"email\": \"support@github.com\",\n" +
+        "    \"verified\": false,\n" +
+        "    \"primary\": false\n" +
+        "  },\n" +
+        "  {\n" +
+        "    \"email\": \"octocat@github.com\",\n" +
+        "    \"verified\": true,\n" +
+        "    \"primary\": true\n" +
+        "  },\n" +
+        "]"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
+    assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github");
+    assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
+    assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("octocat@github.com");
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+  }
+
+  @Test
+  public void should_not_fail_if_no_email() {
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":null}"));
+    // response of api.github.com/user/emails
+    github.enqueue(new MockResponse().setBody("[]"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("ABCD");
+    assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("octocat@github");
+    assertThat(callbackContext.userIdentity.getName()).isEqualTo("monalisa octocat");
+    assertThat(callbackContext.userIdentity.getEmail()).isNull();
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+  }
+
+  @Test
+  public void redirect_browser_to_github_authentication_form_with_group_sync() throws Exception {
+    settings.setProperty("sonar.auth.github.groupsSync", true);
+    DumbInitContext context = new DumbInitContext("the-csrf-state");
+    underTest.init(context);
+    assertThat(context.redirectedTo).isEqualTo(
+      gitHubSettings.webURL() +
+        "login/oauth/authorize" +
+        "?response_type=code" +
+        "&client_id=the_id" +
+        "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+        "&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) +
+        "&state=the-csrf-state");
+  }
+
+  @Test
+  public void callback_on_successful_authentication_with_group_sync() {
+    settings.setProperty("sonar.auth.github.groupsSync", true);
+
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+    // response of api.github.com/user/teams
+    github.enqueue(new MockResponse().setBody("[\n" +
+      "  {\n" +
+      "    \"slug\": \"developers\",\n" +
+      "    \"organization\": {\n" +
+      "      \"login\": \"SonarSource\"\n" +
+      "    }\n" +
+      "  }\n" +
+      "]"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.userIdentity.getGroups()).containsOnly("SonarSource/developers");
+  }
+
+  @Test
+  public void callback_on_successful_authentication_with_group_sync_on_many_pages() {
+    settings.setProperty("sonar.auth.github.groupsSync", true);
+
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+    // responses of api.github.com/user/teams
+    github.enqueue(new MockResponse()
+      .setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"next\", <" + gitHubUrl + "/user/teams?per_page=100&page=2>; rel=\"last\"")
+      .setBody("[\n" +
+        "  {\n" +
+        "    \"slug\": \"developers\",\n" +
+        "    \"organization\": {\n" +
+        "      \"login\": \"SonarSource\"\n" +
+        "    }\n" +
+        "  }\n" +
+        "]"));
+    github.enqueue(new MockResponse()
+      .setHeader("Link", "<" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"prev\", <" + gitHubUrl + "/user/teams?per_page=100&page=1>; rel=\"first\"")
+      .setBody("[\n" +
+        "  {\n" +
+        "    \"slug\": \"sonarsource-developers\",\n" +
+        "    \"organization\": {\n" +
+        "      \"login\": \"SonarQubeCommunity\"\n" +
+        "    }\n" +
+        "  }\n" +
+        "]"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(new TreeSet<>(callbackContext.userIdentity.getGroups())).containsOnly("SonarQubeCommunity/sonarsource-developers", "SonarSource/developers");
+  }
+
+  @Test
+  public void redirect_browser_to_github_authentication_form_with_organizations() throws Exception {
+    settings.setProperty("sonar.auth.github.organizations", "example0, example1");
+    DumbInitContext context = new DumbInitContext("the-csrf-state");
+    underTest.init(context);
+    assertThat(context.redirectedTo).isEqualTo(
+      gitHubSettings.webURL() +
+        "login/oauth/authorize" +
+        "?response_type=code" +
+        "&client_id=the_id" +
+        "&redirect_uri=" + URLEncoder.encode(CALLBACK_URL, StandardCharsets.UTF_8.name()) +
+        "&scope=" + URLEncoder.encode("user:email,read:org", StandardCharsets.UTF_8.name()) +
+        "&state=the-csrf-state");
+  }
+
+  @Test
+  public void callback_on_successful_authentication_with_organizations_with_membership() {
+    settings.setProperty("sonar.auth.github.organizations", "example0, example1");
+
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+    // response of api.github.com/orgs/example0/members/user
+    github.enqueue(new MockResponse().setResponseCode(204));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    underTest.callback(callbackContext);
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity).isNotNull();
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue();
+  }
+
+  @Test
+  public void callback_on_successful_authentication_with_organizations_without_membership() {
+    settings.setProperty("sonar.auth.github.organizations", "first_org,second_org");
+    settings.setProperty("sonar.auth.github.loginStrategy", GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+    // response of api.github.com/orgs/first_org/members/user
+    github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
+    // response of api.github.com/orgs/second_org/members/user
+    github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    try {
+      underTest.callback(callbackContext);
+      fail("exception expected");
+    } catch (UnauthorizedException e) {
+      assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'first_org', 'second_org'");
+    }
+  }
+
+  @Test
+  public void callback_on_successful_authentication_with_organizations_without_membership_with_unique_login_strategy() {
+    settings.setProperty("sonar.auth.github.organizations", "example");
+    settings.setProperty("sonar.auth.github.loginStrategy", GitHubSettings.LOGIN_STRATEGY_UNIQUE);
+
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+    // response of api.github.com/orgs/example0/members/user
+    github.enqueue(new MockResponse().setResponseCode(404).setBody("{}"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    try {
+      underTest.callback(callbackContext);
+      fail("exception expected");
+    } catch (UnauthorizedException e) {
+      assertThat(e.getMessage()).isEqualTo("'octocat' must be a member of at least one organization: 'example'");
+    }
+  }
+
+  @Test
+  public void callback_throws_ISE_if_error_when_requesting_user_profile() {
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // api.github.com/user crashes
+    github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
+
+    DumbCallbackContext callbackContext = new DumbCallbackContext(newRequest("the-verifier-code"));
+    try {
+      underTest.callback(callbackContext);
+      fail("exception expected");
+    } catch (IllegalStateException e) {
+      assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "user'. HTTP code: 500, response: {error}");
+    }
+
+    assertThat(callbackContext.csrfStateVerified.get()).isTrue();
+    assertThat(callbackContext.userIdentity).isNull();
+    assertThat(callbackContext.redirectedToRequestedPage.get()).isFalse();
+  }
+
+  @Test
+  public void callback_throws_ISE_if_error_when_checking_membership() {
+    settings.setProperty("sonar.auth.github.organizations", "example");
+
+    github.enqueue(newSuccessfulAccessTokenResponse());
+    // response of api.github.com/user
+    github.enqueue(new MockResponse().setBody("{\"id\":\"ABCD\", \"login\":\"octocat\", \"name\":\"monalisa octocat\",\"email\":\"octocat@github.com\"}"));
+    // crash of api.github.com/orgs/example/members/user
+    github.enqueue(new MockResponse().setResponseCode(500).setBody("{error}"));
+
+    HttpServletRequest request = newRequest("the-verifier-code");
+    DumbCallbackContext callbackContext = new DumbCallbackContext(request);
+    try {
+      underTest.callback(callbackContext);
+      fail("exception expected");
+    } catch (IllegalStateException e) {
+      assertThat(e.getMessage()).isEqualTo("Fail to execute request '" + gitHubSettings.apiURL() + "orgs/example/members/octocat'. HTTP code: 500, response: {error}");
+    }
+  }
+
+  /**
+   * Response sent by GitHub to SonarQube when generating an access token
+   */
+  private static MockResponse newSuccessfulAccessTokenResponse() {
+    // github does not return the standard JSON format but plain-text
+    // see https://developer.github.com/v3/oauth/
+    return new MockResponse().setBody("access_token=e72e16c7e42f292c6912e7710c838347ae178b4a&scope=user%2Cgist&token_type=bearer");
+  }
+
+  private static HttpServletRequest newRequest(String verifierCode) {
+    HttpServletRequest request = mock(HttpServletRequest.class);
+    when(request.getParameter("code")).thenReturn(verifierCode);
+    return request;
+  }
+
+  private static class DumbCallbackContext implements OAuth2IdentityProvider.CallbackContext {
+    final HttpServletRequest request;
+    final AtomicBoolean csrfStateVerified = new AtomicBoolean(false);
+    final AtomicBoolean redirectedToRequestedPage = new AtomicBoolean(false);
+    UserIdentity userIdentity = null;
+
+    public DumbCallbackContext(HttpServletRequest request) {
+      this.request = request;
+    }
+
+    @Override
+    public void verifyCsrfState() {
+      this.csrfStateVerified.set(true);
+    }
+
+    @Override
+    public void verifyCsrfState(String parameterName) {
+      throw new UnsupportedOperationException("not used");
+    }
+
+    @Override
+    public void redirectToRequestedPage() {
+      redirectedToRequestedPage.set(true);
+    }
+
+    @Override
+    public void authenticate(UserIdentity userIdentity) {
+      this.userIdentity = userIdentity;
+    }
+
+    @Override
+    public String getCallbackUrl() {
+      return CALLBACK_URL;
+    }
+
+    @Override
+    public HttpServletRequest getRequest() {
+      return request;
+    }
+
+    @Override
+    public HttpServletResponse getResponse() {
+      throw new UnsupportedOperationException("not used");
+    }
+  }
+
+  private static class DumbInitContext implements OAuth2IdentityProvider.InitContext {
+    String redirectedTo = null;
+    private final String generatedCsrfState;
+
+    public DumbInitContext(String generatedCsrfState) {
+      this.generatedCsrfState = generatedCsrfState;
+    }
+
+    @Override
+    public String generateCsrfState() {
+      return generatedCsrfState;
+    }
+
+    @Override
+    public void redirectTo(String url) {
+      this.redirectedTo = url;
+    }
+
+    @Override
+    public String getCallbackUrl() {
+      return CALLBACK_URL;
+    }
+
+    @Override
+    public HttpServletRequest getRequest() {
+      return null;
+    }
+
+    @Override
+    public HttpServletResponse getResponse() {
+      return null;
+    }
+  }
+}
diff --git a/server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java
new file mode 100644 (file)
index 0000000..cad5184
--- /dev/null
@@ -0,0 +1,120 @@
+/*
+ * 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.github;
+
+import java.util.Arrays;
+import java.util.List;
+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 org.sonar.api.server.authentication.UserIdentity;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class UserIdentityFactoryImplTest {
+
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private MapSettings settings = new MapSettings(new PropertyDefinitions(GitHubSettings.definitions()));
+  private UserIdentityFactoryImpl underTest = new UserIdentityFactoryImpl(new GitHubSettings(settings.asConfig()));
+
+  /**
+   * Keep the same login as at GitHub
+   */
+  @Test
+  public void create_for_provider_strategy() {
+    GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com");
+    settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+    UserIdentity identity = underTest.create(gson, gson.getEmail(), null);
+
+    assertThat(identity.getProviderId()).isEqualTo("ABCD");
+    assertThat(identity.getLogin()).isEqualTo("octocat");
+    assertThat(identity.getName()).isEqualTo("monalisa octocat");
+    assertThat(identity.getEmail()).isEqualTo("octocat@github.com");
+  }
+
+  @Test
+  public void no_email() {
+    GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", null);
+    settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+    UserIdentity identity = underTest.create(gson, null, null);
+
+    assertThat(identity.getLogin()).isEqualTo("octocat");
+    assertThat(identity.getName()).isEqualTo("monalisa octocat");
+    assertThat(identity.getEmail()).isNull();
+  }
+
+  @Test
+  public void create_for_provider_strategy_with_teams() {
+    GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com");
+    List<GsonTeam> teams = Arrays.asList(
+      new GsonTeam("developers", new GsonTeam.GsonOrganization("SonarSource")));
+    settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_PROVIDER_ID);
+
+    UserIdentity identity = underTest.create(gson, null, teams);
+
+    assertThat(identity.getGroups()).containsOnly("SonarSource/developers");
+  }
+
+  @Test
+  public void create_for_unique_login_strategy() {
+    GsonUser gson = new GsonUser("ABCD", "octocat", "monalisa octocat", "octocat@github.com");
+    settings.setProperty(GitHubSettings.LOGIN_STRATEGY, GitHubSettings.LOGIN_STRATEGY_UNIQUE);
+
+    UserIdentity identity = underTest.create(gson, null, null);
+
+    assertThat(identity.getLogin()).isEqualTo("octocat@github");
+    assertThat(identity.getName()).isEqualTo("monalisa octocat");
+    assertThat(identity.getEmail()).isNull();
+  }
+
+  @Test
+  public void empty_name_is_replaced_by_provider_login() {
+    GsonUser gson = new GsonUser("ABCD", "octocat", "", "octocat@github.com");
+
+    UserIdentity identity = underTest.create(gson, null, null);
+
+    assertThat(identity.getName()).isEqualTo("octocat");
+  }
+
+  @Test
+  public void null_name_is_replaced_by_provider_login() {
+    GsonUser gson = new GsonUser("ABCD", "octocat", null, "octocat@github.com");
+
+    UserIdentity identity = underTest.create(gson, null, null);
+
+    assertThat(identity.getName()).isEqualTo("octocat");
+  }
+
+  @Test
+  public void throw_ISE_if_strategy_is_not_supported() {
+    settings.setProperty(GitHubSettings.LOGIN_STRATEGY, "xxx");
+
+    expectedException.expect(IllegalStateException.class);
+    expectedException.expectMessage("Login strategy not supported : xxx");
+
+    underTest.create(new GsonUser("ABCD", "octocat", "octocat", "octocat@github.com"), null, null);
+  }
+}
index f30f08323b0aeee8c3563cf3e538034e8f6d2f42..54f97687849fda51dda946edb737ec8d2b8b4b7e 100644 (file)
@@ -10,6 +10,7 @@ dependencies {
     compile 'com.github.scribejava:scribejava-apis'
     compile 'com.github.scribejava:scribejava-core'
     compile 'com.google.code.gson:gson'
+    compile project(':server:sonar-auth-common')
 
     compileOnly 'com.google.code.findbugs:jsr305'
     compileOnly 'com.squareup.okhttp3:okhttp'
index 5921bac826b8668b2ed8520de01d94f3e1ec54a0..ab0de2d184f3fdbcb6468879d17d281b918fe9c5 100644 (file)
@@ -62,7 +62,9 @@ public class GitLabIdentityProvider implements OAuth2IdentityProvider {
   @Override
   public Display getDisplay() {
     return Display.builder()
-      .setIconPath("/images/gitlab-icon-rgb.svg").setBackgroundColor("#6a4fbb").build();
+      .setIconPath("/images/gitlab-icon-rgb.svg")
+      .setBackgroundColor("#6a4fbb")
+      .build();
   }
 
   @Override
index 9abfcbe35acb4bf5c0046f4adf39cc91d8127f07..06a922ae6c7d96bd26a78ccb94dd34a2f2575ff8 100644 (file)
 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;
+import org.sonar.auth.OAuthRestClient;
 
 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;
@@ -49,7 +37,7 @@ public class GitLabRestClient {
   }
 
   GsonUser getUser(OAuth20Service scribe, OAuth2AccessToken accessToken) {
-    try (Response response = executeRequest(settings.url() + API_SUFFIX + "/user", scribe, accessToken)) {
+    try (Response response = OAuthRestClient.executeRequest(settings.url() + API_SUFFIX + "/user", scribe, accessToken)) {
       String responseBody = response.getBody();
       return GsonUser.parse(responseBody);
     } catch (IOException e) {
@@ -58,59 +46,6 @@ public class GitLabRestClient {
   }
 
   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);
-    }
+    return OAuthRestClient.executePaginatedRequest(settings.url() + API_SUFFIX + "/groups", scribe, accessToken, GsonGroup::parse);
   }
-
-  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()));
-  }
-
 }
index c4c7c39a2d854939421e18890f0c0f9fd1a39241..07c874b3377a8b974fc48c0b9bd30f676081f644 100644 (file)
@@ -40,7 +40,7 @@ You can delegate authentication to GitHub Enterprise using a dedicated GitHub OA
 1. You'll need to first create a GitHub OAuth application. Click [here](https://developer.github.com/apps/building-oauth-apps/creating-an-oauth-app/) for general instructions:
    1. "Homepage URL" is the public URL to your SonarQube server, for example "https://sonarqube.mycompany.com". For security reasons HTTP is not supported. HTTPS must be used. The public URL is configured in SonarQube at **[Administration -> General -> Server base URL](/#sonarqube-admin#/admin/settings)**
    1. "Authorization callback URL" is <Homepage URL>/oauth2/callback, for example "https://sonarqube.mycompany.com/oauth2/callback"
-1. In SonarQube, navigate to **[Administration > Configuration > General Settings > GitHub](/#sonarqube-admin#/admin/settings?category=github)**:
+1. In SonarQube, navigate to **[Administration > Configuration > General Settings > Security > GitHub](/#sonarqube-admin#/admin/settings?category=security)**:
    1. Set **Enabled** to `true`
    1. Set the **Client ID** to the value provided by the GitHub developer application
    1. Set the **Client Secret** to the value provided by the GitHub developer application
index a0562ef3717fc45cd66c2c09405521b511e8940c..3c28d130cbdd32391a270f42d9ccb259c5d613be 100644 (file)
@@ -47,7 +47,7 @@ import static org.sonar.core.extension.ExtensionProviderSupport.isExtensionProvi
  */
 public abstract class ServerExtensionInstaller {
 
-  private static final Set<String> NO_MORE_COMPATIBLE_PLUGINS = ImmutableSet.of("authgitlab");
+  private static final Set<String> NO_MORE_COMPATIBLE_PLUGINS = ImmutableSet.of("authgithub", "authgitlab");
 
   private final SonarRuntime sonarRuntime;
   private final PluginRepository pluginRepository;
index 5c97ab11f5de13ee004122976cf5f0b4f8c6944f..1aa8714d5476ba7f3452ee9bbd1fb1815c519ea7 100644 (file)
@@ -64,6 +64,18 @@ public class ServerExtensionInstallerTest {
     assertThat(componentContainer.getPicoContainer().getComponents()).contains(fooPlugin);
   }
 
+  @Test
+  public void fail_when_detecting_github_auth_plugin() {
+    PluginInfo foo = newPlugin("authgithub", "GitHub Auth");
+    pluginRepository.add(foo, mock(Plugin.class));
+    ComponentContainer componentContainer = new ComponentContainer();
+
+    expectedException.expect(MessageException.class);
+    expectedException.expectMessage("Plugins 'GitHub Auth' are no more compatible with SonarQube");
+
+    underTest.installExtensions(componentContainer);
+  }
+
   @Test
   public void fail_when_detecting_gitlab_auth_plugin() {
     PluginInfo foo = newPlugin("authgitlab", "GitLab Auth");
diff --git a/server/sonar-web/public/images/github.svg b/server/sonar-web/public/images/github.svg
new file mode 100644 (file)
index 0000000..89a559a
--- /dev/null
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" version="1.1" x="0" y="0" viewBox="0 0 438.5 438.5" xml:space="preserve">
+  <path fill="#fff"
+        d="M409.1 114.6c-19.6-33.6-46.2-60.2-79.8-79.8C295.7 15.2 259.1 5.4 219.3 5.4c-39.8 0-76.5 9.8-110.1 29.4 -33.6 19.6-60.2 46.2-79.8 79.8C9.8 148.2 0 184.9 0 224.6c0 47.8 13.9 90.7 41.8 128.9 27.9 38.2 63.9 64.6 108.1 79.2 5.1 1 8.9 0.3 11.4-2 2.5-2.3 3.7-5.1 3.7-8.6 0-0.6 0-5.7-0.1-15.4 -0.1-9.7-0.1-18.2-0.1-25.4l-6.6 1.1c-4.2 0.8-9.5 1.1-15.8 1 -6.4-0.1-13-0.8-19.8-2 -6.9-1.2-13.2-4.1-19.1-8.6 -5.9-4.5-10.1-10.3-12.6-17.6l-2.9-6.6c-1.9-4.4-4.9-9.2-9-14.6 -4.1-5.3-8.2-8.9-12.4-10.8l-2-1.4c-1.3-1-2.6-2.1-3.7-3.4 -1.1-1.3-2-2.7-2.6-4 -0.6-1.3-0.1-2.4 1.4-3.3 1.5-0.9 4.3-1.3 8.3-1.3l5.7 0.9c3.8 0.8 8.5 3 14.1 6.9 5.6 3.8 10.2 8.8 13.8 14.8 4.4 7.8 9.7 13.8 15.8 17.8 6.2 4.1 12.4 6.1 18.7 6.1 6.3 0 11.7-0.5 16.3-1.4 4.6-1 8.8-2.4 12.8-4.3 1.7-12.8 6.4-22.6 14-29.4 -10.8-1.1-20.6-2.9-29.3-5.1 -8.7-2.3-17.6-6-26.8-11.1 -9.2-5.1-16.9-11.5-23-19.1 -6.1-7.6-11.1-17.6-15-30 -3.9-12.4-5.9-26.6-5.9-42.8 0-23 7.5-42.6 22.6-58.8 -7-17.3-6.4-36.7 2-58.2 5.5-1.7 13.7-0.4 24.6 3.9 10.9 4.3 18.8 8 23.8 11 5 3 9.1 5.6 12.1 7.7 17.7-4.9 36-7.4 54.8-7.4s37.1 2.5 54.8 7.4l10.8-6.8c7.4-4.6 16.2-8.8 26.3-12.6 10.1-3.8 17.8-4.9 23.1-3.1 8.6 21.5 9.3 40.9 2.3 58.2 15 16.2 22.6 35.8 22.6 58.8 0 16.2-2 30.5-5.9 43 -3.9 12.5-8.9 22.5-15.1 30 -6.2 7.5-13.9 13.9-23.1 19 -9.2 5.1-18.2 8.9-26.8 11.1 -8.7 2.3-18.4 4-29.3 5.1 9.9 8.6 14.8 22.1 14.8 40.5v60.2c0 3.4 1.2 6.3 3.6 8.6 2.4 2.3 6.1 3 11.3 2 44.2-14.7 80.2-41.1 108.1-79.2 27.9-38.2 41.8-81.1 41.8-128.9C438.5 184.9 428.7 148.2 409.1 114.6z"/>
+</svg>
index 87b08f8c225936cec661a2ce4bd4f1814e14e3f0..ff0bcaecbbce1acbdcea4b5544f5e29e7f6a48d8 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-github')
   compile project(':server:sonar-auth-gitlab')
   compile project(':server:sonar-ce-task-projectanalysis')
   compile project(':server:sonar-process')
index 2ae9b1a7cc8fda47ed3b57c912d5988f96a0e86a..beeb497e73891aecdf880846d7e6251e44f05348 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.github.GitHubModule;
 import org.sonar.auth.gitlab.GitLabModule;
 import org.sonar.ce.task.projectanalysis.notification.ReportAnalysisFailureNotificationModule;
 import org.sonar.ce.task.projectanalysis.taskprocessor.ReportTaskProcessor;
@@ -351,6 +352,7 @@ public class PlatformLevel4 extends PlatformLevel {
       // authentication
       AuthenticationModule.class,
       AuthenticationWsModule.class,
+      GitHubModule.class,
       GitLabModule.class,
 
       // users
index 6f681f2228b496ea24d226dfcca541d7da804dbd..d736e103bf8527b25f87df42b7217fec5efd7d2a 100644 (file)
@@ -2,6 +2,8 @@ rootProject.name = 'sonarqube'
 
 include 'plugins:sonar-xoo-plugin'
 
+include 'server:sonar-auth-common'
+include 'server:sonar-auth-github'
 include 'server:sonar-auth-gitlab'
 include 'server:sonar-ce'
 include 'server:sonar-ce-common'
@@ -51,3 +53,4 @@ buildCache {
     enabled = !isCiServer
   }
 }
+
index b34a483f4f9e4f23304c9ca0217bb2f94b10559b..2981154d0c753cb5b1773d3d1969cf01077c8194 100644 (file)
@@ -50,7 +50,6 @@ dependencies {
   jdbc_mssql 'com.microsoft.sqlserver:mssql-jdbc'
   jdbc_postgresql 'org.postgresql:postgresql'
 
-  bundledPlugin 'org.sonarsource.auth.github:sonar-auth-github-plugin:1.5.0.870@jar'
   bundledPlugin 'org.sonarsource.auth.saml:sonar-auth-saml-plugin:1.1.0.181@jar'
   bundledPlugin 'org.sonarsource.css:sonar-css-plugin@jar'
   bundledPlugin "org.sonarsource.dotnet:sonar-csharp-plugin@jar"
index 105b7be3c1ca6584b4db3278005a96a18ffb5086..7b60bfc990985249e1dc78de13f81808b700d828 100644 (file)
@@ -934,6 +934,8 @@ property.category.general.subProjects=Sub-projects
 property.category.organizations=Organizations
 property.category.security=Security
 property.category.security.encryption=Encryption
+property.category.security.github=GitHub
+property.category.security.github.description=In order to enable GitHub authentication:<ul><li>SonarQube must be publicly accessible through HTTPS only</li><li>The property 'sonar.core.serverBaseURL' must be set to this public HTTPS URL</li><li>In your GitHub profile, you need to create a Developer Application for which the 'Authorization callback URL' must be set to <code>'&lt;value_of_sonar.core.serverBaseURL_property&gt;/oauth2/callback'</code>.</li></ul>
 property.category.security.gitlab=Gitlab
 property.category.java=Java
 property.category.differentialViews=New Code