aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-auth-saml/src
diff options
context:
space:
mode:
authorMatteo Mara <matteo.mara@sonarsource.com>2022-09-12 14:05:35 +0200
committersonartech <sonartech@sonarsource.com>2022-09-15 20:03:03 +0000
commit492c498c7400666923316b7df3bf9befa0183398 (patch)
tree79a2cdcf0f09b1077009f934b889ab101ae8820e /server/sonar-auth-saml/src
parent934ad1652a7a1e528bb1165cc8cc3e096b1cc208 (diff)
downloadsonarqube-492c498c7400666923316b7df3bf9befa0183398.tar.gz
sonarqube-492c498c7400666923316b7df3bf9befa0183398.zip
SONAR-17296 build the callback response for displaying the SAML Authentication status.
Diffstat (limited to 'server/sonar-auth-saml/src')
-rw-r--r--server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java86
-rw-r--r--server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticationStatus.java79
-rw-r--r--server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java208
-rw-r--r--server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java134
-rw-r--r--server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java3
-rw-r--r--server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlStatusChecker.java101
-rw-r--r--server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java85
-rw-r--r--server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java4
-rw-r--r--server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java2
-rw-r--r--server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlStatusCheckerTest.java186
-rw-r--r--server/sonar-auth-saml/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker1
-rw-r--r--server/sonar-auth-saml/src/test/resources/samlAuthResultComplete.html208
-rw-r--r--server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html208
13 files changed, 1173 insertions, 132 deletions
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java
new file mode 100644
index 00000000000..0a03099e203
--- /dev/null
+++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java
@@ -0,0 +1,86 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.google.common.io.Resources;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.json.JSONObject;
+
+public class SamlAuthStatusPageGenerator {
+
+ private static final String WEB_CONTEXT = "%WEB_CONTEXT%";
+ private static final String STATUS = "%STATUS%";
+ private static final String ERRORS = "%ERRORS%";
+ private static final String WARNINGS = "%WARNINGS%";
+ private static final String AVAILABLE_ATTRIBUTES = "%AVAILABLE_ATTRIBUTES%";
+ private static final String ATTRIBUTE_MAPPINGS = "%ATTRIBUTE_MAPPINGS%";
+
+ private static final String HTML_TEMPLATE_NAME = "samlAuthResult.html";
+
+ private SamlAuthStatusPageGenerator() {
+ throw new IllegalStateException("This Utility class cannot be instantiated");
+ }
+
+ public static String getSamlAuthStatusHtml(SamlAuthenticationStatus samlAuthenticationStatus) {
+ Map<String, String> substitutionsMap = getSubstitutionsMap(samlAuthenticationStatus);
+ String htmlTemplate = getPlainTemplate();
+
+ return substitutionsMap
+ .keySet()
+ .stream()
+ .reduce(htmlTemplate, (accumulator, pattern) -> accumulator.replace(pattern, substitutionsMap.get(pattern)));
+ }
+
+ private static Map<String, String> getSubstitutionsMap(SamlAuthenticationStatus samlAuthenticationStatus) {
+ return Map.of(
+ WEB_CONTEXT, "",
+ STATUS, toJsString(samlAuthenticationStatus.getStatus()),
+ ERRORS, toJsArrayFromList(samlAuthenticationStatus.getErrors()),
+ WARNINGS, toJsArrayFromList(samlAuthenticationStatus.getWarnings()),
+ AVAILABLE_ATTRIBUTES, new JSONObject(samlAuthenticationStatus.getAvailableAttributes()).toString(),
+ ATTRIBUTE_MAPPINGS, new JSONObject(samlAuthenticationStatus.getMappedAttributes()).toString());
+ }
+
+ private static String toJsString(@Nullable String inputString) {
+ return String.format("'%s'", inputString != null ? inputString.replace("'", "\\'") : "");
+ }
+
+ private static String toJsArrayFromList(List<String> inputArray) {
+ return "[" + inputArray.stream()
+ .map(SamlAuthStatusPageGenerator::toJsString)
+ .collect(Collectors.joining(",")) + "]";
+ }
+
+ private static String getPlainTemplate() {
+ URL url = Resources.getResource(HTML_TEMPLATE_NAME);
+ try {
+ return Resources.toString(url, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IllegalStateException("Cannot read the template " + HTML_TEMPLATE_NAME);
+ }
+ }
+
+}
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticationStatus.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticationStatus.java
new file mode 100644
index 00000000000..90775cc8f67
--- /dev/null
+++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticationStatus.java
@@ -0,0 +1,79 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class SamlAuthenticationStatus {
+
+ private String status = "";
+
+ private Map<String, List<String>> availableAttributes = new HashMap<>();
+
+ private Map<String, Collection<String>> mappedAttributes = new HashMap<>();
+
+ private List<String> errors = new ArrayList<>();
+
+ private List<String> warnings = new ArrayList<>();
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public Map<String, List<String>> getAvailableAttributes() {
+ return availableAttributes;
+ }
+
+ public void setAvailableAttributes(Map<String, List<String>> availableAttributes) {
+ this.availableAttributes = availableAttributes;
+ }
+
+ public Map<String, Collection<String>> getMappedAttributes() {
+ return mappedAttributes;
+ }
+
+ public void setMappedAttributes(Map<String, Collection<String>> mappedAttributes) {
+ this.mappedAttributes = mappedAttributes;
+ }
+
+ public List<String> getErrors() {
+ return errors;
+ }
+
+ public void setErrors(List<String> errors) {
+ this.errors = errors;
+ }
+
+ public List<String> getWarnings() {
+ return warnings;
+ }
+
+ public void setWarnings(List<String> warnings) {
+ this.warnings = warnings;
+ }
+}
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java
new file mode 100644
index 00000000000..0375433e0c1
--- /dev/null
+++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java
@@ -0,0 +1,208 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.Optional;
+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.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;
+import static org.sonar.auth.saml.SamlIdentityProvider.RSA_SHA_256_URL;
+
+public class SamlAuthenticator {
+
+ private static final Logger LOGGER = Loggers.get(SamlAuthenticator.class);
+
+ private static final String ANY_URL = "http://anyurl";
+ private static final String STATE_REQUEST_PARAMETER = "RelayState";
+
+ private final SamlSettings samlSettings;
+ private final SamlMessageIdChecker samlMessageIdChecker;
+
+ public SamlAuthenticator(SamlSettings samlSettings, SamlMessageIdChecker samlMessageIdChecker) {
+ this.samlSettings = samlSettings;
+ this.samlMessageIdChecker = samlMessageIdChecker;
+ }
+
+ public UserIdentity buildUserIdentity(OAuth2IdentityProvider.CallbackContext context, HttpServletRequest processedRequest) {
+ Auth auth = this.initSamlAuth(processedRequest, context.getResponse());
+ processResponse(auth);
+ context.verifyCsrfState(STATE_REQUEST_PARAMETER);
+
+ LOGGER.trace("Name ID : {}", getNameId(auth));
+ checkAuthentication(auth);
+ this.checkMessageId(auth);
+
+
+ LOGGER.trace("Attributes received : {}", getAttributes(auth));
+ String login = this.getLogin(auth);
+ UserIdentity.Builder userIdentityBuilder = UserIdentity.builder()
+ .setProviderLogin(login)
+ .setName(this.getName(auth));
+ this.getEmail(auth).ifPresent(userIdentityBuilder::setEmail);
+ userIdentityBuilder.setGroups(this.getGroups(auth));
+
+ return userIdentityBuilder.build();
+ }
+
+ public void initLogin(String callbackUrl, String relayState, HttpServletRequest request, HttpServletResponse response) {
+ Auth auth = this.initSamlAuth(callbackUrl, request, response);
+ login(auth, relayState);
+ }
+
+ private Auth initSamlAuth(HttpServletRequest request, HttpServletResponse response) {
+ return initSamlAuth(null, request, response);
+ }
+
+ private Auth initSamlAuth(@Nullable String callbackUrl, HttpServletRequest request, HttpServletResponse response) {
+ try {
+ return new Auth(initSettings(callbackUrl), request, response);
+ } catch (SettingsException e) {
+ throw new IllegalStateException("Failed to create a SAML Auth", e);
+ }
+ }
+
+ private Saml2Settings initSettings(@Nullable String callbackUrl) {
+ Map<String, Object> samlData = new HashMap<>();
+ samlData.put("onelogin.saml2.strict", true);
+
+ 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());
+
+ // Service Provider configuration
+ samlData.put("onelogin.saml2.sp.entityid", samlSettings.getApplicationId());
+ if (samlSettings.isSignRequestsEnabled()) {
+ samlData.put("onelogin.saml2.security.authnrequest_signed", true);
+ samlData.put("onelogin.saml2.security.logoutrequest_signed", true);
+ samlData.put("onelogin.saml2.security.logoutresponse_signed", true);
+ samlData.put("onelogin.saml2.sp.x509cert", samlSettings.getServiceProviderCertificate());
+ samlData.put("onelogin.saml2.sp.privatekey",
+ samlSettings.getServiceProviderPrivateKey().orElseThrow(() -> new IllegalArgumentException("Service provider private key is missing")));
+ } else {
+ samlSettings.getServiceProviderPrivateKey().ifPresent(privateKey -> samlData.put("onelogin.saml2.sp.privatekey", privateKey));
+ }
+ samlData.put("onelogin.saml2.security.signature_algorithm", RSA_SHA_256_URL);
+
+ // 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);
+
+ var saml2Settings = new SettingsBuilder().fromValues(samlData).build();
+ if (samlSettings.getServiceProviderPrivateKey().isPresent() && saml2Settings.getSPkey() == null) {
+ LOGGER.error("Error in parsing service provider private key, please make sure that it is in PKCS 8 format.");
+ }
+ return saml2Settings;
+ }
+
+ private static void login(Auth auth, String relayState) {
+ try {
+ auth.login(relayState);
+ } catch (IOException | SettingsException e) {
+ throw new IllegalStateException("Failed to initialize SAML authentication plugin", e);
+ }
+ }
+
+ private static void processResponse(Auth auth) {
+ try {
+ auth.processResponse();
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to process the authentication 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 String getLogin(Auth auth) {
+ return getNonNullFirstAttribute(auth, samlSettings.getUserLogin());
+ }
+
+ private String getName(Auth auth) {
+ return getNonNullFirstAttribute(auth, samlSettings.getUserName());
+ }
+
+ private Optional<String> getEmail(Auth auth) {
+ return samlSettings.getUserEmail().map(userEmailField -> getFirstAttribute(auth, userEmailField));
+ }
+
+ private Set<String> getGroups(Auth auth) {
+ return samlSettings.getGroupName().map(groupsField -> getGroups(auth, groupsField)).orElse(emptySet());
+ }
+
+ 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 static String getNameId(Auth auth) {
+ return auth.getNameId();
+ }
+
+ private static Map<String, List<String>> getAttributes(Auth auth) {
+ return auth.getAttributes();
+ }
+
+ private void checkMessageId(Auth auth) {
+ samlMessageIdChecker.check(auth);
+ }
+}
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java
index 1a8751e9152..796f6a04337 100644
--- a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java
+++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java
@@ -19,44 +19,20 @@
*/
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 java.util.regex.Pattern;
-import javax.annotation.CheckForNull;
-import javax.annotation.Nullable;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
-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 Pattern HTTPS_PATTERN = Pattern.compile("https?://");
- private static final String KEY = "saml";
-
- private static final Logger LOGGER = Loggers.get(SamlIdentityProvider.class);
+ public static final String KEY = "saml";
- private static final String ANY_URL = "http://anyurl";
- private static final String STATE_REQUEST_PARAMETER = "RelayState";
public static final String RSA_SHA_256_URL = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";
private final SamlSettings samlSettings;
@@ -97,12 +73,8 @@ public class SamlIdentityProvider implements OAuth2IdentityProvider {
@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);
- }
+ SamlAuthenticator samlAuthenticator = new SamlAuthenticator(samlSettings, samlMessageIdChecker);
+ samlAuthenticator.initLogin(context.getCallbackUrl(), context.generateCsrfState(), context.getRequest(), context.getResponse());
}
@Override
@@ -116,105 +88,11 @@ public class SamlIdentityProvider implements OAuth2IdentityProvider {
//
HttpServletRequest processedRequest = useProxyHeadersInRequest(context.getRequest());
- Auth auth = newAuth(initSettings(null), processedRequest, context.getResponse());
- processResponse(auth);
- context.verifyCsrfState(STATE_REQUEST_PARAMETER);
-
- LOGGER.trace("Name ID : {}", auth.getNameId());
- checkAuthentication(auth);
- samlMessageIdChecker.check(auth);
-
- LOGGER.trace("Attributes received : {}", auth.getAttributes());
- String login = getNonNullFirstAttribute(auth, samlSettings.getUserLogin());
- UserIdentity.Builder userIdentityBuilder = UserIdentity.builder()
- .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());
+ SamlAuthenticator samlAuthenticator = new SamlAuthenticator(samlSettings, samlMessageIdChecker);
+ UserIdentity userIdentity = samlAuthenticator.buildUserIdentity(context, processedRequest);
+ context.authenticate(userIdentity);
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<>();
- samlData.put("onelogin.saml2.strict", true);
-
- 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());
-
- // Service Provider configuration
- samlData.put("onelogin.saml2.sp.entityid", samlSettings.getApplicationId());
- if (samlSettings.isSignRequestsEnabled()) {
- samlData.put("onelogin.saml2.security.authnrequest_signed", true);
- samlData.put("onelogin.saml2.security.logoutrequest_signed", true);
- samlData.put("onelogin.saml2.security.logoutresponse_signed", true);
- samlData.put("onelogin.saml2.sp.x509cert", samlSettings.getServiceProviderCertificate());
- samlData.put("onelogin.saml2.sp.privatekey",
- samlSettings.getServiceProviderPrivateKey().orElseThrow(() -> new IllegalArgumentException("Service provider private key is missing")));
- } else {
- samlSettings.getServiceProviderPrivateKey().ifPresent(privateKey -> samlData.put("onelogin.saml2.sp.privatekey", privateKey));
- }
- samlData.put("onelogin.saml2.security.signature_algorithm", RSA_SHA_256_URL);
-
- // 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);
-
- var saml2Settings = new SettingsBuilder().fromValues(samlData).build();
- if (samlSettings.getServiceProviderPrivateKey().isPresent() && saml2Settings.getSPkey() == null) {
- LOGGER.error("Error in parsing service provider private key, please make sure that it is in PKCS 8 format.");
- }
- return saml2Settings;
}
private static HttpServletRequest useProxyHeadersInRequest(HttpServletRequest request) {
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java
index 98c1863395f..64d070a807c 100644
--- a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java
+++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java
@@ -30,7 +30,8 @@ public class SamlModule extends Module {
add(
SamlIdentityProvider.class,
SamlMessageIdChecker.class,
- SamlSettings.class);
+ SamlSettings.class,
+ SamlAuthenticator.class);
List<PropertyDefinition> definitions = SamlSettings.definitions();
add(definitions.toArray(new Object[definitions.size()]));
}
diff --git a/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlStatusChecker.java b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlStatusChecker.java
new file mode 100644
index 00000000000..f23b8af9255
--- /dev/null
+++ b/server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlStatusChecker.java
@@ -0,0 +1,101 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static org.sonar.auth.saml.SamlSettings.GROUP_NAME_ATTRIBUTE;
+import static org.sonar.auth.saml.SamlSettings.USER_EMAIL_ATTRIBUTE;
+import static org.sonar.auth.saml.SamlSettings.USER_LOGIN_ATTRIBUTE;
+import static org.sonar.auth.saml.SamlSettings.USER_NAME_ATTRIBUTE;
+
+public class SamlStatusChecker {
+
+ private SamlStatusChecker() {
+ throw new IllegalStateException("This Utility class cannot be instantiated");
+ }
+
+ public static SamlAuthenticationStatus getSamlAuthenticationStatus(Auth auth, SamlSettings samlSettings) {
+
+ SamlAuthenticationStatus samlAuthenticationStatus = new SamlAuthenticationStatus();
+
+ try {
+ auth.processResponse();
+ } catch (Exception e) {
+ samlAuthenticationStatus.getErrors().add(e.getMessage());
+ }
+
+ samlAuthenticationStatus.getErrors().addAll(auth.getErrors().stream().filter(Objects::nonNull).collect(Collectors.toList()));
+ if (auth.getLastErrorReason() != null) {
+ samlAuthenticationStatus.getErrors().add(auth.getLastErrorReason());
+ }
+ samlAuthenticationStatus.setAvailableAttributes(auth.getAttributes());
+ samlAuthenticationStatus.setMappedAttributes(getAttributesMapping(auth, samlSettings));
+
+ samlAuthenticationStatus.setWarnings(samlAuthenticationStatus.getErrors().isEmpty() ? generateWarnings(auth, samlSettings) : new ArrayList<>());
+ samlAuthenticationStatus.setStatus(samlAuthenticationStatus.getErrors().isEmpty() ? "success" : "error");
+
+ return samlAuthenticationStatus;
+
+ }
+
+ private static Map<String, Collection<String>> getAttributesMapping(Auth auth, SamlSettings samlSettings) {
+ Map<String, Collection<String>> attributesMapping = new HashMap<>();
+
+ attributesMapping.put("User login value", auth.getAttribute(samlSettings.getUserLogin()));
+ attributesMapping.put("User name value", auth.getAttribute(samlSettings.getUserName()));
+
+ samlSettings.getUserEmail().ifPresent(emailFieldName -> attributesMapping.put("User email value", auth.getAttribute(emailFieldName)));
+
+ samlSettings.getGroupName().ifPresent(groupFieldName -> attributesMapping.put("Groups value", auth.getAttribute(groupFieldName)));
+
+ return attributesMapping;
+ }
+
+ private static List<String> generateWarnings(Auth auth, SamlSettings samlSettings) {
+ List<String> warnings = new ArrayList<>();
+ warnings.addAll(generateMappingWarnings(auth, samlSettings));
+ return warnings;
+ }
+
+ private static List<String> generateMappingWarnings(Auth auth, SamlSettings samlSettings) {
+ Map<String, String> mappings = Map.of(
+ USER_NAME_ATTRIBUTE, samlSettings.getUserName(),
+ USER_LOGIN_ATTRIBUTE, samlSettings.getUserLogin(),
+ USER_EMAIL_ATTRIBUTE, samlSettings.getUserEmail().orElse(""),
+ GROUP_NAME_ATTRIBUTE, samlSettings.getGroupName().orElse(""));
+ List<String> warnings = new ArrayList<>();
+
+ mappings.forEach((key, value) -> {
+ if (!value.isEmpty() && (auth.getAttribute(value) == null || auth.getAttribute(value).isEmpty())) {
+ warnings.add(String.format("Mapping not found for the property %s, the field %s is not available in the SAML response.", key, value));
+ }
+ });
+
+ return warnings;
+ }
+}
diff --git a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java
new file mode 100644
index 00000000000..966d61d440f
--- /dev/null
+++ b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java
@@ -0,0 +1,85 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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.google.common.io.Resources;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.auth.saml.SamlAuthStatusPageGenerator.getSamlAuthStatusHtml;
+
+public class SamlAuthStatusPageGeneratorTest {
+
+ private final SamlAuthenticationStatus samlAuthenticationStatus = mock(SamlAuthenticationStatus.class);
+
+ private static final String HTML_TEMPLATE_NAME = "samlAuthResultComplete.html";
+ private static final String EMPTY_HTML_TEMPLATE_NAME = "samlAuthResultEmpty.html";
+
+ @Test
+ public void test_full_html_generation() {
+
+ when(samlAuthenticationStatus.getStatus()).thenReturn("success");
+ when(samlAuthenticationStatus.getErrors()).thenReturn(List.of("error1", "error2 'with message'"));
+ when(samlAuthenticationStatus.getWarnings()).thenReturn(List.of("warning1", "warning2 'with message'"));
+ when(samlAuthenticationStatus.getAvailableAttributes()).thenReturn(Map.of("key1", List.of("value1", "value2 with weird chars \n\t\"\\"), "key2", List.of("value3", "value4")));
+ when(samlAuthenticationStatus.getMappedAttributes()).thenReturn(Map.of("key1", List.of("value1", "value2"), "key2", List.of("value3", "value4")));
+
+ String completeHtmlTemplate = getSamlAuthStatusHtml(samlAuthenticationStatus);
+ String expectedTemplate = loadTemplateFromResources(HTML_TEMPLATE_NAME);
+
+ assertEquals(expectedTemplate, completeHtmlTemplate);
+
+ }
+
+ @Test
+ public void test_full_html_generation_with_empty_values() {
+
+ when(samlAuthenticationStatus.getStatus()).thenReturn(null);
+ when(samlAuthenticationStatus.getErrors()).thenReturn(new ArrayList<>());
+ when(samlAuthenticationStatus.getWarnings()).thenReturn(new ArrayList<>());
+ when(samlAuthenticationStatus.getAvailableAttributes()).thenReturn(new HashMap<>());
+ when(samlAuthenticationStatus.getMappedAttributes()).thenReturn(new HashMap<>());
+
+ String completeHtmlTemplate = getSamlAuthStatusHtml(samlAuthenticationStatus);
+ String expectedTemplate = loadTemplateFromResources(EMPTY_HTML_TEMPLATE_NAME);
+
+ assertEquals(expectedTemplate, completeHtmlTemplate);
+
+ }
+
+ private String loadTemplateFromResources(String templateName) {
+ URL url = Resources.getResource(templateName);
+ try {
+ return Resources.toString(url, StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+}
diff --git a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java
index 76ebc901b26..1e7eba451ee 100644
--- a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java
+++ b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java
@@ -127,7 +127,7 @@ public class SamlIdentityProviderTest {
assertThatThrownBy(() -> underTest.init(context))
.isInstanceOf(IllegalStateException.class)
- .hasMessage("Fail to create Auth");
+ .hasMessage("Failed to create a SAML Auth");
}
@Test
@@ -278,7 +278,7 @@ public class SamlIdentityProviderTest {
assertThatThrownBy(() -> underTest.callback(callbackContext))
.isInstanceOf(IllegalStateException.class)
- .hasMessage("Fail to create Auth");
+ .hasMessage("Failed to create a SAML Auth");
}
@Test
diff --git a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java
index f16a9f62a17..688d27d6b6e 100644
--- a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java
+++ b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java
@@ -30,6 +30,6 @@ public class SamlModuleTest {
public void verify_count_of_added_components() {
ListContainer container = new ListContainer();
new SamlModule().configure(container);
- assertThat(container.getAddedObjects()).hasSize(16);
+ assertThat(container.getAddedObjects()).hasSize(17);
}
}
diff --git a/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlStatusCheckerTest.java b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlStatusCheckerTest.java
new file mode 100644
index 00000000000..5fd571394b5
--- /dev/null
+++ b/server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlStatusCheckerTest.java
@@ -0,0 +1,186 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2022 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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.Test;
+import org.sonar.api.config.PropertyDefinitions;
+import org.sonar.api.config.internal.MapSettings;
+import org.sonar.api.utils.System2;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.sonar.auth.saml.SamlSettings.USER_EMAIL_ATTRIBUTE;
+import static org.sonar.auth.saml.SamlSettings.USER_LOGIN_ATTRIBUTE;
+import static org.sonar.auth.saml.SamlStatusChecker.getSamlAuthenticationStatus;
+
+public class SamlStatusCheckerTest {
+
+ private static final String IDP_CERTIFICATE = "-----BEGIN CERTIFICATE-----MIIF5zCCA8+gAwIBAgIUIXv9OVs/XUicgR1bsV9uccYhHfowDQYJKoZIhvcNAQELBQAwgYIxCzAJBgNVBAYTAkFVMQ8wDQYDVQQIDAZHRU5FVkExEDAOBgNVBAcMB1ZFUk5JRVIxDjAMBgNVBAoMBVNPTkFSMQ0wCwYDVQQLDARRVUJFMQ8wDQYDVQQDDAZaaXBlbmcxIDAeBgkqhkiG9w0BCQEWEW5vcmVwbHlAZ21haWwuY29tMB4XDTIyMDYxMzEzMTQyN1oXDTMyMDYxMDEzMTQyN1owgYIxCzAJBgNVBAYTAkFVMQ8wDQYDVQQIDAZHRU5FVkExEDAOBgNVBAcMB1ZFUk5JRVIxDjAMBgNVBAoMBVNPTkFSMQ0wCwYDVQQLDARRVUJFMQ8wDQYDVQQDDAZaaXBlbmcxIDAeBgkqhkiG9w0BCQEWEW5vcmVwbHlAZ21haWwuY29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAu3nFXYvIYedpR84aZkdo/3yB5XHM+YCFJcDsVO10zEblLknfQsiMPa1Xd9Ustnpxw6P/SyzIJmO9jiMOdeCeY98a74jP7d4JPaO6h3l9IbWAcYeijQg956nlsVFY3FHDGr+7Pb8QcOAyV3v89jiF9DFB8wXS+5UfYr2OfoRRb4li39ezDyDdl5OLlM11nEss2z1mEv+sUUloTcyrgj37Psgewkvyym6tFGSgkV9Za4SVRhHFyThY1VFrYZSJFTnapUYaRc7kMxzwX/AAHUDJrmYcaVc5B8ODp4w2AxDJheQyCVfXjPFaUqBMG2U/rYfVXu0Za7Pn/vUo4UaSThwCBKDehCwz+65TLdA+NxyGDxnvY/SksOyLLGCmu8tKkXdu0pznnIhBXEGvjUIVS7d6a/8geg91NoTWau3i0RF+Dw/5N9DSzpld15bPtb5Ce3Bie19uvfvuH9eg+D8x/hfF6f3il4sPlIKdO/OVdM28LRfmDqmqQNPudvbqz7xy4ARuxk6ARa4d+aT9zovpwvxNGTr7h1mdgOUtUCdIXL3SHNjdwdAAz0uCWzvExbFu+NQ+V5+Xnkx71hyPFv9+DLVGIu7JhdYs806wKshO13Nga38ig6gu37lpVhfpZXhKywUiigG6LXAeyWWkMk+vlf9McZdMBD16dZP4kTsvP+rPVnUCAwEAAaNTMFEwHQYDVR0OBBYEFI5UVLtTySvbGqH7UP8xTL4wxZq3MB8GA1UdIwQYMBaAFI5UVLtTySvbGqH7UP8xTL4wxZq3MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBABAtXsKNWx0sDDFA53qZ1zRyWKWAMoh95pawFCrKgTEW4ZrA73pa790eE1Y+vT6qUXKI4li9skIDa+6psCdxhZIrHPRAnVZVeB2373Bxr5bw/XQ8elRCjWeMULbYJ9tgsLV0I9CiEP0a6Tm8t0yDVXNUfx36E5fkgLSrxoRo8XJzxHbJCnLVXHdaNBxOT7jVcom6Wo4PB2bsjVzhHm6amn5hZp4dMHm0Mv0ln1wH8jVnizHQBLsGMzvvl58+9s1pP17ceRDkpNDz+EQyA+ZArqkW1MqtwVhbzz8QgMprhflKkArrsC7v06Jv8fqUbn9LvtYK9IwHTX7J8dFcsO/gUC5PevYT3nriN3Azb20ggSQ1yOEMozvj5T96S6itfHPit7vyEQ84JPrEqfuQDZQ/LKZQqfvuXX1aAG3TU3TMWB9VMMFsTuMFS8bfrhMX77g0Ud4qJcBOYOH3hR59agSdd2QZNLP3zZsYQHLLQkq94jdTXKTqm/w7mlPFKV59HjTbHBhTtxBHMft/mvvLEuC9KKFfAOXYQ6V+s9Nk0BW4ggEfewaX58OBuy7ISqRtRFPGia18YRzzHqkhjubJYMPkIfYpFVd+C0II3F0kdy8TtpccjyKo9bcHMLxO4n8PDAl195CPthMi8gUvT008LGEotr+3kXsouTEZTT0glXKLdO2W-----END CERTIFICATE-----";
+
+ private static final String SP_CERTIFICATE = "MIICoTCCAYkCBgGBXPscaDANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDDAlzb25hcnF1YmUwHhcNMjIwNjEzMTIxMTA5WhcNMzIwNjEzMTIxMjQ5WjAUMRIwEAYDVQQDDAlzb25hcnF1YmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDSFoT371C0/klZuPgvKbGItkmTaf5CweNXL8u389d98aOXRpDQ7maTXdV/W+VcL8vUWg8yG6nn8CRwweYnGTNdn9UAdhgknvxQe3pq3EwOJyls4Fpiq6YTh+DQfiZUQizjFjDOr/GG5O2lNvTRkI4XZj/XnWjRqVZwttiA5tm1sKkvGdyOQljwn4Jja/VbITdV8GASumx66Bil/wamSsqIzm2RjsOOGSsf5VjYUPwDobpuSf+j4DLtWjem/9vIzI2wcE30uC8LBAgO3JAlIS9NQrchjS9xhMJRohOoitaSPmqsOy7D2BH0h7XX6TNgv/WYTkBY4eZPao3PsL2A6AmhAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAMBmTHUK4w+DX21tmhqdwq0WqLH5ZAkwtiocDxFXiJ4GRrUWUh3BaXsgOHB8YYnNTDfScjaU0sZMEyfC0su1zsN8B7NFckg7RcZCHuBYdgIEAmvK4YM6s6zNsiKKwt66p2MNeL+o0acrT2rYjQ1L5QDj0gpfJQAT4N7xTZfuSc2iwjotaQfvcgsO8EZlcDVrL4UuyWLbuRUlSQjxHWGYaxCW+I3enK1+8fGpF3O+k9ZQ8xt5nJsalpsZvHcPLA4IBOmjsSHqSkhg4EIAWL/sJZ1KNct4hHh5kToUTu+Q6e949VeBkWgj4O+rcGDgiN2frGiEEc0EMv8KCSENRRRrO2k=";
+ private static final String SP_PRIVATE_KEY = "-----BEGIN PRIVATE KEY-----MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDSFoT371C0/klZuPgvKbGItkmTaf5CweNXL8u389d98aOXRpDQ7maTXdV/W+VcL8vUWg8yG6nn8CRwweYnGTNdn9UAdhgknvxQe3pq3EwOJyls4Fpiq6YTh+DQfiZUQizjFjDOr/GG5O2lNvTRkI4XZj/XnWjRqVZwttiA5tm1sKkvGdyOQljwn4Jja/VbITdV8GASumx66Bil/wamSsqIzm2RjsOOGSsf5VjYUPwDobpuSf+j4DLtWjem/9vIzI2wcE30uC8LBAgO3JAlIS9NQrchjS9xhMJRohOoitaSPmqsOy7D2BH0h7XX6TNgv/WYTkBY4eZPao3PsL2A6AmhAgMBAAECggEBAJj11HJAR96/leBBkFGmZaBIOGGgNoOcb023evfADhGgsZ8evamhKgX5t8w2uFPaaOl/eLje82Hvslh2lH+7FW8BRDBFy2Y+ay6d+I99PdLAKKUg5C4bE5v8vm6OqpGGbPAZ5AdYit3QKEa2MKG0QgA/bhQqg3rDdDA0sIWJjtF9MLv7LI7Tm0qgiHOKsI0MEBFk+ZoibgKWYh/dnfGDRWyC3Puqe13rdSheNJYUDR/0QMkd/EJNpLWv06uk+w8w2lU4RgN6TiV76ZZUIGZAAHFgMELJysgtBTCkOQY5roPu17OmMZjKfxngeIfNyd42q3/T6DmUbbwNYfP2HRMoiMECgYEA6SVc1mZ4ykytC9M61rZwT+2zXtJKudQVa0qpTtkf0aznRmnDOuc1bL7ewKIIIp9r5HKVteO6SKgpHmrP+qmvbwZ0Pz51Zg0MetoSmT9m0599/tOU2k6OI09dvQ4Xa3ccN5Czl61Q/HkMeAIDny8MrhGVBwhallE4J4fm/OjuVK0CgYEA5q6IVgqZtfcV1azIF6uOFt6blfn142zrwq0fF39jog2f+4jXaBKw6L4aP0HvIL83UArGppYY31894bLb6YL4EjS2JNbABM2VnJpJd4oGopOE42GCZlZRpf751zOptYAN23NFSujLlfaUfMbyrqIbRFC2DCdzNTU50GT5SAXX80UCgYEAlyvQvHwJCjMZaTd3SU1WGZ1o1qzIIyHvGXh5u1Rxm0TfWPquyfys2WwRhxoI6FoyXRgnFp8oZIAU2VIstL1dsUGgEnnvKVKAqw/HS3Keu80IpziNpdeVtjN59mGysc2zkBvVNx38Cxh6Cz5TFt4s/JkN5ld2VU0oeglWrtph3qkCgYALszZ/BrKdJBcba1QKv0zJpCjIBpGOI2whx54YFwH6qi4/F8W1JZ2LcHjsVG/IfWpUyPciY+KHEdGVrPiyc04Zvkquu6WpmLPJ6ZloUrvbaxgGYF+4yRADF1ecrqYg6onJY6NUFVKeHI+TdJPCf75aTK2vGCEjxbtU8ooiOQmm8QKBgEGe9ZdrwTP9rMQ35jYtzU+dT06k1r9BE9Q8CmrXl0HwK717ZWboX4J0YoFjxZC8PDsMl3p46MJ83rKbLU728uKig1AkZo7/OedxTWvezjZ1+lDyjC2EguXbgY1ecSC2HbJh9g+v8RUuhWxuA7RYoW92xVtKj+6l4vMadVP4Myp8-----END PRIVATE KEY-----";
+
+ private final MapSettings settings = new MapSettings(new PropertyDefinitions(System2.INSTANCE, SamlSettings.definitions()));
+
+ private final Auth auth = mock(Auth.class);
+
+ private SamlAuthenticationStatus samlAuthenticationStatus;
+
+ @Test
+ public void authentication_status_is_success_when_no_errors() {
+ setSettings();
+
+ when(auth.getErrors()).thenReturn(new ArrayList<String>());
+ when(auth.getAttributes()).thenReturn(getEmptyAttributes());
+
+ samlAuthenticationStatus = getSamlAuthenticationStatus(auth, new SamlSettings(settings.asConfig()));
+
+ assertEquals("success", samlAuthenticationStatus.getStatus());
+ assertTrue(samlAuthenticationStatus.getErrors().isEmpty());
+ }
+
+ @Test
+ public void authentication_status_is_unsuccessful_when_errors_are_reported() {
+ setSettings();
+ when(auth.getErrors()).thenReturn(Collections.singletonList("Error in Authentication"));
+ when(auth.getLastErrorReason()).thenReturn("Authentication failed due to a missing parameter.");
+ when(auth.getAttributes()).thenReturn(getEmptyAttributes());
+
+ samlAuthenticationStatus = getSamlAuthenticationStatus(auth, new SamlSettings(settings.asConfig()));
+
+ assertEquals("error", samlAuthenticationStatus.getStatus());
+ assertFalse(samlAuthenticationStatus.getErrors().isEmpty());
+ assertEquals(2, samlAuthenticationStatus.getErrors().size());
+ assertTrue(samlAuthenticationStatus.getErrors().contains("Authentication failed due to a missing parameter."));
+ assertTrue(samlAuthenticationStatus.getErrors().contains("Error in Authentication"));
+ }
+
+ @Test
+ public void authentication_status_is_unsuccessful_when_processResponse_throws_exception() {
+ setSettings();
+ try {
+ doThrow(new Exception("Exception when processing the response")).when(auth).processResponse();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ when(auth.getAttributes()).thenReturn(getEmptyAttributes());
+
+ samlAuthenticationStatus = getSamlAuthenticationStatus(auth, new SamlSettings(settings.asConfig()));
+
+ assertEquals("error", samlAuthenticationStatus.getStatus());
+ assertFalse(samlAuthenticationStatus.getErrors().isEmpty());
+ assertEquals(1, samlAuthenticationStatus.getErrors().size());
+ assertTrue(samlAuthenticationStatus.getErrors().contains("Exception when processing the response"));
+ }
+
+ @Test
+ public void authentication_has_warnings_when_mappings_are_not_correct() {
+ setSettings();
+ settings.setProperty(USER_LOGIN_ATTRIBUTE, "wrongLoginField");
+ settings.setProperty(USER_EMAIL_ATTRIBUTE, "wrongEmailField");
+ when(auth.getErrors()).thenReturn(new ArrayList<String>());
+ when(auth.getAttributes()).thenReturn(getResponseAttributes());
+ getResponseAttributes().forEach((key, value) -> when(auth.getAttribute(key)).thenReturn(value));
+
+ samlAuthenticationStatus = getSamlAuthenticationStatus(auth, new SamlSettings(settings.asConfig()));
+
+ assertEquals("success", samlAuthenticationStatus.getStatus());
+ assertTrue(samlAuthenticationStatus.getErrors().isEmpty());
+ assertEquals(2, samlAuthenticationStatus.getWarnings().size());
+ assertTrue(samlAuthenticationStatus.getWarnings()
+ .contains(String.format("Mapping not found for the property %s, the field %s is not available in the SAML response.", USER_LOGIN_ATTRIBUTE, "wrongLoginField")));
+ assertTrue(samlAuthenticationStatus.getWarnings()
+ .contains(String.format("Mapping not found for the property %s, the field %s is not available in the SAML response.", USER_EMAIL_ATTRIBUTE, "wrongEmailField")));
+ }
+
+ @Test
+ public void authentication_has_no_warnings_when_optional_mappings_are_not_provided() {
+ setSettings();
+ settings.setProperty(USER_EMAIL_ATTRIBUTE, (String) null);
+ when(auth.getErrors()).thenReturn(new ArrayList<String>());
+ when(auth.getAttributes()).thenReturn(getResponseAttributes());
+ getResponseAttributes().forEach((key, value) -> when(auth.getAttribute(key)).thenReturn(value));
+
+ samlAuthenticationStatus = getSamlAuthenticationStatus(auth, new SamlSettings(settings.asConfig()));
+
+ assertEquals("success", samlAuthenticationStatus.getStatus());
+ assertTrue(samlAuthenticationStatus.getErrors().isEmpty());
+ assertTrue(samlAuthenticationStatus.getWarnings().isEmpty());
+ }
+
+ @Test
+ public void mapped_attributes_are_complete_when_mapping_fields_are_correct() {
+ setSettings();
+ when(auth.getErrors()).thenReturn(new ArrayList<String>());
+ when(auth.getAttributes()).thenReturn(getResponseAttributes());
+ getResponseAttributes().forEach((key, value) -> when(auth.getAttribute(key)).thenReturn(value));
+
+ samlAuthenticationStatus = getSamlAuthenticationStatus(auth, new SamlSettings(settings.asConfig()));
+
+ assertEquals("success", samlAuthenticationStatus.getStatus());
+ assertTrue(samlAuthenticationStatus.getErrors().isEmpty());
+ assertTrue(samlAuthenticationStatus.getWarnings().isEmpty());
+ assertEquals(4, samlAuthenticationStatus.getAvailableAttributes().size());
+ assertEquals(4, samlAuthenticationStatus.getMappedAttributes().size());
+
+ assertTrue(samlAuthenticationStatus.getAvailableAttributes().keySet().containsAll(Set.of("login", "name", "email", "groups")));
+ assertTrue(samlAuthenticationStatus.getMappedAttributes().keySet().containsAll(Set.of("User login value", "User name value", "User email value", "Groups value")));
+ }
+
+ private void setSettings() {
+ 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", IDP_CERTIFICATE);
+ settings.setProperty("sonar.auth.saml.sp.privateKey.secured", SP_PRIVATE_KEY);
+ settings.setProperty("sonar.auth.saml.sp.certificate.secured", SP_CERTIFICATE);
+ 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");
+ }
+
+ private Map<String, List<String>> getResponseAttributes() {
+ return Map.of(
+ "login", Collections.singletonList("loginId"),
+ "name", Collections.singletonList("userName"),
+ "email", Collections.singletonList("user@sonar.com"),
+ "groups", List.of("group1", "group2"));
+ }
+
+ private Map<String, List<String>> getEmptyAttributes() {
+ return Map.of(
+ "login", Collections.singletonList(""),
+ "name", Collections.singletonList(""),
+ "email", Collections.singletonList(""),
+ "groups", Collections.singletonList(""));
+ }
+
+}
diff --git a/server/sonar-auth-saml/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/server/sonar-auth-saml/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 00000000000..1f0955d450f
--- /dev/null
+++ b/server/sonar-auth-saml/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/server/sonar-auth-saml/src/test/resources/samlAuthResultComplete.html b/server/sonar-auth-saml/src/test/resources/samlAuthResultComplete.html
new file mode 100644
index 00000000000..fd3b9ac729a
--- /dev/null
+++ b/server/sonar-auth-saml/src/test/resources/samlAuthResultComplete.html
@@ -0,0 +1,208 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8" charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+ <link rel="apple-touch-icon" sizes="57x57" href="/apple-touch-icon-57x57.png" />
+ <link rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-60x60.png" />
+ <link rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-72x72.png" />
+ <link rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-76x76.png" />
+ <link
+ rel="apple-touch-icon"
+ sizes="114x114"
+ href="/apple-touch-icon-114x114.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="120x120"
+ href="/apple-touch-icon-120x120.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="144x144"
+ href="/apple-touch-icon-144x144.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="152x152"
+ href="/apple-touch-icon-152x152.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="180x180"
+ href="/apple-touch-icon-180x180.png"
+ />
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+ <meta name="application-name" content="SonarQube" />
+ <meta name="msapplication-TileColor" content="#FFFFFF" />
+ <meta name="msapplication-TileImage" content="/mstile-512x512.png" />
+ <title>SAML Authentication Test</title>
+
+ <style>
+ body {
+ background-color: #f3f3f3;
+ }
+
+ h1 {
+ margin: 0 8px 8px;
+ }
+ h2 {
+ margin: 0 0 8px;
+ }
+
+ ul {
+ list-style: none;
+ margin: 0 8px;
+ padding: 0;
+ }
+
+ li + li {
+ padding-top: 12px;
+ margin-top: 12px;
+ border-top: 1px solid rgba(150, 150, 150, 0.5);
+ }
+
+ table {
+ border-collapse: collapse;
+ }
+
+ tr:nth-child(2n) {
+ background-color: #e6e6e6;
+ }
+
+ td {
+ border: 1px solid #a3a3a3;
+ padding: 4px 24px 4px 8px;
+ vertical-align: top;
+ font-family: "Courier New", Courier, monospace;
+ }
+
+ #content {
+ padding: 16px;
+ }
+
+ .box {
+ padding: 8px;
+ margin: 8px;
+ border: 1px solid #e6e6e6;
+ background-color: white;
+ box-shadow: 2px 2px 3px 0px rgba(0, 0, 0, 0.5);
+ }
+
+ #status {
+ padding: 16px 8px;
+ font-size: large;
+ color: white;
+ }
+
+ .error {
+ background-color: #d02f3a;
+ }
+
+ .success {
+ background-color: #008a25;
+ }
+ </style>
+ </head>
+
+ <body>
+ <div id="content">
+ <h1>SAML Authentication Test</h1>
+ <div class="box">
+ <div id="status"></div>
+ </div>
+ </div>
+
+ <script>
+ function createBox() {
+ const box = document.createElement("div");
+ box.className = "box";
+ return box;
+ }
+
+ function createSectionTitle(title) {
+ const node = document.createElement("h2");
+ node.innerText = title;
+ return node;
+ }
+
+ function createList(arr, className = "") {
+ const list = document.createElement("ul");
+
+ arr.forEach((item) => {
+ const message = document.createElement("li");
+ message.className = className;
+ message.innerText = item;
+ list.appendChild(message);
+ });
+
+ return list;
+ }
+
+ function createTable(obj) {
+ const table = document.createElement("table");
+ const tbody = document.createElement("tbody");
+ table.appendChild(tbody);
+
+ Object.keys(obj).forEach((key) => {
+ const row = document.createElement("tr");
+
+ const keyNode = document.createElement("td");
+ keyNode.innerText = key;
+ row.appendChild(keyNode);
+
+ const valueNode = document.createElement("td");
+ // wrap in array, to handle single values as well
+ valueNode.innerHTML = [].concat(obj[key]).join("<br />");
+ row.appendChild(valueNode);
+
+ tbody.appendChild(row);
+ });
+
+ return table;
+ }
+
+ function addSection(container, title, contents) {
+ const box = createBox();
+
+ box.appendChild(createSectionTitle(title));
+ box.appendChild(contents);
+
+ container.appendChild(box);
+ }
+
+ const status = 'success';
+ const attributes = {"key1":["value1","value2 with weird chars \n\t\"\\"],"key2":["value3","value4"]};
+ const mappings = {"key1":["value1","value2"],"key2":["value3","value4"]};
+ const errors = ['error1','error2 \'with message\''];
+ const warnings = ['warning1','warning2 \'with message\''];
+
+ // Switch status class
+ const statusNode = document.querySelector("#status");
+ statusNode.classList.add(status);
+ statusNode.innerText = status;
+
+ // generate content
+ const container = document.querySelector("#content");
+
+ if (warnings && warnings.length > 0) {
+ addSection(container, "Warnings", createList(warnings));
+ }
+
+ if (status === "error" && errors && errors.length > 0) {
+ addSection(container, "Errors", createList(errors));
+ }
+
+ if (status === "success") {
+ if (attributes && Object.keys(attributes).length > 0) {
+ addSection(container, "Available attributes", createTable(attributes));
+ }
+
+ if (mappings && Object.keys(mappings).length > 0) {
+ addSection(container, "Attribute mappings", createTable(mappings));
+ }
+ }
+ </script>
+ </body>
+</html>
diff --git a/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html b/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html
new file mode 100644
index 00000000000..febbb26026f
--- /dev/null
+++ b/server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html
@@ -0,0 +1,208 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8" charset="UTF-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
+ <link rel="apple-touch-icon" sizes="57x57" href="/apple-touch-icon-57x57.png" />
+ <link rel="apple-touch-icon" sizes="60x60" href="/apple-touch-icon-60x60.png" />
+ <link rel="apple-touch-icon" sizes="72x72" href="/apple-touch-icon-72x72.png" />
+ <link rel="apple-touch-icon" sizes="76x76" href="/apple-touch-icon-76x76.png" />
+ <link
+ rel="apple-touch-icon"
+ sizes="114x114"
+ href="/apple-touch-icon-114x114.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="120x120"
+ href="/apple-touch-icon-120x120.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="144x144"
+ href="/apple-touch-icon-144x144.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="152x152"
+ href="/apple-touch-icon-152x152.png"
+ />
+ <link
+ rel="apple-touch-icon"
+ sizes="180x180"
+ href="/apple-touch-icon-180x180.png"
+ />
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
+ <meta name="application-name" content="SonarQube" />
+ <meta name="msapplication-TileColor" content="#FFFFFF" />
+ <meta name="msapplication-TileImage" content="/mstile-512x512.png" />
+ <title>SAML Authentication Test</title>
+
+ <style>
+ body {
+ background-color: #f3f3f3;
+ }
+
+ h1 {
+ margin: 0 8px 8px;
+ }
+ h2 {
+ margin: 0 0 8px;
+ }
+
+ ul {
+ list-style: none;
+ margin: 0 8px;
+ padding: 0;
+ }
+
+ li + li {
+ padding-top: 12px;
+ margin-top: 12px;
+ border-top: 1px solid rgba(150, 150, 150, 0.5);
+ }
+
+ table {
+ border-collapse: collapse;
+ }
+
+ tr:nth-child(2n) {
+ background-color: #e6e6e6;
+ }
+
+ td {
+ border: 1px solid #a3a3a3;
+ padding: 4px 24px 4px 8px;
+ vertical-align: top;
+ font-family: "Courier New", Courier, monospace;
+ }
+
+ #content {
+ padding: 16px;
+ }
+
+ .box {
+ padding: 8px;
+ margin: 8px;
+ border: 1px solid #e6e6e6;
+ background-color: white;
+ box-shadow: 2px 2px 3px 0px rgba(0, 0, 0, 0.5);
+ }
+
+ #status {
+ padding: 16px 8px;
+ font-size: large;
+ color: white;
+ }
+
+ .error {
+ background-color: #d02f3a;
+ }
+
+ .success {
+ background-color: #008a25;
+ }
+ </style>
+ </head>
+
+ <body>
+ <div id="content">
+ <h1>SAML Authentication Test</h1>
+ <div class="box">
+ <div id="status"></div>
+ </div>
+ </div>
+
+ <script>
+ function createBox() {
+ const box = document.createElement("div");
+ box.className = "box";
+ return box;
+ }
+
+ function createSectionTitle(title) {
+ const node = document.createElement("h2");
+ node.innerText = title;
+ return node;
+ }
+
+ function createList(arr, className = "") {
+ const list = document.createElement("ul");
+
+ arr.forEach((item) => {
+ const message = document.createElement("li");
+ message.className = className;
+ message.innerText = item;
+ list.appendChild(message);
+ });
+
+ return list;
+ }
+
+ function createTable(obj) {
+ const table = document.createElement("table");
+ const tbody = document.createElement("tbody");
+ table.appendChild(tbody);
+
+ Object.keys(obj).forEach((key) => {
+ const row = document.createElement("tr");
+
+ const keyNode = document.createElement("td");
+ keyNode.innerText = key;
+ row.appendChild(keyNode);
+
+ const valueNode = document.createElement("td");
+ // wrap in array, to handle single values as well
+ valueNode.innerHTML = [].concat(obj[key]).join("<br />");
+ row.appendChild(valueNode);
+
+ tbody.appendChild(row);
+ });
+
+ return table;
+ }
+
+ function addSection(container, title, contents) {
+ const box = createBox();
+
+ box.appendChild(createSectionTitle(title));
+ box.appendChild(contents);
+
+ container.appendChild(box);
+ }
+
+ const status = '';
+ const attributes = {};
+ const mappings = {};
+ const errors = [];
+ const warnings = [];
+
+ // Switch status class
+ const statusNode = document.querySelector("#status");
+ statusNode.classList.add(status);
+ statusNode.innerText = status;
+
+ // generate content
+ const container = document.querySelector("#content");
+
+ if (warnings && warnings.length > 0) {
+ addSection(container, "Warnings", createList(warnings));
+ }
+
+ if (status === "error" && errors && errors.length > 0) {
+ addSection(container, "Errors", createList(errors));
+ }
+
+ if (status === "success") {
+ if (attributes && Object.keys(attributes).length > 0) {
+ addSection(container, "Available attributes", createTable(attributes));
+ }
+
+ if (mappings && Object.keys(mappings).length > 0) {
+ addSection(container, "Attribute mappings", createTable(mappings));
+ }
+ }
+ </script>
+ </body>
+</html>