content: // 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 java.io.ByteArrayInputStream;
  import java.io.ByteArrayOutputStream;
  import java.io.File;
  import java.io.IOException;
  import java.io.ObjectInputStream;
  import java.io.ObjectOutput;
  import java.io.ObjectOutputStream;
  import java.security.InvalidKeyException;
  import java.security.KeyPair;
  import java.security.NoSuchAlgorithmException;
  import java.security.NoSuchProviderException;
  import java.security.PrivateKey;
  import java.security.PublicKey;
  import java.security.SignatureException;
  import java.security.cert.CertificateException;
  import java.security.cert.X509Certificate;
  import java.util.ArrayList;
  import java.util.Collection;
  import java.util.HashMap;
  import java.util.List;
  import java.util.Map;
  import java.util.Timer;
  import java.util.TimerTask;
  
  import javax.inject.Inject;
  import javax.xml.stream.FactoryConfigurationError;
  
  import org.apache.cloudstack.api.command.AuthorizeSAMLSSOCmd;
  import org.apache.cloudstack.api.command.GetServiceProviderMetaDataCmd;
  import org.apache.cloudstack.api.command.ListAndSwitchSAMLAccountCmd;
  import org.apache.cloudstack.api.command.ListIdpsCmd;
  import org.apache.cloudstack.api.command.ListSamlAuthorizationCmd;
  import org.apache.cloudstack.api.command.SAML2LoginAPIAuthenticatorCmd;
  import org.apache.cloudstack.api.command.SAML2LogoutAPIAuthenticatorCmd;
  import org.apache.cloudstack.framework.config.ConfigKey;
  import org.apache.cloudstack.framework.config.Configurable;
  import org.apache.cloudstack.framework.security.keystore.KeystoreDao;
  import org.apache.cloudstack.framework.security.keystore.KeystoreVO;
  import org.apache.cloudstack.utils.security.CertUtils;
  import org.apache.commons.codec.binary.Base64;
  import org.apache.commons.httpclient.HttpClient;
  import org.bouncycastle.operator.OperatorCreationException;
  import org.opensaml.DefaultBootstrap;
  import org.opensaml.common.xml.SAMLConstants;
  import org.opensaml.saml2.metadata.ContactPerson;
  import org.opensaml.saml2.metadata.EmailAddress;
  import org.opensaml.saml2.metadata.EntitiesDescriptor;
  import org.opensaml.saml2.metadata.EntityDescriptor;
  import org.opensaml.saml2.metadata.IDPSSODescriptor;
  import org.opensaml.saml2.metadata.KeyDescriptor;
  import org.opensaml.saml2.metadata.OrganizationDisplayName;
  import org.opensaml.saml2.metadata.OrganizationName;
  import org.opensaml.saml2.metadata.OrganizationURL;
  import org.opensaml.saml2.metadata.SingleLogoutService;
  import org.opensaml.saml2.metadata.SingleSignOnService;
  import org.opensaml.saml2.metadata.provider.AbstractReloadingMetadataProvider;
  import org.opensaml.saml2.metadata.provider.FilesystemMetadataProvider;
  import org.opensaml.saml2.metadata.provider.HTTPMetadataProvider;
  import org.opensaml.saml2.metadata.provider.MetadataProviderException;
  import org.opensaml.xml.ConfigurationException;
  import org.opensaml.xml.XMLObject;
  import org.opensaml.xml.security.credential.UsageType;
  import org.opensaml.xml.security.keyinfo.KeyInfoHelper;
  import org.springframework.stereotype.Component;
  
  import com.cloud.domain.Domain;
  import com.cloud.user.DomainManager;
  import com.cloud.user.User;
  import com.cloud.user.UserVO;
  import com.cloud.user.dao.UserDao;
  import com.cloud.utils.PropertiesUtil;
  import com.cloud.utils.component.AdapterBase;
  
  @Component
  public class SAML2AuthManagerImpl extends AdapterBase implements SAML2AuthManager, Configurable {
  
      private SAMLProviderMetadata _spMetadata = new SAMLProviderMetadata();
      private Map<String, SAMLProviderMetadata> _idpMetadataMap = new HashMap<String, SAMLProviderMetadata>();
  
      private String idpSingleSignOnUrl;
      private String idpSingleLogOutUrl;
  
      private Timer _timer;
      private int _refreshInterval = SAMLPluginConstants.SAML_REFRESH_INTERVAL;
      private AbstractReloadingMetadataProvider _idpMetaDataProvider;
  
      public String getSAMLIdentityProviderMetadataURL(){
          return SAMLIdentityProviderMetadataURL.value();
      }
  
      @Inject
      private KeystoreDao _ksDao;
  
      @Inject
      private SAMLTokenDao _samlTokenDao;
  
      @Inject
      private UserDao _userDao;
  
      @Inject
      DomainManager _domainMgr;
  
      @Override
      public boolean start() {
          if (isSAMLPluginEnabled()) {
              logger.info("SAML auth plugin loaded");
              return setup();
          } else {
              logger.info("SAML auth plugin not enabled so not loading");
              return super.start();
          }
      }
  
      @Override
      public boolean stop() {
          if (_timer != null) {
              _timer.cancel();
          }
          return super.stop();
      }
  
      protected boolean initSP() {
          KeystoreVO keyStoreVO = _ksDao.findByName(SAMLPluginConstants.SAMLSP_KEYPAIR);
          if (keyStoreVO == null) {
              try {
                  KeyPair keyPair = CertUtils.generateRandomKeyPair(4096);
                  _ksDao.save(SAMLPluginConstants.SAMLSP_KEYPAIR,
                          SAMLUtils.encodePrivateKey(keyPair.getPrivate()),
                          SAMLUtils.encodePublicKey(keyPair.getPublic()), "samlsp-keypair");
                  keyStoreVO = _ksDao.findByName(SAMLPluginConstants.SAMLSP_KEYPAIR);
                  logger.info("No SAML keystore found, created and saved a new Service Provider keypair");
              } catch (final NoSuchProviderException | NoSuchAlgorithmException e) {
                  logger.error("Unable to create and save SAML keypair, due to: ", e);
              }
          }
  
          String spId = SAMLServiceProviderID.value();
          String spSsoUrl = SAMLServiceProviderSingleSignOnURL.value();
          String spSloUrl = SAMLServiceProviderSingleLogOutURL.value();
          String spOrgName = SAMLServiceProviderOrgName.value();
          String spOrgUrl = SAMLServiceProviderOrgUrl.value();
          String spContactPersonName = SAMLServiceProviderContactPersonName.value();
          String spContactPersonEmail = SAMLServiceProviderContactEmail.value();
          KeyPair spKeyPair = null;
          X509Certificate spX509Key = null;
          if (keyStoreVO != null) {
              final PrivateKey privateKey = SAMLUtils.decodePrivateKey(keyStoreVO.getCertificate());
              final PublicKey publicKey = SAMLUtils.decodePublicKey(keyStoreVO.getKey());
              if (privateKey != null && publicKey != null) {
                  spKeyPair = new KeyPair(publicKey, privateKey);
                  KeystoreVO x509VO = _ksDao.findByName(SAMLPluginConstants.SAMLSP_X509CERT);
                  if (x509VO == null) {
                      try {
                          spX509Key = SAMLUtils.generateRandomX509Certificate(spKeyPair);
                          ByteArrayOutputStream bos = new ByteArrayOutputStream();
                          ObjectOutput out = new ObjectOutputStream(bos);
                          out.writeObject(spX509Key);
                          out.flush();
                          _ksDao.save(SAMLPluginConstants.SAMLSP_X509CERT, Base64.encodeBase64String(bos.toByteArray()), "", "samlsp-x509cert");
                          bos.close();
                      } catch (final NoSuchAlgorithmException | NoSuchProviderException | CertificateException | SignatureException | InvalidKeyException | IOException | OperatorCreationException e) {
                          logger.error("SAML plugin won't be able to use X509 signed authentication", e);
                      }
                  } else {
                      try {
                          ByteArrayInputStream bi = new ByteArrayInputStream(Base64.decodeBase64(x509VO.getCertificate()));
                          ObjectInputStream si = new ObjectInputStream(bi);
                          spX509Key = (X509Certificate) si.readObject();
                          bi.close();
                      } catch (IOException | ClassNotFoundException ignored) {
                          logger.error("SAML Plugin won't be able to use X509 signed authentication. Failed to load X509 Certificate from Database.");
                      }
                  }
              }
          }
          if (spKeyPair != null && spX509Key != null
                  && spId != null && spSsoUrl != null && spSloUrl != null
                  && spOrgName != null && spOrgUrl != null
                  && spContactPersonName != null && spContactPersonEmail != null) {
              _spMetadata.setEntityId(spId);
              _spMetadata.setOrganizationName(spOrgName);
              _spMetadata.setOrganizationUrl(spOrgUrl);
              _spMetadata.setContactPersonName(spContactPersonName);
              _spMetadata.setContactPersonEmail(spContactPersonEmail);
              _spMetadata.setSsoUrl(spSsoUrl);
              _spMetadata.setSloUrl(spSloUrl);
              _spMetadata.setKeyPair(spKeyPair);
              _spMetadata.setSigningCertificate(spX509Key);
              _spMetadata.setEncryptionCertificate(spX509Key);
              return true;
          }
          return false;
      }
  
      private void addIdpToMap(EntityDescriptor descriptor, Map<String, SAMLProviderMetadata> idpMap) {
          SAMLProviderMetadata idpMetadata = new SAMLProviderMetadata();
          idpMetadata.setEntityId(descriptor.getEntityID());
          logger.debug("Adding IdP to the list of discovered IdPs: " + descriptor.getEntityID());
          if (descriptor.getOrganization() != null) {
              if (descriptor.getOrganization().getDisplayNames() != null) {
                  for (OrganizationDisplayName orgName : descriptor.getOrganization().getDisplayNames()) {
                      if (orgName != null && orgName.getName() != null) {
                          idpMetadata.setOrganizationName(orgName.getName().getLocalString());
                          break;
                      }
                  }
              }
              if (idpMetadata.getOrganizationName() == null && descriptor.getOrganization().getOrganizationNames() != null) {
                  for (OrganizationName orgName : descriptor.getOrganization().getOrganizationNames()) {
                      if (orgName != null && orgName.getName() != null) {
                          idpMetadata.setOrganizationName(orgName.getName().getLocalString());
                          break;
                      }
                  }
              }
              if (descriptor.getOrganization().getURLs() != null) {
                  for (OrganizationURL organizationURL : descriptor.getOrganization().getURLs()) {
                      if (organizationURL != null && organizationURL.getURL() != null) {
                          idpMetadata.setOrganizationUrl(organizationURL.getURL().getLocalString());
                          break;
                      }
                  }
              }
          }
          if (descriptor.getContactPersons() != null) {
              for (ContactPerson person : descriptor.getContactPersons()) {
                  if (person == null || (person.getGivenName() == null && person.getSurName() == null)
                          || person.getEmailAddresses() == null) {
                      continue;
                  }
                  if (person.getGivenName() != null) {
                      idpMetadata.setContactPersonName(person.getGivenName().getName());
  
                  } else if (person.getSurName() != null) {
                      idpMetadata.setContactPersonName(person.getSurName().getName());
                  }
                  for (EmailAddress emailAddress : person.getEmailAddresses()) {
                      if (emailAddress != null && emailAddress.getAddress() != null) {
                          idpMetadata.setContactPersonEmail(emailAddress.getAddress());
                      }
                  }
                  if (idpMetadata.getContactPersonName() != null && idpMetadata.getContactPersonEmail() != null) {
                      break;
                  }
              }
          }
  
          IDPSSODescriptor idpDescriptor = descriptor.getIDPSSODescriptor(SAMLConstants.SAML20P_NS);
          if (idpDescriptor != null) {
              if (idpDescriptor.getSingleSignOnServices() != null) {
                  for (SingleSignOnService ssos : idpDescriptor.getSingleSignOnServices()) {
                      if (ssos.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) {
                          idpMetadata.setSsoUrl(ssos.getLocation());
                      }
                  }
              }
              if (idpDescriptor.getSingleLogoutServices() != null) {
                  for (SingleLogoutService slos : idpDescriptor.getSingleLogoutServices()) {
                      if (slos.getBinding().equals(SAMLConstants.SAML2_REDIRECT_BINDING_URI)) {
                          idpMetadata.setSloUrl(slos.getLocation());
                      }
                  }
              }
  
              X509Certificate unspecifiedKey = null;
              if (idpDescriptor.getKeyDescriptors() != null) {
                  for (KeyDescriptor kd : idpDescriptor.getKeyDescriptors()) {
                      if (kd.getUse() == UsageType.SIGNING) {
                          try {
                              idpMetadata.setSigningCertificate(KeyInfoHelper.getCertificates(kd.getKeyInfo()).get(0));
                          } catch (CertificateException ignored) {
                              logger.info("[ignored] encountered invalid certificate signing.", ignored);
                          }
                      }
                      if (kd.getUse() == UsageType.ENCRYPTION) {
                          try {
                              idpMetadata.setEncryptionCertificate(KeyInfoHelper.getCertificates(kd.getKeyInfo()).get(0));
                          } catch (CertificateException ignored) {
                              logger.info("[ignored] encountered invalid certificate encryption.", ignored);
                          }
                      }
                      if (kd.getUse() == UsageType.UNSPECIFIED) {
                          try {
                              unspecifiedKey = KeyInfoHelper.getCertificates(kd.getKeyInfo()).get(0);
                          } catch (CertificateException ignored) {
                              logger.info("[ignored] encountered invalid certificate.", ignored);
                          }
                      }
                  }
              }
              if (idpMetadata.getSigningCertificate() == null && unspecifiedKey != null) {
                  idpMetadata.setSigningCertificate(unspecifiedKey);
              }
              if (idpMetadata.getEncryptionCertificate() == null && unspecifiedKey != null) {
                  idpMetadata.setEncryptionCertificate(unspecifiedKey);
              }
              if (idpMap.containsKey(idpMetadata.getEntityId())) {
                  logger.warn("Duplicate IdP metadata found with entity Id: " + idpMetadata.getEntityId());
              }
              idpMap.put(idpMetadata.getEntityId(), idpMetadata);
          }
      }
  
      private void discoverAndAddIdp(XMLObject metadata, Map<String, SAMLProviderMetadata> idpMap) {
          if (metadata instanceof EntityDescriptor) {
              EntityDescriptor entityDescriptor = (EntityDescriptor) metadata;
              addIdpToMap(entityDescriptor, idpMap);
          } else if (metadata instanceof EntitiesDescriptor) {
              EntitiesDescriptor entitiesDescriptor = (EntitiesDescriptor) metadata;
              if (entitiesDescriptor.getEntityDescriptors() != null) {
                  for (EntityDescriptor entityDescriptor: entitiesDescriptor.getEntityDescriptors()) {
                      addIdpToMap(entityDescriptor, idpMap);
                  }
              }
              if (entitiesDescriptor.getEntitiesDescriptors() != null) {
                  for (EntitiesDescriptor entitiesDescriptorInner: entitiesDescriptor.getEntitiesDescriptors()) {
                      discoverAndAddIdp(entitiesDescriptorInner, idpMap);
                  }
              }
          }
      }
  
      class MetadataRefreshTask extends TimerTask {
          @Override
          public void run() {
              if (_idpMetaDataProvider == null) {
                  return;
              }
              logger.debug("Starting SAML IDP Metadata Refresh Task");
  
              Map <String, SAMLProviderMetadata> metadataMap = new HashMap<String, SAMLProviderMetadata>();
              try {
                  discoverAndAddIdp(_idpMetaDataProvider.getMetadata(), metadataMap);
                  _idpMetadataMap = metadataMap;
                  expireTokens();
                  logger.debug("Finished refreshing SAML Metadata and expiring old auth tokens");
              } catch (MetadataProviderException e) {
                  logger.warn("SAML Metadata Refresh task failed with exception: " + e.getMessage());
              }
  
          }
      }
  
      private boolean setup() {
          if (!initSP()) {
              logger.error("SAML Plugin failed to initialize, please fix the configuration and restart management server");
              return false;
          }
          _timer = new Timer();
          final HttpClient client = new HttpClient();
          final String idpMetaDataUrl = getSAMLIdentityProviderMetadataURL();
          if (SAMLTimeout.value() != null && SAMLTimeout.value() > SAMLPluginConstants.SAML_REFRESH_INTERVAL) {
              _refreshInterval = SAMLTimeout.value();
          }
          try {
              DefaultBootstrap.bootstrap();
              if (idpMetaDataUrl.startsWith("http")) {
                  _idpMetaDataProvider = new HTTPMetadataProvider(_timer, client, idpMetaDataUrl);
              } else {
                  File metadataFile = PropertiesUtil.findConfigFile(idpMetaDataUrl);
                  if (metadataFile == null) {
                      logger.error("Provided Metadata is not a URL, Unable to locate metadata file from local path: " + idpMetaDataUrl);
                      return false;
                  }
                  else{
                      logger.debug("Provided Metadata is not a URL, trying to read metadata file from local path: " + metadataFile.getAbsolutePath());
                      _idpMetaDataProvider = new FilesystemMetadataProvider(_timer, metadataFile);
                  }
              }
              _idpMetaDataProvider.setRequireValidMetadata(true);
              _idpMetaDataProvider.setParserPool(SAMLUtils.getSaferParserPool());
              _idpMetaDataProvider.initialize();
              _timer.scheduleAtFixedRate(new MetadataRefreshTask(), 0, _refreshInterval * 1000);
  
          } catch (MetadataProviderException e) {
              logger.error("Unable to read SAML2 IDP MetaData URL, error:" + e.getMessage());
              logger.error("SAML2 Authentication may be unavailable");
              return false;
          } catch (ConfigurationException | FactoryConfigurationError e) {
              logger.error("OpenSAML bootstrapping failed: error: " + e.getMessage());
              return false;
          } catch (NullPointerException e) {
              logger.error("Unable to setup SAML Auth Plugin due to NullPointerException" +
                      " please check the SAML global settings: " + e.getMessage());
              return false;
          }
          return true;
      }
  
      @Override
      public SAMLProviderMetadata getSPMetadata() {
          return _spMetadata;
      }
  
      @Override
      public SAMLProviderMetadata getIdPMetadata(String entityId) {
          if (entityId != null && _idpMetadataMap.containsKey(entityId)) {
              return _idpMetadataMap.get(entityId);
          }
          String defaultIdpId = SAMLDefaultIdentityProviderId.value();
          if (defaultIdpId != null && _idpMetadataMap.containsKey(defaultIdpId)) {
              return _idpMetadataMap.get(defaultIdpId);
          }
          // In case of a single IdP, return that as default
          if (_idpMetadataMap.size() == 1) {
              return _idpMetadataMap.values().iterator().next();
          }
          return null;
      }
  
      @Override
      public Collection<SAMLProviderMetadata> getAllIdPMetadata() {
          return _idpMetadataMap.values();
      }
  
      @Override
      public boolean isUserAuthorized(Long userId, String entityId) {
          UserVO user = _userDao.getUser(userId);
          if (user != null) {
              if (user.getSource().equals(User.Source.SAML2) &&
                      user.getExternalEntity().equalsIgnoreCase(entityId)) {
                  return true;
              }
          }
          return false;
      }
  
      @Override
      public boolean authorizeUser(Long userId, String entityId, boolean enable) {
          UserVO user = _userDao.getUser(userId);
          if (user != null) {
              if (enable) {
                  user.setExternalEntity(entityId);
                  user.setSource(User.Source.SAML2);
              } else {
                  if (user.getSource().equals(User.Source.SAML2)) {
                      user.setSource(User.Source.SAML2DISABLED);
                  } else {
                      return false;
                  }
              }
              _userDao.update(user.getId(), user);
              return true;
          }
          return false;
      }
  
      @Override
      public void saveToken(String authnId, String domainPath, String entity) {
          Long domainId = null;
          if (domainPath != null) {
              Domain domain = _domainMgr.findDomainByPath(domainPath);
              if (domain != null) {
                  domainId = domain.getId();
              }
          }
          SAMLTokenVO token = new SAMLTokenVO(authnId, domainId, entity);
          if (_samlTokenDao.findByUuid(authnId) == null) {
              _samlTokenDao.persist(token);
          } else {
              logger.warn("Duplicate SAML token for entity=" + entity + " token id=" + authnId + " domain=" + domainPath);
          }
      }
  
      @Override
      public SAMLTokenVO getToken(String authnId) {
          return _samlTokenDao.findByUuid(authnId);
      }
  
      @Override
      public void purgeToken(SAMLTokenVO token) {
          if (token != null) {
              _samlTokenDao.remove(token.getId());
          }
      }
  
      @Override
      public void expireTokens() {
          _samlTokenDao.expireTokens();
      }
  
      public Boolean isSAMLPluginEnabled() {
          return SAMLIsPluginEnabled.value();
      }
  
      @Override
      public String getConfigComponentName() {
          return "SAML2-PLUGIN";
      }
  
      @Override
      public List<Class<?>> getAuthCommands() {
          List<Class<?>> cmdList = new ArrayList<Class<?>>();
          if (!isSAMLPluginEnabled()) {
              return cmdList;
          }
          cmdList.add(SAML2LoginAPIAuthenticatorCmd.class);
          cmdList.add(SAML2LogoutAPIAuthenticatorCmd.class);
          cmdList.add(GetServiceProviderMetaDataCmd.class);
          cmdList.add(ListIdpsCmd.class);
          cmdList.add(ListAndSwitchSAMLAccountCmd.class);
          return cmdList;
      }
  
      @Override
      public List<Class<?>> getCommands() {
          List<Class<?>> cmdList = new ArrayList<Class<?>>();
          if (!isSAMLPluginEnabled()) {
              return cmdList;
          }
          cmdList.add(AuthorizeSAMLSSOCmd.class);
          cmdList.add(ListSamlAuthorizationCmd.class);
          return cmdList;
      }
  
      @Override
      public ConfigKey<?>[] getConfigKeys() {
          return new ConfigKey<?>[] {
                  SAMLIsPluginEnabled, SAMLServiceProviderID,
                  SAMLServiceProviderContactPersonName, SAMLServiceProviderContactEmail,
                  SAMLServiceProviderOrgName, SAMLServiceProviderOrgUrl,
                  SAMLServiceProviderSingleSignOnURL, SAMLServiceProviderSingleLogOutURL,
                  SAMLCloudStackRedirectionUrl, SAMLUserAttributeName,
                  SAMLIdentityProviderMetadataURL, SAMLDefaultIdentityProviderId,
                  SAMLSignatureAlgorithm, SAMLAppendDomainSuffix, SAMLTimeout, SAMLCheckSignature,
                  SAMLForceAuthn, SAMLUserSessionKeyPathAttribute, SAMLRequirePasswordLogin};
      }
  }
  