path: "**/test-results/**/*.xml" | path: "**/test-results/**/*.xml" | ||||
format: junit | format: junit | ||||
# SAML QA is executed in a dedicated task in order to not slow down the pipeline, as a Keycloak server docker image is required. | |||||
qa_saml_task: | |||||
depends_on: build | |||||
# Comment the following line and commit with message "DO NOT MERGE" in order to run | |||||
# this task on your branch | |||||
only_if: $CIRRUS_BRANCH == "branch-nightly-build" | |||||
gke_container: | |||||
dockerfile: private/docker/Dockerfile-build | |||||
builder_image_project: ci-cd-215716 | |||||
builder_image_name: docker-builder-v1 | |||||
cluster_name: cirrus-uscentral1a-cluster | |||||
zone: us-central1-a | |||||
namespace: default | |||||
cpu: 2.4 | |||||
memory: 10Gb | |||||
additional_containers: | |||||
- name: keycloak | |||||
image: jboss/keycloak:7.0.0 | |||||
port: 8080 | |||||
cpu: 1 | |||||
memory: 1Gb | |||||
env: | |||||
KEYCLOAK_USER: admin | |||||
KEYCLOAK_PASSWORD: admin | |||||
env: | |||||
# No need to clone the full history. | |||||
# Depth of 1 is not enough because it would fail the build in case of consecutive pushes | |||||
# (example of error: "Hard resetting to c968ecaf7a1942dacecd78480b3751ac74d53c33...Failed to force reset to c968ecaf7a1942dacecd78480b3751ac74d53c33: object not found!") | |||||
CIRRUS_CLONE_DEPTH: 50 | |||||
QA_CATEGORY: SAML | |||||
gradle_cache: | |||||
folder: ~/.gradle/caches | |||||
script: | |||||
- ./private/cirrus/cirrus-qa.sh h2 | |||||
cleanup_before_cache_script: | |||||
- ./private/cirrus/cleanup-gradle-cache.sh | |||||
on_failure: | |||||
reports_artifacts: | |||||
path: "**/build/reports/**/*" | |||||
screenshots_artifacts: | |||||
path: "**/build/screenshots/**/*" | |||||
junit_artifacts: | |||||
path: "**/test-results/**/*.xml" | |||||
format: junit | |||||
promote_task: | promote_task: | ||||
depends_on: | depends_on: | ||||
- build | - build | ||||
- validate | - validate | ||||
- qa | - qa | ||||
- qa_saml | |||||
only_if: $CIRRUS_BRANCH !=~ "dogfood/.*" && $CIRRUS_BRANCH != "public_master" && $CIRRUS_BRANCH != "branch-nightly-build" | only_if: $CIRRUS_BRANCH !=~ "dogfood/.*" && $CIRRUS_BRANCH != "public_master" && $CIRRUS_BRANCH != "branch-nightly-build" | ||||
gke_container: | gke_container: | ||||
dockerfile: private/docker/Dockerfile-build | dockerfile: private/docker/Dockerfile-build |
testCompile 'commons-lang:commons-lang' | testCompile 'commons-lang:commons-lang' | ||||
testCompile 'com.squareup.okhttp3:mockwebserver' | testCompile 'com.squareup.okhttp3:mockwebserver' | ||||
testCompile 'com.squareup.okhttp3:okhttp' | |||||
testCompile 'junit:junit' | testCompile 'junit:junit' | ||||
testCompile 'org.assertj:assertj-core' | testCompile 'org.assertj:assertj-core' | ||||
testCompile 'org.mockito:mockito-core' | testCompile 'org.mockito:mockito-core' |
compileOnly 'com.squareup.okhttp3:okhttp' | compileOnly 'com.squareup.okhttp3:okhttp' | ||||
compileOnly 'javax.servlet:javax.servlet-api' | compileOnly 'javax.servlet:javax.servlet-api' | ||||
compileOnly project(':sonar-core') | compileOnly project(':sonar-core') | ||||
compileOnly project(':sonar-ws') | |||||
testCompile 'com.squareup.okhttp3:mockwebserver' | testCompile 'com.squareup.okhttp3:mockwebserver' | ||||
testCompile 'com.squareup.okhttp3:okhttp' | testCompile 'com.squareup.okhttp3:okhttp' |
compileOnly 'com.squareup.okhttp3:okhttp' | compileOnly 'com.squareup.okhttp3:okhttp' | ||||
compileOnly 'javax.servlet:javax.servlet-api' | compileOnly 'javax.servlet:javax.servlet-api' | ||||
compileOnly project(':sonar-core') | compileOnly project(':sonar-core') | ||||
compileOnly project(':sonar-ws') | |||||
testCompile 'com.squareup.okhttp3:mockwebserver' | testCompile 'com.squareup.okhttp3:mockwebserver' | ||||
testCompile 'com.squareup.okhttp3:okhttp' | testCompile 'com.squareup.okhttp3:okhttp' |
description = 'SonarQube :: Authentication :: SAML' | |||||
configurations { | |||||
testCompile.extendsFrom compileOnly | |||||
} | |||||
ext { | |||||
oneLoginVersion = '2.5.0' | |||||
} | |||||
dependencies { | |||||
// please keep the list ordered | |||||
compile "com.onelogin:java-saml:${oneLoginVersion}" | |||||
compile "com.onelogin:java-saml-core:${oneLoginVersion}" | |||||
compileOnly 'com.google.code.findbugs:jsr305' | |||||
compileOnly 'com.squareup.okhttp3:okhttp' | |||||
compileOnly 'javax.servlet:javax.servlet-api' | |||||
compileOnly project(':sonar-core') | |||||
testCompile 'com.tngtech.java:junit-dataprovider' | |||||
testCompile 'junit:junit' | |||||
testCompile 'org.assertj:assertj-core' | |||||
testCompile 'org.mockito:mockito-core' | |||||
} |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
package org.sonar.auth.saml; | |||||
import com.onelogin.saml2.Auth; | |||||
import com.onelogin.saml2.exception.SettingsException; | |||||
import com.onelogin.saml2.settings.Saml2Settings; | |||||
import com.onelogin.saml2.settings.SettingsBuilder; | |||||
import java.io.IOException; | |||||
import java.util.Collection; | |||||
import java.util.HashMap; | |||||
import java.util.HashSet; | |||||
import java.util.List; | |||||
import java.util.Map; | |||||
import java.util.Set; | |||||
import javax.annotation.CheckForNull; | |||||
import javax.annotation.Nullable; | |||||
import javax.servlet.http.HttpServletRequest; | |||||
import javax.servlet.http.HttpServletResponse; | |||||
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 java.util.Collections.emptySet; | |||||
import static java.util.Objects.requireNonNull; | |||||
@ServerSide | |||||
public class SamlIdentityProvider implements OAuth2IdentityProvider { | |||||
private static final String KEY = "saml"; | |||||
private static final Logger LOGGER = Loggers.get(SamlIdentityProvider.class); | |||||
private static final String ANY_URL = "http://anyurl"; | |||||
private static final String STATE_REQUEST_PARAMETER = "RelayState"; | |||||
private final SamlSettings samlSettings; | |||||
public SamlIdentityProvider(SamlSettings samlSettings) { | |||||
this.samlSettings = samlSettings; | |||||
} | |||||
@Override | |||||
public String getKey() { | |||||
return KEY; | |||||
} | |||||
@Override | |||||
public String getName() { | |||||
return samlSettings.getProviderName(); | |||||
} | |||||
@Override | |||||
public Display getDisplay() { | |||||
return Display.builder() | |||||
.setIconPath("/images/saml.png") | |||||
.setBackgroundColor("#444444") | |||||
.build(); | |||||
} | |||||
@Override | |||||
public boolean isEnabled() { | |||||
return samlSettings.isEnabled(); | |||||
} | |||||
@Override | |||||
public boolean allowsUsersToSignUp() { | |||||
return true; | |||||
} | |||||
@Override | |||||
public void init(InitContext context) { | |||||
try { | |||||
Auth auth = newAuth(initSettings(context.getCallbackUrl()), context.getRequest(), context.getResponse()); | |||||
auth.login(context.generateCsrfState()); | |||||
} catch (IOException | SettingsException e) { | |||||
throw new IllegalStateException("Fail to intialize SAML authentication plugin", e); | |||||
} | |||||
} | |||||
@Override | |||||
public void callback(CallbackContext context) { | |||||
Auth auth = newAuth(initSettings(null), context.getRequest(), context.getResponse()); | |||||
processResponse(auth); | |||||
context.verifyCsrfState(STATE_REQUEST_PARAMETER); | |||||
LOGGER.trace("Name ID : {}", auth.getNameId()); | |||||
checkAuthentication(auth); | |||||
LOGGER.trace("Attributes received : {}", auth.getAttributes()); | |||||
String login = getNonNullFirstAttribute(auth, samlSettings.getUserLogin()); | |||||
UserIdentity.Builder userIdentityBuilder = UserIdentity.builder() | |||||
.setLogin(login) | |||||
.setProviderLogin(login) | |||||
.setName(getNonNullFirstAttribute(auth, samlSettings.getUserName())); | |||||
samlSettings.getUserEmail().ifPresent( | |||||
email -> userIdentityBuilder.setEmail(getFirstAttribute(auth, email))); | |||||
samlSettings.getGroupName().ifPresent( | |||||
group -> userIdentityBuilder.setGroups(getGroups(auth, group))); | |||||
context.authenticate(userIdentityBuilder.build()); | |||||
context.redirectToRequestedPage(); | |||||
} | |||||
private static Auth newAuth(Saml2Settings saml2Settings, HttpServletRequest request, HttpServletResponse response) { | |||||
try { | |||||
return new Auth(saml2Settings, request, response); | |||||
} catch (SettingsException e) { | |||||
throw new IllegalStateException("Fail to create Auth", e); | |||||
} | |||||
} | |||||
private static void processResponse(Auth auth) { | |||||
try { | |||||
auth.processResponse(); | |||||
} catch (Exception e) { | |||||
throw new IllegalStateException("Fail to process response", e); | |||||
} | |||||
} | |||||
private static void checkAuthentication(Auth auth) { | |||||
List<String> errors = auth.getErrors(); | |||||
if (auth.isAuthenticated() && errors.isEmpty()) { | |||||
return; | |||||
} | |||||
String errorReason = auth.getLastErrorReason(); | |||||
throw new UnauthorizedException(errorReason != null && !errorReason.isEmpty() ? errorReason : "Unknown error reason"); | |||||
} | |||||
private static String getNonNullFirstAttribute(Auth auth, String key) { | |||||
String attribute = getFirstAttribute(auth, key); | |||||
requireNonNull(attribute, String.format("%s is missing", key)); | |||||
return attribute; | |||||
} | |||||
@CheckForNull | |||||
private static String getFirstAttribute(Auth auth, String key) { | |||||
Collection<String> attribute = auth.getAttribute(key); | |||||
if (attribute == null || attribute.isEmpty()) { | |||||
return null; | |||||
} | |||||
return attribute.iterator().next(); | |||||
} | |||||
private static Set<String> getGroups(Auth auth, String groupAttribute) { | |||||
Collection<String> attribute = auth.getAttribute(groupAttribute); | |||||
if (attribute == null || attribute.isEmpty()) { | |||||
return emptySet(); | |||||
} | |||||
return new HashSet<>(attribute); | |||||
} | |||||
private Saml2Settings initSettings(@Nullable String callbackUrl) { | |||||
Map<String, Object> samlData = new HashMap<>(); | |||||
// TODO strict mode is unfortunately not compatible with HTTPS configuration on reverse proxy => | |||||
// https://jira.sonarsource.com/browse/SQAUTHSAML-8 | |||||
samlData.put("onelogin.saml2.strict", false); | |||||
samlData.put("onelogin.saml2.idp.entityid", samlSettings.getProviderId()); | |||||
samlData.put("onelogin.saml2.idp.single_sign_on_service.url", samlSettings.getLoginUrl()); | |||||
samlData.put("onelogin.saml2.idp.x509cert", samlSettings.getCertificate()); | |||||
samlData.put("onelogin.saml2.sp.entityid", samlSettings.getApplicationId()); | |||||
// During callback, the callback URL is by definition not needed, but the Saml2Settings does never allow this setting to be empty... | |||||
samlData.put("onelogin.saml2.sp.assertion_consumer_service.url", callbackUrl != null ? callbackUrl : ANY_URL); | |||||
SettingsBuilder builder = new SettingsBuilder(); | |||||
return builder | |||||
.fromValues(samlData) | |||||
.build(); | |||||
} | |||||
} |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
package org.sonar.auth.saml; | |||||
import java.util.List; | |||||
import org.sonar.api.config.PropertyDefinition; | |||||
import org.sonar.core.platform.Module; | |||||
public class SamlModule extends Module { | |||||
@Override | |||||
protected void configureModule() { | |||||
add( | |||||
SamlIdentityProvider.class, | |||||
SamlSettings.class); | |||||
List<PropertyDefinition> definitions = SamlSettings.definitions(); | |||||
add(definitions.toArray(new Object[definitions.size()])); | |||||
} | |||||
} |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
package org.sonar.auth.saml; | |||||
import java.util.Arrays; | |||||
import java.util.List; | |||||
import java.util.Optional; | |||||
import org.sonar.api.config.Configuration; | |||||
import org.sonar.api.config.PropertyDefinition; | |||||
import org.sonar.api.server.ServerSide; | |||||
import static java.lang.String.valueOf; | |||||
import static org.sonar.api.PropertyType.BOOLEAN; | |||||
@ServerSide | |||||
public class SamlSettings { | |||||
private static final String ENABLED = "sonar.auth.saml.enabled"; | |||||
private static final String PROVIDER_ID = "sonar.auth.saml.providerId"; | |||||
private static final String PROVIDER_NAME = "sonar.auth.saml.providerName"; | |||||
private static final String APPLICATION_ID = "sonar.auth.saml.applicationId"; | |||||
private static final String LOGIN_URL = "sonar.auth.saml.loginUrl"; | |||||
private static final String CERTIFICATE = "sonar.auth.saml.certificate.secured"; | |||||
private static final String USER_LOGIN_ATTRIBUTE = "sonar.auth.saml.user.login"; | |||||
private static final String USER_NAME_ATTRIBUTE = "sonar.auth.saml.user.name"; | |||||
private static final String USER_EMAIL_ATTRIBUTE = "sonar.auth.saml.user.email"; | |||||
private static final String GROUP_NAME_ATTRIBUTE = "sonar.auth.saml.group.name"; | |||||
private static final String CATEGORY = "security"; | |||||
private static final String SUBCATEGORY = "saml"; | |||||
private final Configuration configuration; | |||||
public SamlSettings(Configuration configuration) { | |||||
this.configuration = configuration; | |||||
} | |||||
String getProviderId() { | |||||
return configuration.get(PROVIDER_ID).orElseThrow(() -> new IllegalArgumentException("Provider ID is missing")); | |||||
} | |||||
String getProviderName() { | |||||
return configuration.get(PROVIDER_NAME).orElseThrow(() -> new IllegalArgumentException("Provider Name is missing")); | |||||
} | |||||
String getApplicationId() { | |||||
return configuration.get(APPLICATION_ID).orElseThrow(() -> new IllegalArgumentException("Application ID is missing")); | |||||
} | |||||
String getLoginUrl() { | |||||
return configuration.get(LOGIN_URL).orElseThrow(() -> new IllegalArgumentException("Login URL is missing")); | |||||
} | |||||
String getCertificate() { | |||||
return configuration.get(CERTIFICATE).orElseThrow(() -> new IllegalArgumentException("Certificate is missing")); | |||||
} | |||||
String getUserLogin() { | |||||
return configuration.get(USER_LOGIN_ATTRIBUTE).orElseThrow(() -> new IllegalArgumentException("User login attribute is missing")); | |||||
} | |||||
String getUserName() { | |||||
return configuration.get(USER_NAME_ATTRIBUTE).orElseThrow(() -> new IllegalArgumentException("User name attribute is missing")); | |||||
} | |||||
Optional<String> getUserEmail() { | |||||
return configuration.get(USER_EMAIL_ATTRIBUTE); | |||||
} | |||||
Optional<String> getGroupName() { | |||||
return configuration.get(GROUP_NAME_ATTRIBUTE); | |||||
} | |||||
boolean isEnabled() { | |||||
return configuration.getBoolean(ENABLED).orElse(false) && | |||||
configuration.get(PROVIDER_ID).isPresent() && | |||||
configuration.get(APPLICATION_ID).isPresent() && | |||||
configuration.get(LOGIN_URL).isPresent() && | |||||
configuration.get(CERTIFICATE).isPresent() && | |||||
configuration.get(USER_LOGIN_ATTRIBUTE).isPresent() && | |||||
configuration.get(USER_NAME_ATTRIBUTE).isPresent(); | |||||
} | |||||
static List<PropertyDefinition> definitions() { | |||||
return Arrays.asList( | |||||
PropertyDefinition.builder(ENABLED) | |||||
.name("Enabled") | |||||
.description("Enable SAML users to login. Value is ignored if provider ID, login url, certificate, login, name attributes are not defined.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.type(BOOLEAN) | |||||
.defaultValue(valueOf(false)) | |||||
.index(1) | |||||
.build(), | |||||
PropertyDefinition.builder(APPLICATION_ID) | |||||
.name("Application ID") | |||||
.description("Identifier of the application.") | |||||
.defaultValue("sonarqube") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(2) | |||||
.build(), | |||||
PropertyDefinition.builder(PROVIDER_NAME) | |||||
.name("Provider Name") | |||||
.description("Name displayed for the provider in the login page.") | |||||
.defaultValue("SAML") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(3) | |||||
.build(), | |||||
PropertyDefinition.builder(PROVIDER_ID) | |||||
.name("Provider ID") | |||||
.description("Identifier of the identity provider, the entity that provides SAML authentication.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(4) | |||||
.build(), | |||||
PropertyDefinition.builder(LOGIN_URL) | |||||
.name("SAML login url") | |||||
.description("SAML login URL for the identity provider.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(5) | |||||
.build(), | |||||
PropertyDefinition.builder(CERTIFICATE) | |||||
.name("Provider certificate") | |||||
.description("X.509 certificate for the identity provider.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(6) | |||||
.build(), | |||||
PropertyDefinition.builder(USER_LOGIN_ATTRIBUTE) | |||||
.name("SAML user login attribute") | |||||
.description("Attribute defining the user login in SAML.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(7) | |||||
.build(), | |||||
PropertyDefinition.builder(USER_NAME_ATTRIBUTE) | |||||
.name("SAML user name attribute") | |||||
.description("Attribute defining the user name in SAML.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(8) | |||||
.build(), | |||||
PropertyDefinition.builder(USER_EMAIL_ATTRIBUTE) | |||||
.name("SAML user email attribute") | |||||
.description("Attribute defining the user email in SAML.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(9) | |||||
.build(), | |||||
PropertyDefinition.builder(GROUP_NAME_ATTRIBUTE) | |||||
.name("SAML group attribute") | |||||
.description("Attribute defining the user groups in SAML. " + | |||||
"Users are associated to the default group only if no attribute is defined.") | |||||
.category(CATEGORY) | |||||
.subCategory(SUBCATEGORY) | |||||
.index(10) | |||||
.build()); | |||||
} | |||||
} |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
@ParametersAreNonnullByDefault | |||||
package org.sonar.auth.saml; | |||||
import javax.annotation.ParametersAreNonnullByDefault; |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
package org.sonar.auth.saml; | |||||
import java.io.IOException; | |||||
import java.io.InputStream; | |||||
import java.nio.charset.StandardCharsets; | |||||
import java.util.HashMap; | |||||
import java.util.Map; | |||||
import java.util.concurrent.atomic.AtomicBoolean; | |||||
import javax.servlet.http.HttpServletRequest; | |||||
import javax.servlet.http.HttpServletResponse; | |||||
import org.junit.Rule; | |||||
import org.junit.Test; | |||||
import org.junit.rules.ExpectedException; | |||||
import org.sonar.api.config.PropertyDefinitions; | |||||
import org.sonar.api.config.internal.MapSettings; | |||||
import org.sonar.api.internal.apachecommons.io.IOUtils; | |||||
import org.sonar.api.server.authentication.OAuth2IdentityProvider; | |||||
import org.sonar.api.server.authentication.UnauthorizedException; | |||||
import org.sonar.api.server.authentication.UserIdentity; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
import static org.mockito.ArgumentMatchers.anyString; | |||||
import static org.mockito.Mockito.mock; | |||||
import static org.mockito.Mockito.verify; | |||||
import static org.mockito.Mockito.when; | |||||
public class SamlIdentityProviderTest { | |||||
@Rule | |||||
public ExpectedException expectedException = ExpectedException.none(); | |||||
private MapSettings settings = new MapSettings(new PropertyDefinitions(SamlSettings.definitions())); | |||||
private SamlIdentityProvider underTest = new SamlIdentityProvider(new SamlSettings(settings.asConfig())); | |||||
@Test | |||||
public void check_fields() { | |||||
setSettings(true); | |||||
assertThat(underTest.getKey()).isEqualTo("saml"); | |||||
assertThat(underTest.getName()).isEqualTo("SAML"); | |||||
assertThat(underTest.getDisplay().getIconPath()).isEqualTo("/static/authsaml/saml.png"); | |||||
assertThat(underTest.getDisplay().getBackgroundColor()).isEqualTo("#444444"); | |||||
assertThat(underTest.allowsUsersToSignUp()).isTrue(); | |||||
} | |||||
@Test | |||||
public void provider_name_is_provided_by_setting() { | |||||
// Default value | |||||
assertThat(underTest.getName()).isEqualTo("SAML"); | |||||
settings.setProperty("sonar.auth.saml.providerName", "My Provider"); | |||||
assertThat(underTest.getName()).isEqualTo("My Provider"); | |||||
} | |||||
@Test | |||||
public void is_enabled() { | |||||
setSettings(true); | |||||
assertThat(underTest.isEnabled()).isTrue(); | |||||
setSettings(false); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void init() throws IOException { | |||||
setSettings(true); | |||||
DumbInitContext context = new DumbInitContext(); | |||||
underTest.init(context); | |||||
verify(context.response).sendRedirect(anyString()); | |||||
assertThat(context.generateCsrfState.get()).isTrue(); | |||||
} | |||||
@Test | |||||
public void fail_to_init_when_login_url_is_invalid() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "invalid"); | |||||
DumbInitContext context = new DumbInitContext(); | |||||
expectedException.expect(IllegalStateException.class); | |||||
expectedException.expectMessage("Fail to create Auth"); | |||||
underTest.init(context); | |||||
} | |||||
@Test | |||||
public void callback() { | |||||
setSettings(true); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_full_response.txt"); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.redirectedToRequestedPage.get()).isTrue(); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("johndoe"); | |||||
assertThat(callbackContext.verifyState.get()).isTrue(); | |||||
} | |||||
@Test | |||||
public void callback_on_full_response() { | |||||
setSettings(true); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_full_response.txt"); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("johndoe"); | |||||
assertThat(callbackContext.userIdentity.getName()).isEqualTo("John Doe"); | |||||
assertThat(callbackContext.userIdentity.getEmail()).isEqualTo("johndoe@email.com"); | |||||
assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("johndoe"); | |||||
assertThat(callbackContext.userIdentity.getGroups()).containsExactlyInAnyOrder("developer", "product-manager"); | |||||
} | |||||
@Test | |||||
public void callback_on_minimal_response() { | |||||
setSettings(true); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_minimal_response.txt"); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("johndoe"); | |||||
assertThat(callbackContext.userIdentity.getName()).isEqualTo("John Doe"); | |||||
assertThat(callbackContext.userIdentity.getEmail()).isNull(); | |||||
assertThat(callbackContext.userIdentity.getProviderLogin()).isEqualTo("johndoe"); | |||||
assertThat(callbackContext.userIdentity.getGroups()).isEmpty(); | |||||
} | |||||
@Test | |||||
public void callback_does_not_sync_group_when_group_setting_is_not_set() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.saml.group.name", (String) null); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_full_response.txt"); | |||||
underTest.callback(callbackContext); | |||||
assertThat(callbackContext.userIdentity.getLogin()).isEqualTo("johndoe"); | |||||
assertThat(callbackContext.userIdentity.getGroups()).isEmpty(); | |||||
} | |||||
@Test | |||||
public void fail_to_callback_when_login_is_missing() { | |||||
setSettings(true); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_response_without_login.txt"); | |||||
expectedException.expect(NullPointerException.class); | |||||
expectedException.expectMessage("login is missing"); | |||||
underTest.callback(callbackContext); | |||||
} | |||||
@Test | |||||
public void fail_to_callback_when_name_is_missing() { | |||||
setSettings(true); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_response_without_name.txt"); | |||||
expectedException.expect(NullPointerException.class); | |||||
expectedException.expectMessage("name is missing"); | |||||
underTest.callback(callbackContext); | |||||
} | |||||
@Test | |||||
public void fail_to_callback_when_certificate_is_invalid() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", "invalid"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_full_response.txt"); | |||||
expectedException.expect(IllegalStateException.class); | |||||
expectedException.expectMessage("Fail to create Auth"); | |||||
underTest.callback(callbackContext); | |||||
} | |||||
@Test | |||||
public void fail_to_callback_when_using_wrong_certificate() { | |||||
setSettings(true); | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", "-----BEGIN CERTIFICATE-----\n" + | |||||
"MIIEIzCCAwugAwIBAgIUHUzPjy5E2TmnsmTRT2sIUBRXFF8wDQYJKoZIhvcNAQEF\n" + | |||||
"BQAwXDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC1NvbmFyU291cmNlMRUwEwYDVQQL\n" + | |||||
"DAxPbmVMb2dpbiBJZFAxIDAeBgNVBAMMF09uZUxvZ2luIEFjY291bnQgMTMxMTkx\n" + | |||||
"MB4XDTE4MDcxOTA4NDUwNVoXDTIzMDcxOTA4NDUwNVowXDELMAkGA1UEBhMCVVMx\n" + | |||||
"FDASBgNVBAoMC1NvbmFyU291cmNlMRUwEwYDVQQLDAxPbmVMb2dpbiBJZFAxIDAe\n" + | |||||
"BgNVBAMMF09uZUxvZ2luIEFjY291bnQgMTMxMTkxMIIBIjANBgkqhkiG9w0BAQEF\n" + | |||||
"AAOCAQ8AMIIBCgKCAQEArlpKHm4EkJiQyy+4GtZBixcy7fWnreB96T7cOoWLmWkK\n" + | |||||
"05FM5M/boWHZsvaNAuHsoCAMzIY3/l+55WbORzAxsloH7rvDaDrdPYQN+sU9bzsD\n" + | |||||
"ZkmDGDmA3QBSm/h/p5SiMkWU5Jg34toDdM0rmzUStIOMq6Gh/Ykx3fRRSjswy48x\n" + | |||||
"wfZLy+0wU7lasHqdfk54dVbb7mCm9J3iHZizvOt2lbtzGbP6vrrjpzvZm43ZRgP8\n" + | |||||
"FapYA8G3lczdIaG4IaLW6kYIRORd0UwI7IAwkao3uIo12rh1T6DLVyzjOs9PdIkb\n" + | |||||
"HbICN2EehB/ut3wohuPwmwp2UmqopIMVVaBSsmSlYwIDAQABo4HcMIHZMAwGA1Ud\n" + | |||||
"EwEB/wQCMAAwHQYDVR0OBBYEFAXGFMKYgtpzCpfpBUPQ1H/9AeDrMIGZBgNVHSME\n" + | |||||
"gZEwgY6AFAXGFMKYgtpzCpfpBUPQ1H/9AeDroWCkXjBcMQswCQYDVQQGEwJVUzEU\n" + | |||||
"MBIGA1UECgwLU29uYXJTb3VyY2UxFTATBgNVBAsMDE9uZUxvZ2luIElkUDEgMB4G\n" + | |||||
"A1UEAwwXT25lTG9naW4gQWNjb3VudCAxMzExOTGCFB1Mz48uRNk5p7Jk0U9rCFAU\n" + | |||||
"VxRfMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQUFAAOCAQEAPHgi9IdDaTxD\n" + | |||||
"R5R8KHMdt385Uq8XC5pd0Li6y5RR2k6SKjThCt+eQU7D0Y2CyYU27vfCa2DQV4hJ\n" + | |||||
"4v4UfQv3NR/fYfkVSsNpxjBXBI3YWouxt2yg7uwdZBdgGYd37Yv3g9PdIZenjOhr\n" + | |||||
"Ck6WjdleMAWHRgJpocmB4IOESSyTfUul3jFupWnkbnn8c0ue6zwXd7LA1/yjVT2l\n" + | |||||
"Yh45+lz25aIOlyyo7OUw2TD15LIl8OOIuWRS4+UWy5+VdhXMbmpSEQH+Byod90g6\n" + | |||||
"A1bKpOFhRBzcxaZ6B2hB4SqjTBzS9zdmJyyFs/WNJxHri3aorcdqG9oUakjJJqqX\n" + | |||||
"E13skIMV2g==\n" + | |||||
"-----END CERTIFICATE-----\n"); | |||||
DumbCallbackContext callbackContext = new DumbCallbackContext("encoded_full_response.txt"); | |||||
expectedException.expect(UnauthorizedException.class); | |||||
expectedException.expectMessage("Signature validation failed. SAML Response rejected"); | |||||
underTest.callback(callbackContext); | |||||
} | |||||
private void setSettings(boolean enabled) { | |||||
if (enabled) { | |||||
settings.setProperty("sonar.auth.saml.applicationId", "MyApp"); | |||||
settings.setProperty("sonar.auth.saml.providerId", "http://localhost:8080/auth/realms/sonarqube"); | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "http://localhost:8080/auth/realms/sonarqube/protocol/saml"); | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", | |||||
"MIICoTCCAYkCBgFksusMzTANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlzb25hcnF1YmUwHhcNMTgwNzE5MTQyMDA2WhcNMjgwNzE5MTQyMTQ2WjAUMRIwEAYDVQQDDAlzb25hcnF1YmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEOth5gxpTs1f3bFGUD8hO97eMIsDZvvE3PZeKoeTRG7mOLu6rfLXphG3fE3E6/xqUhPP5p9hJl9DwgaMewhdZhfHqtOw6/SPMCQNFVNw9FQ7lprWKg8cZygYLDxhObEvCWPek8KcMb/vlKD8c8ha374O9qET51CVogDM5ropp02q0ELxoUKXqphKH4+sGXRVnDHaEsFHxse1HnciZT5mF1G45vxDItdAnWKkXYKVHC+Et52tCieqM0ygpQF1lWVJFXVOqsi03YkMu7IkWvSSfAw+uEcfmquT7FbxJ2n5gp94odAkQB0HK3fABrHr+G+n2QvWG6WwQPJTL0Ov0w+tNAgMBAAEwDQYJKoZIhvcNAQELBQADggEBACQfOrJF98nunKz6CN+YZXXMYhzQiqTD0MlzCg+Rdhir+WC/ru3Kz8omv52W/sXEMNQbEZBksVLl8W/1xeBS41Sf1nfutU560v/j3/OmOcnCw4qebqFH7nB8RL8vA4rGx430W/PeeUMikY1mSjlwhnJGiICQ3Y8I2qM6QWEr/Df2/gFCW2YnHbnS6Q/OwRQi+UFIzKklSQQa0gAnqfM4oSKU2OMhzScinWg1buMYfJSXgd4qIhPvRsZpqBsdt/OSrU2D5Y2YfSu8oIcxBRgJoERH5BV9GdOID4fS+TYw0M0QO/ORetNw1mA/8Npsy8okF8Cn7fDgbnWC8uz+/xDc14I="); | |||||
settings.setProperty("sonar.auth.saml.user.login", "login"); | |||||
settings.setProperty("sonar.auth.saml.user.name", "name"); | |||||
settings.setProperty("sonar.auth.saml.user.email", "email"); | |||||
settings.setProperty("sonar.auth.saml.group.name", "groups"); | |||||
settings.setProperty("sonar.auth.saml.enabled", true); | |||||
} else { | |||||
settings.setProperty("sonar.auth.saml.enabled", false); | |||||
} | |||||
} | |||||
private static class DumbInitContext implements OAuth2IdentityProvider.InitContext { | |||||
private HttpServletResponse response = mock(HttpServletResponse.class); | |||||
private final AtomicBoolean generateCsrfState = new AtomicBoolean(false); | |||||
@Override | |||||
public String generateCsrfState() { | |||||
generateCsrfState.set(true); | |||||
return null; | |||||
} | |||||
@Override | |||||
public void redirectTo(String url) { | |||||
} | |||||
@Override | |||||
public String getCallbackUrl() { | |||||
return "http://localhost/oauth/callback/saml"; | |||||
} | |||||
@Override | |||||
public HttpServletRequest getRequest() { | |||||
return mock(HttpServletRequest.class); | |||||
} | |||||
@Override | |||||
public HttpServletResponse getResponse() { | |||||
return response; | |||||
} | |||||
} | |||||
private static class DumbCallbackContext implements OAuth2IdentityProvider.CallbackContext { | |||||
private HttpServletResponse response = mock(HttpServletResponse.class); | |||||
private HttpServletRequest request = mock(HttpServletRequest.class); | |||||
private final AtomicBoolean redirectedToRequestedPage = new AtomicBoolean(false); | |||||
private final AtomicBoolean verifyState = new AtomicBoolean(false); | |||||
private UserIdentity userIdentity = null; | |||||
public DumbCallbackContext(String encodedResponseFile) { | |||||
when(getRequest().getRequestURL()).thenReturn(new StringBuffer("http://localhost/oauth/callback/saml")); | |||||
Map<String, String[]> parameterMap = new HashMap<>(); | |||||
parameterMap.put("SAMLResponse", new String[] {loadResponse(encodedResponseFile)}); | |||||
when(getRequest().getParameterMap()).thenReturn(parameterMap); | |||||
} | |||||
private String loadResponse(String file) { | |||||
try (InputStream json = getClass().getResourceAsStream("SamlIdentityProviderTest/" + file)) { | |||||
return IOUtils.toString(json, StandardCharsets.UTF_8.name()); | |||||
} catch (IOException e) { | |||||
throw new IllegalStateException(e); | |||||
} | |||||
} | |||||
@Override | |||||
public void verifyCsrfState() { | |||||
throw new IllegalStateException("This method should not be called !"); | |||||
} | |||||
@Override | |||||
public void verifyCsrfState(String parameterName) { | |||||
assertThat(parameterName).isEqualTo("RelayState"); | |||||
verifyState.set(true); | |||||
} | |||||
@Override | |||||
public void redirectToRequestedPage() { | |||||
redirectedToRequestedPage.set(true); | |||||
} | |||||
@Override | |||||
public void authenticate(UserIdentity userIdentity) { | |||||
this.userIdentity = userIdentity; | |||||
} | |||||
@Override | |||||
public String getCallbackUrl() { | |||||
return "http://localhost/oauth/callback/saml"; | |||||
} | |||||
@Override | |||||
public HttpServletRequest getRequest() { | |||||
return request; | |||||
} | |||||
@Override | |||||
public HttpServletResponse getResponse() { | |||||
return response; | |||||
} | |||||
} | |||||
} |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
package org.sonar.auth.saml; | |||||
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 SamlModuleTest { | |||||
@Test | |||||
public void verify_count_of_added_components() { | |||||
ComponentContainer container = new ComponentContainer(); | |||||
new SamlModule().configure(container); | |||||
assertThat(container.size()).isEqualTo(COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER + 12); | |||||
} | |||||
} |
/* | |||||
* SonarQube | |||||
* Copyright (C) 2009-2019 SonarSource SA | |||||
* mailto:info AT sonarsource DOT com | |||||
* | |||||
* This program is free software; you can redistribute it and/or | |||||
* modify it under the terms of the GNU Lesser General Public | |||||
* License as published by the Free Software Foundation; either | |||||
* version 3 of the License, or (at your option) any later version. | |||||
* | |||||
* This program is distributed in the hope that it will be useful, | |||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||||
* Lesser General Public License for more details. | |||||
* | |||||
* You should have received a copy of the GNU Lesser General Public License | |||||
* along with this program; if not, write to the Free Software Foundation, | |||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||||
*/ | |||||
package org.sonar.auth.saml; | |||||
import com.tngtech.java.junit.dataprovider.DataProvider; | |||||
import com.tngtech.java.junit.dataprovider.DataProviderRunner; | |||||
import com.tngtech.java.junit.dataprovider.UseDataProvider; | |||||
import org.junit.Rule; | |||||
import org.junit.Test; | |||||
import org.junit.rules.ExpectedException; | |||||
import org.junit.runner.RunWith; | |||||
import org.sonar.api.config.PropertyDefinitions; | |||||
import org.sonar.api.config.internal.MapSettings; | |||||
import static org.assertj.core.api.Assertions.assertThat; | |||||
@RunWith(DataProviderRunner.class) | |||||
public class SamlSettingsTest { | |||||
@Rule | |||||
public ExpectedException expectedException = ExpectedException.none(); | |||||
private MapSettings settings = new MapSettings(new PropertyDefinitions(SamlSettings.definitions())); | |||||
private SamlSettings underTest = new SamlSettings(settings.asConfig()); | |||||
@Test | |||||
public void return_application_id() { | |||||
settings.setProperty("sonar.auth.saml.applicationId", "MyApp"); | |||||
assertThat(underTest.getApplicationId()).isEqualTo("MyApp"); | |||||
} | |||||
@Test | |||||
public void return_default_value_of_application_id() { | |||||
assertThat(underTest.getApplicationId()).isEqualTo("sonarqube"); | |||||
} | |||||
@Test | |||||
public void return_provider_name() { | |||||
settings.setProperty("sonar.auth.saml.providerName", "MyProviderName"); | |||||
assertThat(underTest.getProviderName()).isEqualTo("MyProviderName"); | |||||
} | |||||
@Test | |||||
public void return_default_value_of_application_name() { | |||||
assertThat(underTest.getProviderName()).isEqualTo("SAML"); | |||||
} | |||||
@Test | |||||
public void return_provider_id() { | |||||
settings.setProperty("sonar.auth.saml.applicationId", "http://localhost:8080/auth/realms/sonarqube"); | |||||
assertThat(underTest.getApplicationId()).isEqualTo("http://localhost:8080/auth/realms/sonarqube"); | |||||
} | |||||
@Test | |||||
public void return_login_url() { | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "http://localhost:8080/"); | |||||
assertThat(underTest.getLoginUrl()).isEqualTo("http://localhost:8080/"); | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "http://localhost:8080"); | |||||
assertThat(underTest.getLoginUrl()).isEqualTo("http://localhost:8080"); | |||||
} | |||||
@Test | |||||
public void return_certificate() { | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", "ABCDEFG"); | |||||
assertThat(underTest.getCertificate()).isEqualTo("ABCDEFG"); | |||||
} | |||||
@Test | |||||
public void return_user_login_attribute() { | |||||
settings.setProperty("sonar.auth.saml.user.login", "userLogin"); | |||||
assertThat(underTest.getUserLogin()).isEqualTo("userLogin"); | |||||
} | |||||
@Test | |||||
public void return_user_name_attribute() { | |||||
settings.setProperty("sonar.auth.saml.user.name", "userName"); | |||||
assertThat(underTest.getUserName()).isEqualTo("userName"); | |||||
} | |||||
@Test | |||||
public void return_user_email_attribute() { | |||||
settings.setProperty("sonar.auth.saml.user.email", "userEmail"); | |||||
assertThat(underTest.getUserEmail().get()).isEqualTo("userEmail"); | |||||
} | |||||
@Test | |||||
public void return_empty_user_email_when_no_setting() { | |||||
assertThat(underTest.getUserEmail()).isNotPresent(); | |||||
} | |||||
@Test | |||||
public void return_group_name_attribute() { | |||||
settings.setProperty("sonar.auth.saml.group.name", "groupName"); | |||||
assertThat(underTest.getGroupName().get()).isEqualTo("groupName"); | |||||
} | |||||
@Test | |||||
public void return_empty_group_name_when_no_setting() { | |||||
assertThat(underTest.getGroupName()).isNotPresent(); | |||||
} | |||||
@Test | |||||
public void is_enabled() { | |||||
settings.setProperty("sonar.auth.saml.applicationId", "MyApp"); | |||||
settings.setProperty("sonar.auth.saml.providerId", "http://localhost:8080/auth/realms/sonarqube"); | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "http://localhost:8080/auth/realms/sonarqube/protocol/saml"); | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", "ABCDEFG"); | |||||
settings.setProperty("sonar.auth.saml.user.login", "login"); | |||||
settings.setProperty("sonar.auth.saml.user.name", "name"); | |||||
settings.setProperty("sonar.auth.saml.enabled", true); | |||||
assertThat(underTest.isEnabled()).isTrue(); | |||||
settings.setProperty("sonar.auth.saml.enabled", false); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void is_enabled_using_default_values() { | |||||
settings.setProperty("sonar.auth.saml.providerId", "http://localhost:8080/auth/realms/sonarqube"); | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "http://localhost:8080/auth/realms/sonarqube/protocol/saml"); | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", "ABCDEFG"); | |||||
settings.setProperty("sonar.auth.saml.user.login", "login"); | |||||
settings.setProperty("sonar.auth.saml.user.name", "name"); | |||||
settings.setProperty("sonar.auth.saml.enabled", true); | |||||
assertThat(underTest.isEnabled()).isTrue(); | |||||
} | |||||
@DataProvider | |||||
public static Object[][] settingsRequiredToEnablePlugin() { | |||||
return new Object[][] { | |||||
{"sonar.auth.saml.providerId"}, | |||||
{"sonar.auth.saml.loginUrl"}, | |||||
{"sonar.auth.saml.certificate.secured"}, | |||||
{"sonar.auth.saml.user.login"}, | |||||
{"sonar.auth.saml.user.name"}, | |||||
{"sonar.auth.saml.enabled"}, | |||||
}; | |||||
} | |||||
@Test | |||||
@UseDataProvider("settingsRequiredToEnablePlugin") | |||||
public void is_enabled_return_false_when_one_required_setting_is_missing(String setting) { | |||||
initAllSettings(); | |||||
settings.setProperty(setting, (String) null); | |||||
assertThat(underTest.isEnabled()).isFalse(); | |||||
} | |||||
@Test | |||||
public void fail_to_get_provider_id_when_null() { | |||||
expectedException.expect(IllegalArgumentException.class); | |||||
expectedException.expectMessage("Provider ID is missing"); | |||||
underTest.getProviderId(); | |||||
} | |||||
@Test | |||||
public void fail_to_get_login_url_when_null() { | |||||
expectedException.expect(IllegalArgumentException.class); | |||||
expectedException.expectMessage("Login URL is missing"); | |||||
underTest.getLoginUrl(); | |||||
} | |||||
@Test | |||||
public void fail_to_get_certificate_when_null() { | |||||
expectedException.expect(IllegalArgumentException.class); | |||||
expectedException.expectMessage("Certificate is missing"); | |||||
underTest.getCertificate(); | |||||
} | |||||
@Test | |||||
public void fail_to_get_user_login_attribute_when_null() { | |||||
expectedException.expect(IllegalArgumentException.class); | |||||
expectedException.expectMessage("User login attribute is missing"); | |||||
underTest.getUserLogin(); | |||||
} | |||||
@Test | |||||
public void fail_to_get_user_name_attribute_when_null() { | |||||
expectedException.expect(IllegalArgumentException.class); | |||||
expectedException.expectMessage("User name attribute is missing"); | |||||
underTest.getUserName(); | |||||
} | |||||
private void initAllSettings() { | |||||
settings.setProperty("sonar.auth.saml.applicationId", "MyApp"); | |||||
settings.setProperty("sonar.auth.saml.providerId", "http://localhost:8080/auth/realms/sonarqube"); | |||||
settings.setProperty("sonar.auth.saml.loginUrl", "http://localhost:8080/auth/realms/sonarqube/protocol/saml"); | |||||
settings.setProperty("sonar.auth.saml.certificate.secured", "ABCDEFG"); | |||||
settings.setProperty("sonar.auth.saml.user.login", "login"); | |||||
settings.setProperty("sonar.auth.saml.user.name", "name"); | |||||
settings.setProperty("sonar.auth.saml.enabled", true); | |||||
} | |||||
} |
# How to generate test responses for unit tests requiring encoded user response | |||||
1. Set the server log in TRACE | |||||
2. Login with a user | |||||
3. Search in the logs for "[c.o.saml2.Auth] processResponse success -->" | |||||
4. The value after the "-->" is the encoded response that can be used in test |
[[collapse]] | [[collapse]] | ||||
| ## In SonarQube, Configure SAML authentication | | ## In SonarQube, Configure SAML authentication | ||||
| Go to **[Administration > Configuration > General Settings > SAML > Authentication](/#sonarqube-admin#/admin/settings?category=saml)** | |||||
| Go to **[Administration > Configuration > General Settings > Security > SAML](/#sonarqube-admin#/admin/settings?category=security)** | |||||
| * **Enabled** should be set to true | | * **Enabled** should be set to true | ||||
| * **Application ID** is the value of the "Client ID" you set in Keycloak (for example "sonarqube") | | * **Application ID** is the value of the "Client ID" you set in Keycloak (for example "sonarqube") | ||||
| * **Provider ID** is the value of the "EntityDescriptor" > "entityID" attribute in the XML configuration file (for example "http://keycloak:8080/auth/realms/sonarqube" where sonarqube is the name of the realm) | | * **Provider ID** is the value of the "EntityDescriptor" > "entityID" attribute in the XML configuration file (for example "http://keycloak:8080/auth/realms/sonarqube" where sonarqube is the name of the realm) | ||||
| * **SAML login url** is the value of "SingleSignOnService" > "Location" attribute in the XML configuration file (for example "http://keycloak:8080/auth/realms/sonarqube/protocol/saml") | | * **SAML login url** is the value of "SingleSignOnService" > "Location" attribute in the XML configuration file (for example "http://keycloak:8080/auth/realms/sonarqube/protocol/saml") | ||||
| * **Provider certificate** is the value of "dsig:X509Certificate" node in the XML configuration file | |||||
| * **Provider certificate** is the value you get from *Reaml Settings* -> *Keys* -> click on the *Certificate* button | |||||
| * **SAML user login attribute** is the value set in the login mapper in "SAML Attribute Name" | | * **SAML user login attribute** is the value set in the login mapper in "SAML Attribute Name" | ||||
| * **SAML user name attribute** is the value set in the name mapper in "SAML Attribute Name" | | * **SAML user name attribute** is the value set in the name mapper in "SAML Attribute Name" | ||||
| * (Optional) **SAML user email attribute** is the value set in the email mapper in "SAML Attribute Name" | | * (Optional) **SAML user email attribute** is the value set in the email mapper in "SAML Attribute Name" |
*/ | */ | ||||
public abstract class ServerExtensionInstaller { | public abstract class ServerExtensionInstaller { | ||||
private static final Set<String> NO_MORE_COMPATIBLE_PLUGINS = ImmutableSet.of("authgithub", "authgitlab"); | |||||
private static final Set<String> NO_MORE_COMPATIBLE_PLUGINS = ImmutableSet.of("authgithub", "authgitlab", "authsaml"); | |||||
private final SonarRuntime sonarRuntime; | private final SonarRuntime sonarRuntime; | ||||
private final PluginRepository pluginRepository; | private final PluginRepository pluginRepository; |
underTest.installExtensions(componentContainer); | underTest.installExtensions(componentContainer); | ||||
} | } | ||||
@Test | |||||
public void fail_when_detecting_saml_auth_plugin() { | |||||
PluginInfo foo = newPlugin("authsaml", "SAML Auth"); | |||||
pluginRepository.add(foo, mock(Plugin.class)); | |||||
ComponentContainer componentContainer = new ComponentContainer(); | |||||
expectedException.expect(MessageException.class); | |||||
expectedException.expectMessage("Plugins 'SAML Auth' are no more compatible with SonarQube"); | |||||
underTest.installExtensions(componentContainer); | |||||
} | |||||
private static PluginInfo newPlugin(String key, String name) { | private static PluginInfo newPlugin(String key, String name) { | ||||
PluginInfo plugin = mock(PluginInfo.class); | PluginInfo plugin = mock(PluginInfo.class); | ||||
when(plugin.getKey()).thenReturn(key); | when(plugin.getKey()).thenReturn(key); |
compile project(':sonar-core') | compile project(':sonar-core') | ||||
compile project(':server:sonar-auth-github') | compile project(':server:sonar-auth-github') | ||||
compile project(':server:sonar-auth-gitlab') | compile project(':server:sonar-auth-gitlab') | ||||
compile project(':server:sonar-auth-saml') | |||||
compile project(':server:sonar-ce-task-projectanalysis') | compile project(':server:sonar-ce-task-projectanalysis') | ||||
compile project(':server:sonar-process') | compile project(':server:sonar-process') | ||||
compile project(':server:sonar-webserver-core') | compile project(':server:sonar-webserver-core') |
import org.sonar.api.server.rule.RulesDefinitionXmlLoader; | import org.sonar.api.server.rule.RulesDefinitionXmlLoader; | ||||
import org.sonar.auth.github.GitHubModule; | import org.sonar.auth.github.GitHubModule; | ||||
import org.sonar.auth.gitlab.GitLabModule; | import org.sonar.auth.gitlab.GitLabModule; | ||||
import org.sonar.auth.saml.SamlModule; | |||||
import org.sonar.ce.task.projectanalysis.notification.ReportAnalysisFailureNotificationModule; | import org.sonar.ce.task.projectanalysis.notification.ReportAnalysisFailureNotificationModule; | ||||
import org.sonar.ce.task.projectanalysis.taskprocessor.ReportTaskProcessor; | import org.sonar.ce.task.projectanalysis.taskprocessor.ReportTaskProcessor; | ||||
import org.sonar.core.component.DefaultResourceTypes; | import org.sonar.core.component.DefaultResourceTypes; | ||||
AuthenticationWsModule.class, | AuthenticationWsModule.class, | ||||
GitHubModule.class, | GitHubModule.class, | ||||
GitLabModule.class, | GitLabModule.class, | ||||
SamlModule.class, | |||||
// users | // users | ||||
UserSessionFactoryImpl.class, | UserSessionFactoryImpl.class, | ||||
TypeValidationModule.class, | TypeValidationModule.class, | ||||
//New Code Periods | |||||
// New Code Periods | |||||
NewCodePeriodsWsModule.class, | NewCodePeriodsWsModule.class, | ||||
// Project Links | // Project Links |
include 'server:sonar-auth-common' | include 'server:sonar-auth-common' | ||||
include 'server:sonar-auth-github' | include 'server:sonar-auth-github' | ||||
include 'server:sonar-auth-gitlab' | include 'server:sonar-auth-gitlab' | ||||
include 'server:sonar-auth-saml' | |||||
include 'server:sonar-ce' | include 'server:sonar-ce' | ||||
include 'server:sonar-ce-common' | include 'server:sonar-ce-common' | ||||
include 'server:sonar-ce-task' | include 'server:sonar-ce-task' |
jdbc_mssql 'com.microsoft.sqlserver:mssql-jdbc' | jdbc_mssql 'com.microsoft.sqlserver:mssql-jdbc' | ||||
jdbc_postgresql 'org.postgresql:postgresql' | jdbc_postgresql 'org.postgresql:postgresql' | ||||
bundledPlugin 'org.sonarsource.auth.saml:sonar-auth-saml-plugin:1.1.0.181@jar' | |||||
bundledPlugin 'org.sonarsource.css:sonar-css-plugin@jar' | bundledPlugin 'org.sonarsource.css:sonar-css-plugin@jar' | ||||
bundledPlugin "org.sonarsource.dotnet:sonar-csharp-plugin@jar" | bundledPlugin "org.sonarsource.dotnet:sonar-csharp-plugin@jar" | ||||
bundledPlugin "org.sonarsource.dotnet:sonar-vbnet-plugin@jar" | bundledPlugin "org.sonarsource.dotnet:sonar-vbnet-plugin@jar" |
property.category.security.github=GitHub | property.category.security.github=GitHub | ||||
property.category.security.github.description=In order to enable GitHub 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 GitHub profile, you need to create a Developer Application for which the 'Authorization callback URL' must be set to <code>'<value_of_sonar.core.serverBaseURL_property>/oauth2/callback'</code>.</li></ul> | property.category.security.github.description=In order to enable GitHub 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 GitHub profile, you need to create a Developer Application for which the 'Authorization callback URL' must be set to <code>'<value_of_sonar.core.serverBaseURL_property>/oauth2/callback'</code>.</li></ul> | ||||
property.category.security.gitlab=Gitlab | property.category.security.gitlab=Gitlab | ||||
property.category.security.saml=SAML | |||||
property.category.security.saml.description=In order to enable SAML authentication, the property 'sonar.core.serverBaseURL' must be set to the public URL | |||||
property.category.java=Java | property.category.java=Java | ||||
property.category.differentialViews=New Code | property.category.differentialViews=New Code | ||||
property.category.codeCoverage=Code Coverage | property.category.codeCoverage=Code Coverage |