All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.elasticsearch.xpack.security.authc.saml.SamlMetadataCommand Maven / Gradle / Ivy

There is a newer version: 8.16.1
Show newest version
/*
 * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
 * or more contributor license agreements. Licensed under the Elastic License
 * 2.0; you may not use this file except in compliance with the Elastic License
 * 2.0.
 */
package org.elasticsearch.xpack.security.authc.saml;

import joptsimple.OptionParser;
import joptsimple.OptionSet;
import joptsimple.OptionSpec;

import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.elasticsearch.cli.ExitCodes;
import org.elasticsearch.cli.ProcessInfo;
import org.elasticsearch.cli.Terminal;
import org.elasticsearch.cli.UserException;
import org.elasticsearch.common.Strings;
import org.elasticsearch.common.cli.KeyStoreAwareCommand;
import org.elasticsearch.common.logging.Loggers;
import org.elasticsearch.common.settings.KeyStoreWrapper;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.ssl.PemUtils;
import org.elasticsearch.common.util.LocaleUtils;
import org.elasticsearch.common.util.concurrent.ThreadContext;
import org.elasticsearch.core.CheckedFunction;
import org.elasticsearch.core.PathUtils;
import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.env.Environment;
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
import org.elasticsearch.xpack.core.security.authc.RealmSettings;
import org.elasticsearch.xpack.core.security.authc.saml.SamlRealmSettings;
import org.elasticsearch.xpack.core.ssl.CertParsingUtils;
import org.elasticsearch.xpack.security.authc.saml.SamlSpMetadataBuilder.ContactInfo;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.core.xml.io.MarshallingException;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.opensaml.saml.saml2.metadata.EntityDescriptor;
import org.opensaml.saml.saml2.metadata.impl.EntityDescriptorMarshaller;
import org.opensaml.security.credential.Credential;
import org.opensaml.security.x509.BasicX509Credential;
import org.opensaml.xmlsec.signature.Signature;
import org.opensaml.xmlsec.signature.support.SignatureConstants;
import org.opensaml.xmlsec.signature.support.Signer;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;

import java.io.IOException;
import java.io.InputStream;
import java.io.Writer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.Key;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

/**
 * CLI tool to generate SAML Metadata for a Service Provider (realm)
 */
class SamlMetadataCommand extends KeyStoreAwareCommand {

    static final String METADATA_SCHEMA = "saml-schema-metadata-2.0.xsd";

    private final OptionSpec outputPathSpec;
    private final OptionSpec batchSpec;
    private final OptionSpec realmSpec;
    private final OptionSpec localeSpec;
    private final OptionSpec serviceNameSpec;
    private final OptionSpec attributeSpec;
    private final OptionSpec orgNameSpec;
    private final OptionSpec orgDisplayNameSpec;
    private final OptionSpec orgUrlSpec;
    private final OptionSpec contactsSpec;
    private final OptionSpec signingPkcs12PathSpec;
    private final OptionSpec signingCertPathSpec;
    private final OptionSpec signingKeyPathSpec;
    private final OptionSpec keyPasswordSpec;
    private final CheckedFunction keyStoreFunction;
    private KeyStoreWrapper keyStoreWrapper;

    SamlMetadataCommand() {
        this((environment) -> {
            KeyStoreWrapper ksWrapper = KeyStoreWrapper.load(environment.configFile());
            return ksWrapper;
        });
    }

    SamlMetadataCommand(CheckedFunction keyStoreFunction) {
        super("Generate Service Provider Metadata for a SAML realm");
        outputPathSpec = parser.accepts("out", "path of the xml file that should be generated").withRequiredArg();
        batchSpec = parser.accepts("batch", "Do not prompt");
        realmSpec = parser.accepts("realm", "name of the elasticsearch realm for which metadata should be generated").withRequiredArg();
        localeSpec = parser.accepts("locale", "the locale to be used for elements that require a language").withRequiredArg();
        serviceNameSpec = parser.accepts("service-name", "the name to apply to the attribute consuming service").withRequiredArg();
        attributeSpec = parser.accepts("attribute", "additional SAML attributes to request").withRequiredArg();
        orgNameSpec = parser.accepts("organisation-name", "the name of the organisation operating this service").withRequiredArg();
        orgDisplayNameSpec = parser.accepts("organisation-display-name", "the display-name of the organisation operating this service")
            .availableIf(orgNameSpec)
            .withRequiredArg();
        orgUrlSpec = parser.accepts("organisation-url", "the URL of the organisation operating this service")
            .requiredIf(orgNameSpec)
            .withRequiredArg();
        contactsSpec = parser.accepts("contacts", "Include contact information in metadata").availableUnless(batchSpec);
        signingPkcs12PathSpec = parser.accepts(
            "signing-bundle",
            "path to an existing key pair (in PKCS#12 format) to be used for " + "signing "
        ).withRequiredArg();
        signingCertPathSpec = parser.accepts("signing-cert", "path to an existing signing certificate")
            .availableUnless(signingPkcs12PathSpec)
            .withRequiredArg();
        signingKeyPathSpec = parser.accepts("signing-key", "path to an existing signing private key")
            .availableIf(signingCertPathSpec)
            .requiredIf(signingCertPathSpec)
            .withRequiredArg();
        keyPasswordSpec = parser.accepts("signing-key-password", "password for an existing signing private key or keypair")
            .withOptionalArg();
        this.keyStoreFunction = keyStoreFunction;
    }

    @Override
    public void close() throws IOException {
        super.close();
        if (keyStoreWrapper != null) {
            keyStoreWrapper.close();
        }
    }

    @Override
    public void execute(Terminal terminal, OptionSet options, Environment env, ProcessInfo processInfo) throws Exception {
        // OpenSAML prints a lot of _stuff_ at info level, that really isn't needed in a command line tool.
        Loggers.setLevel(LogManager.getLogger("org.opensaml"), Level.WARN);

        final Logger logger = LogManager.getLogger(getClass());
        SamlUtils.initialize(logger);

        final EntityDescriptor descriptor = buildEntityDescriptor(terminal, options, env);
        Element element = possiblySignDescriptor(terminal, options, descriptor, env);

        final Path xml = writeOutput(terminal, options, element);
        validateXml(terminal, xml);
    }

    // package-protected for testing
    EntityDescriptor buildEntityDescriptor(Terminal terminal, OptionSet options, Environment env) throws Exception {
        final boolean batch = options.has(batchSpec);

        final RealmConfig realm = findRealm(terminal, options, env);
        final Settings realmSettings = realm.settings().getByPrefix(RealmSettings.realmSettingPrefix(realm.identifier()));
        terminal.println(
            Terminal.Verbosity.VERBOSE,
            "Using realm configuration\n=====\n" + realmSettings.toDelimitedString('\n') + "====="
        );
        final Locale locale = findLocale(options);
        terminal.println(Terminal.Verbosity.VERBOSE, "Using locale: " + locale.toLanguageTag());

        final SpConfiguration spConfig = SamlRealm.getSpConfiguration(realm);
        final SamlSpMetadataBuilder builder = new SamlSpMetadataBuilder(locale, spConfig.getEntityId()).assertionConsumerServiceUrl(
            spConfig.getAscUrl()
        )
            .singleLogoutServiceUrl(spConfig.getLogoutUrl())
            .encryptionCredentials(spConfig.getEncryptionCredentials())
            .signingCredential(spConfig.getSigningConfiguration().getCredential())
            .authnRequestsSigned(spConfig.getSigningConfiguration().shouldSign(AuthnRequest.DEFAULT_ELEMENT_LOCAL_NAME))
            .nameIdFormat(realm.getSetting(SamlRealmSettings.NAMEID_FORMAT))
            .serviceName(option(serviceNameSpec, options, env.settings().get("cluster.name")));

        Map attributes = getAttributeNames(options, realm);
        for (String attr : attributes.keySet()) {
            final String name;
            String friendlyName;
            final String settingName = attributes.get(attr);
            final String attributeSource = settingName == null ? "command line" : '"' + settingName + '"';
            if (attr.contains(":")) {
                name = attr;
                if (batch) {
                    friendlyName = settingName;
                } else {
                    friendlyName = terminal.readText(
                        "What is the friendly name for "
                            + attributeSource
                            + " attribute \""
                            + attr
                            + "\" [default: "
                            + (settingName == null ? "none" : settingName)
                            + "] "
                    );
                    if (Strings.isNullOrEmpty(friendlyName)) {
                        friendlyName = settingName;
                    }
                }
            } else {
                if (batch) {
                    throw new UserException(
                        ExitCodes.CONFIG,
                        "Option " + batchSpec.toString() + " is specified, but attribute " + attr + " appears to be a FriendlyName value"
                    );
                }
                friendlyName = attr;
                name = requireText(
                    terminal,
                    "What is the standard (urn) name for " + attributeSource + " attribute \"" + attr + "\" (required): "
                );
            }
            terminal.println(Terminal.Verbosity.VERBOSE, "Requesting attribute '" + name + "' (FriendlyName: '" + friendlyName + "')");
            builder.withAttribute(friendlyName, name);
        }

        if (options.has(orgNameSpec) && options.has(orgUrlSpec)) {
            String name = orgNameSpec.value(options);
            builder.organization(name, option(orgDisplayNameSpec, options, name), orgUrlSpec.value(options));
        }

        if (options.has(contactsSpec)) {
            terminal.println("\nPlease enter the personal details for each contact to be included in the metadata");
            do {
                final String givenName = requireText(terminal, "What is the given name for the contact: ");
                final String surName = requireText(terminal, "What is the surname for the contact: ");
                final String displayName = givenName + ' ' + surName;
                final String email = requireText(terminal, "What is the email address for " + displayName + ": ");
                String type;
                while (true) {
                    type = requireText(terminal, "What is the contact type for " + displayName + ": ");
                    if (ContactInfo.TYPES.containsKey(type)) {
                        break;
                    } else {
                        terminal.errorPrintln(
                            "Type '"
                                + type
                                + "' is not valid. Valid values are "
                                + Strings.collectionToCommaDelimitedString(ContactInfo.TYPES.keySet())
                        );
                    }
                }
                builder.withContact(type, givenName, surName, email);
            } while (terminal.promptYesNo("Enter details for another contact", true));
        }

        return builder.build();
    }

    // package-protected for testing
    Element possiblySignDescriptor(Terminal terminal, OptionSet options, EntityDescriptor descriptor, Environment env)
        throws UserException {
        try {
            final EntityDescriptorMarshaller marshaller = new EntityDescriptorMarshaller();
            if (options.has(signingPkcs12PathSpec) || (options.has(signingCertPathSpec) && options.has(signingKeyPathSpec))) {
                Signature signature = (Signature) XMLObjectProviderRegistrySupport.getBuilderFactory()
                    .getBuilder(Signature.DEFAULT_ELEMENT_NAME)
                    .buildObject(Signature.DEFAULT_ELEMENT_NAME);
                signature.setSigningCredential(buildSigningCredential(terminal, options, env));
                signature.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256);
                signature.setCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
                descriptor.setSignature(signature);
                Element element = marshaller.marshall(descriptor);
                Signer.signObject(signature);
                return element;
            } else {
                return marshaller.marshall(descriptor);
            }
        } catch (Exception e) {
            String errorMessage;
            if (e instanceof MarshallingException) {
                errorMessage = "Error serializing Metadata to file";
            } else if (e instanceof org.opensaml.xmlsec.signature.support.SignatureException) {
                errorMessage = "Error attempting to sign Metadata";
            } else {
                errorMessage = "Error building signing credentials from provided keyPair";
            }
            terminal.errorPrintln(Terminal.Verbosity.SILENT, errorMessage);
            terminal.errorPrintln("The following errors were found:");
            printExceptions(terminal, e);
            throw new UserException(ExitCodes.CANT_CREATE, "Unable to create metadata document");
        }
    }

    private Path writeOutput(Terminal terminal, OptionSet options, Element element) throws Exception {
        final Path outputFile = resolvePath(option(outputPathSpec, options, "saml-elasticsearch-metadata.xml"));
        final Writer writer = Files.newBufferedWriter(outputFile);
        SamlUtils.print(element, writer, true);
        terminal.println("\nWrote SAML metadata to " + outputFile);
        return outputFile;
    }

    private Credential buildSigningCredential(Terminal terminal, OptionSet options, Environment env) throws Exception {
        X509Certificate signingCertificate;
        PrivateKey signingKey;
        char[] password = getChars(keyPasswordSpec.value(options));
        if (options.has(signingPkcs12PathSpec)) {
            Path p12Path = resolvePath(signingPkcs12PathSpec.value(options));
            Map keys = withPassword(
                "certificate bundle (" + p12Path + ")",
                password,
                terminal,
                keyPassword -> CertParsingUtils.readPkcs12KeyPairs(p12Path, keyPassword, a -> keyPassword)
            );

            if (keys.size() != 1) {
                throw new IllegalArgumentException(
                    "expected a single key in file [" + p12Path.toAbsolutePath() + "] but found [" + keys.size() + "]"
                );
            }
            final Map.Entry pair = keys.entrySet().iterator().next();
            signingCertificate = (X509Certificate) pair.getKey();
            signingKey = (PrivateKey) pair.getValue();
        } else {
            Path cert = resolvePath(signingCertPathSpec.value(options));
            Path key = resolvePath(signingKeyPathSpec.value(options));
            signingCertificate = CertParsingUtils.readX509Certificate(cert);
            signingKey = readSigningKey(key, password, terminal);
        }
        return new BasicX509Credential(signingCertificate, signingKey);
    }

    private static  T withPassword(
        String description,
        char[] password,
        Terminal terminal,
        CheckedFunction body
    ) throws E {
        if (password == null) {
            char[] promptedValue = terminal.readSecret("Enter password for " + description + " : ");
            try {
                return body.apply(promptedValue);
            } finally {
                Arrays.fill(promptedValue, (char) 0);
            }
        } else {
            return body.apply(password);
        }
    }

    private static char[] getChars(String password) {
        return password == null ? null : password.toCharArray();
    }

    private static PrivateKey readSigningKey(Path path, char[] password, Terminal terminal) throws Exception {
        AtomicReference passwordReference = new AtomicReference<>(password);
        try {
            return PemUtils.readPrivateKey(path, () -> {
                if (password != null) {
                    return password;
                }
                char[] promptedValue = terminal.readSecret("Enter password for the signing key (" + path.getFileName() + ") : ");
                passwordReference.set(promptedValue);
                return promptedValue;
            });
        } finally {
            if (passwordReference.get() != null) {
                Arrays.fill(passwordReference.get(), (char) 0);
            }
        }
    }

    private static void validateXml(Terminal terminal, Path xml) throws Exception {
        try (InputStream xmlInput = Files.newInputStream(xml)) {
            SamlUtils.validate(xmlInput, METADATA_SCHEMA);
            terminal.println(Terminal.Verbosity.VERBOSE, "The generated metadata file conforms to the SAML metadata schema");
        } catch (SAXException e) {
            terminal.errorPrintln(
                Terminal.Verbosity.SILENT,
                "Error - The generated metadata file does not conform to the " + "SAML metadata schema"
            );
            terminal.errorPrintln("While validating " + xml.toString() + " the follow errors were found:");
            printExceptions(terminal, e);
            throw new UserException(ExitCodes.CODE_ERROR, "Generated metadata is not valid");
        }
    }

    private static void printExceptions(Terminal terminal, Throwable throwable) {
        terminal.errorPrintln(" - " + throwable.getMessage());
        for (Throwable sup : throwable.getSuppressed()) {
            printExceptions(terminal, sup);
        }
        if (throwable.getCause() != null && throwable.getCause() != throwable) {
            printExceptions(terminal, throwable.getCause());
        }
    }

    @SuppressForbidden(reason = "CLI tool working from current directory")
    private static Path resolvePath(String name) {
        return PathUtils.get(name).normalize();
    }

    private static String requireText(Terminal terminal, String prompt) {
        String value = null;
        while (Strings.isNullOrEmpty(value)) {
            value = terminal.readText(prompt);
        }
        return value;
    }

    private static  T option(OptionSpec spec, OptionSet options, T defaultValue) {
        if (options.has(spec)) {
            return spec.value(options);
        } else {
            return defaultValue;
        }
    }

    /**
     * Map of saml-attribute name to configuration-setting name
     */
    private Map getAttributeNames(OptionSet options, RealmConfig realm) {
        Map attributes = new LinkedHashMap<>();
        for (String a : attributeSpec.values(options)) {
            attributes.put(a, null);
        }
        final String prefix = RealmSettings.realmSettingPrefix(realm.identifier()) + SamlRealmSettings.AttributeSetting.ATTRIBUTES_PREFIX;
        final Settings attributeSettings = realm.settings().getByPrefix(prefix);
        for (String key : sorted(attributeSettings.keySet())) {
            final String attr = attributeSettings.get(key);
            attributes.put(attr, key);
        }
        return attributes;
    }

    // We sort this Set so that it is deterministic for testing
    private static SortedSet sorted(Set strings) {
        return new TreeSet<>(strings);
    }

    /**
     * @TODO REALM-SETTINGS[TIM] This can be redone a lot now the realm settings are keyed by type
     */
    private RealmConfig findRealm(Terminal terminal, OptionSet options, Environment env) throws Exception {

        keyStoreWrapper = keyStoreFunction.apply(env);
        final Settings settings;
        if (keyStoreWrapper != null) {
            decryptKeyStore(keyStoreWrapper, terminal);

            final Settings.Builder settingsBuilder = Settings.builder();
            settingsBuilder.put(env.settings(), true);
            if (settingsBuilder.getSecureSettings() == null) {
                settingsBuilder.setSecureSettings(keyStoreWrapper);
            }
            settings = settingsBuilder.build();
        } else {
            settings = env.settings();
        }

        final Map realms = RealmSettings.getRealmSettings(settings);
        if (options.has(realmSpec)) {
            final String name = realmSpec.value(options);
            final RealmConfig.RealmIdentifier identifier = new RealmConfig.RealmIdentifier(SamlRealmSettings.TYPE, name);
            final Settings realmSettings = realms.get(identifier);
            if (realmSettings == null) {
                throw new UserException(ExitCodes.CONFIG, "No such realm '" + name + "' defined in " + env.configFile());
            }
            if (isSamlRealm(identifier)) {
                return buildRealm(identifier, env, settings);
            } else {
                throw new UserException(ExitCodes.CONFIG, "Realm '" + name + "' is not a SAML realm (is '" + identifier.getType() + "')");
            }
        } else {
            final List> saml = realms.entrySet()
                .stream()
                .filter(entry -> isSamlRealm(entry.getKey()))
                .toList();
            if (saml.isEmpty()) {
                throw new UserException(ExitCodes.CONFIG, "There is no SAML realm configured in " + env.configFile());
            }
            if (saml.size() > 1) {
                terminal.errorPrintln("Using configuration in " + env.configFile());
                terminal.errorPrintln(
                    "Found multiple SAML realms: "
                        + saml.stream().map(Map.Entry::getKey).map(Object::toString).collect(Collectors.joining(", "))
                );
                terminal.errorPrintln("Use the -" + optionName(realmSpec) + " option to specify an explicit realm");
                throw new UserException(
                    ExitCodes.CONFIG,
                    "Found multiple SAML realms, please specify one with '-" + optionName(realmSpec) + "'"
                );
            }
            final Map.Entry entry = saml.get(0);
            terminal.println("Building metadata for SAML realm " + entry.getKey());
            return buildRealm(entry.getKey(), env, settings);
        }
    }

    private static String optionName(OptionSpec spec) {
        return spec.options().get(0);
    }

    private static RealmConfig buildRealm(RealmConfig.RealmIdentifier identifier, Environment env, Settings globalSettings) {
        return new RealmConfig(identifier, globalSettings, env, new ThreadContext(globalSettings));
    }

    private static boolean isSamlRealm(RealmConfig.RealmIdentifier realmIdentifier) {
        return SamlRealmSettings.TYPE.equals(realmIdentifier.getType());
    }

    private Locale findLocale(OptionSet options) {
        if (options.has(localeSpec)) {
            return LocaleUtils.parse(localeSpec.value(options));
        } else {
            return Locale.getDefault();
        }
    }

    // For testing
    OptionParser getParser() {
        return parser;
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy