diff options
author | Matteo Mara <matteo.mara@sonarsource.com> | 2022-09-12 14:05:35 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-09-15 20:03:03 +0000 |
commit | 492c498c7400666923316b7df3bf9befa0183398 (patch) | |
tree | 79a2cdcf0f09b1077009f934b889ab101ae8820e /server/sonar-auth-saml/src | |
parent | 934ad1652a7a1e528bb1165cc8cc3e096b1cc208 (diff) | |
download | sonarqube-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')
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> |