diff options
author | Julien Lancelot <julien.lancelot@sonarsource.com> | 2019-09-24 11:44:20 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2019-10-07 20:21:06 +0200 |
commit | 42f0cff638b6b7055acc6cf75bbb2215867d0474 (patch) | |
tree | 9c4bbdb045a1cabe844c14fdda2413c1b94a7fcb /server | |
parent | daf5a60dd259039b97fd3598f894169d7ecc74e5 (diff) | |
download | sonarqube-42f0cff638b6b7055acc6cf75bbb2215867d0474.tar.gz sonarqube-42f0cff638b6b7055acc6cf75bbb2215867d0474.zip |
SONAR-12471 Embed GitHub authentication
Diffstat (limited to 'server')
34 files changed, 2402 insertions, 71 deletions
diff --git a/server/sonar-auth-common/build.gradle b/server/sonar-auth-common/build.gradle new file mode 100644 index 00000000000..9a669541dac --- /dev/null +++ b/server/sonar-auth-common/build.gradle @@ -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 index 00000000000..f07b9e0b595 --- /dev/null +++ b/server/sonar-auth-common/src/main/java/org/sonar/auth/OAuthRestClient.java @@ -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 index 00000000000..00a16681355 --- /dev/null +++ b/server/sonar-auth-common/src/main/java/org/sonar/auth/package-info.java @@ -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 index 00000000000..7f1826e3e12 --- /dev/null +++ b/server/sonar-auth-common/src/test/java/org/sonar/auth/OAuthRestClientTest.java @@ -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 index 00000000000..3bc4be0a024 --- /dev/null +++ b/server/sonar-auth-github/build.gradle @@ -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 index 00000000000..26d2d74c3dc --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubIdentityProvider.java @@ -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 index 00000000000..1f349d13fce --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubModule.java @@ -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 index 00000000000..5c533680fa4 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubRestClient.java @@ -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 index 00000000000..7c3f7a2ced9 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GitHubSettings.java @@ -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 index 00000000000..662f1f4adee --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonEmail.java @@ -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 index 00000000000..8c51c8227a6 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonTeam.java @@ -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 index 00000000000..f113adf4187 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/GsonUser.java @@ -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 index 00000000000..fc5828ef8b3 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/ScribeGitHubApi.java @@ -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 index 00000000000..4e9085f76cb --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactory.java @@ -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 index 00000000000..a93798a17d6 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityFactoryImpl.java @@ -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 index 00000000000..4b3dbdf7dd0 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/UserIdentityGenerator.java @@ -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 index 00000000000..153f45e5378 --- /dev/null +++ b/server/sonar-auth-github/src/main/java/org/sonar/auth/github/package-info.java @@ -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 index 00000000000..ac78fd84048 --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubIdentityProviderTest.java @@ -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 index 00000000000..0589b7a2e27 --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubModuleTest.java @@ -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 index 00000000000..422990b7e85 --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GitHubSettingsTest.java @@ -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 index 00000000000..40d1d1d589c --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonEmailTest.java @@ -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 index 00000000000..01b346ff883 --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonTeamTest.java @@ -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 index 00000000000..7e15cacab5d --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/GsonUserTest.java @@ -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 index 00000000000..e3ef3551850 --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/IntegrationTest.java @@ -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 index 00000000000..cad51844221 --- /dev/null +++ b/server/sonar-auth-github/src/test/java/org/sonar/auth/github/UserIdentityFactoryImplTest.java @@ -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); + } +} diff --git a/server/sonar-auth-gitlab/build.gradle b/server/sonar-auth-gitlab/build.gradle index f30f08323b0..54f97687849 100644 --- a/server/sonar-auth-gitlab/build.gradle +++ b/server/sonar-auth-gitlab/build.gradle @@ -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' diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java index 5921bac826b..ab0de2d184f 100644 --- a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java +++ b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabIdentityProvider.java @@ -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 diff --git a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java index 9abfcbe35ac..06a922ae6c7 100644 --- a/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java +++ b/server/sonar-auth-gitlab/src/main/java/org/sonar/auth/gitlab/GitLabRestClient.java @@ -20,26 +20,14 @@ 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())); - } - } diff --git a/server/sonar-docs/src/pages/instance-administration/delegated-auth.md b/server/sonar-docs/src/pages/instance-administration/delegated-auth.md index c4c7c39a2d8..07c874b3377 100644 --- a/server/sonar-docs/src/pages/instance-administration/delegated-auth.md +++ b/server/sonar-docs/src/pages/instance-administration/delegated-auth.md @@ -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 diff --git a/server/sonar-server-common/src/main/java/org/sonar/server/plugins/ServerExtensionInstaller.java b/server/sonar-server-common/src/main/java/org/sonar/server/plugins/ServerExtensionInstaller.java index a0562ef3717..3c28d130cbd 100644 --- a/server/sonar-server-common/src/main/java/org/sonar/server/plugins/ServerExtensionInstaller.java +++ b/server/sonar-server-common/src/main/java/org/sonar/server/plugins/ServerExtensionInstaller.java @@ -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; diff --git a/server/sonar-server-common/src/test/java/org/sonar/server/plugins/ServerExtensionInstallerTest.java b/server/sonar-server-common/src/test/java/org/sonar/server/plugins/ServerExtensionInstallerTest.java index 5c97ab11f5d..1aa8714d547 100644 --- a/server/sonar-server-common/src/test/java/org/sonar/server/plugins/ServerExtensionInstallerTest.java +++ b/server/sonar-server-common/src/test/java/org/sonar/server/plugins/ServerExtensionInstallerTest.java @@ -65,6 +65,18 @@ public class ServerExtensionInstallerTest { } @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"); pluginRepository.add(foo, mock(Plugin.class)); diff --git a/server/sonar-web/public/images/github.svg b/server/sonar-web/public/images/github.svg new file mode 100644 index 00000000000..89a559abe96 --- /dev/null +++ b/server/sonar-web/public/images/github.svg @@ -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> diff --git a/server/sonar-webserver/build.gradle b/server/sonar-webserver/build.gradle index 87b08f8c225..ff0bcaecbbc 100644 --- a/server/sonar-webserver/build.gradle +++ b/server/sonar-webserver/build.gradle @@ -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') diff --git a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java index 2ae9b1a7cc8..beeb497e738 100644 --- a/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java +++ b/server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java @@ -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 |