Browse Source

SONAR-15428 support bitbucket.org auth

tags/9.2.0.49834
Pierre 2 years ago
parent
commit
352673af8f
26 changed files with 1568 additions and 0 deletions
  1. 25
    0
      server/sonar-auth-bitbucket/build.gradle
  2. 195
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java
  3. 41
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java
  4. 49
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java
  5. 128
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java
  6. 44
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java
  7. 56
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java
  8. 69
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java
  9. 49
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java
  10. 42
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java
  11. 49
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java
  12. 47
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java
  13. 23
    0
      server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java
  14. 4
    0
      server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties
  15. 10
    0
      server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg
  16. 93
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java
  17. 37
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java
  18. 99
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java
  19. 101
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java
  20. 37
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java
  21. 300
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java
  22. 56
    0
      server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java
  23. 10
    0
      server/sonar-web/public/images/alm/bitbucket-white.svg
  24. 1
    0
      server/sonar-webserver/build.gradle
  25. 2
    0
      server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java
  26. 1
    0
      settings.gradle

+ 25
- 0
server/sonar-auth-bitbucket/build.gradle View File

@@ -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'
}

+ 195
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketIdentityProvider.java View File

@@ -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;
}

}

+ 41
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketModule.java View File

@@ -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));
}

}

+ 49
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketScribeApi.java View File

@@ -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";
}
}

+ 128
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/BitbucketSettings.java View File

@@ -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()
);
}

}

+ 44
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmail.java View File

@@ -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;
}
}

+ 56
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonEmails.java View File

@@ -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;
}
}

+ 69
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonUser.java View File

@@ -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);
}
}

+ 49
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspace.java View File

@@ -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;
}

}

+ 42
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMembership.java View File

@@ -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;
}

}

+ 49
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/GsonWorkspaceMemberships.java View File

@@ -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);
}

}

+ 47
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/UserIdentityFactory.java View File

@@ -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;
}

}

+ 23
- 0
server/sonar-auth-bitbucket/src/main/java/org/sonar/auth/bitbucket/package-info.java View File

@@ -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;

+ 4
- 0
server/sonar-auth-bitbucket/src/main/resources/org/sonar/l10n/authbitbucket.properties View File

@@ -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>'&lt;value_of_sonar.core.serverBaseURL_property&gt;/oauth2/callback'</code>, and the permission should be set to Account:Read</li></ul>



+ 10
- 0
server/sonar-auth-bitbucket/src/main/resources/static/bitbucket.svg View File

@@ -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>

+ 93
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketIdentityProviderTest.java View File

@@ -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);
}
}

}

+ 37
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketModuleTest.java View File

@@ -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);
}

}

+ 99
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/BitbucketSettingsTest.java View File

@@ -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);
}

}

+ 101
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonEmailsTest.java View File

@@ -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();
}
}

+ 37
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/GsonUserTest.java View File

@@ -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");
}

}

+ 300
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/IntegrationTest.java View File

@@ -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;
}
}
}

+ 56
- 0
server/sonar-auth-bitbucket/src/test/java/org/sonar/auth/bitbucket/UserIdentityFactoryTest.java View File

@@ -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");
}

}

+ 10
- 0
server/sonar-web/public/images/alm/bitbucket-white.svg View File

@@ -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>

+ 1
- 0
server/sonar-webserver/build.gradle View File

@@ -12,6 +12,7 @@ dependencies {
compile 'com.google.guava:guava'
compile 'org.apache.tomcat.embed:tomcat-embed-core'
compile project(':sonar-core')
compile project(':server:sonar-auth-bitbucket')
compile project(':server:sonar-auth-github')
compile project(':server:sonar-auth-gitlab')
compile project(':server:sonar-auth-ldap')

+ 2
- 0
server/sonar-webserver/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel4.java View File

@@ -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,

+ 1
- 0
settings.gradle View File

@@ -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'

Loading…
Cancel
Save