// 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.apache.log4j.Logger;
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 static final Logger s_logger = Logger.getLogger(SAML2AuthManagerImpl.class);

    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()) {
            s_logger.info("SAML auth plugin loaded");
            return setup();
        } else {
            s_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);
                s_logger.info("No SAML keystore found, created and saved a new Service Provider keypair");
            } catch (final NoSuchProviderException | NoSuchAlgorithmException e) {
                s_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) {
                        s_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) {
                        s_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());
        s_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) {
                            s_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) {
                            s_logger.info("[ignored] encountered invalid certificate encryption.", ignored);
                        }
                    }
                    if (kd.getUse() == UsageType.UNSPECIFIED) {
                        try {
                            unspecifiedKey = KeyInfoHelper.getCertificates(kd.getKeyInfo()).get(0);
                        } catch (CertificateException ignored) {
                            s_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())) {
                s_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;
            }
            s_logger.debug("Starting SAML IDP Metadata Refresh Task");

            Map <String, SAMLProviderMetadata> metadataMap = new HashMap<String, SAMLProviderMetadata>();
            try {
                discoverAndAddIdp(_idpMetaDataProvider.getMetadata(), metadataMap);
                _idpMetadataMap = metadataMap;
                expireTokens();
                s_logger.debug("Finished refreshing SAML Metadata and expiring old auth tokens");
            } catch (MetadataProviderException e) {
                s_logger.warn("SAML Metadata Refresh task failed with exception: " + e.getMessage());
            }

        }
    }

    private boolean setup() {
        if (!initSP()) {
            s_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) {
                    s_logger.error("Provided Metadata is not a URL, Unable to locate metadata file from local path: " + idpMetaDataUrl);
                    return false;
                }
                else{
                    s_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) {
            s_logger.error("Unable to read SAML2 IDP MetaData URL, error:" + e.getMessage());
            s_logger.error("SAML2 Authentication may be unavailable");
            return false;
        } catch (ConfigurationException | FactoryConfigurationError e) {
            s_logger.error("OpenSAML bootstrapping failed: error: " + e.getMessage());
            return false;
        } catch (NullPointerException e) {
            s_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 {
            s_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};
    }
}
