From bfd3509fb499703dedaa2f3512cb46164ddc1ff4 Mon Sep 17 00:00:00 2001 From: Zipeng WU Date: Mon, 1 Feb 2021 12:39:03 +0100 Subject: [PATCH] SONAR-14372 move alm validation endpoint to CE --- build.gradle | 1 + server/sonar-alm-client/build.gradle | 2 + .../BitbucketServerRestClient.java | 15 ++ .../alm/client/bitbucketserver/User.java | 39 ++++ .../alm/client/bitbucketserver/UserList.java | 44 ++++ .../github/GithubApplicationClient.java | 9 + .../github/GithubApplicationClientImpl.java | 104 ++++++++- .../github/config/GithubAppConfiguration.java | 112 ++++++++++ .../client/github/config/package-info.java | 23 ++ .../alm/client/github/security/AppToken.java | 85 +++++++ .../github/security/GithubAppSecurity.java | 32 +++ .../security/GithubAppSecurityImpl.java | 106 +++++++++ .../alm/client/gitlab/GitlabHttpClient.java | 81 +++++++ .../org/sonar/alm/client/gitlab/GsonApp.java | 49 +++++ .../org/sonar/alm/client/gitlab/GsonId.java | 48 ++++ .../sonar/alm/client/gitlab/GsonMarkdown.java | 45 ++++ .../GithubApplicationClientImplTest.java | 151 ++++++++++++- .../config/GithubAppConfigurationTest.java | 172 +++++++++++++++ .../client/github/security/AppTokenTest.java | 51 +++++ .../security/GithubAppSecurityImplTest.java | 186 ++++++++++++++++ .../almsettings/ws/AlmSettingsWsModule.java | 1 + .../server/almsettings/ws/DeleteAction.java | 2 - .../server/almsettings/ws/ListAction.java | 3 +- .../server/almsettings/ws/ValidateAction.java | 145 ++++++++++++ .../ws/AlmSettingsWsModuleTest.java | 2 +- .../almsettings/ws/ValidateActionTest.java | 208 ++++++++++++++++++ .../platformlevel/PlatformLevel4.java | 3 + sonar-application/build.gradle | 2 +- 28 files changed, 1709 insertions(+), 12 deletions(-) create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java create mode 100644 server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java create mode 100644 server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java create mode 100644 server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java create mode 100644 server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java diff --git a/build.gradle b/build.gradle index 4dffcea0a78..f1c933423fb 100644 --- a/build.gradle +++ b/build.gradle @@ -288,6 +288,7 @@ subprojects { entry 'jjwt-impl' entry 'jjwt-jackson' } + dependency 'com.auth0:java-jwt:3.10.3' dependency 'io.netty:netty-all:4.1.48.Final' dependency 'com.sun.mail:javax.mail:1.5.6' dependency 'javax.annotation:javax.annotation-api:1.3.2' diff --git a/server/sonar-alm-client/build.gradle b/server/sonar-alm-client/build.gradle index f546848d630..e33c1f3de7a 100644 --- a/server/sonar-alm-client/build.gradle +++ b/server/sonar-alm-client/build.gradle @@ -7,6 +7,8 @@ dependencies { compile 'com.google.guava:guava' compile 'com.squareup.okhttp3:okhttp' compile 'commons-codec:commons-codec' + compile 'com.auth0:java-jwt' + compile 'org.bouncycastle:bcpkix-jdk15on:1.64' testCompile project(':sonar-plugin-api-impl') diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java index 7a3ba3b6303..e410ac25f56 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/BitbucketServerRestClient.java @@ -65,6 +65,21 @@ public class BitbucketServerRestClient { .build(); } + public void validateUrl(String serverUrl) { + HttpUrl url = buildUrl(serverUrl, "/rest/api/1.0/repos"); + doGet("", url, r -> buildGson().fromJson(r.body().charStream(), RepositoryList.class)); + } + + public void validateToken(String serverUrl, String token) { + HttpUrl url = buildUrl(serverUrl, "/rest/api/1.0/users"); + doGet(token, url, r -> buildGson().fromJson(r.body().charStream(), UserList.class)); + } + + public void validateReadPermission(String serverUrl, String personalAccessToken) { + HttpUrl url = buildUrl(serverUrl, "/rest/api/1.0/repos"); + doGet(personalAccessToken, url, r -> buildGson().fromJson(r.body().charStream(), RepositoryList.class)); + } + public RepositoryList getRepos(String serverUrl, String token, @Nullable String project, @Nullable String repo) { String projectOrEmpty = Optional.ofNullable(project).orElse(""); String repoOrEmpty = Optional.ofNullable(repo).orElse(""); diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java new file mode 100644 index 00000000000..633bc0d2a32 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/User.java @@ -0,0 +1,39 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.bitbucketserver; + +import com.google.gson.annotations.SerializedName; + +public class User { + + @SerializedName("name") + private String name; + + @SerializedName("slug") + private String slug; + + @SerializedName("id") + private long id; + + public User() { + // http://stackoverflow.com/a/18645370/229031 + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java new file mode 100644 index 00000000000..df01dc613df --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/bitbucketserver/UserList.java @@ -0,0 +1,44 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.bitbucketserver; + +import com.google.gson.annotations.SerializedName; +import java.util.ArrayList; +import java.util.List; + +public class UserList { + + @SerializedName("isLastPage") + private final boolean isLastPage; + + @SerializedName("values") + private final List values; + + public UserList() { + // http://stackoverflow.com/a/18645370/229031 + this(false, new ArrayList<>()); + } + + public UserList(boolean isLastPage, List values) { + this.isLastPage = isLastPage; + this.values = values; + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java index eb98cdcafbc..13260a3e18c 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClient.java @@ -26,6 +26,8 @@ import java.util.Optional; import javax.annotation.CheckForNull; import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; + +import org.sonar.alm.client.github.config.GithubAppConfiguration; import org.sonar.alm.client.github.security.AccessToken; import org.sonar.alm.client.github.security.UserAccessToken; import org.sonar.api.server.ServerSide; @@ -53,6 +55,13 @@ public interface GithubApplicationClient { */ Repositories listRepositories(String appUrl, AccessToken accessToken, String organization, @Nullable String query, int page, int pageSize); + void checkApiEndpoint(GithubAppConfiguration githubAppConfiguration); + + /** + * Checks if an app has all the permissions required. + */ + void checkAppPermissions(GithubAppConfiguration githubAppConfiguration); + /** * Returns the repository identified by the repositoryKey owned by the provided organization. */ diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java index 361d206a6a1..3fc47352673 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/GithubApplicationClientImpl.java @@ -20,32 +20,54 @@ package org.sonar.alm.client.github; import com.google.gson.Gson; + import java.io.IOException; +import java.net.URI; import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import javax.annotation.Nullable; + import org.sonar.alm.client.github.GithubApplicationHttpClient.GetResponse; import org.sonar.alm.client.github.GithubBinding.GsonGithubRepository; import org.sonar.alm.client.github.GithubBinding.GsonInstallations; import org.sonar.alm.client.github.GithubBinding.GsonRepositorySearch; +import org.sonar.alm.client.github.config.GithubAppConfiguration; import org.sonar.alm.client.github.security.AccessToken; +import org.sonar.alm.client.github.security.AppToken; +import org.sonar.alm.client.github.security.GithubAppSecurity; import org.sonar.alm.client.github.security.UserAccessToken; +import org.sonar.alm.client.gitlab.GsonApp; +import org.sonar.api.internal.apachecommons.lang.StringUtils; import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; import static com.google.common.base.Preconditions.checkArgument; import static java.lang.String.format; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; +import static java.util.stream.Collectors.toList; public class GithubApplicationClientImpl implements GithubApplicationClient { private static final Logger LOG = Loggers.get(GithubApplicationClientImpl.class); - private static final Gson GSON = new Gson(); + protected static final Gson GSON = new Gson(); - private final GithubApplicationHttpClient appHttpClient; + protected static final String WRITE_PERMISSION_NAME = "write"; + protected static final String READ_PERMISSION_NAME = "read"; + protected static final String FAILED_TO_REQUEST_BEGIN_MSG = "Failed to request "; - public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient) { + protected final GithubApplicationHttpClient appHttpClient; + protected final GithubAppSecurity appSecurity; + + public GithubApplicationClientImpl(GithubApplicationHttpClient appHttpClient, GithubAppSecurity appSecurity) { this.appHttpClient = appHttpClient; + this.appSecurity = appSecurity; } private static void checkPageArgs(int page, int pageSize) { @@ -53,6 +75,68 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { checkArgument(pageSize > 0 && pageSize <= 100, "'pageSize' must be a value larger than 0 and smaller or equal to 100."); } + + @Override + public void checkApiEndpoint(GithubAppConfiguration githubAppConfiguration) { + if (StringUtils.isBlank(githubAppConfiguration.getApiEndpoint())) { + throw new IllegalArgumentException("Missing URL"); + } + + URI apiEndpoint; + try { + apiEndpoint = URI.create(githubAppConfiguration.getApiEndpoint()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid URL, " + e.getMessage()); + } + + if (!"http".equalsIgnoreCase(apiEndpoint.getScheme()) && !"https".equalsIgnoreCase(apiEndpoint.getScheme())) { + throw new IllegalArgumentException("Only http and https schemes are supported"); + } else if (!"api.github.com".equalsIgnoreCase(apiEndpoint.getHost()) && !apiEndpoint.getPath().toLowerCase(Locale.ENGLISH).startsWith("/api/v3")) { + throw new IllegalArgumentException("Invalid GitHub URL"); + } + } + + @Override + public void checkAppPermissions(GithubAppConfiguration githubAppConfiguration) { + AppToken appToken = appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey()); + + Map permissions = new HashMap<>(); + permissions.put("checks", WRITE_PERMISSION_NAME); + permissions.put("pull_requests", WRITE_PERMISSION_NAME); + permissions.put("statuses", READ_PERMISSION_NAME); + permissions.put("metadata", READ_PERMISSION_NAME); + + String endPoint = "/app"; + GetResponse response; + try { + response = appHttpClient.get(githubAppConfiguration.getApiEndpoint(), appToken, endPoint); + } catch (IOException e) { + LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + githubAppConfiguration.getApiEndpoint() + endPoint, e); + throw new IllegalArgumentException("Failed to validate configuration, check URL and Private Key"); + } + if (response.getCode() == HTTP_OK) { + Map perms = handleResponse(response, endPoint, GsonApp.class) + .map(GsonApp::getPermissions) + .orElseThrow(() -> new IllegalArgumentException("Failed to get app permissions, unexpected response body")); + List missingPermissions = permissions.entrySet().stream() + .filter(permission -> !Objects.equals(permission.getValue(), perms.get(permission.getKey()))) + .map(Map.Entry::getKey) + .collect(toList()); + + if (!missingPermissions.isEmpty()) { + String message = missingPermissions.stream() + .map(perm -> perm + " is '" + perms.get(perm) + "', should be '" + permissions.get(perm) + "'") + .collect(Collectors.joining(", ")); + + throw new IllegalArgumentException("Missing permissions; permission granted on " + message); + } + } else if (response.getCode() == HTTP_UNAUTHORIZED || response.getCode() == HTTP_FORBIDDEN) { + throw new IllegalArgumentException("Authentication failed, verify the Client Id, Client Secret and Private Key fields"); + } else { + throw new IllegalArgumentException("Failed to check permissions with Github, check the configuration"); + } + } + @Override public Organizations listOrganizations(String appUrl, AccessToken accessToken, int page, int pageSize) { checkPageArgs(page, pageSize); @@ -71,7 +155,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { organizations.setOrganizations(gsonInstallations.get().installations.stream() .map(gsonInstallation -> new Organization(gsonInstallation.account.id, gsonInstallation.account.login, null, null, null, null, null, gsonInstallation.targetType)) - .collect(Collectors.toList())); + .collect(toList())); } return organizations; @@ -100,7 +184,7 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { if (gsonRepositories.get().items != null) { repositories.setRepositories(gsonRepositories.get().items.stream() .map(gsonRepository -> new Repository(gsonRepository.id, gsonRepository.name, gsonRepository.isPrivate, gsonRepository.fullName, gsonRepository.url)) - .collect(Collectors.toList())); + .collect(toList())); } return repositories; @@ -161,4 +245,14 @@ public class GithubApplicationClientImpl implements GithubApplicationClient { throw new IllegalStateException("Failed to create GitHub's user access token", e); } } + + + protected static Optional handleResponse(GithubApplicationHttpClient.Response response, String endPoint, Class gsonClass) { + try { + return response.getContent().map(c -> GSON.fromJson(c, gsonClass)); + } catch (Exception e) { + LOG.warn(FAILED_TO_REQUEST_BEGIN_MSG + endPoint, e); + return Optional.empty(); + } + } } diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java new file mode 100644 index 00000000000..c7c6bc9eb8b --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/GithubAppConfiguration.java @@ -0,0 +1,112 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.config; + +import com.google.common.base.MoreObjects; +import java.util.regex.Pattern; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; + +import static java.lang.String.format; + +public class GithubAppConfiguration { + + private static final Pattern TRAILING_SLASHES = Pattern.compile("/+$"); + + private final Long id; + private final String privateKey; + private final String apiEndpoint; + + public GithubAppConfiguration(@Nullable Long id, @Nullable String privateKey, @Nullable String apiEndpoint) { + this.id = id; + this.privateKey = privateKey; + this.apiEndpoint = sanitizedEndPoint(apiEndpoint); + } + + /** + * Check configuration is complete with {@link #isComplete()} before calling this method. + * + * @throws IllegalStateException if configuration is not complete + */ + public long getId() { + checkConfigurationComplete(); + return id; + } + + public String getApiEndpoint() { + checkConfigurationComplete(); + return apiEndpoint; + } + + /** + * Check configuration is complete with {@link #isComplete()} before calling this method. + * + * @throws IllegalStateException if configuration is not complete + */ + public String getPrivateKey() { + checkConfigurationComplete(); + return privateKey; + } + + private void checkConfigurationComplete() { + if (!isComplete()) { + throw new IllegalStateException(format("Configuration is not complete : %s", toString())); + } + } + + public boolean isComplete() { + return id != null && + privateKey != null && + apiEndpoint != null; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(GithubAppConfiguration.class) + .add("id", id) + .add("privateKey", secureToString(privateKey)) + .add("apiEndpoint", toString(apiEndpoint)) + .toString(); + } + + @CheckForNull + private static String toString(@Nullable String s) { + if (s == null) { + return null; + } + return '\'' + s + '\''; + } + + @CheckForNull + private static String secureToString(@Nullable String token) { + if (token == null) { + return null; + } + return "'***(" + token.length() + ")***'"; + } + + private static String sanitizedEndPoint(@Nullable String endPoint) { + if (endPoint == null) { + return null; + } + return TRAILING_SLASHES.matcher(endPoint).replaceAll(""); + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java new file mode 100644 index 00000000000..c1f97be01fd --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/config/package-info.java @@ -0,0 +1,23 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.config; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java new file mode 100644 index 00000000000..d05edea299c --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/AppToken.java @@ -0,0 +1,85 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.security; + +import javax.annotation.concurrent.Immutable; + +import static java.util.Objects.requireNonNull; + +/** + * JWT (Json Web Token) to authenticate API requests on behalf + * of the SonarCloud App. + * + * Token expires after {@link #EXPIRATION_PERIOD_IN_MINUTES} minutes. + * + * IMPORTANT + * Rate limit is 5'000 API requests per hour for ALL the clients + * of the SonarCloud App (all instances of {@link AppToken} from Compute Engines/web servers + * and from the other SonarSource services using the App). For example three calls with + * three different tokens will consume 3 hits. Remaining quota will be 4'997. + * When the token is expired, the rate limit is 60 calls per hour for the public IP + * of the machine. BE CAREFUL, THAT SHOULD NEVER OCCUR. + * + * See https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app + */ +@Immutable +public class AppToken implements AccessToken { + + // SONARCLOUD-468 maximum allowed by GitHub is 10 minutes but we use 9 minutes just in case clocks are not synchronized + static final int EXPIRATION_PERIOD_IN_MINUTES = 9; + + private final String jwt; + + public AppToken(String jwt) { + this.jwt = requireNonNull(jwt, "jwt can't be null"); + } + + @Override + public String getValue() { + return jwt; + } + + @Override + public String getAuthorizationHeaderPrefix() { + return "Bearer"; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AppToken appToken = (AppToken) o; + return jwt.equals(appToken.jwt); + } + + @Override + public int hashCode() { + return jwt.hashCode(); + } + + @Override + public String toString() { + return jwt; + } +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java new file mode 100644 index 00000000000..a5ed35bffe3 --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurity.java @@ -0,0 +1,32 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.security; + +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.server.ServerSide; + +@ServerSide +@ComputeEngineSide +public interface GithubAppSecurity { + /** + * @throws IllegalArgumentException if the key is invalid + */ + AppToken createAppToken(long appId, String privateKey); +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java new file mode 100644 index 00000000000..5a6aabd481d --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/github/security/GithubAppSecurityImpl.java @@ -0,0 +1,106 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.security; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.RSAKeyProvider; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.Security; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class GithubAppSecurityImpl implements GithubAppSecurity { + + private final Clock clock; + + public GithubAppSecurityImpl(Clock clock) { + this.clock = clock; + } + + @Override + public AppToken createAppToken(long appId, String privateKey) { + Algorithm algorithm = readApplicationPrivateKey(appId, privateKey); + LocalDateTime now = LocalDateTime.now(clock); + // Expiration period is configurable and could be greater if needed. + // See https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app + LocalDateTime expiresAt = now.plus(AppToken.EXPIRATION_PERIOD_IN_MINUTES, ChronoUnit.MINUTES); + ZoneOffset offset = clock.getZone().getRules().getOffset(now); + Date nowDate = Date.from(now.toInstant(offset)); + Date expiresAtDate = Date.from(expiresAt.toInstant(offset)); + JWTCreator.Builder builder = JWT.create() + .withIssuer(String.valueOf(appId)) + .withIssuedAt(nowDate) + .withExpiresAt(expiresAtDate); + return new AppToken(builder.sign(algorithm)); + } + + private static Algorithm readApplicationPrivateKey(long appId, String encodedPrivateKey) { + byte[] decodedPrivateKey = encodedPrivateKey.getBytes(UTF_8); + try (PemReader pemReader = new PemReader(new InputStreamReader(new ByteArrayInputStream(decodedPrivateKey)))) { + Security.addProvider(new BouncyCastleProvider()); + + PemObject pemObject = pemReader.readPemObject(); + if (pemObject == null) { + throw new IllegalArgumentException("Failed to decode Github Application private key"); + } + + PKCS8EncodedKeySpec keySpec1 = new PKCS8EncodedKeySpec(pemObject.getContent()); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = keyFactory.generatePrivate(keySpec1); + return Algorithm.RSA256(new RSAKeyProvider() { + @Override + public RSAPublicKey getPublicKeyById(String keyId) { + throw new UnsupportedOperationException("getPublicKeyById not implemented"); + } + + @Override + public RSAPrivateKey getPrivateKey() { + return (RSAPrivateKey) privateKey; + } + + @Override + public String getPrivateKeyId() { + return "github_app_" + appId; + } + }); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid Github Application private key", e); + } finally { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME); + } + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java index e0d453c56e2..f621e3d5a1d 100644 --- a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GitlabHttpClient.java @@ -30,11 +30,13 @@ import java.util.Optional; import javax.annotation.Nullable; import okhttp3.OkHttpClient; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; import org.sonar.alm.client.TimeoutConfiguration; import org.sonar.api.server.ServerSide; import org.sonar.api.utils.log.Logger; import org.sonar.api.utils.log.Loggers; +import org.sonarqube.ws.MediaTypes; import org.sonarqube.ws.client.OkHttpClientBuilder; import static java.net.HttpURLConnection.HTTP_FORBIDDEN; @@ -55,6 +57,85 @@ public class GitlabHttpClient { .build(); } + + public void checkReadPermission(@Nullable String gitlabUrl, @Nullable String personalAccessToken) { + checkProjectAccess(gitlabUrl, personalAccessToken, "Could not validate GitLab read permission. Got an unexpected answer."); + } + + public void checkUrl(@Nullable String gitlabUrl) { + checkProjectAccess(gitlabUrl, null, "Could not validate GitLab url. Got an unexpected answer."); + } + + private void checkProjectAccess(@Nullable String gitlabUrl, @Nullable String personalAccessToken, String errorMessage) { + String url = String.format("%s/projects", gitlabUrl); + + LOG.debug(String.format("get projects : [%s]", url)); + Request.Builder builder = new Request.Builder() + .url(url) + .get(); + + if (personalAccessToken != null) { + builder.addHeader(PRIVATE_TOKEN, personalAccessToken); + } + + Request request = builder.build(); + + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response, errorMessage); + Project.parseJsonArray(response.body().string()); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to verify read permission. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalArgumentException(errorMessage); + } + } + + public void checkToken(String gitlabUrl, String personalAccessToken) { + String url = String.format("%s/user", gitlabUrl); + + LOG.debug(String.format("get current user : [%s]", url)); + Request.Builder builder = new Request.Builder() + .addHeader(PRIVATE_TOKEN, personalAccessToken) + .url(url) + .get(); + + Request request = builder.build(); + + String errorMessage = "Could not validate GitLab token. Got an unexpected answer."; + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response, errorMessage); + GsonId.parseOne(response.body().string()); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to verify token. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalArgumentException(errorMessage); + } + } + + public void checkWritePermission(String gitlabUrl, String personalAccessToken) { + String url = String.format("%s/markdown", gitlabUrl); + + LOG.debug(String.format("verify write permission by formating some markdown : [%s]", url)); + Request.Builder builder = new Request.Builder() + .url(url) + .addHeader(PRIVATE_TOKEN, personalAccessToken) + .addHeader("Content-Type", MediaTypes.JSON) + .post(RequestBody.create("{\"text\":\"validating write permission\"}".getBytes(UTF_8))); + + Request request = builder.build(); + + String errorMessage = "Could not validate GitLab write permission. Got an unexpected answer."; + try (Response response = client.newCall(request).execute()) { + checkResponseIsSuccessful(response, errorMessage); + GsonMarkdown.parseOne(response.body().string()); + } catch (JsonSyntaxException e) { + throw new IllegalArgumentException("Could not parse GitLab answer to verify write permission. Got a non-json payload as result."); + } catch (IOException e) { + throw new IllegalArgumentException(errorMessage); + } + + } + private static String urlEncode(String value) { try { return URLEncoder.encode(value, UTF_8.toString()); diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java new file mode 100644 index 00000000000..2befa10790e --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonApp.java @@ -0,0 +1,49 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.gitlab; + +import com.google.gson.annotations.SerializedName; + +import java.util.Map; + +public class GsonApp { + @SerializedName("installations_count") + private long installationsCount; + @SerializedName("permissions") + private Map permissions; + + public GsonApp() { + // http://stackoverflow.com/a/18645370/229031 + } + + public GsonApp(long installationsCount, Map permissions) { + this.installationsCount = installationsCount; + this.permissions = permissions; + } + + public long getInstallationsCount() { + return installationsCount; + } + + public Map getPermissions() { + return permissions; + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java new file mode 100644 index 00000000000..96948306fbc --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonId.java @@ -0,0 +1,48 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.gitlab; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +public class GsonId { + + @SerializedName("id") + private final long id; + + public GsonId() { + // http://stackoverflow.com/a/18645370/229031 + this(0); + } + + public GsonId(long id) { + this.id = id; + } + + public long getId() { + return id; + } + + public static GsonId parseOne(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, GsonId.class); + } + +} diff --git a/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java new file mode 100644 index 00000000000..39eac294c1d --- /dev/null +++ b/server/sonar-alm-client/src/main/java/org/sonar/alm/client/gitlab/GsonMarkdown.java @@ -0,0 +1,45 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.gitlab; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import javax.annotation.Nullable; + +public class GsonMarkdown { + + @SerializedName("html") + private final String html; + + public GsonMarkdown() { + // http://stackoverflow.com/a/18645370/229031 + this(null); + } + + public GsonMarkdown(@Nullable String html) { + this.html = html; + } + + public static GsonMarkdown parseOne(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, GsonMarkdown.class); + } + +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java index 75d800ae4d4..4cf51c3f5df 100644 --- a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/GithubApplicationClientImplTest.java @@ -29,13 +29,20 @@ import org.junit.Before; import org.junit.ClassRule; import org.junit.Test; import org.junit.runner.RunWith; +import org.sonar.alm.client.github.config.GithubAppConfiguration; import org.sonar.alm.client.github.security.AccessToken; +import org.sonar.alm.client.github.security.AppToken; +import org.sonar.alm.client.github.security.GithubAppSecurity; import org.sonar.alm.client.github.security.UserAccessToken; import org.sonar.api.utils.log.LogTester; import org.sonar.api.utils.log.LoggerLevel; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.groups.Tuple.tuple; import static org.mockito.ArgumentMatchers.any; @@ -50,16 +57,142 @@ public class GithubApplicationClientImplTest { public static LogTester logTester = new LogTester().setLevel(LoggerLevel.WARN); private GithubApplicationHttpClientImpl httpClient = mock(GithubApplicationHttpClientImpl.class); + private GithubAppSecurity appSecurity = mock(GithubAppSecurity.class); + private GithubAppConfiguration githubAppConfiguration = mock(GithubAppConfiguration.class); private GithubApplicationClient underTest; private String appUrl = "Any URL"; @Before public void setup() { - underTest = new GithubApplicationClientImpl(httpClient); + when(githubAppConfiguration.getApiEndpoint()).thenReturn(appUrl); + underTest = new GithubApplicationClientImpl(httpClient, appSecurity); logTester.clear(); } + @Test + @UseDataProvider("invalidApiEndpoints") + public void checkApiEndpoint_Invalid(String url, String expectedMessage) { + GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url); + + assertThatThrownBy(() -> underTest.checkApiEndpoint(configuration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } + + @DataProvider + public static Object[][] invalidApiEndpoints() { + return new Object[][] { + {"", "Missing URL"}, + {"ftp://api.github.com", "Only http and https schemes are supported"}, + {"https://github.com", "Invalid GitHub URL"} + }; + } + + @Test + @UseDataProvider("validApiEndpoints") + public void checkApiEndpoint(String url) { + GithubAppConfiguration configuration = new GithubAppConfiguration(1L, "", url); + + assertThatCode(() -> underTest.checkApiEndpoint(configuration)).isNull(); + } + + @DataProvider + public static Object[][] validApiEndpoints() { + return new Object[][] { + {"https://github.sonarsource.com/api/v3"}, + {"https://api.github.com"}, + {"https://github.sonarsource.com/api/v3/"}, + {"https://api.github.com/"}, + {"HTTPS://api.github.com/"}, + {"HTTP://api.github.com/"}, + {"HtTpS://github.SonarSource.com/api/v3"}, + {"HtTpS://github.sonarsource.com/api/V3"}, + {"HtTpS://github.sonarsource.COM/ApI/v3"} + }; + } + + @Test + public void checkAppPermissions_IOException() throws IOException { + AppToken appToken = mockAppToken(); + + when(httpClient.get(appUrl, appToken, "/app")).thenThrow(new IOException("OOPS")); + + assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Failed to validate configuration, check URL and Private Key"); + } + + @Test + @UseDataProvider("checkAppPermissionsErrorCodes") + public void checkAppPermissions_ErrorCodes(int errorCode, String expectedMessage) throws IOException { + AppToken appToken = mockAppToken(); + + when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new ErrorGetResponse(errorCode, null)); + + assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(expectedMessage); + } + + @DataProvider + public static Object[][] checkAppPermissionsErrorCodes() { + return new Object[][] { + {HTTP_UNAUTHORIZED, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"}, + {HTTP_FORBIDDEN, "Authentication failed, verify the Client Id, Client Secret and Private Key fields"}, + {HTTP_NOT_FOUND, "Failed to check permissions with Github, check the configuration"} + }; + } + + @Test + public void checkAppPermissions_MissingPermissions() throws IOException { + AppToken appToken = mockAppToken(); + + when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse("{}")); + + assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Failed to get app permissions, unexpected response body"); + } + + @Test + public void checkAppPermissions_IncorrectPermissions() throws IOException { + AppToken appToken = mockAppToken(); + + String json = "{" + + " \"permissions\": {\n" + + " \"checks\": \"read\",\n" + + " \"metadata\": \"read\",\n" + + " \"statuses\": \"read\",\n" + + " \"pull_requests\": \"read\"\n" + + " }\n" + + "}"; + + when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json)); + + assertThatThrownBy(() -> underTest.checkAppPermissions(githubAppConfiguration)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Missing permissions; permission granted on pull_requests is 'read', should be 'write', checks is 'read', should be 'write'"); + } + + @Test + public void checkAppPermissions() throws IOException { + AppToken appToken = mockAppToken(); + + String json = "{" + + " \"permissions\": {\n" + + " \"checks\": \"write\",\n" + + " \"metadata\": \"read\",\n" + + " \"statuses\": \"read\",\n" + + " \"pull_requests\": \"write\"\n" + + " }\n" + + "}"; + + when(httpClient.get(appUrl, appToken, "/app")).thenReturn(new OkGetResponse(json)); + + assertThatCode(() -> underTest.checkAppPermissions(githubAppConfiguration)).isNull(); + } + @Test @UseDataProvider("githubServers") public void createUserAccessToken_returns_empty_if_access_token_cant_be_created(String apiUrl, String appUrl) throws IOException { @@ -664,12 +797,28 @@ public class GithubApplicationClientImplTest { .containsOnly(1296269L, "Hello-World", "octocat/Hello-World", "https://github.sonarsource.com/api/v3/repos/octocat/Hello-World", false); } + private AppToken mockAppToken() { + String jwt = randomAlphanumeric(5); + when(appSecurity.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).thenReturn(new AppToken(jwt)); + return new AppToken(jwt); + } + private static class OkGetResponse extends Response { private OkGetResponse(String content) { super(200, content); } } + private static class ErrorGetResponse extends Response { + ErrorGetResponse() { + super(401, null); + } + + ErrorGetResponse(int code, String content) { + super(code, content); + } + } + private static class Response implements GithubApplicationHttpClient.GetResponse { private final int code; private final String content; diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java new file mode 100644 index 00000000000..6dd3e131e31 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/config/GithubAppConfigurationTest.java @@ -0,0 +1,172 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.config; + +import com.tngtech.java.junit.dataprovider.DataProvider; +import com.tngtech.java.junit.dataprovider.DataProviderRunner; +import com.tngtech.java.junit.dataprovider.UseDataProvider; + +import java.util.Random; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +import org.apache.commons.lang.ArrayUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; + +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(DataProviderRunner.class) +public class GithubAppConfigurationTest { + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + @Test + @UseDataProvider("incompleteConfigurationParametersSonarQube") + public void isComplete_returns_false_if_configuration_is_incomplete_on_SonarQube(@Nullable Long applicationId, @Nullable String privateKey, @Nullable String apiEndpoint) { + GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint); + + assertThat(underTest.isComplete()).isFalse(); + } + + @Test + @UseDataProvider("incompleteConfigurationParametersSonarQube") + public void getId_throws_ISE_if_config_is_incomplete(@Nullable Long applicationId, @Nullable String privateKey, @Nullable String apiEndpoint) { + GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint); + + expectConfigurationIncompleteISE(); + + underTest.getId(); + } + + @Test + public void getId_returns_applicationId_if_configuration_is_valid() { + long applicationId = new Random().nextLong(); + GithubAppConfiguration underTest = newValidConfiguration(applicationId); + + assertThat(underTest.getId()).isEqualTo(applicationId); + } + + @Test + @UseDataProvider("incompleteConfigurationParametersSonarQube") + public void getPrivateKeyFile_throws_ISE_if_config_is_incomplete(@Nullable Long applicationId, @Nullable String privateKey, @Nullable String apiEndpoint) { + GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint); + + expectConfigurationIncompleteISE(); + + underTest.getPrivateKey(); + } + + @DataProvider + public static Object[][] incompleteConfigurationParametersSonarQube() { + long applicationId = new Random().nextLong(); + String privateKey = randomAlphabetic(9); + String apiEndpoint = randomAlphabetic(11); + + return generateNullCombination(new Object[] { + applicationId, + privateKey, + apiEndpoint + }); + } + + @Test + public void toString_displays_complete_configuration() { + long id = 34; + String privateKey = randomAlphabetic(3); + String apiEndpoint = randomAlphabetic(7); + + GithubAppConfiguration underTest = new GithubAppConfiguration(id, privateKey, apiEndpoint); + + assertThat(underTest) + .hasToString(String.format("GithubAppConfiguration{id=%s, privateKey='***(3)***', apiEndpoint='%s'}", id, apiEndpoint)); + } + + @Test + public void toString_displays_incomplete_configuration() { + GithubAppConfiguration underTest = new GithubAppConfiguration(null, null, null); + + assertThat(underTest) + .hasToString("GithubAppConfiguration{id=null, privateKey=null, apiEndpoint=null}"); + } + + @Test + public void toString_displays_privateKey_as_stars() { + GithubAppConfiguration underTest = new GithubAppConfiguration(null, randomAlphabetic(555), null); + + assertThat(underTest) + .hasToString( + "GithubAppConfiguration{id=null, privateKey='***(555)***', apiEndpoint=null}"); + } + + @Test + public void equals_is_not_implemented() { + long applicationId = new Random().nextLong(); + String privateKey = randomAlphabetic(8); + String apiEndpoint = randomAlphabetic(7); + + GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint); + + assertThat(underTest) + .isEqualTo(underTest) + .isNotEqualTo(new GithubAppConfiguration(applicationId, privateKey, apiEndpoint)); + } + + @Test + public void hashcode_is_based_on_all_fields() { + long applicationId = new Random().nextLong(); + String privateKey = randomAlphabetic(8); + String apiEndpoint = randomAlphabetic(7); + + GithubAppConfiguration underTest = new GithubAppConfiguration(applicationId, privateKey, apiEndpoint); + + assertThat(underTest).hasSameHashCodeAs(underTest); + assertThat(underTest.hashCode()).isNotEqualTo(new GithubAppConfiguration(applicationId, privateKey, apiEndpoint)); + } + + private void expectConfigurationIncompleteISE() { + expectedException.expect(IllegalStateException.class); + expectedException.expectMessage("Configuration is not complete"); + } + + private GithubAppConfiguration newValidConfiguration(long applicationId) { + return new GithubAppConfiguration(applicationId, randomAlphabetic(6), randomAlphabetic(6)); + } + + private static Object[][] generateNullCombination(Object[] objects) { + Object[][] firstPossibleValues = new Object[][] { + {null}, + {objects[0]} + }; + if (objects.length == 1) { + return firstPossibleValues; + } + + Object[][] subCombinations = generateNullCombination(ArrayUtils.subarray(objects, 1, objects.length)); + + return Stream.of(subCombinations) + .flatMap(combination -> Stream.of(firstPossibleValues).map(firstValue -> ArrayUtils.addAll(firstValue, combination))) + .filter(array -> ArrayUtils.contains(array, null)) + .toArray(Object[][]::new); + } +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java new file mode 100644 index 00000000000..f9e5560520f --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/AppTokenTest.java @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.security; + +import org.junit.Test; +import org.sonar.alm.client.github.security.AppToken; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AppTokenTest { + + @Test + public void test_value() { + AppToken underTest = new AppToken("foo"); + + assertThat(underTest.toString()) + .isEqualTo(underTest.getValue()) + .isEqualTo("foo"); + + assertThat(underTest.getAuthorizationHeaderPrefix()).isEqualTo("Bearer"); + } + + @Test + public void test_equals_hashCode() { + AppToken foo = new AppToken("foo"); + + assertThat(foo) + .isEqualTo(foo) + .isEqualTo(new AppToken("foo")) + .isNotEqualTo(new AppToken("bar")) + .hasSameHashCodeAs(foo) + .hasSameHashCodeAs(new AppToken("foo")); + } +} diff --git a/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java new file mode 100644 index 00000000000..d558e2b54d2 --- /dev/null +++ b/server/sonar-alm-client/src/test/java/org/sonar/alm/client/github/security/GithubAppSecurityImplTest.java @@ -0,0 +1,186 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.alm.client.github.security; + +import com.tngtech.java.junit.dataprovider.DataProviderRunner; + +import java.io.IOException; +import java.security.spec.InvalidKeySpecException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Random; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.sonar.alm.client.github.config.GithubAppConfiguration; + +import static org.apache.commons.lang.RandomStringUtils.randomAlphabetic; +import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@RunWith(DataProviderRunner.class) +public class GithubAppSecurityImplTest { + private Clock clock = Clock.fixed(Instant.ofEpochSecond(132_600_888L), ZoneId.systemDefault()); + private GithubAppSecurityImpl underTest = new GithubAppSecurityImpl(clock); + + @Test + public void createAppToken_fails_with_IAE_if_privateKey_content_is_garbage() { + String garbage = randomAlphanumeric(555); + GithubAppConfiguration githubAppConfiguration = createAppConfigurationForPrivateKey(garbage); + + assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())) + .isInstanceOf(IllegalArgumentException.class) + .hasRootCauseMessage("Failed to decode Github Application private key"); + + } + + @Test + public void createAppToken_fails_with_IAE_if_privateKey_PKCS8_content_is_missing_end_comment() { + String incompletePrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEA6C29ZdvrwHOu7Eewv+xvUd4inCnACTzAHukHKTSY4R16+lRI\n" + + "YC5qZ8Xo304J7lLhN4/d4Xnof3lDXZOHthVbJKik4fOuEGbTXTIcuFs3hdJtrJsb\n" + + "antv8SOl5iR4fYRAf2AILMdtZI4iMSicBLIIttR+wVXo6NJYMjpj1OuAU3uN8eET\n" + + "Gge09oJT3QOUBem7N8uaYi/p5uAfsf2/SVNsoMPV624X4kgNcyj/TMa6BosFJ8Y3\n" + + "oeg0Aguk2yuHhAnixDVGoz6N7Go0QjEipVNix2JOOJwpFH4k2iZfM6n+8sJTLilq\n" + + "yzT53JW/XI+M5AXVj4OjBJ/2yMPi3RFMNTdgRwIDAQABAoIBACcYBIsRI7oNAIgi\n" + + "bh1y1y+mwpce5Inpo8PQovcKNy+4gguCg4lGZ34/sb1f64YoiGmNnOOpXj+QkIpC\n" + + "HBjJscYTa2fsWwPB/Jb1qCZWnZu32eW1XEFqtWeaBAYjX/JqgV2xMs8vaTkEQbeb\n" + + "SeH0hEkcsJcnOwdw247hjAu+96WWlyt10ZGgQaWPfXsdtelbaoaturNAVAJHdl9e\n" + + "TIknCIbtLlbz/FtzjtCtdeiWr8gbKdVkshGtA8SKVhXGQwDwENjUkAUtSJ0aXR1t\n" + + "+UjQcTISk7LiiYs0MrJ/CKoJ7mShwx7+YF3hgyqQ0qaqHwt9Yyd7wzWdCgdM5Eha\n" + + "ccioIskCgYEA+EDJmcM5NGu5AYpZ1ogmG6jzsefAlr2NG1PQ/U03twal/B+ygAQb\n" + + "5dholrq+aF+45Hrzfxije3Zrvpb08vxzKAs20lOlJsKftx2zkLR+mNvWTAORuO16\n" + + "lG0c0cgYAKA1ld4R8KB8NmbuNb1w4LYZuyuFIEVmm2B3ca141WNHBwMCgYEA72yK\n" + + "B4+xxomZn6dtbCGQZxziaI9WH/KEfDemKO5cfPlynQjmmMkiDpcyHa7mvdU+PGh3\n" + + "g+OmQxORXMmBkHEnYS1fl3ac3U5sLiHAQBmTKKcLuVQlIU4oDu/K6WEGL9DdPtaK\n" + + "gyOOWtSnfHTbT0bZ4IMm+gzdc4bCuEjvYyUhzG0CgYAEN011MAyTqFSvAwN9kjhb\n" + + "deYVmmL57GQuF6FP+/S7RgChpIQqimdS4vb7wFYlfaKtNq1V9jwoh51S0kt8qO7n\n" + + "ujEHJ2aBnwKJYJbBGV+hBvK/vbvG0TmotaWspmJJ+G6QigHx/Te+0Maw4PO+zTjo\n" + + "pdeP8b3JW70LkC+iKBp3swKBgFL/nm32m1tHEjFtehpVHFkSg05Z+jJDATiKlhh0\n" + + "YS2Vz+yuTDpE54CFW4M8wZKnXNbWJDBdd6KjIu42kKrA/zTJ5Ox92u1BJXFsk9fk\n" + + "xcX++qp5iBGepXZgHEiBMQLcdgY1m3jQl6XXOGSFog0+c4NIE/f1A8PrwI7gAdSt\n" + + "56SVAoGBAJp214Fo0oheMTTYKVtXuGiH/v3JNG1jKFgsmHqndf4wy7U6bbNctEzc\n" + + "ZXNIacuhWmko6YejMrWNhE57sX812MhXGZq6y0sYZGKtp7oDv8G3rWD6bpZywpcV\n" + + "kTtMJxm8J64u6bAkpWG3BocJP9qbXeAbILo1wuXgYqABBrpA9nnc"; + GithubAppConfiguration githubAppConfiguration = createAppConfigurationForPrivateKey(incompletePrivateKey); + + assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())) + .isInstanceOf(IllegalArgumentException.class) + .hasRootCauseInstanceOf(IOException.class) + .hasRootCauseMessage("-----END RSA PRIVATE KEY not found"); + } + + @Test + public void createAppToken_fails_with_IAE_if_privateKey_PKCS8_content_is_corrupted() { + String corruptedPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEA6C29ZdvrwHOu7Eewv+xvUd4inCnACTzAHukHKTSY4R16+lRI\n" + + "YC5qZ8Xo304J7lLhN4/d4Xnof3lDXZOHthVbJKik4fOuEGbTXTIcuFs3hdJtrJsb\n" + + "antv8SOl5iR4fYRAf2AILMdtZI4iMSicBLIIttR+wVXo6NJYMjpj1OuAU3uN8eET\n" + + "Gge09oJT3QOUBem7N8uaYi/p5uAfsf2/SVNsoMPV624X4kgNcyj/TMa6BosFJ8Y3\n" + + "oeg0Aguk2yuHhAnixDVGoz6N7Go0QjEipVNix2JOOJwpFH4k2iZfM6n+8sJTLilq\n" + + "yzT53JW/XI+M5AXVj4OjBJ/2yMPi3RFMNTdgRwIDAQABAoIBACcYBIsRI7oNAIgi\n" + + "bh1y1y+mwpce5Inpo8PQovcKNy+4gguCg4lGZ34/sb1f64YoiGmNnOOpXj+QkIpC\n" + + "HBjJscYTa2fsWwPB/Jb1qCZWnZu32eW1XEFqtWeaBAYjX/JqgV2xMs8vaTkEQbeb\n" + + // "SeH0hEkcsJcnOwdw247hjAu+96WWlyt10ZGgQaWPfXsdtelbaoaturNAVAJHdl9e\n" + + // "TIknCIbtLlbz/FtzjtCtdeiWr8gbKdVkshGtA8SKVhXGQwDwENjUkAUtSJ0aXR1t\n" + + // "+UjQcTISk7LiiYs0MrJ/CKoJ7mShwx7+YF3hgyqQ0qaqHwt9Yyd7wzWdCgdM5Eha\n" + + // "ccioIskCgYEA+EDJmcM5NGu5AYpZ1ogmG6jzsefAlr2NG1PQ/U03twal/B+ygAQb\n" + + // "5dholrq+aF+45Hrzfxije3Zrvpb08vxzKAs20lOlJsKftx2zkLR+mNvWTAORuO16\n" + + // "lG0c0cgYAKA1ld4R8KB8NmbuNb1w4LYZuyuFIEVmm2B3ca141WNHBwMCgYEA72yK\n" + + // "B4+xxomZn6dtbCGQZxziaI9WH/KEfDemKO5cfPlynQjmmMkiDpcyHa7mvdU+PGh3\n" + + "g+OmQxORXMmBkHEnYS1fl3ac3U5sLiHAQBmTKKcLuVQlIU4oDu/K6WEGL9DdPtaK\n" + + "gyOOWtSnfHTbT0bZ4IMm+gzdc4bCuEjvYyUhzG0CgYAEN011MAyTqFSvAwN9kjhb\n" + + "deYVmmL57GQuF6FP+/S7RgChpIQqimdS4vb7wFYlfaKtNq1V9jwoh51S0kt8qO7n\n" + + "ujEHJ2aBnwKJYJbBGV+hBvK/vbvG0TmotaWspmJJ+G6QigHx/Te+0Maw4PO+zTjo\n" + + "pdeP8b3JW70LkC+iKBp3swKBgFL/nm32m1tHEjFtehpVHFkSg05Z+jJDATiKlhh0\n" + + "YS2Vz+yuTDpE54CFW4M8wZKnXNbWJDBdd6KjIu42kKrA/zTJ5Ox92u1BJXFsk9fk\n" + + "xcX++qp5iBGepXZgHEiBMQLcdgY1m3jQl6XXOGSFog0+c4NIE/f1A8PrwI7gAdSt\n" + + "56SVAoGBAJp214Fo0oheMTTYKVtXuGiH/v3JNG1jKFgsmHqndf4wy7U6bbNctEzc\n" + + "ZXNIacuhWmko6YejMrWNhE57sX812MhXGZq6y0sYZGKtp7oDv8G3rWD6bpZywpcV\n" + + "kTtMJxm8J64u6bAkpWG3BocJP9qbXeAbILo1wuXgYqABBrpA9nnc\n" + + "-----END RSA PRIVATE KEY-----"; + GithubAppConfiguration githubAppConfiguration = createAppConfigurationForPrivateKey(corruptedPrivateKey); + + assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())) + .isInstanceOf(IllegalArgumentException.class) + .hasCauseInstanceOf(InvalidKeySpecException.class); + } + + @Test + public void getApplicationJWTToken_throws_ISE_if_conf_is_not_complete() { + GithubAppConfiguration githubAppConfiguration = createAppConfiguration(false); + assertThatThrownBy(() -> underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())) + .isInstanceOf(IllegalStateException.class); + } + + @Test + public void getApplicationJWTToken_returns_token_if_app_config_and_private_key_are_valid() { + GithubAppConfiguration githubAppConfiguration = createAppConfiguration(true); + + assertThat(underTest.createAppToken(githubAppConfiguration.getId(), githubAppConfiguration.getPrivateKey())).isNotNull(); + } + + private GithubAppConfiguration createAppConfiguration(boolean validConfiguration) { + if (validConfiguration) { + return createAppConfiguration(); + } else { + return new GithubAppConfiguration(null, null, null); + } + } + + private GithubAppConfiguration createAppConfiguration() { + return new GithubAppConfiguration(new Random().nextLong(), REAL_PRIVATE_KEY, randomAlphanumeric(5)); + } + + private GithubAppConfiguration createAppConfigurationForPrivateKey(String privateKey) { + long applicationId = new Random().nextInt(654); + return new GithubAppConfiguration(applicationId, privateKey, randomAlphabetic(8)); + } + + private static final String REAL_PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIIEowIBAAKCAQEA6C29ZdvrwHOu7Eewv+xvUd4inCnACTzAHukHKTSY4R16+lRI\n" + + "YC5qZ8Xo304J7lLhN4/d4Xnof3lDXZOHthVbJKik4fOuEGbTXTIcuFs3hdJtrJsb\n" + + "antv8SOl5iR4fYRAf2AILMdtZI4iMSicBLIIttR+wVXo6NJYMjpj1OuAU3uN8eET\n" + + "Gge09oJT3QOUBem7N8uaYi/p5uAfsf2/SVNsoMPV624X4kgNcyj/TMa6BosFJ8Y3\n" + + "oeg0Aguk2yuHhAnixDVGoz6N7Go0QjEipVNix2JOOJwpFH4k2iZfM6n+8sJTLilq\n" + + "yzT53JW/XI+M5AXVj4OjBJ/2yMPi3RFMNTdgRwIDAQABAoIBACcYBIsRI7oNAIgi\n" + + "bh1y1y+mwpce5Inpo8PQovcKNy+4gguCg4lGZ34/sb1f64YoiGmNnOOpXj+QkIpC\n" + + "HBjJscYTa2fsWwPB/Jb1qCZWnZu32eW1XEFqtWeaBAYjX/JqgV2xMs8vaTkEQbeb\n" + + "SeH0hEkcsJcnOwdw247hjAu+96WWlyt10ZGgQaWPfXsdtelbaoaturNAVAJHdl9e\n" + + "TIknCIbtLlbz/FtzjtCtdeiWr8gbKdVkshGtA8SKVhXGQwDwENjUkAUtSJ0aXR1t\n" + + "+UjQcTISk7LiiYs0MrJ/CKoJ7mShwx7+YF3hgyqQ0qaqHwt9Yyd7wzWdCgdM5Eha\n" + + "ccioIskCgYEA+EDJmcM5NGu5AYpZ1ogmG6jzsefAlr2NG1PQ/U03twal/B+ygAQb\n" + + "5dholrq+aF+45Hrzfxije3Zrvpb08vxzKAs20lOlJsKftx2zkLR+mNvWTAORuO16\n" + + "lG0c0cgYAKA1ld4R8KB8NmbuNb1w4LYZuyuFIEVmm2B3ca141WNHBwMCgYEA72yK\n" + + "B4+xxomZn6dtbCGQZxziaI9WH/KEfDemKO5cfPlynQjmmMkiDpcyHa7mvdU+PGh3\n" + + "g+OmQxORXMmBkHEnYS1fl3ac3U5sLiHAQBmTKKcLuVQlIU4oDu/K6WEGL9DdPtaK\n" + + "gyOOWtSnfHTbT0bZ4IMm+gzdc4bCuEjvYyUhzG0CgYAEN011MAyTqFSvAwN9kjhb\n" + + "deYVmmL57GQuF6FP+/S7RgChpIQqimdS4vb7wFYlfaKtNq1V9jwoh51S0kt8qO7n\n" + + "ujEHJ2aBnwKJYJbBGV+hBvK/vbvG0TmotaWspmJJ+G6QigHx/Te+0Maw4PO+zTjo\n" + + "pdeP8b3JW70LkC+iKBp3swKBgFL/nm32m1tHEjFtehpVHFkSg05Z+jJDATiKlhh0\n" + + "YS2Vz+yuTDpE54CFW4M8wZKnXNbWJDBdd6KjIu42kKrA/zTJ5Ox92u1BJXFsk9fk\n" + + "xcX++qp5iBGepXZgHEiBMQLcdgY1m3jQl6XXOGSFog0+c4NIE/f1A8PrwI7gAdSt\n" + + "56SVAoGBAJp214Fo0oheMTTYKVtXuGiH/v3JNG1jKFgsmHqndf4wy7U6bbNctEzc\n" + + "ZXNIacuhWmko6YejMrWNhE57sX812MhXGZq6y0sYZGKtp7oDv8G3rWD6bpZywpcV\n" + + "kTtMJxm8J64u6bAkpWG3BocJP9qbXeAbILo1wuXgYqABBrpA9nnc\n" + + "-----END RSA PRIVATE KEY-----"; +} diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java index 9f6196b2333..e1ed3e3fa24 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/AlmSettingsWsModule.java @@ -30,6 +30,7 @@ public class AlmSettingsWsModule extends Module { DeleteAction.class, ListAction.class, ListDefinitionsAction.class, + ValidateAction.class, //Azure alm settings, CreateAzureAction.class, UpdateAzureAction.class, diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java index 84caed6da9f..e19249ecba8 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/DeleteAction.java @@ -25,8 +25,6 @@ import org.sonar.api.server.ws.WebService; import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.alm.setting.AlmSettingDto; -import org.sonar.server.almsettings.ws.AlmSettingsSupport; -import org.sonar.server.almsettings.ws.AlmSettingsWsAction; import org.sonar.server.user.UserSession; public class DeleteAction implements AlmSettingsWsAction { diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java index 4717d1851a1..ebfc8b5ef8c 100644 --- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ListAction.java @@ -22,6 +22,7 @@ package org.sonar.server.almsettings.ws; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; + import org.sonar.api.server.ws.Change; import org.sonar.api.server.ws.Request; import org.sonar.api.server.ws.Response; @@ -30,8 +31,6 @@ import org.sonar.db.DbClient; import org.sonar.db.DbSession; import org.sonar.db.alm.setting.AlmSettingDto; import org.sonar.db.project.ProjectDto; -import org.sonar.server.almsettings.ws.AlmSettingsSupport; -import org.sonar.server.almsettings.ws.AlmSettingsWsAction; import org.sonar.server.component.ComponentFinder; import org.sonar.server.user.UserSession; import org.sonarqube.ws.AlmSettings.AlmSetting; diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java new file mode 100644 index 00000000000..5966cda5773 --- /dev/null +++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/almsettings/ws/ValidateAction.java @@ -0,0 +1,145 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.server.almsettings.ws; + +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient; +import org.sonar.alm.client.github.GithubApplicationClient; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.config.GithubAppConfiguration; +import org.sonar.alm.client.gitlab.GitlabHttpClient; +import org.sonar.api.server.ws.Request; +import org.sonar.api.server.ws.Response; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbClient; +import org.sonar.db.DbSession; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.server.user.UserSession; + +import static org.apache.commons.lang.StringUtils.isBlank; + +public class ValidateAction implements AlmSettingsWsAction { + + private static final String PARAM_KEY = "key"; + + private final DbClient dbClient; + private final UserSession userSession; + private final AlmSettingsSupport almSettingsSupport; + private final AzureDevOpsHttpClient azureDevOpsHttpClient; + private final GitlabHttpClient gitlabHttpClient; + private final GithubApplicationClient githubApplicationClient; + private final BitbucketServerRestClient bitbucketServerRestClient; + + public ValidateAction(DbClient dbClient, UserSession userSession, AlmSettingsSupport almSettingsSupport, + AzureDevOpsHttpClient azureDevOpsHttpClient, + GithubApplicationClientImpl githubApplicationClient, GitlabHttpClient gitlabHttpClient, + BitbucketServerRestClient bitbucketServerRestClient) { + this.dbClient = dbClient; + this.userSession = userSession; + this.almSettingsSupport = almSettingsSupport; + this.azureDevOpsHttpClient = azureDevOpsHttpClient; + this.githubApplicationClient = githubApplicationClient; + this.gitlabHttpClient = gitlabHttpClient; + this.bitbucketServerRestClient = bitbucketServerRestClient; + } + + @Override + public void define(WebService.NewController context) { + WebService.NewAction action = context.createAction("validate") + .setDescription("Validate an ALM Setting by checking connectivity and permissions
" + + "Requires the 'Administer System' permission") + .setSince("8.6") + .setHandler(this); + + action.createParam(PARAM_KEY) + .setRequired(true) + .setMaximumLength(200) + .setDescription("Unique key of the ALM settings"); + } + + @Override + public void handle(Request request, Response response) { + userSession.checkIsSystemAdministrator(); + doHandle(request); + response.noContent(); + } + + private void doHandle(Request request) { + String key = request.mandatoryParam(PARAM_KEY); + + try (DbSession dbSession = dbClient.openSession(false)) { + AlmSettingDto almSettingDto = almSettingsSupport.getAlmSetting(dbSession, key); + switch (almSettingDto.getAlm()) { + case GITLAB: + validateGitlab(almSettingDto); + break; + case GITHUB: + validateGitHub(almSettingDto); + break; + case BITBUCKET: + validateBitbucketServer(almSettingDto); + break; + case AZURE_DEVOPS: + validateAzure(almSettingDto); + break; + } + } + } + + private void validateAzure(AlmSettingDto almSettingDto) { + try { + azureDevOpsHttpClient.checkPAT(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Azure URL or Personal Access Token", e); + } + } + + private void validateGitlab(AlmSettingDto almSettingDto) { + gitlabHttpClient.checkUrl(almSettingDto.getUrl()); + gitlabHttpClient.checkToken(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken()); + gitlabHttpClient.checkReadPermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken()); + gitlabHttpClient.checkWritePermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken()); + } + + private void validateGitHub(AlmSettingDto settings) { + long appId; + try { + appId = Long.parseLong(settings.getAppId()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid appId; " + e.getMessage()); + } + if (isBlank(settings.getClientId())) { + throw new IllegalArgumentException("Missing Client Id"); + } + if (isBlank(settings.getClientSecret())) { + throw new IllegalArgumentException("Missing Client Secret"); + } + GithubAppConfiguration configuration = new GithubAppConfiguration(appId, settings.getPrivateKey(), settings.getUrl()); + + githubApplicationClient.checkApiEndpoint(configuration); + githubApplicationClient.checkAppPermissions(configuration); + } + + private void validateBitbucketServer(AlmSettingDto almSettingDto) { + bitbucketServerRestClient.validateUrl(almSettingDto.getUrl()); + bitbucketServerRestClient.validateToken(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken()); + bitbucketServerRestClient.validateReadPermission(almSettingDto.getUrl(), almSettingDto.getPersonalAccessToken()); + } +} diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java index bab08fc152d..bf28d5d8722 100644 --- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/AlmSettingsWsModuleTest.java @@ -31,7 +31,7 @@ public class AlmSettingsWsModuleTest { public void verify_count_of_added_components() { ComponentContainer container = new ComponentContainer(); new AlmSettingsWsModule().configure(container); - assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 13); + assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 14); } } \ No newline at end of file diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java new file mode 100644 index 00000000000..8f01e672e53 --- /dev/null +++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/almsettings/ws/ValidateActionTest.java @@ -0,0 +1,208 @@ +/* + * SonarQube + * Copyright (C) 2009-2021 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.server.almsettings.ws; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.ArgumentCaptor; +import org.sonar.alm.client.azure.AzureDevOpsHttpClient; +import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient; +import org.sonar.alm.client.github.GithubApplicationClientImpl; +import org.sonar.alm.client.github.config.GithubAppConfiguration; +import org.sonar.alm.client.gitlab.GitlabHttpClient; +import org.sonar.api.server.ws.WebService; +import org.sonar.db.DbTester; +import org.sonar.db.alm.setting.AlmSettingDto; +import org.sonar.db.user.UserDto; +import org.sonar.server.almsettings.MultipleAlmFeatureProvider; +import org.sonar.server.component.ComponentFinder; +import org.sonar.server.exceptions.ForbiddenException; +import org.sonar.server.exceptions.NotFoundException; +import org.sonar.server.tester.UserSessionRule; +import org.sonar.server.ws.WsActionTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +public class ValidateActionTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + @Rule + public UserSessionRule userSession = UserSessionRule.standalone(); + @Rule + public DbTester db = DbTester.create(); + + private final MultipleAlmFeatureProvider multipleAlmFeatureProvider = mock(MultipleAlmFeatureProvider.class); + private final ComponentFinder componentFinder = new ComponentFinder(db.getDbClient(), null); + private final AlmSettingsSupport almSettingsSupport = new AlmSettingsSupport(db.getDbClient(), userSession, componentFinder, multipleAlmFeatureProvider); + private final AzureDevOpsHttpClient azureDevOpsHttpClient = mock(AzureDevOpsHttpClient.class); + private final GitlabHttpClient gitlabHttpClient = mock(GitlabHttpClient.class); + private final GithubApplicationClientImpl githubApplicationClient = mock(GithubApplicationClientImpl.class); + private final BitbucketServerRestClient bitbucketServerRestClient = mock(BitbucketServerRestClient.class); + private final WsActionTester ws = new WsActionTester( + new ValidateAction(db.getDbClient(), userSession, almSettingsSupport, azureDevOpsHttpClient, githubApplicationClient, gitlabHttpClient, + bitbucketServerRestClient)); + + @Test + public void fail_when_key_does_not_match_existing_alm_setting() { + UserDto user = db.users().insertUser(); + userSession.logIn(user).setSystemAdministrator(); + + expectedException.expect(NotFoundException.class); + expectedException.expectMessage("ALM setting with key 'unknown' cannot be found"); + + ws.newRequest() + .setParam("key", "unknown") + .execute(); + } + + @Test + public void fail_when_missing_administer_system_permission() { + UserDto user = db.users().insertUser(); + userSession.logIn(user); + + expectedException.expect(ForbiddenException.class); + + ws.newRequest() + .setParam("key", "any key") + .execute(); + } + + @Test + public void gitlab_validation_checks() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitlabAlmSetting()); + + ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute(); + + verify(gitlabHttpClient).checkUrl(almSetting.getUrl()); + verify(gitlabHttpClient).checkToken(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + verify(gitlabHttpClient).checkReadPermission(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + verify(gitlabHttpClient).checkWritePermission(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + } + + @Test + public void github_validation_checks() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setClientId("clientId") + .setClientSecret("clientSecret"))); + + ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute(); + + ArgumentCaptor configurationArgumentCaptor = ArgumentCaptor.forClass(GithubAppConfiguration.class); + verify(githubApplicationClient).checkApiEndpoint(configurationArgumentCaptor.capture()); + verify(githubApplicationClient).checkAppPermissions(configurationArgumentCaptor.capture()); + + assertThat(configurationArgumentCaptor.getAllValues()).hasSize(2) + .extracting(GithubAppConfiguration::getApiEndpoint) + .contains(almSetting.getUrl(), almSetting.getUrl()); + } + + @Test + public void github_validation_checks_invalid_appId() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setAppId("abc") + .setClientId("clientId").setClientSecret("clientSecret"))); + + assertThatThrownBy(() -> ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid appId; For input string: \"abc\""); + } + + @Test + public void github_validation_checks_missing_clientId() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setClientSecret("clientSecret"))); + + assertThatThrownBy(() -> ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Missing Client Id"); + } + + @Test + public void github_validation_checks_missing_clientSecret() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertGitHubAlmSetting(settings -> settings.setClientId("clientId"))); + + assertThatThrownBy(() -> ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Missing Client Secret"); + + } + + @Test + public void bitbucketServer_validation_checks() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertBitbucketAlmSetting()); + + ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute(); + + verify(bitbucketServerRestClient).validateUrl(almSetting.getUrl()); + verify(bitbucketServerRestClient).validateToken(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + verify(bitbucketServerRestClient).validateReadPermission(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + } + + @Test + public void azure_devops_validation_checks() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertAzureAlmSetting()); + + ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute(); + + verify(azureDevOpsHttpClient).checkPAT(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + } + + @Test + public void azure_devops_validation_check_fails() { + AlmSettingDto almSetting = insertAlmSetting(db.almSettings().insertAzureAlmSetting()); + + doThrow(IllegalArgumentException.class) + .when(azureDevOpsHttpClient).checkPAT(almSetting.getUrl(), almSetting.getPersonalAccessToken()); + + assertThatThrownBy(() -> ws.newRequest() + .setParam("key", almSetting.getKey()) + .execute()).isInstanceOf(IllegalArgumentException.class).hasMessage("Invalid Azure URL or Personal Access Token"); + } + + private AlmSettingDto insertAlmSetting(AlmSettingDto almSettingDto) { + UserDto user = db.users().insertUser(); + userSession.logIn(user).setSystemAdministrator(); + return almSettingDto; + } + + @Test + public void definition() { + WebService.Action def = ws.getDef(); + + assertThat(def.since()).isEqualTo("8.6"); + assertThat(def.isPost()).isFalse(); + assertThat(def.params()) + .extracting(WebService.Param::key, WebService.Param::isRequired) + .containsExactlyInAnyOrder(tuple("key", true)); + } + +} 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 039039fc8f0..adb64eaeea3 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 @@ -20,11 +20,13 @@ package org.sonar.server.platform.platformlevel; import java.util.List; + import org.sonar.alm.client.TimeoutConfigurationImpl; import org.sonar.alm.client.azure.AzureDevOpsHttpClient; import org.sonar.alm.client.bitbucketserver.BitbucketServerRestClient; import org.sonar.alm.client.github.GithubApplicationClientImpl; import org.sonar.alm.client.github.GithubApplicationHttpClientImpl; +import org.sonar.alm.client.github.security.GithubAppSecurityImpl; import org.sonar.alm.client.gitlab.GitlabHttpClient; import org.sonar.api.profiles.AnnotationProfileParser; import org.sonar.api.profiles.XMLProfileParser; @@ -496,6 +498,7 @@ public class PlatformLevel4 extends PlatformLevel { // ALM integrations TimeoutConfigurationImpl.class, ImportHelper.class, + GithubAppSecurityImpl.class, GithubApplicationClientImpl.class, GithubApplicationHttpClientImpl.class, BitbucketServerRestClient.class, diff --git a/sonar-application/build.gradle b/sonar-application/build.gradle index 4b433567978..abfeca30e91 100644 --- a/sonar-application/build.gradle +++ b/sonar-application/build.gradle @@ -165,7 +165,7 @@ zip.doFirst { // Check the size of the archive zip.doLast { def minLength = 236000000 - def maxLength = 251000000 + def maxLength = 256000000 def length = archiveFile.get().asFile.length() if (length < minLength) -- 2.39.5