diff --git a/api/pom.xml b/api/pom.xml index abfa2c59e02..ba547dfa47a 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -35,6 +35,11 @@ com.google.code.gson gson + + javax.servlet + servlet-api + ${cs.servlet.version} + org.apache.cloudstack cloud-framework-db diff --git a/api/src/org/apache/cloudstack/api/ApiConstants.java b/api/src/org/apache/cloudstack/api/ApiConstants.java index f89aa14df15..6baa95c39ab 100755 --- a/api/src/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/org/apache/cloudstack/api/ApiConstants.java @@ -514,6 +514,7 @@ public class ApiConstants { public static final String VMPROFILE_ID = "vmprofileid"; public static final String VMGROUP_ID = "vmgroupid"; public static final String CS_URL = "csurl"; + public static final String IDP_URL = "idpurl"; public static final String SCALEUP_POLICY_IDS = "scaleuppolicyids"; public static final String SCALEDOWN_POLICY_IDS = "scaledownpolicyids"; public static final String SCALEUP_POLICIES = "scaleuppolicies"; diff --git a/server/src/com/cloud/api/ApiServerService.java b/api/src/org/apache/cloudstack/api/ApiServerService.java similarity index 84% rename from server/src/com/cloud/api/ApiServerService.java rename to api/src/org/apache/cloudstack/api/ApiServerService.java index 5d078c31605..69215c51658 100644 --- a/server/src/com/cloud/api/ApiServerService.java +++ b/api/src/org/apache/cloudstack/api/ApiServerService.java @@ -14,23 +14,19 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -package com.cloud.api; - -import java.util.Map; - -import javax.servlet.http.HttpSession; - -import org.apache.cloudstack.api.ServerApiException; +package org.apache.cloudstack.api; import com.cloud.exception.CloudAuthenticationException; +import javax.servlet.http.HttpSession; +import java.util.Map; public interface ApiServerService { public boolean verifyRequest(Map requestParameters, Long userId) throws ServerApiException; public Long fetchDomainId(String domainUUID); - public void loginUser(HttpSession session, String username, String password, Long domainId, String domainPath, String loginIpAddress, - Map requestParameters) throws CloudAuthenticationException; + public ResponseObject loginUser(HttpSession session, String username, String password, Long domainId, String domainPath, String loginIpAddress, + Map requestParameters) throws CloudAuthenticationException; public void logoutUser(long userId); diff --git a/server/src/com/cloud/api/auth/APIAuthenticationManager.java b/api/src/org/apache/cloudstack/api/auth/APIAuthenticationManager.java similarity index 96% rename from server/src/com/cloud/api/auth/APIAuthenticationManager.java rename to api/src/org/apache/cloudstack/api/auth/APIAuthenticationManager.java index 7fd4da92a59..5d4d664538c 100644 --- a/server/src/com/cloud/api/auth/APIAuthenticationManager.java +++ b/api/src/org/apache/cloudstack/api/auth/APIAuthenticationManager.java @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -package com.cloud.api.auth; +package org.apache.cloudstack.api.auth; import com.cloud.utils.component.PluggableService; diff --git a/server/src/com/cloud/api/auth/APIAuthenticationType.java b/api/src/org/apache/cloudstack/api/auth/APIAuthenticationType.java similarity index 95% rename from server/src/com/cloud/api/auth/APIAuthenticationType.java rename to api/src/org/apache/cloudstack/api/auth/APIAuthenticationType.java index bdd37ec5e79..e8c7f0f5eda 100644 --- a/server/src/com/cloud/api/auth/APIAuthenticationType.java +++ b/api/src/org/apache/cloudstack/api/auth/APIAuthenticationType.java @@ -14,7 +14,7 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -package com.cloud.api.auth; +package org.apache.cloudstack.api.auth; public enum APIAuthenticationType { LOGIN_API, LOGOUT_API diff --git a/server/src/com/cloud/api/auth/APIAuthenticator.java b/api/src/org/apache/cloudstack/api/auth/APIAuthenticator.java similarity index 92% rename from server/src/com/cloud/api/auth/APIAuthenticator.java rename to api/src/org/apache/cloudstack/api/auth/APIAuthenticator.java index 90cd7ec7a8a..67fa1d8816e 100644 --- a/server/src/com/cloud/api/auth/APIAuthenticator.java +++ b/api/src/org/apache/cloudstack/api/auth/APIAuthenticator.java @@ -14,12 +14,13 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -package com.cloud.api.auth; +package org.apache.cloudstack.api.auth; import org.apache.cloudstack.api.ServerApiException; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; +import java.util.List; import java.util.Map; /* @@ -36,6 +37,8 @@ public interface APIAuthenticator { public String authenticate(String command, Map params, HttpSession session, String remoteAddress, String responseType, StringBuilder auditTrailSb, final HttpServletResponse resp) throws ServerApiException; + public APIAuthenticationType getAPIType(); + public void setAuthenticators(List authenticators); } diff --git a/api/src/org/apache/cloudstack/api/auth/PluggableAPIAuthenticator.java b/api/src/org/apache/cloudstack/api/auth/PluggableAPIAuthenticator.java new file mode 100644 index 00000000000..e1e46b86052 --- /dev/null +++ b/api/src/org/apache/cloudstack/api/auth/PluggableAPIAuthenticator.java @@ -0,0 +1,25 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.auth; + +import com.cloud.utils.component.Adapter; + +import java.util.List; + +public interface PluggableAPIAuthenticator extends Adapter { + public List> getAuthCommands(); +} \ No newline at end of file diff --git a/client/tomcatconf/commands.properties.in b/client/tomcatconf/commands.properties.in index 006a4ff1b08..09b7ddc8ee9 100644 --- a/client/tomcatconf/commands.properties.in +++ b/client/tomcatconf/commands.properties.in @@ -21,8 +21,11 @@ ### CloudStack authentication commands login=15 logout=15 + +### SAML SSO/SLO commands samlsso=15 samlslo=15 +getSPMetadata=15 ### Account commands createAccount=7 diff --git a/core/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml b/core/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml index d4dcc80b2b5..f1566b13021 100644 --- a/core/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml +++ b/core/resources/META-INF/cloudstack/api/spring-core-lifecycle-api-context-inheritable.xml @@ -35,6 +35,12 @@ value="com.cloud.server.auth.UserAuthenticator" /> + + + + + - + + + + + + + org.opensaml opensaml - 2.6.1 + ${cs.opensaml.version} + + + org.apache.cloudstack + cloud-utils + ${project.version} + + + org.apache.cloudstack + cloud-api + ${project.version} diff --git a/plugins/user-authenticators/saml2/resources/META-INF/cloudstack/saml2/spring-saml2-context.xml b/plugins/user-authenticators/saml2/resources/META-INF/cloudstack/saml2/spring-saml2-context.xml index f244292c3b6..92f89b8dfbc 100644 --- a/plugins/user-authenticators/saml2/resources/META-INF/cloudstack/saml2/spring-saml2-context.xml +++ b/plugins/user-authenticators/saml2/resources/META-INF/cloudstack/saml2/spring-saml2-context.xml @@ -25,8 +25,12 @@ http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd"> - + + + + + diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/GetServiceProviderMetaDataCmd.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/GetServiceProviderMetaDataCmd.java new file mode 100644 index 00000000000..16ee0889e99 --- /dev/null +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/GetServiceProviderMetaDataCmd.java @@ -0,0 +1,202 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command; + +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.user.Account; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; +import org.apache.cloudstack.api.response.SAMLMetaDataResponse; +import org.apache.cloudstack.saml.SAML2AuthManager; +import org.apache.log4j.Logger; +import org.opensaml.Configuration; +import org.opensaml.DefaultBootstrap; +import org.opensaml.common.xml.SAMLConstants; +import org.opensaml.saml2.core.NameIDType; +import org.opensaml.saml2.metadata.AssertionConsumerService; +import org.opensaml.saml2.metadata.EntityDescriptor; +import org.opensaml.saml2.metadata.KeyDescriptor; +import org.opensaml.saml2.metadata.NameIDFormat; +import org.opensaml.saml2.metadata.SPSSODescriptor; +import org.opensaml.saml2.metadata.SingleLogoutService; +import org.opensaml.saml2.metadata.impl.AssertionConsumerServiceBuilder; +import org.opensaml.saml2.metadata.impl.EntityDescriptorBuilder; +import org.opensaml.saml2.metadata.impl.KeyDescriptorBuilder; +import org.opensaml.saml2.metadata.impl.NameIDFormatBuilder; +import org.opensaml.saml2.metadata.impl.SPSSODescriptorBuilder; +import org.opensaml.saml2.metadata.impl.SingleLogoutServiceBuilder; +import org.opensaml.xml.ConfigurationException; +import org.opensaml.xml.io.Marshaller; +import org.opensaml.xml.io.MarshallingException; +import org.opensaml.xml.security.SecurityException; +import org.opensaml.xml.security.credential.UsageType; +import org.opensaml.xml.security.keyinfo.KeyInfoGenerator; +import org.opensaml.xml.security.x509.BasicX509Credential; +import org.opensaml.xml.security.x509.X509KeyInfoGeneratorFactory; +import org.w3c.dom.Document; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.stream.FactoryConfigurationError; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerException; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.IOException; +import java.io.StringWriter; +import java.util.List; +import java.util.Map; + +@APICommand(name = "getSPMetadata", description = "Returns SAML2 CloudStack Service Provider MetaData", responseObject = SAMLMetaDataResponse.class, entityType = {}) +public class GetServiceProviderMetaDataCmd extends BaseCmd implements APIAuthenticator { + public static final Logger s_logger = Logger.getLogger(GetServiceProviderMetaDataCmd.class.getName()); + private static final String s_name = "spmetadataresponse"; + + @Inject + ApiServerService _apiServer; + + SAML2AuthManager _samlAuthManager; + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_TYPE_NORMAL; + } + + @Override + public void execute() throws ServerApiException { + throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication plugin api, cannot be used directly"); + } + + @Override + public String authenticate(String command, Map params, HttpSession session, String remoteAddress, String responseType, StringBuilder auditTrailSb, HttpServletResponse resp) throws ServerApiException { + SAMLMetaDataResponse response = new SAMLMetaDataResponse(); + response.setResponseName(getCommandName()); + + try { + DefaultBootstrap.bootstrap(); + } catch (ConfigurationException | FactoryConfigurationError e) { + s_logger.error("OpenSAML Bootstrapping error: " + e.getMessage()); + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), + "OpenSAML Bootstrapping error while creating SP MetaData", + params, responseType)); + } + + EntityDescriptor spEntityDescriptor = new EntityDescriptorBuilder().buildObject(); + spEntityDescriptor.setEntityID(_samlAuthManager.getServiceProviderId()); + + SPSSODescriptor spSSODescriptor = new SPSSODescriptorBuilder().buildObject(); + spSSODescriptor.setWantAssertionsSigned(true); + spSSODescriptor.setAuthnRequestsSigned(false); + + X509KeyInfoGeneratorFactory keyInfoGeneratorFactory = new X509KeyInfoGeneratorFactory(); + keyInfoGeneratorFactory.setEmitEntityCertificate(true); + KeyInfoGenerator keyInfoGenerator = keyInfoGeneratorFactory.newInstance(); + + KeyDescriptor encKeyDescriptor = new KeyDescriptorBuilder().buildObject(); + encKeyDescriptor.setUse(UsageType.ENCRYPTION); + + KeyDescriptor signKeyDescriptor = new KeyDescriptorBuilder().buildObject(); + signKeyDescriptor.setUse(UsageType.SIGNING); + + BasicX509Credential credential = new BasicX509Credential(); + credential.setEntityCertificate(_samlAuthManager.getIdpSigningKey()); + try { + encKeyDescriptor.setKeyInfo(keyInfoGenerator.generate(credential)); + signKeyDescriptor.setKeyInfo(keyInfoGenerator.generate(credential)); + //TODO: generate own pub/priv keys + //spSSODescriptor.getKeyDescriptors().add(encKeyDescriptor); + //spSSODescriptor.getKeyDescriptors().add(signKeyDescriptor); + } catch (SecurityException ignored) { + } + + NameIDFormat nameIDFormat = new NameIDFormatBuilder().buildObject(); + nameIDFormat.setFormat(NameIDType.PERSISTENT); + spSSODescriptor.getNameIDFormats().add(nameIDFormat); + + AssertionConsumerService assertionConsumerService = new AssertionConsumerServiceBuilder().buildObject(); + assertionConsumerService.setIndex(0); + assertionConsumerService.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI); + assertionConsumerService.setLocation(_samlAuthManager.getSpSingleSignOnUrl()); + + SingleLogoutService ssoService = new SingleLogoutServiceBuilder().buildObject(); + ssoService.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI); + ssoService.setLocation(_samlAuthManager.getSpSingleLogOutUrl()); + + spSSODescriptor.getSingleLogoutServices().add(ssoService); + spSSODescriptor.getAssertionConsumerServices().add(assertionConsumerService); + spSSODescriptor.addSupportedProtocol(SAMLConstants.SAML20P_NS); + spEntityDescriptor.getRoleDescriptors().add(spSSODescriptor); + + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document document = builder.newDocument(); + Marshaller out = Configuration.getMarshallerFactory().getMarshaller(spEntityDescriptor); + out.marshall(spEntityDescriptor, document); + + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + StringWriter stringWriter = new StringWriter(); + StreamResult streamResult = new StreamResult(stringWriter); + DOMSource source = new DOMSource(document); + transformer.transform(source, streamResult); + stringWriter.close(); + response.setMetadata(stringWriter.toString()); + } catch (ParserConfigurationException | IOException | MarshallingException | TransformerException e) { + response.setMetadata("Error creating Service Provider MetaData XML: " + e.getMessage()); + } + + return ApiResponseSerializer.toSerializedString(response, responseType); + } + + @Override + public APIAuthenticationType getAPIType() { + return APIAuthenticationType.LOGIN_API; + } + + @Override + public void setAuthenticators(List authenticators) { + for (PluggableAPIAuthenticator authManager: authenticators) { + if (authManager instanceof SAML2AuthManager) { + _samlAuthManager = (SAML2AuthManager) authManager; + } + } + if (_samlAuthManager == null) { + s_logger.error("No suitable Pluggable Authentication Manager found for SAML2 Login Cmd"); + } + } +} diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/SAML2LoginAPIAuthenticatorCmd.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/SAML2LoginAPIAuthenticatorCmd.java new file mode 100644 index 00000000000..07cfa394e7b --- /dev/null +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/SAML2LoginAPIAuthenticatorCmd.java @@ -0,0 +1,303 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.api.command; + +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.configuration.Config; +import com.cloud.domain.Domain; +import com.cloud.exception.CloudAuthenticationException; +import com.cloud.user.Account; +import com.cloud.user.DomainManager; +import com.cloud.user.User; +import com.cloud.utils.HttpUtils; +import com.cloud.utils.db.EntityManager; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; +import org.apache.cloudstack.api.response.LoginCmdResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.saml.SAML2AuthManager; +import org.apache.cloudstack.utils.auth.SAMLUtils; +import org.apache.log4j.Logger; +import org.opensaml.DefaultBootstrap; +import org.opensaml.saml2.core.Assertion; +import org.opensaml.saml2.core.Attribute; +import org.opensaml.saml2.core.AttributeStatement; +import org.opensaml.saml2.core.AuthnRequest; +import org.opensaml.saml2.core.NameID; +import org.opensaml.saml2.core.NameIDType; +import org.opensaml.saml2.core.Response; +import org.opensaml.saml2.core.StatusCode; +import org.opensaml.xml.ConfigurationException; +import org.opensaml.xml.io.MarshallingException; +import org.opensaml.xml.io.UnmarshallingException; +import org.opensaml.xml.security.x509.BasicX509Credential; +import org.opensaml.xml.signature.Signature; +import org.opensaml.xml.signature.SignatureValidator; +import org.opensaml.xml.validation.ValidationException; +import org.xml.sax.SAXException; + +import javax.inject.Inject; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.stream.FactoryConfigurationError; +import java.io.IOException; +import java.net.URLEncoder; +import java.util.List; +import java.util.Map; + +@APICommand(name = "samlsso", description = "SP initiated SAML Single Sign On", requestHasSensitiveInfo = true, responseObject = LoginCmdResponse.class, entityType = {}) +public class SAML2LoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthenticator { + public static final Logger s_logger = Logger.getLogger(SAML2LoginAPIAuthenticatorCmd.class.getName()); + private static final String s_name = "loginresponse"; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.IDP_URL, type = CommandType.STRING, description = "Identity Provider SSO HTTP-Redirect binding URL", required = true) + private String idpUrl; + + @Inject + ApiServerService _apiServer; + @Inject + EntityManager _entityMgr; + @Inject + ConfigurationDao _configDao; + @Inject + DomainManager _domainMgr; + + SAML2AuthManager _samlAuthManager; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public String getIdpUrl() { + return idpUrl; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_TYPE_NORMAL; + } + + @Override + public void execute() throws ServerApiException { + // We should never reach here + throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication api, cannot be used directly"); + } + + private String buildAuthnRequestUrl(String idpUrl) { + String spId = _samlAuthManager.getServiceProviderId(); + String consumerUrl = _samlAuthManager.getSpSingleSignOnUrl(); + String identityProviderUrl = _samlAuthManager.getIdpSingleSignOnUrl(); + + if (idpUrl != null) { + identityProviderUrl = idpUrl; + } + + String redirectUrl = ""; + try { + DefaultBootstrap.bootstrap(); + AuthnRequest authnRequest = SAMLUtils.buildAuthnRequestObject(spId, identityProviderUrl, consumerUrl); + redirectUrl = identityProviderUrl + "?SAMLRequest=" + SAMLUtils.encodeSAMLRequest(authnRequest); + } catch (ConfigurationException | FactoryConfigurationError | MarshallingException | IOException e) { + s_logger.error("SAML AuthnRequest message building error: " + e.getMessage()); + } + return redirectUrl; + } + + public Response processSAMLResponse(String responseMessage) { + Response responseObject = null; + try { + DefaultBootstrap.bootstrap(); + responseObject = SAMLUtils.decodeSAMLResponse(responseMessage); + + } catch (ConfigurationException | FactoryConfigurationError | ParserConfigurationException | SAXException | IOException | UnmarshallingException e) { + s_logger.error("SAMLResponse processing error: " + e.getMessage()); + } + return responseObject; + } + + @Override + public String authenticate(final String command, final Map params, final HttpSession session, final String remoteAddress, final String responseType, final StringBuilder auditTrailSb, final HttpServletResponse resp) throws ServerApiException { + try { + if (!params.containsKey("SAMLResponse")) { + String idpUrl = null; + final String[] idps = (String[])params.get(ApiConstants.IDP_URL); + if (idps != null && idps.length > 0) { + idpUrl = idps[0]; + } + String redirectUrl = this.buildAuthnRequestUrl(idpUrl); + resp.sendRedirect(redirectUrl); + return ""; + } else { + final String samlResponse = ((String[])params.get(SAMLUtils.SAML_RESPONSE))[0]; + Response processedSAMLResponse = this.processSAMLResponse(samlResponse); + String statusCode = processedSAMLResponse.getStatus().getStatusCode().getValue(); + if (!statusCode.equals(StatusCode.SUCCESS_URI)) { + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), + "Identity Provider send a non-successful authentication status code", + params, responseType)); + } + + if (_samlAuthManager.getIdpSigningKey() != null) { + Signature sig = processedSAMLResponse.getSignature(); + BasicX509Credential credential = new BasicX509Credential(); + credential.setEntityCertificate(_samlAuthManager.getIdpSigningKey()); + SignatureValidator validator = new SignatureValidator(credential); + try { + validator.validate(sig); + } catch (ValidationException e) { + s_logger.error("SAML Response's signature failed to be validated by IDP signing key:" + e.getMessage()); + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), + "SAML Response's signature failed to be validated by IDP signing key", + params, responseType)); + } + } + + String uniqueUserId = null; + String accountName = _configDao.getValue(Config.SAMLUserAccountName.key()); + String domainString = _configDao.getValue(Config.SAMLUserDomain.key()); + + Long domainId = -1L; + Domain domain = _domainMgr.getDomain(domainString); + if (domain != null) { + domainId = domain.getId(); + } else { + try { + domainId = Long.parseLong(domainString); + } catch (NumberFormatException ignore) { + } + } + if (domainId == -1L) { + s_logger.error("The default domain ID for SAML users is not set correct, it should be a UUID"); + } + + String username = null; + String password = SAMLUtils.generateSecureRandomId(); // Random password + String firstName = ""; + String lastName = ""; + String timeZone = ""; + String email = ""; + + Assertion assertion = processedSAMLResponse.getAssertions().get(0); + NameID nameId = assertion.getSubject().getNameID(); + String sessionIndex = assertion.getAuthnStatements().get(0).getSessionIndex(); + session.setAttribute(SAMLUtils.SAML_NAMEID, nameId); + session.setAttribute(SAMLUtils.SAML_SESSION, sessionIndex); + + if (nameId.getFormat().equals(NameIDType.PERSISTENT) || nameId.getFormat().equals(NameIDType.EMAIL)) { + username = nameId.getValue(); + uniqueUserId = SAMLUtils.createSAMLId(username); + if (nameId.getFormat().equals(NameIDType.EMAIL)) { + email = username; + } + } + + AttributeStatement attributeStatement = assertion.getAttributeStatements().get(0); + List attributes = attributeStatement.getAttributes(); + + // Try capturing standard LDAP attributes + for (Attribute attribute: attributes) { + String attributeName = attribute.getName(); + String attributeValue = attribute.getAttributeValues().get(0).getDOM().getTextContent(); + if (attributeName.equalsIgnoreCase("uid") && uniqueUserId == null) { + username = attributeValue; + uniqueUserId = SAMLUtils.createSAMLId(username); + } else if (attributeName.equalsIgnoreCase("givenName")) { + firstName = attributeValue; + } else if (attributeName.equalsIgnoreCase(("sn"))) { + lastName = attributeValue; + } else if (attributeName.equalsIgnoreCase("mail")) { + email = attributeValue; + } + } + + User user = _entityMgr.findByUuid(User.class, uniqueUserId); + if (user == null && uniqueUserId != null && username != null + && accountName != null && domainId != null) { + CallContext.current().setEventDetails("UserName: " + username + ", FirstName :" + password + ", LastName: " + lastName); + user = _accountService.createUser(username, password, firstName, lastName, email, timeZone, accountName, domainId, uniqueUserId); + } + + if (user != null) { + try { + if (_apiServer.verifyUser(user.getId())) { + LoginCmdResponse loginResponse = (LoginCmdResponse) _apiServer.loginUser(session, username, user.getPassword(), domainId, null, remoteAddress, params); + resp.addCookie(new Cookie("userid", loginResponse.getUserId())); + resp.addCookie(new Cookie("domainid", loginResponse.getDomainId())); + resp.addCookie(new Cookie("role", loginResponse.getType())); + resp.addCookie(new Cookie("username", URLEncoder.encode(loginResponse.getUsername(), HttpUtils.UTF_8))); + resp.addCookie(new Cookie("sessionKey", URLEncoder.encode(loginResponse.getSessionKey(), HttpUtils.UTF_8))); + resp.addCookie(new Cookie("account", URLEncoder.encode(loginResponse.getAccount(), HttpUtils.UTF_8))); + resp.addCookie(new Cookie("timezone", URLEncoder.encode(loginResponse.getTimeZone(), HttpUtils.UTF_8))); + resp.addCookie(new Cookie("userfullname", loginResponse.getFirstName() + "%20" + loginResponse.getLastName())); + resp.sendRedirect(_configDao.getValue(Config.SAMLCloudStackRedirectionUrl.key())); + return ApiResponseSerializer.toSerializedString(loginResponse, responseType); + + } + } catch (final CloudAuthenticationException ignored) { + } + } + } + } catch (IOException e) { + auditTrailSb.append("SP initiated SAML authentication using HTTP redirection failed:"); + auditTrailSb.append(e.getMessage()); + } + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), + "Unable to authenticate or retrieve user while performing SAML based SSO", + params, responseType)); + } + + @Override + public APIAuthenticationType getAPIType() { + return APIAuthenticationType.LOGIN_API; + } + + @Override + public void setAuthenticators(List authenticators) { + for (PluggableAPIAuthenticator authManager: authenticators) { + if (authManager instanceof SAML2AuthManager) { + _samlAuthManager = (SAML2AuthManager) authManager; + } + } + if (_samlAuthManager == null) { + s_logger.error("No suitable Pluggable Authentication Manager found for SAML2 Login Cmd"); + } + } +} diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/SAML2LogoutAPIAuthenticatorCmd.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/SAML2LogoutAPIAuthenticatorCmd.java new file mode 100644 index 00000000000..4fa7fb31b6f --- /dev/null +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/command/SAML2LogoutAPIAuthenticatorCmd.java @@ -0,0 +1,169 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command; + +import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.configuration.Config; +import com.cloud.user.Account; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; +import org.apache.cloudstack.api.response.LogoutCmdResponse; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.saml.SAML2AuthManager; +import org.apache.cloudstack.utils.auth.SAMLUtils; +import org.apache.log4j.Logger; +import org.opensaml.DefaultBootstrap; +import org.opensaml.saml2.core.LogoutRequest; +import org.opensaml.saml2.core.NameID; +import org.opensaml.saml2.core.Response; +import org.opensaml.saml2.core.StatusCode; +import org.opensaml.xml.ConfigurationException; +import org.opensaml.xml.io.MarshallingException; +import org.opensaml.xml.io.UnmarshallingException; +import org.xml.sax.SAXException; + +import javax.inject.Inject; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.stream.FactoryConfigurationError; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@APICommand(name = "samlslo", description = "SAML Global Log Out API", responseObject = LogoutCmdResponse.class, entityType = {}) +public class SAML2LogoutAPIAuthenticatorCmd extends BaseCmd implements APIAuthenticator { + public static final Logger s_logger = Logger.getLogger(SAML2LogoutAPIAuthenticatorCmd.class.getName()); + private static final String s_name = "logoutresponse"; + + @Inject + ApiServerService _apiServer; + @Inject + ConfigurationDao _configDao; + SAML2AuthManager _samlAuthManager; + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public String getCommandName() { + return s_name; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_TYPE_NORMAL; + } + + @Override + public void execute() throws ServerApiException { + // We should never reach here + throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication api, cannot be used directly"); + } + + @Override + public String authenticate(String command, Map params, HttpSession session, String remoteAddress, String responseType, StringBuilder auditTrailSb, final HttpServletResponse resp) throws ServerApiException { + auditTrailSb.append("=== SAML SLO Logging out ==="); + LogoutCmdResponse response = new LogoutCmdResponse(); + response.setDescription("success"); + response.setResponseName(getCommandName()); + String responseString = ApiResponseSerializer.toSerializedString(response, responseType); + + if (session == null) { + try { + resp.sendRedirect(_configDao.getValue(Config.SAMLCloudStackRedirectionUrl.key())); + } catch (IOException ignored) { + } + return responseString; + } + + try { + DefaultBootstrap.bootstrap(); + } catch (ConfigurationException | FactoryConfigurationError e) { + s_logger.error("OpenSAML Bootstrapping error: " + e.getMessage()); + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), + "OpenSAML Bootstrapping error while creating SP MetaData", + params, responseType)); + } + + if (params != null && params.containsKey("SAMLResponse")) { + try { + final String samlResponse = ((String[])params.get(SAMLUtils.SAML_RESPONSE))[0]; + Response processedSAMLResponse = SAMLUtils.decodeSAMLResponse(samlResponse); + String statusCode = processedSAMLResponse.getStatus().getStatusCode().getValue(); + if (!statusCode.equals(StatusCode.SUCCESS_URI)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.INTERNAL_ERROR.getHttpCode(), + "SAML SLO LogoutResponse status is not Success", + params, responseType)); + } + } catch (ConfigurationException | FactoryConfigurationError | ParserConfigurationException | SAXException | IOException | UnmarshallingException e) { + s_logger.error("SAMLResponse processing error: " + e.getMessage()); + } + try { + resp.sendRedirect(_configDao.getValue(Config.SAMLCloudStackRedirectionUrl.key())); + } catch (IOException ignored) { + } + return responseString; + } + + NameID nameId = (NameID) session.getAttribute(SAMLUtils.SAML_NAMEID); + String sessionIndex = (String) session.getAttribute(SAMLUtils.SAML_SESSION); + if (nameId == null || sessionIndex == null) { + try { + resp.sendRedirect(_configDao.getValue(Config.SAMLCloudStackRedirectionUrl.key())); + } catch (IOException ignored) { + } + return responseString; + } + LogoutRequest logoutRequest = SAMLUtils.buildLogoutRequest(_samlAuthManager.getIdpSingleLogOutUrl(), _samlAuthManager.getServiceProviderId(), nameId, sessionIndex); + + try { + String redirectUrl = _samlAuthManager.getIdpSingleLogOutUrl() + "?SAMLRequest=" + SAMLUtils.encodeSAMLRequest(logoutRequest); + resp.sendRedirect(redirectUrl); + } catch (MarshallingException | IOException e) { + s_logger.error("SAML SLO error: " + e.getMessage()); + throw new ServerApiException(ApiErrorCode.ACCOUNT_ERROR, _apiServer.getSerializedApiError(ApiErrorCode.ACCOUNT_ERROR.getHttpCode(), + "SAML Single Logout Error", + params, responseType)); + } + return responseString; + } + + @Override + public APIAuthenticationType getAPIType() { + return APIAuthenticationType.LOGOUT_API; + } + + @Override + public void setAuthenticators(List authenticators) { + for (PluggableAPIAuthenticator authManager: authenticators) { + if (authManager instanceof SAML2AuthManager) { + _samlAuthManager = (SAML2AuthManager) authManager; + } + } + if (_samlAuthManager == null) { + s_logger.error("No suitable Pluggable Authentication Manager found for SAML2 Login Cmd"); + } + } +} diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/response/SAMLMetaDataResponse.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/response/SAMLMetaDataResponse.java new file mode 100644 index 00000000000..e091ea49a94 --- /dev/null +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/api/response/SAMLMetaDataResponse.java @@ -0,0 +1,40 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.BaseResponse; + +public class SAMLMetaDataResponse extends BaseResponse { + + @SerializedName("metadata") + @Param(description = "The Metadata XML") + private String metadata; + + public SAMLMetaDataResponse() { + super(); + } + + public String getMetadata() { + return metadata; + } + + public void setMetadata(String metadata) { + this.metadata = metadata; + } +} diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2AuthManager.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2AuthManager.java new file mode 100644 index 00000000000..507fa04409c --- /dev/null +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2AuthManager.java @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.cloudstack.saml; + +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; + +import java.security.cert.X509Certificate; + +public interface SAML2AuthManager extends PluggableAPIAuthenticator { + public String getServiceProviderId(); + public String getIdentityProviderId(); + + public X509Certificate getIdpSigningKey(); + public X509Certificate getIdpEncryptionKey(); + + public String getSpSingleSignOnUrl(); + public String getIdpSingleSignOnUrl(); + + public String getSpSingleLogOutUrl(); + public String getIdpSingleLogOutUrl(); +} diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2AuthManagerImpl.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2AuthManagerImpl.java new file mode 100644 index 00000000000..8480c0e57c0 --- /dev/null +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2AuthManagerImpl.java @@ -0,0 +1,195 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.saml; + +import com.cloud.configuration.Config; +import com.cloud.utils.component.AdapterBase; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; +import org.apache.cloudstack.api.command.GetServiceProviderMetaDataCmd; +import org.apache.cloudstack.api.command.SAML2LoginAPIAuthenticatorCmd; +import org.apache.cloudstack.api.command.SAML2LogoutAPIAuthenticatorCmd; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.log4j.Logger; +import org.opensaml.DefaultBootstrap; +import org.opensaml.common.xml.SAMLConstants; +import org.opensaml.saml2.metadata.EntityDescriptor; +import org.opensaml.saml2.metadata.IDPSSODescriptor; +import org.opensaml.saml2.metadata.KeyDescriptor; +import org.opensaml.saml2.metadata.SingleLogoutService; +import org.opensaml.saml2.metadata.SingleSignOnService; +import org.opensaml.saml2.metadata.provider.HTTPMetadataProvider; +import org.opensaml.saml2.metadata.provider.MetadataProviderException; +import org.opensaml.xml.ConfigurationException; +import org.opensaml.xml.parse.BasicParserPool; +import org.opensaml.xml.security.credential.UsageType; +import org.opensaml.xml.security.keyinfo.KeyInfoHelper; +import org.springframework.stereotype.Component; + +import javax.ejb.Local; +import javax.inject.Inject; +import javax.xml.stream.FactoryConfigurationError; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +@Component +@Local(value = {SAML2AuthManager.class, PluggableAPIAuthenticator.class}) +public class SAML2AuthManagerImpl extends AdapterBase implements SAML2AuthManager { + private static final Logger s_logger = Logger.getLogger(SAML2AuthManagerImpl.class); + + private String serviceProviderId; + private String identityProviderId; + + private X509Certificate idpSigningKey; + private X509Certificate idpEncryptionKey; + + private String spSingleSignOnUrl; + private String idpSingleSignOnUrl; + + private String spSingleLogOutUrl; + private String idpSingleLogOutUrl; + + private HTTPMetadataProvider idpMetaDataProvider; + + @Inject + ConfigurationDao _configDao; + + @Override + public boolean start() { + return isSAMLPluginEnabled() && setup(); + } + + private boolean setup() { + // TODO: In future if need added logic to get SP X509 cert for Idps that need signed requests + + this.serviceProviderId = _configDao.getValue(Config.SAMLServiceProviderID.key()); + this.identityProviderId = _configDao.getValue(Config.SAMLIdentityProviderID.key()); + + this.spSingleSignOnUrl = _configDao.getValue(Config.SAMLServiceProviderSingleSignOnURL.key()); + this.spSingleLogOutUrl = _configDao.getValue(Config.SAMLServiceProviderSingleLogOutURL.key()); + + String idpMetaDataUrl = _configDao.getValue(Config.SAMLIdentityProviderMetadataURL.key()); + + int tolerance = 30000; + String timeout = _configDao.getValue(Config.SAMLTimeout.key()); + if (timeout != null) { + tolerance = Integer.parseInt(timeout); + } + + try { + DefaultBootstrap.bootstrap(); + idpMetaDataProvider = new HTTPMetadataProvider(idpMetaDataUrl, tolerance); + idpMetaDataProvider.setRequireValidMetadata(true); + idpMetaDataProvider.setParserPool(new BasicParserPool()); + idpMetaDataProvider.initialize(); + + EntityDescriptor idpEntityDescriptor = idpMetaDataProvider.getEntityDescriptor(this.identityProviderId); + + IDPSSODescriptor idpssoDescriptor = idpEntityDescriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS); + if (idpssoDescriptor != null) { + for (SingleSignOnService ssos: idpssoDescriptor.getSingleSignOnServices()) { + if (ssos.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) { + this.idpSingleSignOnUrl = ssos.getLocation(); + } + } + + for (SingleLogoutService slos: idpssoDescriptor.getSingleLogoutServices()) { + if (slos.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) { + this.idpSingleLogOutUrl = slos.getLocation(); + } + } + + for (KeyDescriptor kd: idpssoDescriptor.getKeyDescriptors()) { + if (kd.getUse() == UsageType.SIGNING) { + try { + this.idpSigningKey = KeyInfoHelper.getCertificates(kd.getKeyInfo()).get(0); + } catch (CertificateException ignored) { + } + } + if (kd.getUse() == UsageType.ENCRYPTION) { + try { + this.idpEncryptionKey = KeyInfoHelper.getCertificates(kd.getKeyInfo()).get(0); + } catch (CertificateException ignored) { + } + } + } + } else { + s_logger.warn("Provided IDP XML Metadata does not contain IDPSSODescriptor, SAML authentication may not work"); + } + } catch (MetadataProviderException e) { + s_logger.error("Unable to read SAML2 IDP MetaData URL, error:" + e.getMessage()); + s_logger.error("SAML2 Authentication may be unavailable"); + } catch (ConfigurationException | FactoryConfigurationError e) { + s_logger.error("OpenSAML bootstrapping failed: error: " + e.getMessage()); + } + + if (this.idpSingleLogOutUrl == null || this.idpSingleSignOnUrl == null) { + s_logger.error("SAML based authentication won't work"); + } + + return true; + } + + @Override + public List> getAuthCommands() { + if (!isSAMLPluginEnabled()) { + return null; + } + List> cmdList = new ArrayList>(); + cmdList.add(SAML2LoginAPIAuthenticatorCmd.class); + cmdList.add(SAML2LogoutAPIAuthenticatorCmd.class); + cmdList.add(GetServiceProviderMetaDataCmd.class); + return cmdList; + } + + public String getServiceProviderId() { + return serviceProviderId; + } + + public String getIdpSingleSignOnUrl() { + return this.idpSingleSignOnUrl; + } + + public String getIdpSingleLogOutUrl() { + return this.idpSingleLogOutUrl; + } + + public String getSpSingleSignOnUrl() { + return spSingleSignOnUrl; + } + + public String getSpSingleLogOutUrl() { + return spSingleLogOutUrl; + } + + public String getIdentityProviderId() { + return identityProviderId; + } + + public X509Certificate getIdpSigningKey() { + return idpSigningKey; + } + + public X509Certificate getIdpEncryptionKey() { + return idpEncryptionKey; + } + + public Boolean isSAMLPluginEnabled() { + return Boolean.valueOf(_configDao.getValue(Config.SAMLIsPluginEnabled.key())); + } +} diff --git a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/SAML2UserAuthenticator.java b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2UserAuthenticator.java similarity index 88% rename from plugins/user-authenticators/saml2/src/org/apache/cloudstack/SAML2UserAuthenticator.java rename to plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2UserAuthenticator.java index 4d4f1d3e8d1..e623fc21798 100644 --- a/plugins/user-authenticators/saml2/src/org/apache/cloudstack/SAML2UserAuthenticator.java +++ b/plugins/user-authenticators/saml2/src/org/apache/cloudstack/saml/SAML2UserAuthenticator.java @@ -12,7 +12,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -package org.apache.cloudstack; +package org.apache.cloudstack.saml; import com.cloud.server.auth.DefaultUserAuthenticator; import com.cloud.server.auth.UserAuthenticator; @@ -21,6 +21,7 @@ import com.cloud.user.UserAccount; import com.cloud.user.dao.UserAccountDao; import com.cloud.user.dao.UserDao; import com.cloud.utils.Pair; +import org.apache.cloudstack.utils.auth.SAMLUtils; import org.apache.log4j.Logger; import javax.ejb.Local; @@ -47,8 +48,8 @@ public class SAML2UserAuthenticator extends DefaultUserAuthenticator { return new Pair(false, null); } else { User user = _userDao.getUser(userAccount.getId()); - // TODO: check SAMLRequest, signature etc. from requestParameters - if (user != null && user.getUuid().startsWith("saml")) { + if (user != null && SAMLUtils.checkSAMLUserId(user.getUuid()) && + requestParameters != null && requestParameters.containsKey(SAMLUtils.SAML_RESPONSE)) { return new Pair(true, null); } } @@ -58,8 +59,6 @@ public class SAML2UserAuthenticator extends DefaultUserAuthenticator { @Override public String encode(final String password) { - // TODO: Complete method - StringBuilder sb = new StringBuilder(32); - return sb.toString(); + return SAMLUtils.generateSecureRandomId(); } } diff --git a/plugins/user-authenticators/saml2/test/org/apache/cloudstack/SAML2UserAuthenticatorTest.java b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/SAML2UserAuthenticatorTest.java index 8298c6c13dd..6f5150b5c52 100644 --- a/plugins/user-authenticators/saml2/test/org/apache/cloudstack/SAML2UserAuthenticatorTest.java +++ b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/SAML2UserAuthenticatorTest.java @@ -19,21 +19,68 @@ package org.apache.cloudstack; +import com.cloud.server.auth.UserAuthenticator.ActionOnFailedAuthentication; +import com.cloud.user.UserAccountVO; +import com.cloud.user.UserVO; +import com.cloud.user.dao.UserAccountDao; +import com.cloud.user.dao.UserDao; +import com.cloud.utils.Pair; +import org.apache.cloudstack.saml.SAML2UserAuthenticator; +import org.apache.cloudstack.utils.auth.SAMLUtils; +import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; - +import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.runners.MockitoJUnitRunner; +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + @RunWith(MockitoJUnitRunner.class) public class SAML2UserAuthenticatorTest { + @Mock + UserAccountDao userAccountDao; + @Mock + UserDao userDao; + @Test public void encode() { - + Assert.assertTrue(new SAML2UserAuthenticator().encode("random String").length() == 32); } @Test public void authenticate() throws NoSuchFieldException, SecurityException, IllegalArgumentException, IllegalAccessException { + SAML2UserAuthenticator authenticator = new SAML2UserAuthenticator(); + Field daoField = SAML2UserAuthenticator.class.getDeclaredField("_userAccountDao"); + daoField.setAccessible(true); + daoField.set(authenticator, userAccountDao); + + Field userDaoField = SAML2UserAuthenticator.class.getDeclaredField("_userDao"); + userDaoField.setAccessible(true); + userDaoField.set(authenticator, userDao); + + UserAccountVO account = new UserAccountVO(); + account.setPassword("5f4dcc3b5aa765d61d8327deb882cf99"); + account.setId(1L); + + UserVO user = new UserVO(); + user.setUuid(SAMLUtils.createSAMLId("someUID")); + + Mockito.when(userAccountDao.getUserAccount(Mockito.anyString(), Mockito.anyLong())).thenReturn(account); + Mockito.when(userDao.getUser(Mockito.anyLong())).thenReturn(user); + + // When there is no SAMLRequest in params + Pair pair1 = authenticator.authenticate(SAMLUtils.createSAMLId("user1234"), "random", 1l, null); + Assert.assertFalse(pair1.first()); + + // When there is SAMLRequest in params + Map params = new HashMap(); + params.put(SAMLUtils.SAML_RESPONSE, new Object[]{}); + Pair pair2 = authenticator.authenticate(SAMLUtils.createSAMLId("user1234"), "random", 1l, params); + Assert.assertTrue(pair2.first()); } } diff --git a/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/GetServiceProviderMetaDataCmdTest.java b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/GetServiceProviderMetaDataCmdTest.java new file mode 100644 index 00000000000..fbd381d8ac8 --- /dev/null +++ b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/GetServiceProviderMetaDataCmdTest.java @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.api.command; + +import com.cloud.utils.HttpUtils; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.saml.SAML2AuthManager; +import org.apache.cloudstack.utils.auth.SAMLUtils; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; + +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.lang.reflect.Field; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; + +@RunWith(MockitoJUnitRunner.class) +public class GetServiceProviderMetaDataCmdTest { + + @Mock + ApiServerService apiServer; + + @Mock + SAML2AuthManager samlAuthManager; + + @Mock + HttpSession session; + + @Mock + HttpServletResponse resp; + + @Test + public void testAuthenticate() throws NoSuchFieldException, SecurityException, IllegalArgumentException, IllegalAccessException, CertificateParsingException, CertificateEncodingException, NoSuchAlgorithmException, InvalidKeyException, NoSuchProviderException, SignatureException { + GetServiceProviderMetaDataCmd cmd = new GetServiceProviderMetaDataCmd(); + + Field apiServerField = GetServiceProviderMetaDataCmd.class.getDeclaredField("_apiServer"); + apiServerField.setAccessible(true); + apiServerField.set(cmd, apiServer); + + Field managerField = GetServiceProviderMetaDataCmd.class.getDeclaredField("_samlAuthManager"); + managerField.setAccessible(true); + managerField.set(cmd, samlAuthManager); + + String spId = "someSPID"; + String url = "someUrl"; + X509Certificate cert = SAMLUtils.generateRandomX509Certificate(); + Mockito.when(samlAuthManager.getServiceProviderId()).thenReturn(spId); + Mockito.when(samlAuthManager.getIdpSigningKey()).thenReturn(cert); + Mockito.when(samlAuthManager.getIdpSingleLogOutUrl()).thenReturn(url); + Mockito.when(samlAuthManager.getSpSingleLogOutUrl()).thenReturn(url); + + String result = cmd.authenticate("command", null, session, "random", HttpUtils.RESPONSE_TYPE_JSON, new StringBuilder(), resp); + Assert.assertTrue(result.contains("md:EntityDescriptor")); + + Mockito.verify(samlAuthManager, Mockito.atLeast(1)).getServiceProviderId(); + Mockito.verify(samlAuthManager, Mockito.atLeast(1)).getSpSingleSignOnUrl(); + Mockito.verify(samlAuthManager, Mockito.atLeast(1)).getSpSingleLogOutUrl(); + Mockito.verify(samlAuthManager, Mockito.never()).getIdpSingleSignOnUrl(); + Mockito.verify(samlAuthManager, Mockito.never()).getIdpSingleLogOutUrl(); + } + + @Test + public void testGetAPIType() { + Assert.assertTrue(new GetServiceProviderMetaDataCmd().getAPIType() == APIAuthenticationType.LOGIN_API); + } +} \ No newline at end of file diff --git a/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/SAML2LoginAPIAuthenticatorCmdTest.java b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/SAML2LoginAPIAuthenticatorCmdTest.java new file mode 100644 index 00000000000..5769a8fd0ec --- /dev/null +++ b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/SAML2LoginAPIAuthenticatorCmdTest.java @@ -0,0 +1,175 @@ +package org.apache.cloudstack.api.command; + +import com.cloud.domain.Domain; +import com.cloud.user.AccountService; +import com.cloud.user.DomainManager; +import com.cloud.user.User; +import com.cloud.user.UserVO; +import com.cloud.utils.HttpUtils; +import com.cloud.utils.db.EntityManager; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.saml.SAML2AuthManager; +import org.apache.cloudstack.utils.auth.SAMLUtils; +import org.joda.time.DateTime; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; +import org.opensaml.common.SAMLVersion; +import org.opensaml.saml2.core.Assertion; +import org.opensaml.saml2.core.AttributeStatement; +import org.opensaml.saml2.core.AuthnStatement; +import org.opensaml.saml2.core.NameID; +import org.opensaml.saml2.core.NameIDType; +import org.opensaml.saml2.core.Response; +import org.opensaml.saml2.core.Status; +import org.opensaml.saml2.core.StatusCode; +import org.opensaml.saml2.core.Subject; +import org.opensaml.saml2.core.impl.AssertionBuilder; +import org.opensaml.saml2.core.impl.AttributeStatementBuilder; +import org.opensaml.saml2.core.impl.AuthnStatementBuilder; +import org.opensaml.saml2.core.impl.NameIDBuilder; +import org.opensaml.saml2.core.impl.ResponseBuilder; +import org.opensaml.saml2.core.impl.StatusBuilder; +import org.opensaml.saml2.core.impl.StatusCodeBuilder; +import org.opensaml.saml2.core.impl.SubjectBuilder; + +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.lang.reflect.Field; +import java.security.cert.X509Certificate; +import java.util.HashMap; +import java.util.Map; + +@RunWith(MockitoJUnitRunner.class) +public class SAML2LoginAPIAuthenticatorCmdTest { + + @Mock + ApiServerService apiServer; + + @Mock + SAML2AuthManager samlAuthManager; + + @Mock + ConfigurationDao configDao; + + @Mock + EntityManager entityMgr; + + @Mock + DomainManager domainMgr; + + @Mock + AccountService accountService; + + @Mock + Domain domain; + + @Mock + HttpSession session; + + @Mock + HttpServletResponse resp; + + private Response buildMockResponse() throws Exception { + Response samlMessage = new ResponseBuilder().buildObject(); + samlMessage.setID("foo"); + samlMessage.setVersion(SAMLVersion.VERSION_20); + samlMessage.setIssueInstant(new DateTime(0)); + Status status = new StatusBuilder().buildObject(); + StatusCode statusCode = new StatusCodeBuilder().buildObject(); + statusCode.setValue(StatusCode.SUCCESS_URI); + status.setStatusCode(statusCode); + samlMessage.setStatus(status); + Assertion assertion = new AssertionBuilder().buildObject(); + Subject subject = new SubjectBuilder().buildObject(); + NameID nameID = new NameIDBuilder().buildObject(); + nameID.setValue("SOME-UNIQUE-ID"); + nameID.setFormat(NameIDType.PERSISTENT); + subject.setNameID(nameID); + assertion.setSubject(subject); + AuthnStatement authnStatement = new AuthnStatementBuilder().buildObject(); + authnStatement.setSessionIndex("Some Session String"); + assertion.getAuthnStatements().add(authnStatement); + AttributeStatement attributeStatement = new AttributeStatementBuilder().buildObject(); + assertion.getAttributeStatements().add(attributeStatement); + samlMessage.getAssertions().add(assertion); + return samlMessage; + } + + @Test + public void testAuthenticate() throws Exception { + SAML2LoginAPIAuthenticatorCmd cmd = Mockito.spy(new SAML2LoginAPIAuthenticatorCmd()); + + Field apiServerField = SAML2LoginAPIAuthenticatorCmd.class.getDeclaredField("_apiServer"); + apiServerField.setAccessible(true); + apiServerField.set(cmd, apiServer); + + Field managerField = SAML2LoginAPIAuthenticatorCmd.class.getDeclaredField("_samlAuthManager"); + managerField.setAccessible(true); + managerField.set(cmd, samlAuthManager); + + Field accountServiceField = BaseCmd.class.getDeclaredField("_accountService"); + accountServiceField.setAccessible(true); + accountServiceField.set(cmd, accountService); + + Field entityMgrField = SAML2LoginAPIAuthenticatorCmd.class.getDeclaredField("_entityMgr"); + entityMgrField.setAccessible(true); + entityMgrField.set(cmd, entityMgr); + + Field domainMgrField = SAML2LoginAPIAuthenticatorCmd.class.getDeclaredField("_domainMgr"); + domainMgrField.setAccessible(true); + domainMgrField.set(cmd, domainMgr); + + Field configDaoField = SAML2LoginAPIAuthenticatorCmd.class.getDeclaredField("_configDao"); + configDaoField.setAccessible(true); + configDaoField.set(cmd, configDao); + + String spId = "someSPID"; + String url = "someUrl"; + X509Certificate cert = SAMLUtils.generateRandomX509Certificate(); + Mockito.when(samlAuthManager.getServiceProviderId()).thenReturn(spId); + Mockito.when(samlAuthManager.getIdpSigningKey()).thenReturn(null); + Mockito.when(samlAuthManager.getIdpSingleSignOnUrl()).thenReturn(url); + Mockito.when(samlAuthManager.getSpSingleSignOnUrl()).thenReturn(url); + + Mockito.when(session.getAttribute(Mockito.anyString())).thenReturn(null); + Mockito.when(configDao.getValue(Mockito.anyString())).thenReturn("someString"); + + Mockito.when(domain.getId()).thenReturn(1L); + Mockito.when(domainMgr.getDomain(Mockito.anyString())).thenReturn(domain); + UserVO user = new UserVO(); + user.setUuid(SAMLUtils.createSAMLId("someUID")); + Mockito.when(entityMgr.findByUuid(Mockito.eq(User.class), Mockito.anyString())).thenReturn((User) user); + Mockito.when(apiServer.verifyUser(Mockito.anyLong())).thenReturn(false); + + Map params = new HashMap(); + + // SSO redirection test + cmd.authenticate("command", params, session, "random", HttpUtils.RESPONSE_TYPE_JSON, new StringBuilder(), resp); + Mockito.verify(resp, Mockito.times(1)).sendRedirect(Mockito.anyString()); + + // SSO SAMLResponse verification test, this should throw ServerApiException for auth failure + params.put(SAMLUtils.SAML_RESPONSE, new String[]{"Some String"}); + Mockito.stub(cmd.processSAMLResponse(Mockito.anyString())).toReturn(buildMockResponse()); + try { + cmd.authenticate("command", params, session, "random", HttpUtils.RESPONSE_TYPE_JSON, new StringBuilder(), resp); + } catch (ServerApiException ignored) { + } + Mockito.verify(configDao, Mockito.atLeastOnce()).getValue(Mockito.anyString()); + Mockito.verify(domainMgr, Mockito.times(1)).getDomain(Mockito.anyString()); + Mockito.verify(entityMgr, Mockito.times(1)).findByUuid(Mockito.eq(User.class), Mockito.anyString()); + Mockito.verify(apiServer, Mockito.times(1)).verifyUser(Mockito.anyLong()); + } + + @Test + public void testGetAPIType() { + Assert.assertTrue(new GetServiceProviderMetaDataCmd().getAPIType() == APIAuthenticationType.LOGIN_API); + } +} \ No newline at end of file diff --git a/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/SAML2LogoutAPIAuthenticatorCmdTest.java b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/SAML2LogoutAPIAuthenticatorCmdTest.java new file mode 100644 index 00000000000..820132b9a20 --- /dev/null +++ b/plugins/user-authenticators/saml2/test/org/apache/cloudstack/api/command/SAML2LogoutAPIAuthenticatorCmdTest.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.cloudstack.api.command; + +import com.cloud.utils.HttpUtils; +import org.apache.cloudstack.api.ApiServerService; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.framework.config.dao.ConfigurationDao; +import org.apache.cloudstack.saml.SAML2AuthManager; +import org.apache.cloudstack.utils.auth.SAMLUtils; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.runners.MockitoJUnitRunner; + +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.lang.reflect.Field; +import java.security.cert.X509Certificate; + +@RunWith(MockitoJUnitRunner.class) +public class SAML2LogoutAPIAuthenticatorCmdTest { + + @Mock + ApiServerService apiServer; + + @Mock + SAML2AuthManager samlAuthManager; + + @Mock + ConfigurationDao configDao; + + @Mock + HttpSession session; + + @Mock + HttpServletResponse resp; + + @Test + public void testAuthenticate() throws Exception { + SAML2LogoutAPIAuthenticatorCmd cmd = new SAML2LogoutAPIAuthenticatorCmd(); + + Field apiServerField = SAML2LogoutAPIAuthenticatorCmd.class.getDeclaredField("_apiServer"); + apiServerField.setAccessible(true); + apiServerField.set(cmd, apiServer); + + Field managerField = SAML2LogoutAPIAuthenticatorCmd.class.getDeclaredField("_samlAuthManager"); + managerField.setAccessible(true); + managerField.set(cmd, samlAuthManager); + + Field configDaoField = SAML2LogoutAPIAuthenticatorCmd.class.getDeclaredField("_configDao"); + configDaoField.setAccessible(true); + configDaoField.set(cmd, configDao); + + String spId = "someSPID"; + String url = "someUrl"; + X509Certificate cert = SAMLUtils.generateRandomX509Certificate(); + Mockito.when(samlAuthManager.getServiceProviderId()).thenReturn(spId); + Mockito.when(samlAuthManager.getIdpSigningKey()).thenReturn(cert); + Mockito.when(samlAuthManager.getIdpSingleLogOutUrl()).thenReturn(url); + Mockito.when(samlAuthManager.getSpSingleLogOutUrl()).thenReturn(url); + Mockito.when(session.getAttribute(Mockito.anyString())).thenReturn(null); + Mockito.when(configDao.getValue(Mockito.anyString())).thenReturn("someString"); + + cmd.authenticate("command", null, session, "random", HttpUtils.RESPONSE_TYPE_JSON, new StringBuilder(), resp); + Mockito.verify(resp, Mockito.times(1)).sendRedirect(Mockito.anyString()); + Mockito.verify(session, Mockito.atLeastOnce()).getAttribute(Mockito.anyString()); + } + + @Test + public void testGetAPIType() throws Exception { + Assert.assertTrue(new SAML2LogoutAPIAuthenticatorCmd().getAPIType() == APIAuthenticationType.LOGOUT_API); + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 558aaba50cd..09c76e6c39c 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,7 @@ 2.5 2.5.3 2.9.1 + 2.6.1 diff --git a/server/pom.xml b/server/pom.xml index 1b21ebde579..0e517f7854e 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -133,6 +133,11 @@ cloud-engine-components-api ${project.version} + + org.opensaml + opensaml + ${cs.opensaml.version} + diff --git a/server/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index 17681f7c4dc..e2d4d2798a4 100644 --- a/server/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -32,7 +32,10 @@ http://www.springframework.org/schema/util/spring-util-3.0.xsd" > - + + + requestParameters) throws CloudAuthenticationException { // We will always use domainId first. If that does not exist, we will use domain name. If THAT doesn't exist // we will default to ROOT @@ -1003,7 +1050,7 @@ public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiSer final String sessionKey = Base64.encodeBase64String(sessionKeyBytes); session.setAttribute("sessionkey", sessionKey); - return; + return createLoginResponse(session); } throw new CloudAuthenticationException("Failed to authenticate user " + username + " in domain " + domainId + "; please provide valid credentials"); } diff --git a/server/src/com/cloud/api/ApiServlet.java b/server/src/com/cloud/api/ApiServlet.java index 8dff6ebc952..454fc8b1ddd 100644 --- a/server/src/com/cloud/api/ApiServlet.java +++ b/server/src/com/cloud/api/ApiServlet.java @@ -16,9 +16,9 @@ // under the License. package com.cloud.api; -import com.cloud.api.auth.APIAuthenticationManager; -import com.cloud.api.auth.APIAuthenticationType; -import com.cloud.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.APIAuthenticationManager; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; import com.cloud.user.Account; import com.cloud.user.AccountService; import com.cloud.user.User; @@ -26,6 +26,7 @@ import com.cloud.utils.HttpUtils; import com.cloud.utils.StringUtils; import com.cloud.utils.db.EntityManager; import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiServerService; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.managed.context.ManagedContext; diff --git a/server/src/com/cloud/api/auth/APIAuthenticationManagerImpl.java b/server/src/com/cloud/api/auth/APIAuthenticationManagerImpl.java index 886d277f15b..24ccbeb6032 100644 --- a/server/src/com/cloud/api/auth/APIAuthenticationManagerImpl.java +++ b/server/src/com/cloud/api/auth/APIAuthenticationManagerImpl.java @@ -19,6 +19,9 @@ package com.cloud.api.auth; import com.cloud.utils.component.ComponentContext; import com.cloud.utils.component.ManagerBase; import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.auth.APIAuthenticationManager; +import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; import org.apache.log4j.Logger; import javax.ejb.Local; @@ -32,12 +35,21 @@ import java.util.Map; public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuthenticationManager { public static final Logger s_logger = Logger.getLogger(APIAuthenticationManagerImpl.class.getName()); + private List _apiAuthenticators; + private static Map> s_authenticators = null; - private static List> s_commandList = null; public APIAuthenticationManagerImpl() { } + public List getApiAuthenticators() { + return _apiAuthenticators; + } + + public void setApiAuthenticators(List authenticators) { + _apiAuthenticators = authenticators; + } + @Override public boolean start() { s_authenticators = new HashMap>(); @@ -53,12 +65,13 @@ public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuth @Override public List> getCommands() { - if (s_commandList == null) { - s_commandList = new ArrayList>(); - s_commandList.add(DefaultLoginAPIAuthenticatorCmd.class); - s_commandList.add(DefaultLogoutAPIAuthenticatorCmd.class); + List> cmdList = new ArrayList>(); + cmdList.add(DefaultLoginAPIAuthenticatorCmd.class); + cmdList.add(DefaultLogoutAPIAuthenticatorCmd.class); + for (PluggableAPIAuthenticator apiAuthenticator: _apiAuthenticators) { + cmdList.addAll(apiAuthenticator.getAuthCommands()); } - return s_commandList; + return cmdList; } @Override @@ -68,6 +81,7 @@ public class APIAuthenticationManagerImpl extends ManagerBase implements APIAuth try { apiAuthenticator = (APIAuthenticator) s_authenticators.get(name).newInstance(); apiAuthenticator = ComponentContext.inject(apiAuthenticator); + apiAuthenticator.setAuthenticators(_apiAuthenticators); } catch (InstantiationException | IllegalAccessException e) { if (s_logger.isDebugEnabled()) { s_logger.debug("APIAuthenticationManagerImpl::getAPIAuthenticator failed: " + e.getMessage()); diff --git a/server/src/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java b/server/src/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java index f5d633e025f..fa23abd43e4 100644 --- a/server/src/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java +++ b/server/src/com/cloud/api/auth/DefaultLoginAPIAuthenticatorCmd.java @@ -16,7 +16,7 @@ // under the License. package com.cloud.api.auth; -import com.cloud.api.ApiServerService; +import org.apache.cloudstack.api.ApiServerService; import com.cloud.api.response.ApiResponseSerializer; import com.cloud.exception.CloudAuthenticationException; import com.cloud.user.Account; @@ -25,15 +25,17 @@ import org.apache.cloudstack.api.ApiConstants; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; -import org.apache.cloudstack.api.ResponseObject; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; import org.apache.cloudstack.api.response.LoginCmdResponse; import org.apache.log4j.Logger; import javax.inject.Inject; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import java.util.Enumeration; +import java.util.List; import java.util.Map; @APICommand(name = "login", description = "Logs a user into the CloudStack. A successful login attempt will generate a JSESSIONID cookie value that can be passed in subsequent Query command calls until the \"logout\" command has been issued or the session has expired.", requestHasSensitiveInfo = true, responseObject = LoginCmdResponse.class, entityType = {}) @@ -100,54 +102,6 @@ public class DefaultLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthe throw new ServerApiException(ApiErrorCode.METHOD_NOT_ALLOWED, "This is an authentication api, cannot be used directly"); } - private String createLoginResponse(HttpSession session, String responseType) { - LoginCmdResponse response = new LoginCmdResponse(); - response.setTimeout(session.getMaxInactiveInterval()); - - final String user_UUID = (String)session.getAttribute("user_UUID"); - session.removeAttribute("user_UUID"); - response.setUserId(user_UUID); - - final String domain_UUID = (String)session.getAttribute("domain_UUID"); - session.removeAttribute("domain_UUID"); - response.setDomainId(domain_UUID); - - // FIXME: the while loop mess - final Enumeration attrNames = session.getAttributeNames(); - if (attrNames != null) { - while (attrNames.hasMoreElements()) { - final String attrName = (String) attrNames.nextElement(); - final Object attrObj = session.getAttribute(attrName); - if (ApiConstants.USERNAME.equalsIgnoreCase(attrName)) { - response.setUsername(attrObj.toString()); - } - if (ApiConstants.ACCOUNT.equalsIgnoreCase(attrName)) { - response.setAccount(attrObj.toString()); - } - if (ApiConstants.FIRSTNAME.equalsIgnoreCase(attrName)) { - response.setFirstName(attrObj.toString()); - } - if (ApiConstants.LASTNAME.equalsIgnoreCase(attrName)) { - response.setLastName(attrObj.toString()); - } - if (ApiConstants.TYPE.equalsIgnoreCase(attrName)) { - response.setType((attrObj.toString())); - } - if (ApiConstants.TIMEZONE.equalsIgnoreCase(attrName)) { - response.setTimeZone(attrObj.toString()); - } - if (ApiConstants.REGISTERED.equalsIgnoreCase(attrName)) { - response.setRegistered(attrObj.toString()); - } - if (ApiConstants.SESSIONKEY.equalsIgnoreCase(attrName)) { - response.setSessionKey(attrObj.toString()); - } - } - } - response.setResponseName(getCommandName()); - return ApiResponseSerializer.toSerializedString((ResponseObject) response, responseType); - } - @Override public String authenticate(String command, Map params, HttpSession session, String remoteAddress, String responseType, StringBuilder auditTrailSb, final HttpServletResponse resp) throws ServerApiException { @@ -197,10 +151,8 @@ public class DefaultLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthe if (username != null) { final String pwd = ((password == null) ? null : password[0]); try { - _apiServer.loginUser(session, username[0], pwd, domainId, domain, remoteAddress, params); - auditTrailSb.insert(0, "(userId=" + session.getAttribute("userid") + " accountId=" + ((Account) session.getAttribute("accountobj")).getId() + - " sessionId=" + session.getId() + ")"); - return createLoginResponse(session, responseType); + return ApiResponseSerializer.toSerializedString(_apiServer.loginUser(session, username[0], pwd, domainId, domain, remoteAddress, params), + responseType); } catch (final CloudAuthenticationException ex) { // TODO: fall through to API key, or just fail here w/ auth error? (HTTP 401) try { @@ -222,4 +174,8 @@ public class DefaultLoginAPIAuthenticatorCmd extends BaseCmd implements APIAuthe public APIAuthenticationType getAPIType() { return APIAuthenticationType.LOGIN_API; } + + @Override + public void setAuthenticators(List authenticators) { + } } diff --git a/server/src/com/cloud/api/auth/DefaultLogoutAPIAuthenticatorCmd.java b/server/src/com/cloud/api/auth/DefaultLogoutAPIAuthenticatorCmd.java index a5802bfcbc6..ee7936ad734 100644 --- a/server/src/com/cloud/api/auth/DefaultLogoutAPIAuthenticatorCmd.java +++ b/server/src/com/cloud/api/auth/DefaultLogoutAPIAuthenticatorCmd.java @@ -22,11 +22,15 @@ import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.PluggableAPIAuthenticator; import org.apache.cloudstack.api.response.LogoutCmdResponse; import org.apache.log4j.Logger; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; +import java.util.List; import java.util.Map; @APICommand(name = "logout", description = "Logs out the user", responseObject = LogoutCmdResponse.class, entityType = {}) @@ -68,4 +72,8 @@ public class DefaultLogoutAPIAuthenticatorCmd extends BaseCmd implements APIAuth public APIAuthenticationType getAPIType() { return APIAuthenticationType.LOGOUT_API; } + + @Override + public void setAuthenticators(List authenticators) { + } } diff --git a/server/src/com/cloud/configuration/Config.java b/server/src/com/cloud/configuration/Config.java index b499df58bd4..85277386f82 100755 --- a/server/src/com/cloud/configuration/Config.java +++ b/server/src/com/cloud/configuration/Config.java @@ -1379,6 +1379,86 @@ public enum Config { "300000", "The allowable clock difference in milliseconds between when an SSO login request is made and when it is received.", null), + SAMLIsPluginEnabled( + "Advanced", + ManagementServer.class, + Boolean.class, + "saml2.enabled", + "false", + "Set it to true to enable SAML SSO plugin", + null), + SAMLUserAccountName( + "Advanced", + ManagementServer.class, + String.class, + "saml2.default.accountname", + "admin", + "The name of the default account to use when creating users from SAML SSO", + null), + SAMLUserDomain( + "Advanced", + ManagementServer.class, + String.class, + "saml2.default.domainid", + "1", + "The default domain UUID to use when creating users from SAML SSO", + null), + SAMLCloudStackRedirectionUrl( + "Advanced", + ManagementServer.class, + String.class, + "saml2.redirect.url", + "http://localhost:8080/client", + "The CloudStack UI url the SSO should redirected to when successful", + null), + SAMLServiceProviderID( + "Advanced", + ManagementServer.class, + String.class, + "saml2.sp.id", + "org.apache.cloudstack", + "SAML2 Service Provider Identifier String", + null), + SAMLServiceProviderSingleSignOnURL( + "Advanced", + ManagementServer.class, + String.class, + "saml2.sp.sso.url", + "http://localhost:8080/client/api?command=samlsso", + "SAML2 CloudStack Service Provider Single Sign On URL", + null), + SAMLServiceProviderSingleLogOutURL( + "Advanced", + ManagementServer.class, + String.class, + "saml2.sp.slo.url", + "http://localhost:8080/client/api?command=samlslo", + "SAML2 CloudStack Service Provider Single Log Out URL", + null), + SAMLIdentityProviderID( + "Advanced", + ManagementServer.class, + String.class, + "saml2.idp.id", + "https://openidp.feide.no", + "SAML2 Identity Provider Identifier String", + null), + SAMLIdentityProviderMetadataURL( + "Advanced", + ManagementServer.class, + String.class, + "saml2.idp.metadata.url", + "https://openidp.feide.no/simplesaml/saml2/idp/metadata.php", + "SAML2 Identity Provider Metadata XML Url", + null), + SAMLTimeout( + "Advanced", + ManagementServer.class, + Long.class, + "saml2.timeout", + "30000", + "SAML2 IDP Metadata Downloading and parsing etc. activity timeout in milliseconds", + null), //NetworkType("Hidden", ManagementServer.class, String.class, "network.type", "vlan", "The type of network that this deployment will use.", "vlan,direct"), RouterRamSize("Hidden", NetworkOrchestrationService.class, Integer.class, "router.ram.size", "128", "Default RAM for router VM (in MB).", null), diff --git a/server/test/com/cloud/api/ApiServletTest.java b/server/test/com/cloud/api/ApiServletTest.java index 0a9029663fa..1a9c13d3e74 100644 --- a/server/test/com/cloud/api/ApiServletTest.java +++ b/server/test/com/cloud/api/ApiServletTest.java @@ -16,9 +16,9 @@ // under the License. package com.cloud.api; -import com.cloud.api.auth.APIAuthenticationManager; -import com.cloud.api.auth.APIAuthenticationType; -import com.cloud.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.auth.APIAuthenticationManager; +import org.apache.cloudstack.api.auth.APIAuthenticationType; +import org.apache.cloudstack.api.auth.APIAuthenticator; import com.cloud.server.ManagementServer; import com.cloud.user.Account; import com.cloud.user.AccountService; diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index 256870441e2..95f06c8c1aa 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -114,6 +114,7 @@ known_categories = { 'login': 'Authentication', 'logout': 'Authentication', 'saml': 'Authentication', + 'getSPMetadata': 'Authentication', 'Capacity': 'System Capacity', 'NetworkDevice': 'Network Device', 'ExternalLoadBalancer': 'Ext Load Balancer', diff --git a/ui/scripts/cloudStack.js b/ui/scripts/cloudStack.js index b6dd5593345..38cf501f1aa 100644 --- a/ui/scripts/cloudStack.js +++ b/ui/scripts/cloudStack.js @@ -129,17 +129,25 @@ i.e. calling listCapabilities API with g_sessionKey from $.cookie('sessionKey') will succeed, then userValid will be set to true, then an user object (instead of "false") will be returned, then login screen will be bypassed. */ + var unBoxCookieValue = function (cookieName) { + var cookieValue = $.cookie(cookieName); + if (cookieValue && cookieValue.length > 2 && cookieValue[0] === '"' && cookieValue[cookieValue.length-1] === '"') { + cookieValue = cookieValue.slice(1, cookieValue.length-1); + $.cookie(cookieName, cookieValue, { expires: 1 }); + } + return cookieValue; + }; g_mySession = $.cookie('JSESSIONID'); - g_sessionKey = $.cookie('sessionKey'); - g_role = $.cookie('role'); - g_username = $.cookie('username'); - g_userid = $.cookie('userid'); - g_account = $.cookie('account'); - g_domainid = $.cookie('domainid'); - g_userfullname = $.cookie('userfullname'); - g_timezone = $.cookie('timezone'); + g_sessionKey = unBoxCookieValue('sessionKey'); + g_role = unBoxCookieValue('role'); + g_userid = unBoxCookieValue('userid'); + g_domainid = unBoxCookieValue('domainid'); + g_account = unBoxCookieValue('account'); + g_username = unBoxCookieValue('username'); + g_userfullname = unBoxCookieValue('userfullname'); + g_timezone = unBoxCookieValue('timezone'); if ($.cookie('timezoneoffset') != null) - g_timezoneoffset = isNaN($.cookie('timezoneoffset')) ? null : parseFloat($.cookie('timezoneoffset')); + g_timezoneoffset = isNaN(unBoxCookieValue('timezoneoffset')) ? null : parseFloat(unBoxCookieValue('timezoneoffset')); else g_timezoneoffset = null; } else { //single-sign-on (bypass login screen) diff --git a/utils/pom.xml b/utils/pom.xml index 27eeee37a9f..7dafbba65d8 100755 --- a/utils/pom.xml +++ b/utils/pom.xml @@ -143,6 +143,11 @@ + + org.opensaml + opensaml + ${cs.opensaml.version} + commons-net commons-net diff --git a/utils/src/org/apache/cloudstack/utils/auth/SAMLUtils.java b/utils/src/org/apache/cloudstack/utils/auth/SAMLUtils.java new file mode 100644 index 00000000000..1f31dcafd8f --- /dev/null +++ b/utils/src/org/apache/cloudstack/utils/auth/SAMLUtils.java @@ -0,0 +1,232 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package org.apache.cloudstack.utils.auth; + +import com.cloud.utils.HttpUtils; +import org.apache.log4j.Logger; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.x509.X509V1CertificateGenerator; +import org.joda.time.DateTime; +import org.opensaml.Configuration; +import org.opensaml.common.SAMLVersion; +import org.opensaml.common.xml.SAMLConstants; +import org.opensaml.saml2.core.AuthnContextClassRef; +import org.opensaml.saml2.core.AuthnContextComparisonTypeEnumeration; +import org.opensaml.saml2.core.AuthnRequest; +import org.opensaml.saml2.core.Issuer; +import org.opensaml.saml2.core.LogoutRequest; +import org.opensaml.saml2.core.NameID; +import org.opensaml.saml2.core.NameIDPolicy; +import org.opensaml.saml2.core.NameIDType; +import org.opensaml.saml2.core.RequestedAuthnContext; +import org.opensaml.saml2.core.Response; +import org.opensaml.saml2.core.SessionIndex; +import org.opensaml.saml2.core.impl.AuthnContextClassRefBuilder; +import org.opensaml.saml2.core.impl.AuthnRequestBuilder; +import org.opensaml.saml2.core.impl.IssuerBuilder; +import org.opensaml.saml2.core.impl.LogoutRequestBuilder; +import org.opensaml.saml2.core.impl.NameIDBuilder; +import org.opensaml.saml2.core.impl.NameIDPolicyBuilder; +import org.opensaml.saml2.core.impl.RequestedAuthnContextBuilder; +import org.opensaml.saml2.core.impl.SessionIndexBuilder; +import org.opensaml.xml.ConfigurationException; +import org.opensaml.xml.XMLObject; +import org.opensaml.xml.io.Marshaller; +import org.opensaml.xml.io.MarshallingException; +import org.opensaml.xml.io.Unmarshaller; +import org.opensaml.xml.io.UnmarshallerFactory; +import org.opensaml.xml.io.UnmarshallingException; +import org.opensaml.xml.util.Base64; +import org.opensaml.xml.util.XMLHelper; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.xml.sax.SAXException; + +import javax.security.auth.x500.X500Principal; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringWriter; +import java.math.BigInteger; +import java.net.URLEncoder; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.SecureRandom; +import java.security.Security; +import java.security.SignatureException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; + +public class SAMLUtils { + public static final Logger s_logger = Logger.getLogger(SAMLUtils.class); + + public static final String SAML_RESPONSE = "SAMLResponse"; + public static final String SAML_NS = "saml://"; + public static final String SAML_NAMEID = "SAML_NAMEID"; + public static final String SAML_SESSION = "SAML_SESSION"; + public static final String CERTIFICATE_NAME = "SAMLSP_X509CERTIFICATE"; + + public static String createSAMLId(String uid) { + return SAML_NS + uid; + } + + public static Boolean checkSAMLUserId(String uuid) { + return uuid.startsWith(SAML_NS); + } + + public static String generateSecureRandomId() { + return new BigInteger(160, new SecureRandom()).toString(32); + } + + public static AuthnRequest buildAuthnRequestObject(String spId, String idpUrl, String consumerUrl) { + String authnId = generateSecureRandomId(); + // Issuer object + IssuerBuilder issuerBuilder = new IssuerBuilder(); + Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(spId); + + // NameIDPolicy + NameIDPolicyBuilder nameIdPolicyBuilder = new NameIDPolicyBuilder(); + NameIDPolicy nameIdPolicy = nameIdPolicyBuilder.buildObject(); + nameIdPolicy.setFormat(NameIDType.PERSISTENT); + nameIdPolicy.setSPNameQualifier(spId); + nameIdPolicy.setAllowCreate(true); + + // AuthnContextClass + AuthnContextClassRefBuilder authnContextClassRefBuilder = new AuthnContextClassRefBuilder(); + AuthnContextClassRef authnContextClassRef = authnContextClassRefBuilder.buildObject( + SAMLConstants.SAML20_NS, + "AuthnContextClassRef", "saml"); + authnContextClassRef.setAuthnContextClassRef("urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"); + + // AuthnContex + RequestedAuthnContextBuilder requestedAuthnContextBuilder = new RequestedAuthnContextBuilder(); + RequestedAuthnContext requestedAuthnContext = requestedAuthnContextBuilder.buildObject(); + requestedAuthnContext + .setComparison(AuthnContextComparisonTypeEnumeration.MINIMUM); + requestedAuthnContext.getAuthnContextClassRefs().add( + authnContextClassRef); + + // Creation of AuthRequestObject + AuthnRequestBuilder authRequestBuilder = new AuthnRequestBuilder(); + AuthnRequest authnRequest = authRequestBuilder.buildObject(); + authnRequest.setID(authnId); + authnRequest.setDestination(idpUrl); + authnRequest.setVersion(SAMLVersion.VERSION_20); + authnRequest.setForceAuthn(false); + authnRequest.setIsPassive(false); + authnRequest.setIssuer(issuer); + authnRequest.setIssueInstant(new DateTime()); + authnRequest.setProtocolBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI); + authnRequest.setAssertionConsumerServiceURL(consumerUrl); + //authnRequest.setProviderName(spId); + //authnRequest.setNameIDPolicy(nameIdPolicy); + //authnRequest.setRequestedAuthnContext(requestedAuthnContext); + + return authnRequest; + } + + public static LogoutRequest buildLogoutRequest(String logoutUrl, String spId, NameID sessionNameId, String sessionIndex) { + IssuerBuilder issuerBuilder = new IssuerBuilder(); + Issuer issuer = issuerBuilder.buildObject(); + issuer.setValue(spId); + + SessionIndex sessionIndexElement = new SessionIndexBuilder().buildObject(); + sessionIndexElement.setSessionIndex(sessionIndex); + + NameID nameID = new NameIDBuilder().buildObject(); + nameID.setValue(sessionNameId.getValue()); + nameID.setFormat(sessionNameId.getFormat()); + + LogoutRequest logoutRequest = new LogoutRequestBuilder().buildObject(); + logoutRequest.setID(generateSecureRandomId()); + logoutRequest.setDestination(logoutUrl); + logoutRequest.setVersion(SAMLVersion.VERSION_20); + logoutRequest.setIssueInstant(new DateTime()); + logoutRequest.setIssuer(issuer); + logoutRequest.getSessionIndexes().add(sessionIndexElement); + logoutRequest.setNameID(nameID); + return logoutRequest; + } + + public static String encodeSAMLRequest(XMLObject authnRequest) + throws MarshallingException, IOException { + Marshaller marshaller = Configuration.getMarshallerFactory() + .getMarshaller(authnRequest); + Element authDOM = marshaller.marshall(authnRequest); + StringWriter requestWriter = new StringWriter(); + XMLHelper.writeNode(authDOM, requestWriter); + String requestMessage = requestWriter.toString(); + Deflater deflater = new Deflater(Deflater.DEFLATED, true); + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(byteArrayOutputStream, deflater); + deflaterOutputStream.write(requestMessage.getBytes()); + deflaterOutputStream.close(); + String encodedRequestMessage = Base64.encodeBytes(byteArrayOutputStream.toByteArray(), Base64.DONT_BREAK_LINES); + encodedRequestMessage = URLEncoder.encode(encodedRequestMessage, HttpUtils.UTF_8).trim(); + return encodedRequestMessage; + } + + public static Response decodeSAMLResponse(String responseMessage) + throws ConfigurationException, ParserConfigurationException, + SAXException, IOException, UnmarshallingException { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setNamespaceAware(true); + DocumentBuilder docBuilder = documentBuilderFactory.newDocumentBuilder(); + byte[] base64DecodedResponse = Base64.decode(responseMessage); + Document document = docBuilder.parse(new ByteArrayInputStream(base64DecodedResponse)); + Element element = document.getDocumentElement(); + UnmarshallerFactory unmarshallerFactory = Configuration.getUnmarshallerFactory(); + Unmarshaller unmarshaller = unmarshallerFactory.getUnmarshaller(element); + return (Response) unmarshaller.unmarshall(element); + } + + public static X509Certificate generateRandomX509Certificate() throws NoSuchAlgorithmException, NoSuchProviderException, CertificateEncodingException, SignatureException, InvalidKeyException { + Date validityBeginDate = new Date(System.currentTimeMillis() - 24 * 60 * 60 * 1000); + Date validityEndDate = new Date(System.currentTimeMillis() + 365 * 24 * 60 * 60 * 1000); + + Security.addProvider(new BouncyCastleProvider()); + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(1024, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + X500Principal dnName = new X500Principal("CN=Apache CloudStack"); + X509V1CertificateGenerator certGen = new X509V1CertificateGenerator(); + certGen.setSerialNumber(BigInteger.valueOf(System.currentTimeMillis())); + certGen.setSubjectDN(dnName); + certGen.setIssuerDN(dnName); + certGen.setNotBefore(validityBeginDate); + certGen.setNotAfter(validityEndDate); + certGen.setPublicKey(keyPair.getPublic()); + certGen.setSignatureAlgorithm("SHA256WithRSAEncryption"); + + return certGen.generate(keyPair.getPrivate(), "BC"); + } + +} diff --git a/utils/test/org/apache/cloudstack/utils/auth/SAMLUtilsTest.java b/utils/test/org/apache/cloudstack/utils/auth/SAMLUtilsTest.java new file mode 100644 index 00000000000..1d34ba1b92b --- /dev/null +++ b/utils/test/org/apache/cloudstack/utils/auth/SAMLUtilsTest.java @@ -0,0 +1,67 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// + +package org.apache.cloudstack.utils.auth; + +import junit.framework.TestCase; +import org.junit.Test; +import org.opensaml.saml2.core.AuthnRequest; +import org.opensaml.saml2.core.LogoutRequest; +import org.opensaml.saml2.core.NameID; +import org.opensaml.saml2.core.impl.NameIDBuilder; + +public class SAMLUtilsTest extends TestCase { + + @Test + public void testSAMLId() throws Exception { + assertTrue(SAMLUtils.checkSAMLUserId(SAMLUtils.createSAMLId("someUID"))); + assertFalse(SAMLUtils.checkSAMLUserId("randomUID")); + } + + @Test + public void testGenerateSecureRandomId() throws Exception { + assertTrue(SAMLUtils.generateSecureRandomId().length() == 32); + } + + @Test + public void testBuildAuthnRequestObject() throws Exception { + String consumerUrl = "http://someurl.com"; + String idpUrl = "http://idp.domain.example"; + String spId = "cloudstack"; + AuthnRequest req = SAMLUtils.buildAuthnRequestObject(spId, idpUrl, consumerUrl); + assertEquals(req.getAssertionConsumerServiceURL(), consumerUrl); + assertEquals(req.getDestination(), idpUrl); + assertEquals(req.getIssuer().getValue(), spId); + } + + @Test + public void testBuildLogoutRequest() throws Exception { + String logoutUrl = "http://logoutUrl"; + String spId = "cloudstack"; + String sessionIndex = "12345"; + String nameIdString = "someNameID"; + NameID sessionNameId = new NameIDBuilder().buildObject(); + sessionNameId.setValue(nameIdString); + LogoutRequest req = SAMLUtils.buildLogoutRequest(logoutUrl, spId, sessionNameId, sessionIndex); + assertEquals(req.getDestination(), logoutUrl); + assertEquals(req.getIssuer().getValue(), spId); + assertEquals(req.getNameID().getValue(), nameIdString); + assertEquals(req.getSessionIndexes().get(0).getSessionIndex(), sessionIndex); + } +} \ No newline at end of file