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