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

org.yamcs.security.LdapAuthModule Maven / Gradle / Ivy

There is a newer version: 5.10.9
Show newest version
package org.yamcs.security;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.List;
import java.util.concurrent.TimeUnit;

import javax.naming.Context;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;

import org.yamcs.InitException;
import org.yamcs.Spec;
import org.yamcs.Spec.OptionType;
import org.yamcs.YConfiguration;
import org.yamcs.logging.Log;

import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;

public class LdapAuthModule implements AuthModule {

    private Log log = new Log(LdapAuthModule.class);

    private boolean tls;
    private String providerUrl;
    private Hashtable yamcsEnv;

    private String userBase;
    private String nameAttribute;
    private String userFilter;
    private String[] displayNameAttributes;
    private String[] emailAttributes;
    private String[] searchAttributes;
    private List groupMappings = new ArrayList<>();

    private List groupBase;
    private String groupFilter;
    private String groupFilterUserAttribute;

    private boolean requiredIfKerberos;

    private Cache infoCache = CacheBuilder.newBuilder()
            .expireAfterWrite(24, TimeUnit.HOURS)
            .build();

    @Override
    public Spec getSpec() {
        Spec attributesSpec = new Spec();
        attributesSpec.addOption("name", OptionType.STRING)
                .withDefault("uid");
        attributesSpec.addOption("email", OptionType.LIST_OR_ELEMENT)
                .withElementType(OptionType.STRING)
                .withDefault(Arrays.asList("mail", "email", "userPrincipalName"));
        attributesSpec.addOption("displayName", OptionType.LIST_OR_ELEMENT)
                .withElementType(OptionType.STRING)
                .withDefault("cn");

        Spec groupMappingSpec = new Spec();
        groupMappingSpec.addOption("dn", OptionType.STRING).withRequired(true);
        groupMappingSpec.addOption("role", OptionType.STRING);
        groupMappingSpec.addOption("superuser", OptionType.BOOLEAN);
        groupMappingSpec.requireOneOf("role", "superuser");

        Spec spec = new Spec();
        spec.addOption("host", OptionType.STRING).withRequired(true);
        spec.addOption("port", OptionType.INTEGER);
        spec.addOption("user", OptionType.STRING);
        spec.addOption("password", OptionType.STRING).withSecret(true);
        spec.requireTogether("user", "password");
        spec.addOption("tls", OptionType.BOOLEAN);
        spec.addOption("userBase", OptionType.STRING).withRequired(true);
        spec.addOption("attributes", OptionType.MAP)
                .withSpec(attributesSpec)
                .withApplySpecDefaults(true);
        spec.addOption("userFilter", OptionType.STRING);
        spec.addOption("groupMappings", OptionType.LIST)
                .withElementType(OptionType.MAP)
                .withSpec(groupMappingSpec);

        spec.addOption("groupBase", OptionType.LIST_OR_ELEMENT).withElementType(OptionType.STRING);
        spec.addOption("groupFilter", OptionType.STRING);
        spec.addOption("groupFilterUserAttribute", OptionType.STRING);
        spec.requireTogether("groupBase", "groupFilter", "groupFilterUserAttribute");

        spec.addOption("requiredIfKerberos", OptionType.BOOLEAN).withDefault(false);

        return spec;
    }

    @Override
    public void init(YConfiguration args) throws InitException {
        String host = args.getString("host");

        tls = args.getBoolean("tls", false);
        if (tls) {
            int port = args.getInt("port", 636);
            providerUrl = String.format("ldaps://%s:%s", host, port);
        } else {
            int port = args.getInt("port", 389);
            providerUrl = String.format("ldap://%s:%s", host, port);
        }

        userBase = args.getString("userBase");

        YConfiguration attributesArgs = args.getConfig("attributes");
        nameAttribute = attributesArgs.getString("name");

        userFilter = args.getString("userFilter", "(" + nameAttribute + "={0})");
        if (!userFilter.contains("{0}")) {
            throw new InitException("LDAP user filter should contain the {0} character sequence, "
                    + "which will be replaced with the attempted username");
        }

        displayNameAttributes = attributesArgs.getList("displayName").toArray(new String[0]);
        emailAttributes = attributesArgs.getList("email").toArray(new String[0]);

        groupBase = args.containsKey("groupBase") ? args.getList("groupBase") : null;
        groupFilter = args.getString("groupFilter", null);
        groupFilterUserAttribute = args.getString("groupFilterUserAttribute", null);

        if (args.containsKey("groupMappings")) {
            for (var mappingConfig : args.getConfigList("groupMappings")) {
                var groupMapping = new GroupMapping();
                groupMapping.dn = mappingConfig.getString("dn");
                groupMapping.role = mappingConfig.getString("role", null);
                groupMapping.superuser = mappingConfig.getBoolean("superuser", false);
                groupMappings.add(groupMapping);
            }
        }

        var concat = new HashSet();
        concat.add(nameAttribute);
        concat.addAll(attributesArgs.getList("displayName"));
        concat.addAll(attributesArgs.getList("email"));
        concat.add("memberOf");
        if (groupFilterUserAttribute != null) {
            concat.add(groupFilterUserAttribute);
        }
        searchAttributes = concat.toArray(new String[0]);

        requiredIfKerberos = args.getBoolean("requiredIfKerberos");

        yamcsEnv = new Hashtable<>();
        yamcsEnv.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        yamcsEnv.put(Context.PROVIDER_URL, providerUrl);

        // Referral is needed to support querying of memberOf attribute that is
        // generated through use of dynlist overlay.
        yamcsEnv.put(Context.REFERRAL, "follow");

        yamcsEnv.put(Context.SECURITY_AUTHENTICATION, "simple");
        if (args.containsKey("user")) {
            yamcsEnv.put(Context.SECURITY_PRINCIPAL, args.getString("user"));
        }
        if (args.containsKey("password")) {
            yamcsEnv.put(Context.SECURITY_CREDENTIALS, args.getString("password"));
        }
        if (tls) {
            yamcsEnv.put(Context.SECURITY_PROTOCOL, "ssl");
        }
    }

    @Override
    public AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        if (token instanceof UsernamePasswordToken) {
            String username = ((UsernamePasswordToken) token).getPrincipal();
            char[] password = ((UsernamePasswordToken) token).getPassword();

            LdapUserInfo info;
            try {
                info = searchUserInfo(username);
            } catch (NamingException e) {
                log.warn("Failed to search LDAP for user {}", username, e);
                return null;
            }

            if (info == null) {
                return null;
            }

            bindUser(info.dn, password);
            AuthenticationInfo authenticationInfo = new AuthenticationInfo(this, info.uid);
            authenticationInfo.addExternalIdentity(getClass().getName(), info.dn);
            authenticationInfo.setDisplayName(info.cn);
            authenticationInfo.setEmail(info.email);
            return authenticationInfo;
        } else {
            return null;
        }
    }

    @Override
    public void authenticationSucceeded(AuthenticationInfo authenticationInfo) {
        if (authenticationInfo.isKerberos()) {
            // Note to future self: If we ever want to support multiple LDAP and
            // kerberos modules, then it may become useful to compare the user dn
            // with the kerberos realm before querying LDAP.
            String username = authenticationInfo.getUsername();
            try {
                LdapUserInfo info = searchUserInfo(username);
                if (info == null) {
                    log.warn("User {} not found in LDAP", username);
                } else {
                    authenticationInfo.addExternalIdentity(getClass().getName(), info.dn);
                    authenticationInfo.setDisplayName(info.cn);
                    authenticationInfo.setEmail(info.email);
                }
            } catch (NamingException e) {
                log.warn("Failed to search LDAP for user {}", username, e);
            }
        }
    }

    private LdapUserInfo searchUserInfo(String username) throws NamingException {
        LdapUserInfo info = infoCache.getIfPresent(username);
        if (info != null) {
            return info;
        }

        DirContext ctx = null;
        try {
            ctx = new InitialDirContext(yamcsEnv);
            var controls = new SearchControls();
            controls.setReturningAttributes(searchAttributes);
            controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
            var filter = userFilter.replace("{0}", username);
            var searchResult = getSingleResult(ctx, userBase, filter, controls);
            if (searchResult == null) {
                return null;
            }
            info = new LdapUserInfo();
            // Use the uid from LDAP, just to prevent case sensitivity issues.
            info.uid = (String) searchResult.getAttributes().get(nameAttribute).get();
            info.dn = searchResult.getNameInNamespace();
            info.cn = findAttribute(searchResult, displayNameAttributes);
            info.email = findAttribute(searchResult, emailAttributes);
            info.memberOf = findListAttribute(searchResult, new String[] { "memberOf" });

            if (groupBase != null) {
                controls = new SearchControls();
                controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
                var lookup = findAttribute(searchResult, new String[] { groupFilterUserAttribute });
                if (lookup == null && "dn".equalsIgnoreCase(groupFilterUserAttribute)) {
                    lookup = searchResult.getNameInNamespace();
                }
                if (lookup != null) {
                    filter = groupFilter.replace("{0}", lookup);
                    for (var groupBaseElement : groupBase) {
                        var answer = ctx.search(groupBaseElement, filter, controls);
                        while (answer.hasMore()) {
                            searchResult = answer.next();
                            info.memberOf.add(searchResult.getNameInNamespace());
                        }
                        answer.close();
                    }
                }
            }

            infoCache.put(username, info);
            return info;
        } finally {
            if (ctx != null) {
                ctx.close();
            }
        }
    }

    private void bindUser(String dn, char[] password) throws AuthenticationException {
        // Never bind with empty password, because on many LDAP servers
        // this would make a successful "unauthenticated" simple bind.
        // https://datatracker.ietf.org/doc/html/rfc4513#section-5.1.2
        if (password.length == 0) {
            throw new AuthenticationException("Invalid password (empty)");
        }

        var env = new Hashtable();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory");
        env.put(Context.PROVIDER_URL, providerUrl);
        env.put("com.sun.jndi.ldap.connect.pool", "true");
        env.put(Context.SECURITY_AUTHENTICATION, "simple");
        env.put(Context.SECURITY_PRINCIPAL, dn);
        env.put(Context.SECURITY_CREDENTIALS, new String(password));
        if (tls) {
            env.put(Context.SECURITY_PROTOCOL, "ssl");
        }
        try {
            DirContext ctx = new InitialDirContext(env);
            ctx.close();
        } catch (javax.naming.AuthenticationException e) {
            log.warn("Bind failed for dn '{}'", dn, e);
            throw new AuthenticationException("Invalid password");
        } catch (NamingException e) {
            throw new AuthenticationException(e);
        }
    }

    @Override
    public AuthorizationInfo getAuthorizationInfo(AuthenticationInfo authenticationInfo) throws AuthorizationException {
        AuthorizationInfo authz = new AuthorizationInfo();

        var principal = authenticationInfo.getUsername();
        var info = infoCache.getIfPresent(principal);

        if (authenticationInfo.isKerberos() && requiredIfKerberos && info == null) {
            throw new AuthorizationException("Cannot link Kerberos user with LDAP directory");
        }

        if (info != null) {
            for (var groupMapping : groupMappings) {
                for (var dn : info.memberOf) {
                    if (groupMapping.dn.equalsIgnoreCase(dn)) {
                        if (groupMapping.role != null) {
                            authz.addRole(groupMapping.role);
                        }
                        if (groupMapping.superuser) {
                            authz.grantSuperuser();
                        }
                    }
                }
            }
        }

        return authz;
    }

    @Override
    public boolean verifyValidity(AuthenticationInfo authenticationInfo) {
        return true;
    }

    private SearchResult getSingleResult(DirContext ctx, String searchBase, String filter, SearchControls controls)
            throws NamingException {
        var answer = ctx.search(searchBase, filter, controls);
        if (answer.hasMore()) {
            var result = answer.next();
            answer.close();
            return result;
        }
        return null;
    }

    private String findAttribute(SearchResult result, String[] possibleNames) throws NamingException {
        for (String attrId : possibleNames) {
            Attribute attr = result.getAttributes().get(attrId);
            if (attr != null) {
                return (String) attr.get();
            }
        }
        return null;
    }

    private List findListAttribute(SearchResult result, String[] possibleNames) throws NamingException {
        for (String attrId : possibleNames) {
            var values = new ArrayList();
            var attr = result.getAttributes().get(attrId);
            if (attr != null) {
                var valueEnumeration = attr.getAll();
                while (valueEnumeration.hasMoreElements()) {
                    values.add((String) valueEnumeration.next());
                }
                return values;
            }
        }
        return new ArrayList<>();
    }

    private static final class GroupMapping {
        String dn;
        String role;
        boolean superuser;
    }

    private static final class LdapUserInfo {
        String uid;
        String dn;
        String cn;
        String email;
        List memberOf;
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy