diff options
author | Pierre <pierre.guillot@sonarsource.com> | 2021-09-23 17:27:25 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2021-09-28 20:03:12 +0000 |
commit | 352673af8f3b8035ba7f0e4e1073cc7bdf463a4a (patch) | |
tree | 8a02a43bab767da1b1838492bb52b207aa6d0e28 | |
parent | 4024058c7b8ef575b317f97c66fbc1bffbb89349 (diff) | |
download | sonarqube-352673af8f3b8035ba7f0e4e1073cc7bdf463a4a.tar.gz sonarqube-352673af8f3b8035ba7f0e4e1073cc7bdf463a4a.zip |
SONAR-15428 support bitbucket.org auth
26 files changed, 1568 insertions, 0 deletions
diff --git a/server/sonar-auth-bitbucket/build.gradle b/server/sonar-auth-bitbucket/build.gradle new file mode 100644 index 00000000000..2e5f80aa76e --- /dev/null +++ b/server/sonar-auth-bitbucket/build.gradle @@ -0,0 +1,25 @@ +description = 'SonarQube :: Authentication :: Bitbucket' + +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') + + 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-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java new file mode 100755 index 00000000000..c05d36c1979 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java @@ -0,0 +1,195 @@ +/* + * 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.auth.bitbucket; + +import com.github.scribejava.core.builder.ServiceBuilder; +import com.github.scribejava.core.builder.ServiceBuilderOAuth20; +import com.github.scribejava.core.model.OAuth2AccessToken; +import com.github.scribejava.core.model.OAuthConstants; +import com.github.scribejava.core.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.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import javax.annotation.CheckForNull; +import javax.servlet.http.HttpServletRequest; +import org.sonar.api.server.ServerSide; +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 org.sonar.api.utils.log.Logger; +import org.sonar.api.utils.log.Loggers; + +import static com.google.common.base.Preconditions.checkState; +import static java.lang.String.format; +import static java.util.Arrays.asList; +import static java.util.stream.Collectors.toSet; + +@ServerSide +public class BitbucketIdentityProvider implements OAuth2IdentityProvider { + + private static final Logger LOGGER = Loggers.get(BitbucketIdentityProvider.class); + + public static final String REQUIRED_SCOPE = "account"; + public static final String KEY = "bitbucket"; + + private final BitbucketSettings settings; + private final UserIdentityFactory userIdentityFactory; + private final BitbucketScribeApi scribeApi; + + public BitbucketIdentityProvider(BitbucketSettings settings, UserIdentityFactory userIdentityFactory, BitbucketScribeApi scribeApi) { + this.settings = settings; + this.userIdentityFactory = userIdentityFactory; + this.scribeApi = scribeApi; + } + + @Override + public String getKey() { + return KEY; + } + + @Override + public String getName() { + return "Bitbucket"; + } + + @Override + public Display getDisplay() { + return Display.builder() + .setIconPath("/images/alm/bitbucket-white.svg") + .setBackgroundColor("#0052cc") + .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).build(scribeApi); + String url = scribe.getAuthorizationUrl(state); + context.redirectTo(url); + } + + private ServiceBuilderOAuth20 newScribeBuilder(OAuth2Context context) { + checkState(isEnabled(), "Bitbucket authentication is disabled"); + return new ServiceBuilder(settings.clientId()) + .apiSecret(settings.clientSecret()) + .callback(context.getCallbackUrl()) + .defaultScope(REQUIRED_SCOPE); + } + + @Override + public void callback(CallbackContext context) { + try { + onCallback(context); + } catch (IOException | ExecutionException e) { + throw new IllegalStateException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private void onCallback(CallbackContext context) throws InterruptedException, ExecutionException, IOException { + HttpServletRequest request = context.getRequest(); + OAuth20Service scribe = newScribeBuilder(context).build(scribeApi); + String code = request.getParameter(OAuthConstants.CODE); + OAuth2AccessToken accessToken = scribe.getAccessToken(code); + + GsonUser gsonUser = requestUser(scribe, accessToken); + GsonEmails gsonEmails = requestEmails(scribe, accessToken); + + checkTeamRestriction(scribe, accessToken, gsonUser); + + UserIdentity userIdentity = userIdentityFactory.create(gsonUser, gsonEmails); + context.authenticate(userIdentity); + context.redirectToRequestedPage(); + } + + private GsonUser requestUser(OAuth20Service service, OAuth2AccessToken accessToken) throws InterruptedException, ExecutionException, IOException { + OAuthRequest userRequest = new OAuthRequest(Verb.GET, settings.apiURL() + "2.0/user"); + service.signRequest(accessToken, userRequest); + Response userResponse = service.execute(userRequest); + + if (!userResponse.isSuccessful()) { + throw new IllegalStateException(format("Can not get Bitbucket user profile. HTTP code: %s, response: %s", + userResponse.getCode(), userResponse.getBody())); + } + String userResponseBody = userResponse.getBody(); + return GsonUser.parse(userResponseBody); + } + + @CheckForNull + private GsonEmails requestEmails(OAuth20Service service, OAuth2AccessToken accessToken) throws InterruptedException, ExecutionException, IOException { + OAuthRequest userRequest = new OAuthRequest(Verb.GET, settings.apiURL() + "2.0/user/emails"); + service.signRequest(accessToken, userRequest); + Response emailsResponse = service.execute(userRequest); + if (emailsResponse.isSuccessful()) { + return GsonEmails.parse(emailsResponse.getBody()); + } + return null; + } + + private void checkTeamRestriction(OAuth20Service service, OAuth2AccessToken accessToken, GsonUser user) throws InterruptedException, ExecutionException, IOException { + String[] workspaceAllowed = settings.workspaceAllowedList(); + if (workspaceAllowed != null && workspaceAllowed.length > 0) { + GsonWorkspaceMemberships userWorkspaces = requestWorkspaces(service, accessToken); + String errorMessage = format("User %s is not part of allowed workspaces list", user.getUsername()); + if (userWorkspaces == null || userWorkspaces.getWorkspaces() == null) { + throw new UnauthorizedException(errorMessage); + } else { + Set<String> uniqueUserWorkspaces = new HashSet<>(); + uniqueUserWorkspaces.addAll(userWorkspaces.getWorkspaces().stream().map(w -> w.getWorkspace().getName()).collect(toSet())); + uniqueUserWorkspaces.addAll(userWorkspaces.getWorkspaces().stream().map(w -> w.getWorkspace().getSlug()).collect(toSet())); + List<String> workspaceAllowedList = asList(workspaceAllowed); + if (uniqueUserWorkspaces.stream().noneMatch(workspaceAllowedList::contains)) { + throw new UnauthorizedException(errorMessage); + } + } + } + } + + @CheckForNull + private GsonWorkspaceMemberships requestWorkspaces(OAuth20Service service, OAuth2AccessToken accessToken) throws InterruptedException, ExecutionException, IOException { + OAuthRequest userRequest = new OAuthRequest(Verb.GET, settings.apiURL() + "2.0/user/permissions/workspaces?q=permission=\"member\""); + service.signRequest(accessToken, userRequest); + Response teamsResponse = service.execute(userRequest); + if (teamsResponse.isSuccessful()) { + return GsonWorkspaceMemberships.parse(teamsResponse.getBody()); + } + LOGGER.warn("Fail to retrieve the teams of Bitbucket user: {}", teamsResponse.getBody()); + return null; + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java new file mode 100755 index 00000000000..00c8072bd3b --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java @@ -0,0 +1,41 @@ +/* + * 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.auth.bitbucket; + +import java.util.List; +import org.sonar.api.config.PropertyDefinition; +import org.sonar.core.platform.Module; + +import static org.sonar.auth.bitbucket.BitbucketSettings.definitions; + +public class BitbucketModule extends Module { + + @Override + protected void configureModule() { + add( + BitbucketIdentityProvider.class, + BitbucketSettings.class, + UserIdentityFactory.class, + BitbucketScribeApi.class); + List<PropertyDefinition> definitions = definitions(); + add(definitions.toArray(Object[]::new)); + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java new file mode 100755 index 00000000000..bda66be69c0 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.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.auth.bitbucket; + +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.github.scribejava.core.model.Verb; +import org.sonar.api.server.ServerSide; + +@ServerSide +public class BitbucketScribeApi extends DefaultApi20 { + + private final BitbucketSettings settings; + + public BitbucketScribeApi(BitbucketSettings settings) { + this.settings = settings; + } + + @Override + public String getAccessTokenEndpoint() { + return settings.webURL() + "site/oauth2/access_token"; + } + + @Override + public Verb getAccessTokenVerb() { + return Verb.POST; + } + + @Override + protected String getAuthorizationBaseUrl() { + return settings.webURL() + "site/oauth2/authorize"; + } +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java new file mode 100755 index 00000000000..687cf70973e --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java @@ -0,0 +1,128 @@ +/* + * 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.auth.bitbucket; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; +import javax.annotation.CheckForNull; +import org.sonar.api.CoreProperties; +import org.sonar.api.PropertyType; +import org.sonar.api.config.Configuration; +import org.sonar.api.config.PropertyDefinition; +import org.sonar.api.server.ServerSide; + +import static java.lang.String.format; + +@ServerSide +public class BitbucketSettings { + + private static final Supplier<? extends IllegalStateException> DEFAULT_VALUE_MISSING = () -> new IllegalStateException("Should have a default value"); + public static final String CONSUMER_KEY = "sonar.auth.bitbucket.clientId.secured"; + public static final String CONSUMER_SECRET = "sonar.auth.bitbucket.clientSecret.secured"; + public static final String ENABLED = "sonar.auth.bitbucket.enabled"; + public static final String ALLOW_USERS_TO_SIGN_UP = "sonar.auth.bitbucket.allowUsersToSignUp"; + public static final String WORKSPACE_ALLOWED_LIST = "sonar.auth.bitbucket.workspaces"; + public static final String DEFAULT_API_URL = "https://api.bitbucket.org/"; + public static final String DEFAULT_WEB_URL = "https://bitbucket.org/"; + public static final String SUBCATEGORY = "bitbucket"; + + private final Configuration config; + + public BitbucketSettings(Configuration config) { + this.config = config; + } + + @CheckForNull + public String clientId() { + return config.get(CONSUMER_KEY).orElse(null); + } + + @CheckForNull + public String clientSecret() { + return config.get(CONSUMER_SECRET).orElse(null); + } + + public boolean isEnabled() { + return config.getBoolean(ENABLED).orElseThrow(DEFAULT_VALUE_MISSING) && clientId() != null && clientSecret() != null; + } + + public boolean allowUsersToSignUp() { + return config.getBoolean(ALLOW_USERS_TO_SIGN_UP).orElseThrow(DEFAULT_VALUE_MISSING); + } + + public String[] workspaceAllowedList() { + return config.getStringArray(WORKSPACE_ALLOWED_LIST); + } + + public String webURL() { + return DEFAULT_WEB_URL; + } + + public String apiURL() { + return DEFAULT_API_URL; + } + + public static List<PropertyDefinition> definitions() { + return Arrays.asList( + PropertyDefinition.builder(ENABLED) + .name("Enabled") + .description("Enable Bitbucket users to login. Value is ignored if consumer key and secret are not defined.") + .category(CoreProperties.CATEGORY_ALM_INTEGRATION) + .subCategory(SUBCATEGORY) + .type(PropertyType.BOOLEAN) + .defaultValue(String.valueOf(false)) + .index(1) + .build(), + PropertyDefinition.builder(CONSUMER_KEY) + .name("OAuth consumer key") + .description("Consumer key provided by Bitbucket when registering the consumer.") + .category(CoreProperties.CATEGORY_ALM_INTEGRATION) + .subCategory(SUBCATEGORY) + .index(2) + .build(), + PropertyDefinition.builder(CONSUMER_SECRET) + .name("OAuth consumer secret") + .description("Consumer secret provided by Bitbucket when registering the consumer.") + .category(CoreProperties.CATEGORY_ALM_INTEGRATION) + .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.") + .category(CoreProperties.CATEGORY_ALM_INTEGRATION) + .subCategory(SUBCATEGORY) + .type(PropertyType.BOOLEAN) + .defaultValue(String.valueOf(true)) + .index(4) + .build(), + PropertyDefinition.builder(WORKSPACE_ALLOWED_LIST) + .name("Workspaces") + .description("Only members of at least one of these workspace will be able to authenticate. Keep empty to disable workspace restriction.") + .category(CoreProperties.CATEGORY_ALM_INTEGRATION) + .subCategory(SUBCATEGORY) + .multiValues(true) + .index(5) + .build() + ); + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java new file mode 100755 index 00000000000..ac75eaaa0d0 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.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.auth.bitbucket; + +import com.google.gson.annotations.SerializedName; + +public class GsonEmail { + @SerializedName("is_primary") + private boolean isPrimary; + + @SerializedName("email") + private String email; + + public GsonEmail() { + // even if empty constructor is not required for Gson, it is strongly + // recommended: + // http://stackoverflow.com/a/18645370/229031 + } + + public boolean isPrimary() { + return isPrimary; + } + + public String getEmail() { + return email; + } +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java new file mode 100755 index 00000000000..9fc29183b09 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java @@ -0,0 +1,56 @@ +/* + * 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.auth.bitbucket; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import java.util.List; +import javax.annotation.CheckForNull; + +public class GsonEmails { + + @SerializedName("values") + private List<GsonEmail> emails; + + public GsonEmails() { + // even if empty constructor is not required for Gson, it is strongly + // recommended: + // http://stackoverflow.com/a/18645370/229031 + } + + public List<GsonEmail> getEmails() { + return emails; + } + + public static GsonEmails parse(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, GsonEmails.class); + } + + @CheckForNull + public String extractPrimaryEmail() { + for (GsonEmail gsonEmail : emails) { + if (gsonEmail.isPrimary()) { + return gsonEmail.getEmail(); + } + } + return null; + } +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java new file mode 100755 index 00000000000..6399e2a474d --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java @@ -0,0 +1,69 @@ +/* + * 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.auth.bitbucket; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import javax.annotation.CheckForNull; +import javax.annotation.Nullable; + +/** + * Lite representation of JSON response of GET https://api.bitbucket.org/2.0/user + */ +public class GsonUser { + @SerializedName("username") + private String username; + + @SerializedName("display_name") + private String displayName; + + @SerializedName("uuid") + private String uuid; + + public GsonUser() { + // even if empty constructor is not required for Gson, it is strongly + // recommended: + // http://stackoverflow.com/a/18645370/229031 + } + + GsonUser(String username, @Nullable String displayName, String uuid) { + this.username = username; + this.displayName = displayName; + this.uuid = uuid; + } + + public String getUsername() { + return username; + } + + @CheckForNull + public String getDisplayName() { + return displayName; + } + + public String getUuid() { + return uuid; + } + + public static GsonUser parse(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, GsonUser.class); + } +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java new file mode 100644 index 00000000000..38d4ddb6b66 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.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.auth.bitbucket; + +import com.google.gson.annotations.SerializedName; + +/** + * Lite representation of team https://api.bitbucket.org/2.0/user/permissions/workspaces + */ +public class GsonWorkspace { + + @SerializedName("name") + private String name; + + @SerializedName("slug") + private String slug; + + public GsonWorkspace() { + // even if empty constructor is not required for Gson, it is strongly + // recommended: + // http://stackoverflow.com/a/18645370/229031 + } + + public String getName() { + return name; + } + + public String getSlug() { + return slug; + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java new file mode 100755 index 00000000000..75fe9adb00b --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java @@ -0,0 +1,42 @@ +/* + * 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.auth.bitbucket; + +import com.google.gson.annotations.SerializedName; + +/** + * Lite representation of team https://api.bitbucket.org/2.0/user/permissions/workspaces + */ +public class GsonWorkspaceMembership { + + @SerializedName("workspace") + private GsonWorkspace workspace; + + public GsonWorkspaceMembership() { + // even if empty constructor is not required for Gson, it is strongly + // recommended: + // http://stackoverflow.com/a/18645370/229031 + } + + public GsonWorkspace getWorkspace() { + return workspace; + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java new file mode 100755 index 00000000000..f327e75b657 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.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.auth.bitbucket; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; +import java.util.List; + +/** + * Lite representation of JSON response of GET https://api.bitbucket.org/2.0/user/permissions/workspaces + */ +public class GsonWorkspaceMemberships { + + @SerializedName("values") + private List<GsonWorkspaceMembership> values; + + public GsonWorkspaceMemberships() { + // even if empty constructor is not required for Gson, it is strongly + // recommended: + // http://stackoverflow.com/a/18645370/229031 + } + + public List<GsonWorkspaceMembership> getWorkspaces() { + return values; + } + + public static GsonWorkspaceMemberships parse(String json) { + Gson gson = new Gson(); + return gson.fromJson(json, GsonWorkspaceMemberships.class); + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java new file mode 100755 index 00000000000..96e1e34dd5e --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java @@ -0,0 +1,47 @@ +/* + * 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.auth.bitbucket; + +import javax.annotation.Nullable; +import org.sonar.api.server.ServerSide; +import org.sonar.api.server.authentication.UserIdentity; + +import static java.lang.String.format; + +@ServerSide +public class UserIdentityFactory { + + public UserIdentity create(GsonUser gsonUser, @Nullable GsonEmails gsonEmails) { + UserIdentity.Builder builder = UserIdentity.builder() + .setProviderId(gsonUser.getUuid()) + .setProviderLogin(gsonUser.getUsername()) + .setName(generateName(gsonUser)); + if (gsonEmails != null) { + builder.setEmail(gsonEmails.extractPrimaryEmail()); + } + return builder.build(); + } + + private static String generateName(GsonUser gson) { + String name = gson.getDisplayName(); + return name == null || name.isEmpty() ? gson.getUsername() : name; + } + +} diff --git a/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java new file mode 100755 index 00000000000..899620d0e60 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/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.auth.bitbucket; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties b/server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties new file mode 100755 index 00000000000..6e8776c7b23 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties @@ -0,0 +1,4 @@ +property.category.security.bitbucket=Bitbucket +property.category.security.bitbucket.description=In order to enable Bitbucket authentication:<ul><li>SonarQube must be publicly accessible through HTTPS only</li><li>The property 'sonar.core.serverBaseURL' must be set to this public HTTPS URL</li><li>In your Bitbucket profile, you need to create a OAuth consumer for which the 'Callback URL' must be set to <code>'<value_of_sonar.core.serverBaseURL_property>/oauth2/callback'</code>, and the permission should be set to Account:Read</li></ul> + + diff --git a/server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg b/server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg new file mode 100755 index 00000000000..c9aeed92f74 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg @@ -0,0 +1,10 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" focusable="false" role="presentation"> + <defs> + <linearGradient id="a-acb7415e-40c7-472a-ade9-3b99b6a8fba4" x1="97.526%" x2="46.927%" y1="25.488%" y2="78.776%"> + <stop offset="0%" stop-color="#FFF" stop-opacity=".4"/> + <stop offset="100%" stop-color="#FFF"/> + </linearGradient> + </defs> + <path fill="url(#a-acb7415e-40c7-472a-ade9-3b99b6a8fba4)" d="M20.063 9.297h-5.279l-.886 5.16h-3.656l-4.317 5.116a.763.763 0 0 0 .492.186h11.458a.562.562 0 0 0 .563-.472l1.625-9.99z" transform="matrix(1.33 0 0 1.33 -4 -3.8)"/> + <path fill="#FFF" d="M1.11252 1.52a.74879.74879 0 0 0-.74879.86583L3.5411 21.6296a1.01479 1.01479 0 0 0 .99484.84721l5.89589-7.049h-.82726l-1.29808-6.8628h14.37863l1.0108-6.1712a.7448.7448 0 0 0-.73815-.87381H1.11252z"/> +</svg>
\ No newline at end of file diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java new file mode 100755 index 00000000000..d135fb14285 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java @@ -0,0 +1,93 @@ +/* + * 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.auth.bitbucket; + +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; + +public class BitbucketIdentityProviderTest { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private final MapSettings settings = new MapSettings(); + private final BitbucketSettings bitbucketSettings = new BitbucketSettings(settings.asConfig()); + private final UserIdentityFactory userIdentityFactory = mock(UserIdentityFactory.class); + private final BitbucketScribeApi scribeApi = new BitbucketScribeApi(bitbucketSettings); + private final BitbucketIdentityProvider underTest = new BitbucketIdentityProvider(bitbucketSettings, userIdentityFactory, scribeApi); + + @Test + public void check_fields() { + assertThat(underTest.getKey()).isEqualTo("bitbucket"); + assertThat(underTest.getName()).isEqualTo("Bitbucket"); + assertThat(underTest.getDisplay().getIconPath()).isEqualTo("/images/alm/bitbucket-white.svg"); + assertThat(underTest.getDisplay().getBackgroundColor()).isEqualTo("#0052cc"); + } + + @Test + public void is_enabled() { + enableBitbucketAuthentication(true); + assertThat(underTest.isEnabled()).isTrue(); + + settings.setProperty("sonar.auth.bitbucket.enabled", false); + assertThat(underTest.isEnabled()).isFalse(); + } + + @Test + public void init() { + enableBitbucketAuthentication(true); + 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://bitbucket.org/site/oauth2/authorize?response_type=code&client_id=id&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&scope=account&state=state"); + } + + @Test + public void fail_to_init_when_disabled() { + enableBitbucketAuthentication(false); + OAuth2IdentityProvider.InitContext context = mock(OAuth2IdentityProvider.InitContext.class); + + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Bitbucket authentication is disabled"); + underTest.init(context); + } + + private void enableBitbucketAuthentication(boolean enabled) { + if (enabled) { + settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id"); + settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret"); + settings.setProperty("sonar.auth.bitbucket.enabled", true); + } else { + settings.setProperty("sonar.auth.bitbucket.enabled", false); + } + } + +} diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java new file mode 100755 index 00000000000..274bbf72ccc --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java @@ -0,0 +1,37 @@ +/* + * 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.auth.bitbucket; + +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 BitbucketModuleTest { + + @Test + public void verify_count_of_added_components() { + ComponentContainer container = new ComponentContainer(); + new BitbucketModule().configure(container); + assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 9); + } + +} diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java new file mode 100755 index 00000000000..f3724789d53 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java @@ -0,0 +1,99 @@ +/* + * 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.auth.bitbucket; + +import org.junit.Test; +import org.sonar.api.config.internal.MapSettings; + +import static org.assertj.core.api.Assertions.assertThat; + +public class BitbucketSettingsTest { + + private MapSettings settings = new MapSettings(); + + private BitbucketSettings underTest = new BitbucketSettings(settings.asConfig()); + + @Test + public void is_enabled() { + settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id"); + settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret"); + + settings.setProperty("sonar.auth.bitbucket.enabled", true); + assertThat(underTest.isEnabled()).isTrue(); + + settings.setProperty("sonar.auth.bitbucket.enabled", false); + assertThat(underTest.isEnabled()).isFalse(); + } + + @Test + public void is_enabled_always_return_false_when_client_id_is_null() { + settings.setProperty("sonar.auth.bitbucket.enabled", true); + settings.setProperty("sonar.auth.bitbucket.clientId.secured", (String) null); + settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret"); + + assertThat(underTest.isEnabled()).isFalse(); + } + + @Test + public void is_enabled_always_return_false_when_client_secret_is_null() { + settings.setProperty("sonar.auth.bitbucket.enabled", true); + settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id"); + settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", (String) null); + + assertThat(underTest.isEnabled()).isFalse(); + } + + @Test + public void return_client_id() { + settings.setProperty("sonar.auth.bitbucket.clientId.secured", "id"); + assertThat(underTest.clientId()).isEqualTo("id"); + } + + @Test + public void return_client_secret() { + settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "secret"); + assertThat(underTest.clientSecret()).isEqualTo("secret"); + } + + @Test + public void allow_users_to_sign_up() { + settings.setProperty("sonar.auth.bitbucket.allowUsersToSignUp", "true"); + assertThat(underTest.allowUsersToSignUp()).isTrue(); + + settings.setProperty("sonar.auth.bitbucket.allowUsersToSignUp", "false"); + assertThat(underTest.allowUsersToSignUp()).isFalse(); + } + + @Test + public void default_apiUrl() { + assertThat(underTest.apiURL()).isEqualTo("https://api.bitbucket.org/"); + } + + @Test + public void default_webUrl() { + assertThat(underTest.webURL()).isEqualTo("https://bitbucket.org/"); + } + + @Test + public void definitions() { + assertThat(BitbucketSettings.definitions()).hasSize(5); + } + +} diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java new file mode 100755 index 00000000000..27870a29382 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java @@ -0,0 +1,101 @@ +/* + * 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.auth.bitbucket; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GsonEmailsTest { + + @Test + public void testParse() { + String json = "{" + + "\"pagelen\": 10," + + "\"values\": [" + + "{" + + "\"is_primary\": true," + + "\"is_confirmed\": true," + + "\"type\": \"email\"," + + "\"email\": \"foo@bar.com\"," + + "\"links\": {" + + "\"self\": {" + + "\"href\": \"https://api.bitbucket.org/2.0/user/emails/foo@bar.com\"" + + "}" + + "}" + + "}" + + "]," + + "\"page\": 1," + + "\"size\": 1" + + "}"; + GsonEmails emails = GsonEmails.parse(json); + assertThat(emails.getEmails()).hasSize(1); + assertThat(emails.getEmails().get(0).isPrimary()).isTrue(); + assertThat(emails.getEmails().get(0).getEmail()).isEqualTo("foo@bar.com"); + } + + @Test + public void test_extractPrimaryEmail() { + String json = "{" + + "\"pagelen\": 10," + + "\"values\": [" + + "{" + + "\"is_primary\": false," + + "\"is_confirmed\": true," + + "\"type\": \"email\"," + + "\"email\": \"secondary@bar.com\"," + + "\"links\": {" + + "\"self\": {" + + "\"href\": \"https://api.bitbucket.org/2.0/user/emails/secondary@bar.com\"" + + "}" + + "}" + + "}," + + "{" + + "\"is_primary\": true," + + "\"is_confirmed\": true," + + "\"type\": \"email\"," + + "\"email\": \"primary@bar.com\"," + + "\"links\": {" + + "\"self\": {" + + "\"href\": \"https://api.bitbucket.org/2.0/user/emails/primary@bar.com\"" + + "}" + + "}" + + "}" + + "]," + + "\"page\": 1," + + "\"size\": 2" + + "}"; + String email = GsonEmails.parse(json).extractPrimaryEmail(); + assertThat(email).isEqualTo("primary@bar.com"); + } + + @Test + public void test_extractPrimaryEmail_not_found() { + String json = "{" + + "\"pagelen\": 10," + + "\"values\": [" + + "]," + + "\"page\": 1," + + "\"size\": 0" + + "}"; + String email = GsonEmails.parse(json).extractPrimaryEmail(); + assertThat(email).isNull(); + } +} diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java new file mode 100755 index 00000000000..b47658e4945 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java @@ -0,0 +1,37 @@ +/* + * 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.auth.bitbucket; + +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class GsonUserTest { + + @Test + public void parse_from_json() { + GsonUser underTest = GsonUser.parse("{\"username\":\"john\", \"display_name\":\"John\", \"uuid\":\"ABCD\"}"); + + assertThat(underTest.getUsername()).isEqualTo("john"); + assertThat(underTest.getDisplayName()).isEqualTo("John"); + assertThat(underTest.getUuid()).isEqualTo("ABCD"); + } + +} diff --git a/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java new file mode 100644 index 00000000000..a230f2160a9 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java @@ -0,0 +1,300 @@ +/* + * 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.auth.bitbucket; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +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 org.sonar.api.utils.System2; + +import static java.lang.String.format; +import static java.net.URLEncoder.encode; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +public class IntegrationTest { + + private static final String CALLBACK_URL = "http://localhost/oauth/callback/bitbucket"; + + @Rule + public MockWebServer bitbucket = new MockWebServer(); + + // load settings with default values + private final MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, BitbucketSettings.definitions())); + + private final BitbucketSettings bitbucketSettings = spy(new BitbucketSettings(settings.asConfig())); + private final UserIdentityFactory userIdentityFactory = new UserIdentityFactory(); + private final BitbucketScribeApi scribeApi = new BitbucketScribeApi(bitbucketSettings); + private final BitbucketIdentityProvider underTest = new BitbucketIdentityProvider(bitbucketSettings, userIdentityFactory, scribeApi); + + @Before + public void setUp() { + settings.setProperty("sonar.auth.bitbucket.clientId.secured", "the_id"); + settings.setProperty("sonar.auth.bitbucket.clientSecret.secured", "the_secret"); + settings.setProperty("sonar.auth.bitbucket.enabled", true); + when(bitbucketSettings.webURL()).thenReturn(format("http://%s:%d/", bitbucket.getHostName(), bitbucket.getPort())); + when(bitbucketSettings.apiURL()).thenReturn(format("http://%s:%d/", bitbucket.getHostName(), bitbucket.getPort())); + } + + /** + * First phase: SonarQube redirects browser to Bitbucket authentication form, requesting the + * minimal access rights ("scope") to get user profile. + */ + @Test + public void redirect_browser_to_bitbucket_authentication_form() throws Exception { + DumbInitContext context = new DumbInitContext("the-csrf-state"); + underTest.init(context); + assertThat(context.redirectedTo) + .startsWith(bitbucket.url("site/oauth2/authorize").toString()) + .contains("scope=" + encode("account", StandardCharsets.UTF_8.name())); + } + + /** + * Second phase: Bitbucket redirects browser to SonarQube at /oauth/callback/bitbucket?code={the verifier code}. + * This SonarQube web service sends three requests to Bitbucket: + * <ul> + * <li>get an access token</li> + * <li>get the profile (login, name) of the authenticated user</li> + * <li>get the emails of the authenticated user</li> + * </ul> + */ + @Test + public void authenticate_successfully() throws Exception { + bitbucket.enqueue(newSuccessfulAccessTokenResponse()); + bitbucket.enqueue(newUserResponse("john", "John", "john-uuid")); + bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org")); + + HttpServletRequest request = newRequest("the-verifier-code"); + DumbCallbackContext callbackContext = new DumbCallbackContext(request); + underTest.callback(callbackContext); + + assertThat(callbackContext.csrfStateVerified.get()).isTrue(); + assertThat(callbackContext.userIdentity.getName()).isEqualTo("John"); + assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("john@bitbucket.org"); + assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); + + // Verify the requests sent to Bitbucket + RecordedRequest accessTokenRequest = bitbucket.takeRequest(); + assertThat(accessTokenRequest.getPath()).startsWith("/site/oauth2/access_token"); + RecordedRequest userRequest = bitbucket.takeRequest(); + assertThat(userRequest.getPath()).startsWith("/2.0/user"); + RecordedRequest emailRequest = bitbucket.takeRequest(); + assertThat(emailRequest.getPath()).startsWith("/2.0/user/emails"); + // do not request user workspaces, workspace restriction is disabled by default + assertThat(bitbucket.getRequestCount()).isEqualTo(3); + } + + @Test + public void callback_throws_ISE_if_error_when_requesting_user_profile() { + bitbucket.enqueue(newSuccessfulAccessTokenResponse()); + // https://api.bitbucket.org/2.0/user fails + bitbucket.enqueue(new MockResponse().setResponseCode(500).setBody("{error}")); + + DumbCallbackContext callbackContext = new DumbCallbackContext(newRequest("the-verifier-code")); + + assertThatThrownBy(() -> underTest.callback(callbackContext)) + .hasMessage("Can not get Bitbucket user profile. HTTP code: 500, response: {error}") + .isInstanceOf(IllegalStateException.class); + + assertThat(callbackContext.csrfStateVerified.get()).isTrue(); + assertThat(callbackContext.userIdentity).isNull(); + assertThat(callbackContext.redirectedToRequestedPage.get()).isFalse(); + } + + @Test + public void allow_authentication_if_user_is_member_of_one_restricted_workspace() { + settings.setProperty("sonar.auth.bitbucket.workspaces", new String[] {"workspace1", "workspace2"}); + + bitbucket.enqueue(newSuccessfulAccessTokenResponse()); + bitbucket.enqueue(newUserResponse("john", "John", "john-uuid")); + bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org")); + bitbucket.enqueue(newWorkspacesResponse("workspace3", "workspace2")); + + HttpServletRequest request = newRequest("the-verifier-code"); + DumbCallbackContext callbackContext = new DumbCallbackContext(request); + underTest.callback(callbackContext); + + assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("john@bitbucket.org"); + assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("john"); + assertThat(callbackContext.userIdentity.getProviderId()).isEqualTo("john-uuid"); + assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); + } + + @Test + public void forbid_authentication_if_user_is_not_member_of_one_restricted_workspace() { + settings.setProperty("sonar.auth.bitbucket.workspaces", new String[] {"workspace1", "workspace2"}); + + bitbucket.enqueue(newSuccessfulAccessTokenResponse()); + bitbucket.enqueue(newUserResponse("john", "John", "john-uuid")); + bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org")); + bitbucket.enqueue(newWorkspacesResponse("workspace3")); + DumbCallbackContext context = new DumbCallbackContext(newRequest("the-verifier-code")); + + assertThatThrownBy(() -> underTest.callback(context)) + .isInstanceOf(UnauthorizedException.class); + } + + @Test + public void forbid_authentication_if_user_is_not_member_of_any_workspace() { + settings.setProperty("sonar.auth.bitbucket.workspaces", new String[] {"workspace1", "workspace2"}); + + bitbucket.enqueue(newSuccessfulAccessTokenResponse()); + bitbucket.enqueue(newUserResponse("john", "John", "john-uuid")); + bitbucket.enqueue(newPrimaryEmailResponse("john@bitbucket.org")); + bitbucket.enqueue(newWorkspacesResponse(/* no workspaces */)); + DumbCallbackContext context = new DumbCallbackContext(newRequest("the-verifier-code")); + + assertThatThrownBy(() -> underTest.callback(context)) + .isInstanceOf(UnauthorizedException.class); + } + + /** + * Response sent by Bitbucket to SonarQube when generating an access token + */ + private static MockResponse newSuccessfulAccessTokenResponse() { + return new MockResponse().setBody("{\"access_token\":\"e72e16c7e42f292c6912e7710c838347ae178b4a\",\"scope\":\"user\"}"); + } + + /** + * Response of https://api.bitbucket.org/2.0/user + */ + private static MockResponse newUserResponse(String login, String name, String uuid) { + return new MockResponse().setBody("{\"username\":\"" + login + "\", \"display_name\":\"" + name + "\", \"uuid\":\"" + uuid + "\"}"); + } + + /** + * Response of https://api.bitbucket.org/2.0/user/permissions/workspaces?q=permission="member" + */ + private static MockResponse newWorkspacesResponse(String... workspaces) { + String s = Arrays.stream(workspaces) + .map(w -> "{\"workspace\":{\"name\":\"" + w + "\",\"slug\":\"" + w + "\"}}") + .collect(Collectors.joining(",")); + return new MockResponse().setBody("{\"values\":[" + s + "]}"); + } + + /** + * Response of https://api.bitbucket.org/2.0/user/emails + */ + private static MockResponse newPrimaryEmailResponse(String email) { + return new MockResponse().setBody("{\"values\":[{\"active\": true,\"email\":\"" + email + "\",\"is_primary\": true}]}"); + } + + 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(true); + 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 s) { + } + + @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-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java new file mode 100755 index 00000000000..abf75a03e33 --- /dev/null +++ b/server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java @@ -0,0 +1,56 @@ +/* + * 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.auth.bitbucket; + +import org.junit.Test; +import org.sonar.api.server.authentication.UserIdentity; + +import static org.assertj.core.api.Assertions.assertThat; + +public class UserIdentityFactoryTest { + + private UserIdentityFactory underTest = new UserIdentityFactory(); + + @Test + public void create_login() { + GsonUser gson = new GsonUser("john", "John", "ABCD"); + UserIdentity identity = underTest.create(gson, null); + assertThat(identity.getName()).isEqualTo("John"); + assertThat(identity.getEmail()).isNull(); + assertThat(identity.getProviderId()).isEqualTo("ABCD"); + } + + @Test + public void empty_name_is_replaced_by_provider_login() { + GsonUser gson = new GsonUser("john", "", "ABCD"); + + UserIdentity identity = underTest.create(gson, null); + assertThat(identity.getName()).isEqualTo("john"); + } + + @Test + public void null_name_is_replaced_by_provider_login() { + GsonUser gson = new GsonUser("john", null, "ABCD"); + + UserIdentity identity = underTest.create(gson, null); + assertThat(identity.getName()).isEqualTo("john"); + } + +} diff --git a/server/sonar-web/public/images/alm/bitbucket-white.svg b/server/sonar-web/public/images/alm/bitbucket-white.svg new file mode 100644 index 00000000000..3255ad0a796 --- /dev/null +++ b/server/sonar-web/public/images/alm/bitbucket-white.svg @@ -0,0 +1,10 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" focusable="false" role="presentation"> + <defs> + <linearGradient id="a" x1="97.526%" x2="46.927%" y1="25.488%" y2="78.776%"> + <stop offset="0%" stop-color="#FFF" stop-opacity=".4"/> + <stop offset="100%" stop-color="#FFF"/> + </linearGradient> + </defs> + <path fill="url(#a)" d="M20.063 9.297h-5.279l-.886 5.16h-3.656l-4.317 5.116a.763.763 0 0 0 .492.186h11.458a.562.562 0 0 0 .563-.472l1.625-9.99z" transform="matrix(1.33 0 0 1.33 -4 -3.8)"/> + <path fill="#FFF" d="M1.11252 1.52a.74879.74879 0 0 0-.74879.86583L3.5411 21.6296a1.01479 1.01479 0 0 0 .99484.84721l5.89589-7.049h-.82726l-1.29808-6.8628h14.37863l1.0108-6.1712a.7448.7448 0 0 0-.73815-.87381H1.11252z"/> +</svg>
\ No newline at end of file diff --git a/server/sonar-webserver/build.gradle b/server/sonar-webserver/build.gradle index 2a38762339a..89cf5af8373 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-bitbucket') compile project(':server:sonar-auth-github') compile project(':server:sonar-auth-gitlab') compile project(':server:sonar-auth-ldap') 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 318df07af2d..7bb17f7c582 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 @@ -34,6 +34,7 @@ import org.sonar.api.resources.Languages; import org.sonar.api.resources.ResourceTypes; import org.sonar.api.rules.AnnotationRuleParser; import org.sonar.api.server.rule.RulesDefinitionXmlLoader; +import org.sonar.auth.bitbucket.BitbucketModule; import org.sonar.auth.github.GitHubModule; import org.sonar.auth.gitlab.GitLabModule; import org.sonar.auth.ldap.LdapModule; @@ -344,6 +345,7 @@ public class PlatformLevel4 extends PlatformLevel { // authentication AuthenticationModule.class, AuthenticationWsModule.class, + BitbucketModule.class, GitHubModule.class, GitLabModule.class, LdapModule.class, diff --git a/settings.gradle b/settings.gradle index e1812401f48..9852b8d959a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,7 @@ rootProject.name = 'sonarqube' include 'plugins:sonar-xoo-plugin' include 'server:sonar-auth-common' +include 'server:sonar-auth-bitbucket' include 'server:sonar-auth-github' include 'server:sonar-auth-gitlab' include 'server:sonar-auth-ldap' |