Browse Source

SONAR-17296 build the callback response for displaying the SAML Authentication status.

tags/9.7.0.61563
Matteo Mara 1 year ago
parent
commit
492c498c74
17 changed files with 1339 additions and 132 deletions
  1. 1
    0
      server/sonar-auth-saml/build.gradle
  2. 86
    0
      server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java
  3. 79
    0
      server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticationStatus.java
  4. 208
    0
      server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java
  5. 6
    128
      server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java
  6. 2
    1
      server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java
  7. 101
    0
      server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlStatusChecker.java
  8. 85
    0
      server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java
  9. 2
    2
      server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java
  10. 1
    1
      server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java
  11. 186
    0
      server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlStatusCheckerTest.java
  12. 1
    0
      server/sonar-auth-saml/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
  13. 208
    0
      server/sonar-auth-saml/src/test/resources/samlAuthResultComplete.html
  14. 208
    0
      server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html
  15. 3
    0
      server/sonar-webserver-webapi/build.gradle
  16. 71
    0
      server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/SamlValidationInitAction.java
  17. 91
    0
      server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/SamlValidationInitActionTest.java

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

@@ -17,6 +17,7 @@ dependencies {
compileOnly 'com.google.code.findbugs:jsr305'
compileOnly 'com.squareup.okhttp3:okhttp'
compileOnly 'javax.servlet:javax.servlet-api'
compileOnly 'org.json:json'
compileOnly project(':server:sonar-db-dao')
compileOnly project(':sonar-core')


+ 86
- 0
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthStatusPageGenerator.java View File

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

}

+ 79
- 0
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticationStatus.java View File

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

+ 208
- 0
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlAuthenticator.java View File

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

+ 6
- 128
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlIdentityProvider.java View File

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

+ 2
- 1
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlModule.java View File

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

+ 101
- 0
server/sonar-auth-saml/src/main/java/org/sonar/auth/saml/SamlStatusChecker.java View File

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

+ 85
- 0
server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlAuthStatusPageGeneratorTest.java View File

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

}

+ 2
- 2
server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlIdentityProviderTest.java View File

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

+ 1
- 1
server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlModuleTest.java View File

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

+ 186
- 0
server/sonar-auth-saml/src/test/java/org/sonar/auth/saml/SamlStatusCheckerTest.java View File

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

}

+ 1
- 0
server/sonar-auth-saml/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker View File

@@ -0,0 +1 @@
mock-maker-inline

+ 208
- 0
server/sonar-auth-saml/src/test/resources/samlAuthResultComplete.html View File

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

+ 208
- 0
server/sonar-auth-saml/src/test/resources/samlAuthResultEmpty.html View File

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

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

@@ -22,6 +22,9 @@ dependencies {
compile project(':server:sonar-webserver-ws')
compile project(':server:sonar-webserver-pushapi')
compile project(':server:sonar-alm-client')
compile (project(':server:sonar-auth-saml')) {
exclude group:'org.apache.santuario'
}
compile project(':sonar-scanner-protocol')

compileOnly 'com.google.code.findbugs:jsr305'

+ 71
- 0
server/sonar-webserver-webapi/src/main/java/org/sonar/server/saml/ws/SamlValidationInitAction.java View File

@@ -0,0 +1,71 @@
/*
* 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.server.saml.ws;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.sonar.api.server.ws.WebService;
import org.sonar.api.web.ServletFilter;
import org.sonar.auth.saml.SamlAuthenticator;
import org.sonar.auth.saml.SamlIdentityProvider;
import org.sonar.server.authentication.OAuth2ContextFactory;
import org.sonar.server.ws.ServletFilterHandler;

public class SamlValidationInitAction extends ServletFilter implements SamlAction {

public static final String VALIDATION_RELAY_STATE = "validation-query";
private final SamlAuthenticator samlAuthenticator;
private final OAuth2ContextFactory oAuth2ContextFactory;

public SamlValidationInitAction(SamlAuthenticator samlAuthenticator, OAuth2ContextFactory oAuth2ContextFactory) {
this.samlAuthenticator = samlAuthenticator;
this.oAuth2ContextFactory = oAuth2ContextFactory;
}

@Override
public UrlPattern doGetPattern() {
return UrlPattern.create("/api/saml/validation_init");
}

@Override
public void define(WebService.NewController controller) {
controller
.createAction("validation_init")
.setInternal(true)
.setPost(false)
.setHandler(ServletFilterHandler.INSTANCE)
.setDescription("Initiate a SAML request to the identity Provider for configuration validation purpose.")
.setSince("9.7");
}

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;

samlAuthenticator.initLogin(oAuth2ContextFactory.generateCallbackUrl(SamlIdentityProvider.KEY),
VALIDATION_RELAY_STATE, request, response);
}
}

+ 91
- 0
server/sonar-webserver-webapi/src/test/java/org/sonar/server/saml/ws/SamlValidationInitActionTest.java View File

@@ -0,0 +1,91 @@
/*
* 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.server.saml.ws;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.junit.Before;
import org.junit.Test;
import org.sonar.api.server.ws.WebService;
import org.sonar.auth.saml.SamlAuthenticator;
import org.sonar.server.authentication.OAuth2ContextFactory;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.matches;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

public class SamlValidationInitActionTest {

private SamlValidationInitAction underTest;
private SamlAuthenticator samlAuthenticator;
private OAuth2ContextFactory oAuth2ContextFactory;

@Before
public void setUp() throws Exception {
samlAuthenticator = mock(SamlAuthenticator.class);
oAuth2ContextFactory = mock(OAuth2ContextFactory.class);
underTest = new SamlValidationInitAction(samlAuthenticator, oAuth2ContextFactory);
}

@Test
public void do_get_pattern() {
assertThat(underTest.doGetPattern().matches("/api/saml/validation_init")).isTrue();
assertThat(underTest.doGetPattern().matches("/api/saml")).isFalse();
assertThat(underTest.doGetPattern().matches("/api/saml/validation_init2")).isFalse();
}


@Test
public void do_filter() throws IOException, ServletException {
HttpServletRequest servletRequest = mock(HttpServletRequest.class);
HttpServletResponse servletResponse = mock(HttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
String callbackUrl = "http://localhost:9000/api/validation_test";
when(oAuth2ContextFactory.generateCallbackUrl(anyString()))
.thenReturn(callbackUrl);

underTest.doFilter(servletRequest, servletResponse, filterChain);

verify(samlAuthenticator).initLogin(matches(callbackUrl),
matches(SamlValidationInitAction.VALIDATION_RELAY_STATE),
any(), any());
}

@Test
public void verify_definition() {
String controllerKey = "foo";
WebService.Context context = new WebService.Context();
WebService.NewController newController = context.createController(controllerKey);
underTest.define(newController);
newController.done();

WebService.Action validationInitAction = context.controller(controllerKey).action("validation_init");
assertThat(validationInitAction).isNotNull();
assertThat(validationInitAction.description()).isNotEmpty();
assertThat(validationInitAction.handler()).isNotNull();
}
}

Loading…
Cancel
Save