/*
 *  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.zeppelin.realm;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.naming.AuthenticationException;
import javax.naming.Context;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.PartialResultException;
import javax.naming.SizeLimitExceededException;
import javax.naming.directory.Attribute;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;
import javax.naming.ldap.Control;
import javax.naming.ldap.LdapContext;
import javax.naming.ldap.LdapName;
import javax.naming.ldap.PagedResultsControl;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.security.alias.CredentialProvider;
import org.apache.hadoop.security.alias.CredentialProviderFactory;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.ShiroException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.credential.HashedCredentialsMatcher;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.crypto.hash.DefaultHashService;
import org.apache.shiro.crypto.hash.Hash;
import org.apache.shiro.crypto.hash.HashRequest;
import org.apache.shiro.crypto.hash.HashService;
import org.apache.shiro.realm.ldap.DefaultLdapRealm;
import org.apache.shiro.realm.ldap.JndiLdapContextFactory;
import org.apache.shiro.realm.ldap.LdapContextFactory;
import org.apache.shiro.realm.ldap.LdapUtils;
import org.apache.shiro.session.Session;
import org.apache.shiro.subject.MutablePrincipalCollection;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.util.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Implementation of {@link org.apache.shiro.realm.ldap.JndiLdapRealm} that also returns each user's
 * groups. This implementation is heavily based on org.apache.isis.security.shiro.IsisLdapRealm.
 *
 * <p>This implementation saves looked up ldap groups in Shiro Session to make them
 * easy to be looked up outside of this object
 *
 * <p>Sample config for <tt>shiro.ini</tt>:
 *
 * <p>
 *   [main]
 *   ldapRealm = org.apache.zeppelin.realm.LdapRealm
 *   ldapRealm.contextFactory.url = ldap://localhost:33389
 *   ldapRealm.contextFactory.authenticationMechanism = simple
 *   ldapRealm.contextFactory.systemUsername = uid=guest,ou=people,dc=hadoop,dc= apache,dc=org
 *   ldapRealm.contextFactory.systemPassword = S{ALIAS=ldcSystemPassword}
 *   ldapRealm.hadoopSecurityCredentialPath = jceks://file/user/zeppelin/zeppelin.jceks
 *   ldapRealm.userDnTemplate = uid={0},ou=people,dc=hadoop,dc=apache,dc=org
 *   # Ability to set ldap paging Size if needed default is 100
 *   ldapRealm.pagingSize = 200
 *   ldapRealm.authorizationEnabled = true
 *   ldapRealm.searchBase = dc=hadoop,dc=apache,dc=org
 *   ldapRealm.userSearchBase = dc=hadoop,dc=apache,dc=org
 *   ldapRealm.groupSearchBase = ou=groups,dc=hadoop,dc=apache,dc=org
 *   ldapRealm.userObjectClass = person
 *   ldapRealm.groupObjectClass = groupofnames
 *   # Allow userSearchAttribute to be customized
 *   ldapRealm.userSearchAttributeName = sAMAccountName
 *   ldapRealm.memberAttribute = member
 *   # force usernames returned from ldap to lowercase useful for AD
 *   ldapRealm.userLowerCase = true
 *   # ability set searchScopes subtree (default), one, base
 *   ldapRealm.userSearchScope = subtree;
 *   ldapRealm.groupSearchScope = subtree;
 *   ldapRealm.userSearchFilter = (&(objectclass=person)(sAMAccountName={0}))
 *   ldapRealm.groupSearchFilter = (&(objectclass=groupofnames)(member={0}))
 *   ldapRealm.memberAttributeValueTemplate=cn={0},ou=people,dc=hadoop,dc=apache,dc=org
 *   # enable support for nested groups using the LDAP_MATCHING_RULE_IN_CHAIN operator
 *   ldapRealm.groupSearchEnableMatchingRuleInChain = true
 * <p>
 *   # optional mapping from physical groups to logical application roles
 *   ldapRealm.rolesByGroup = \ LDN_USERS: user_role,\ NYK_USERS: user_role,\ HKG_USERS: user_role,
 *   \GLOBAL_ADMIN: admin_role,\ DEMOS: self-install_role
 * <p>
 *   # optional list of roles that are allowed to authenticate
 *   ldapRealm.allowedRolesForAuthentication = admin_role,user_role
 * <p>
 *   ldapRealm.permissionsByRole=\ user_role = *:ToDoItemsJdo:*:*,\*:ToDoItem:*:*;
 *   \ self-install_role = *:ToDoItemsFixturesService:install:* ; \ admin_role = *
 * <p>
 *   [urls]
 *   **=authcBasic
 * <p>
 *   securityManager.realms = $ldapRealm
 */
public class LdapRealm extends DefaultLdapRealm {

  private static final SearchControls SUBTREE_SCOPE = new SearchControls();
  private static final SearchControls ONELEVEL_SCOPE = new SearchControls();
  private static final SearchControls OBJECT_SCOPE = new SearchControls();
  private static final String SUBJECT_USER_ROLES = "subject.userRoles";
  private static final String SUBJECT_USER_GROUPS = "subject.userGroups";
  private static final String MEMBER_URL = "memberUrl";
  private static final String POSIX_GROUP = "posixGroup";

  // LDAP Operator '1.2.840.113556.1.4.1941'
  // walks the chain of ancestry in objects all the way to the root until it finds a match
  // see https://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx
  private static final String MATCHING_RULE_IN_CHAIN_FORMAT =
      "(&(objectClass=%s)(%s:1.2.840.113556.1.4.1941:=%s))";

  private static final String DEFAULT_PRINCIPAL_REGEX = "(.*)";
  private static final String MEMBER_SUBSTITUTION_TOKEN = "{0}";
  private static final String HASHING_ALGORITHM = "SHA-1";
  private static final Logger LOGGER = LoggerFactory.getLogger(LdapRealm.class);

  static {
    SUBTREE_SCOPE.setSearchScope(SearchControls.SUBTREE_SCOPE);
    ONELEVEL_SCOPE.setSearchScope(SearchControls.ONELEVEL_SCOPE);
    OBJECT_SCOPE.setSearchScope(SearchControls.OBJECT_SCOPE);
  }

  private String searchBase;
  private String userSearchBase;
  private int pagingSize = 100;
  private boolean userLowerCase;
  private String principalRegex = DEFAULT_PRINCIPAL_REGEX;
  private Pattern principalPattern = Pattern.compile(DEFAULT_PRINCIPAL_REGEX);
  private String userDnTemplate = "{0}";
  private String userSearchFilter = null;
  private String groupSearchFilter = null;
  private String userSearchAttributeTemplate = "{0}";
  private String userSearchScope = "subtree";
  private String groupSearchScope = "subtree";
  private boolean groupSearchEnableMatchingRuleInChain;

  private String groupSearchBase;

  private String groupObjectClass = "groupOfNames";

  // typical value: member, uniqueMember, memberUrl
  private String memberAttribute = "member";

  private String groupIdAttribute = "cn";

  private String memberAttributeValuePrefix = "uid=";
  private String memberAttributeValueSuffix = "";

  private final Map<String, String> rolesByGroup = new LinkedHashMap<>();
  private final List<String> allowedRolesForAuthentication = new ArrayList<>();
  private final Map<String, List<String>> permissionsByRole = new LinkedHashMap<>();

  private String hadoopSecurityCredentialPath;
  private static final String KEYSTORE_PASS = "ldapRealm.systemPassword";

  private boolean authorizationEnabled;

  private String userSearchAttributeName;
  private String userObjectClass = "person";

  private final HashService hashService = new DefaultHashService();



  public void setHadoopSecurityCredentialPath(String hadoopSecurityCredentialPath) {
    this.hadoopSecurityCredentialPath = hadoopSecurityCredentialPath;
  }

  public LdapRealm() {
    HashedCredentialsMatcher credentialsMatcher = new HashedCredentialsMatcher(HASHING_ALGORITHM);
    setCredentialsMatcher(credentialsMatcher);
  }

  @Override
  protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
      throws org.apache.shiro.authc.AuthenticationException {
    try {
      return super.doGetAuthenticationInfo(token);
    } catch (org.apache.shiro.authc.AuthenticationException e){
      LOGGER.warn("Encountered Error while authenticating {}: {}", token.getPrincipal(), e.getMessage());
      throw  e;
    }
  }

  @Override
  protected void onInit() {
    super.onInit();
    if (!org.apache.commons.lang3.StringUtils.isEmpty(this.hadoopSecurityCredentialPath)
        && getContextFactory() != null) {
      ((JndiLdapContextFactory) getContextFactory()).setSystemPassword(
          getSystemPassword(this.hadoopSecurityCredentialPath, KEYSTORE_PASS));
    }
  }

  static String getSystemPassword(String hadoopSecurityCredentialPath,
      String keystorePass) {
    String password = "";
    try {
      Configuration configuration = new Configuration();
      configuration.set(CredentialProviderFactory.CREDENTIAL_PROVIDER_PATH,
          hadoopSecurityCredentialPath);
      CredentialProvider provider = CredentialProviderFactory.getProviders(configuration).get(0);
      CredentialProvider.CredentialEntry credEntry = provider.getCredentialEntry(keystorePass);
      if (credEntry != null) {
        password = new String(credEntry.getCredential());
      }
    } catch (IOException e) {
      throw new ShiroException("Error from getting credential entry from keystore", e);
    }
    if (org.apache.commons.lang3.StringUtils.isEmpty(password)) {
      throw new ShiroException("Error getting SystemPassword from the provided keystore:"
          + keystorePass + ", in path:" + hadoopSecurityCredentialPath);
    }
    return password;
  }

  /**
   * This overrides the implementation of queryForAuthenticationInfo inside JndiLdapRealm.
   * In addition to calling the super method for authentication it also tries to validate
   * if this user has atleast one of the allowed roles for authentication. In case the property
   * allowedRolesForAuthentication is empty this check always returns true.
   *
   * @param token the submitted authentication token that triggered the authentication attempt.
   * @param ldapContextFactory factory used to retrieve LDAP connections.
   * @return AuthenticationInfo instance representing the authenticated user's information.
   * @throws NamingException if any LDAP errors occur.
   */
  @Override
  protected AuthenticationInfo queryForAuthenticationInfo(AuthenticationToken token,
      LdapContextFactory ldapContextFactory) throws NamingException {
    AuthenticationInfo info = super.queryForAuthenticationInfo(token, ldapContextFactory);
    // Credentials were verified. Verify that the principal has all allowedRulesForAuthentication
    if (!hasAllowedAuthenticationRules(info.getPrincipals(), ldapContextFactory)) {
      throw new NamingException("Principal does not have any of the allowedRolesForAuthentication");
    }
    return info;
  }

  /**
  * Get groups from LDAP.
  *
  * @param principals
  *            the principals of the Subject whose AuthenticationInfo should
  *            be queried from the LDAP server.
  * @param ldapContextFactory
  *            factory used to retrieve LDAP connections.
  * @return an {@link AuthorizationInfo} instance containing information
  *         retrieved from the LDAP server.
  * @throws NamingException
  *             if any LDAP errors occur during the search.
  */
  @Override
  public AuthorizationInfo queryForAuthorizationInfo(final PrincipalCollection principals,
      final LdapContextFactory ldapContextFactory) throws NamingException {
    if (!isAuthorizationEnabled()) {
      return null;
    }
    final Set<String> roleNames = getRoles(principals, ldapContextFactory);
    LOGGER.debug("RolesNames Authorization: {}", roleNames);
    SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(roleNames);
    Set<String> stringPermissions = permsFor(roleNames);
    simpleAuthorizationInfo.setStringPermissions(stringPermissions);
    return simpleAuthorizationInfo;
  }

  private boolean hasAllowedAuthenticationRules(PrincipalCollection principals,
          final LdapContextFactory ldapContextFactory) {
    boolean allowed = allowedRolesForAuthentication.isEmpty();
    if (!allowed) {
      Set<String> roles = getRoles(principals, ldapContextFactory);
      for (String allowedRole : allowedRolesForAuthentication) {
        if (roles.contains(allowedRole)) {
          LOGGER.debug("Allowed role for user [{}] found.", allowedRole);
          allowed = true;
          break;
        }
      }
    }
    return allowed;
  }

  private Set<String> getRoles(PrincipalCollection principals,
          final LdapContextFactory ldapContextFactory) {
    final String username = (String) getAvailablePrincipal(principals);

    LdapContext systemLdapCtx = null;
    try {
      systemLdapCtx = ldapContextFactory.getSystemLdapContext();
      return rolesFor(principals, username, systemLdapCtx,
        ldapContextFactory, SecurityUtils.getSubject().getSession());
    } catch (Throwable t) {
      LOGGER.warn("Failed to get roles in current context for " + username, t);
      return Collections.emptySet();
    } finally {
      LdapUtils.closeContext(systemLdapCtx);
    }
  }

  protected Set<String> rolesFor(PrincipalCollection principals, String userNameIn,
          final LdapContext ldapCtx, final LdapContextFactory ldapContextFactory, Session session)
          throws NamingException {
    final Set<String> roleNames = new HashSet<>();
    final Set<String> groupNames = new HashSet<>();
    final String userName;
    if (getUserLowerCase()) {
      LOGGER.debug("userLowerCase true");
      userName = userNameIn.toLowerCase();
    } else {
      userName = userNameIn;
    }

    String userDn = getUserDnForSearch(userName);

    // Activate paged results
    int pageSize = getPagingSize();
    LOGGER.debug("Ldap PagingSize: {}", pageSize);
    int numResults = 0;
    try {
      ldapCtx.addToEnvironment(Context.REFERRAL, "ignore");

      ldapCtx.setRequestControls(new Control[]{new PagedResultsControl(pageSize,
            Control.NONCRITICAL)});

      // ldapsearch -h localhost -p 33389 -D
      // uid=guest,ou=people,dc=hadoop,dc=apache,dc=org -w guest-password
      // -b dc=hadoop,dc=apache,dc=org -s sub '(objectclass=*)'
      NamingEnumeration<SearchResult> searchResultEnum = null;
      SearchControls searchControls = getGroupSearchControls();
      try {
        if (groupSearchEnableMatchingRuleInChain) {
          searchResultEnum = ldapCtx.search(
              getGroupSearchBase(),
              String.format(
                  MATCHING_RULE_IN_CHAIN_FORMAT,
                  LdapFilterEncoder.escapeFilterValue(groupObjectClass),
                  LdapFilterEncoder.escapeFilterValue(memberAttribute),
                  LdapFilterEncoder.escapeFilterValue(userDn)),
              searchControls);
          while (searchResultEnum != null && searchResultEnum.hasMore()) {
            // searchResults contains all the groups in search scope
            numResults++;
            final SearchResult group = searchResultEnum.next();

            Attribute attribute = group.getAttributes().get(getGroupIdAttribute());
            String groupName = attribute.get().toString();

            String roleName = roleNameFor(groupName);
            if (roleName != null) {
              roleNames.add(roleName);
            } else {
              roleNames.add(groupName);
            }
          }
        } else {
          // Default group search filter
          String searchFilter = String.format("(objectclass=%1$s)",
              LdapFilterEncoder.escapeFilterValue(groupObjectClass));

          // If group search filter is defined in Shiro config, then use it
          if (groupSearchFilter != null) {
            searchFilter = expandFilterTemplate(groupSearchFilter, userName);
            //searchFilter = String.format("%1$s", groupSearchFilter);
          }
          LOGGER.debug("Group SearchBase|SearchFilter|GroupSearchScope: " + "{}|{}|{}",
              getGroupSearchBase(), searchFilter, groupSearchScope);
          searchResultEnum = ldapCtx.search(
              getGroupSearchBase(),
              searchFilter,
              searchControls);
          while (searchResultEnum != null && searchResultEnum.hasMore()) {
            // searchResults contains all the groups in search scope
            numResults++;
            final SearchResult group = searchResultEnum.next();
            addRoleIfMember(userDn, group, roleNames, groupNames, ldapContextFactory);
          }
        }
      } catch (PartialResultException e) {
        LOGGER.debug("Ignoring PartitalResultException");
      } finally {
        if (searchResultEnum != null) {
          searchResultEnum.close();
        }
      }
      // Re-activate paged results
      ldapCtx.setRequestControls(new Control[]{new PagedResultsControl(pageSize,
              null, Control.CRITICAL)});
    } catch (SizeLimitExceededException e) {
      LOGGER.info("Only retrieved first {} groups due to SizeLimitExceededException.", numResults);
    } catch (IOException e) {
      LOGGER.error("Unabled to setup paged results");
    }
    // save role names and group names in session so that they can be
    // easily looked up outside of this object
    session.setAttribute(SUBJECT_USER_ROLES, roleNames);
    session.setAttribute(SUBJECT_USER_GROUPS, groupNames);
    if (!groupNames.isEmpty() && (principals instanceof MutablePrincipalCollection)) {
      ((MutablePrincipalCollection) principals).addAll(groupNames, getName());
    }
    LOGGER.debug("User RoleNames: {}::{}", userName, roleNames);
    return roleNames;
  }

  protected String getUserDnForSearch(String userName) {
    if (userSearchAttributeName == null || userSearchAttributeName.isEmpty()) {
      // memberAttributeValuePrefix and memberAttributeValueSuffix
      // were computed from memberAttributeValueTemplate
      return memberDn(userName);
    } else {
      return getUserDn(userName);
    }
  }

  private void addRoleIfMember(final String userDn, final SearchResult group,
      final Set<String> roleNames, final Set<String> groupNames,
      final LdapContextFactory ldapContextFactory) throws NamingException {
    NamingEnumeration<? extends Attribute> attributeEnum = null;
    NamingEnumeration<?> ne = null;
    try {
      LdapName userLdapDn = new LdapName(userDn);
      Attribute attribute = group.getAttributes().get(getGroupIdAttribute());
      String groupName = attribute.get().toString();

      attributeEnum = group.getAttributes().getAll();
      while (attributeEnum.hasMore()) {
        final Attribute attr = attributeEnum.next();
        if (!memberAttribute.equalsIgnoreCase(attr.getID())) {
          continue;
        }
        ne = attr.getAll();
        while (ne.hasMore()) {
          String attrValue = ne.next().toString();
          if (memberAttribute.equalsIgnoreCase(MEMBER_URL)) {
            boolean dynamicGroupMember = isUserMemberOfDynamicGroup(userLdapDn, attrValue,
                ldapContextFactory);
            if (dynamicGroupMember) {
              groupNames.add(groupName);
              String roleName = roleNameFor(groupName);
              if (roleName != null) {
                roleNames.add(roleName);
              } else {
                roleNames.add(groupName);
              }
            }
          } else {
            // posix groups' members don' include the entire dn
            if (groupObjectClass.equalsIgnoreCase(POSIX_GROUP)) {
              attrValue = memberDn(attrValue);
            }
            if (userLdapDn.equals(new LdapName(attrValue))) {
              groupNames.add(groupName);
              String roleName = roleNameFor(groupName);
              if (roleName != null) {
                roleNames.add(roleName);
              } else {
                roleNames.add(groupName);
              }
              break;
            }
          }
        }
      }
    } finally {
      try {
        if (attributeEnum != null) {
          attributeEnum.close();
        }
      } finally {
        if (ne != null) {
          ne.close();
        }
      }
    }
  }

  private String memberDn(String attrValue) {
    return memberAttributeValuePrefix + attrValue + memberAttributeValueSuffix;
  }

  public Map<String, String> getListRoles() {
    Map<String, String> groupToRoles = getRolesByGroup();
    Map<String, String> roles = new HashMap<>();
    for (Map.Entry<String, String> entry : groupToRoles.entrySet()) {
      roles.put(entry.getValue(), entry.getKey());
    }
    return roles;
  }

  private String roleNameFor(String groupName) {
    return !rolesByGroup.isEmpty() ? rolesByGroup.get(groupName) : groupName;
  }

  private Set<String> permsFor(Set<String> roleNames) {
    Set<String> perms = new LinkedHashSet<>(); // preserve order
    for (String role : roleNames) {
      List<String> permsForRole = permissionsByRole.get(role);
      LOGGER.debug("PermsForRole: {}", role);
      LOGGER.debug("PermByRole: {}", permsForRole);
      if (permsForRole != null) {
        perms.addAll(permsForRole);
      }
    }
    return perms;
  }

  public String getSearchBase() {
    return searchBase;
  }

  public void setSearchBase(String searchBase) {
    this.searchBase = searchBase;
  }

  public String getUserSearchBase() {
    return (userSearchBase != null && !userSearchBase.isEmpty()) ? userSearchBase : searchBase;
  }

  public void setUserSearchBase(String userSearchBase) {
    this.userSearchBase = userSearchBase;
  }

  public int getPagingSize() {
    return pagingSize;
  }

  public void setPagingSize(int pagingSize) {
    this.pagingSize = pagingSize;
  }

  public String getGroupSearchBase() {
    return (groupSearchBase != null && !groupSearchBase.isEmpty()) ? groupSearchBase : searchBase;
  }

  public void setGroupSearchBase(String groupSearchBase) {
    this.groupSearchBase = groupSearchBase;
  }

  public String getGroupObjectClass() {
    return groupObjectClass;
  }

  public void setGroupObjectClass(String groupObjectClassAttribute) {
    this.groupObjectClass = groupObjectClassAttribute;
  }

  public String getMemberAttribute() {
    return memberAttribute;
  }

  public void setMemberAttribute(String memberAttribute) {
    this.memberAttribute = memberAttribute;
  }

  public String getGroupIdAttribute() {
    return groupIdAttribute;
  }

  public void setGroupIdAttribute(String groupIdAttribute) {
    this.groupIdAttribute = groupIdAttribute;
  }

  /**
  * Set Member Attribute Template for LDAP.
  *
  * @param template
  *            DN template to be used to query ldap.
  * @throws IllegalArgumentException
  *             if template is empty or null.
  */
  public void setMemberAttributeValueTemplate(String template) {
    if (!StringUtils.hasText(template)) {
      String msg = "User DN template cannot be null or empty.";
      throw new IllegalArgumentException(msg);
    }
    int index = template.indexOf(MEMBER_SUBSTITUTION_TOKEN);
    if (index < 0) {
      String msg = "Member attribute value template must contain the '" + MEMBER_SUBSTITUTION_TOKEN
          + "' replacement token to understand how to " + "parse the group members.";
      throw new IllegalArgumentException(msg);
    }
    String prefix = template.substring(0, index);
    String suffix = template.substring(prefix.length() + MEMBER_SUBSTITUTION_TOKEN.length());
    this.memberAttributeValuePrefix = prefix;
    this.memberAttributeValueSuffix = suffix;
  }

  public void setAllowedRolesForAuthentication(List<String> allowedRolesForAuthencation) {
    this.allowedRolesForAuthentication.addAll(allowedRolesForAuthencation);
  }

  public void setRolesByGroup(Map<String, String> rolesByGroup) {
    this.rolesByGroup.putAll(rolesByGroup);
  }

  public Map<String, String> getRolesByGroup() {
    return rolesByGroup;
  }

  public void setPermissionsByRole(String permissionsByRoleStr) {
    permissionsByRole.putAll(parsePermissionByRoleString(permissionsByRoleStr));
  }

  public Map<String, List<String>> getPermissionsByRole() {
    return permissionsByRole;
  }

  public boolean isAuthorizationEnabled() {
    return authorizationEnabled;
  }

  public void setAuthorizationEnabled(boolean authorizationEnabled) {
    this.authorizationEnabled = authorizationEnabled;
  }

  public String getUserSearchAttributeName() {
    return userSearchAttributeName;
  }

  /**
  * Set User Search Attribute Name for LDAP.
  *
  * @param userSearchAttributeName
  *            userAttribute to search ldap.
  */
  public void setUserSearchAttributeName(String userSearchAttributeName) {
    if (userSearchAttributeName != null) {
      userSearchAttributeName = userSearchAttributeName.trim();
    }
    this.userSearchAttributeName = userSearchAttributeName;
  }

  public String getUserObjectClass() {
    return userObjectClass;
  }

  public void setUserObjectClass(String userObjectClass) {
    this.userObjectClass = userObjectClass;
  }

  private Map<String, List<String>> parsePermissionByRoleString(String permissionsByRoleStr) {
    Map<String, List<String>> perms = new HashMap<>();

    // split by semicolon ; then by eq = then by comma ,
    StringTokenizer stSem = new StringTokenizer(permissionsByRoleStr, ";");
    while (stSem.hasMoreTokens()) {
      String roleAndPerm = stSem.nextToken();
      StringTokenizer stEq = new StringTokenizer(roleAndPerm, "=");
      if (stEq.countTokens() != 2) {
        continue;
      }
      String role = stEq.nextToken().trim();
      String perm = stEq.nextToken().trim();
      StringTokenizer stCom = new StringTokenizer(perm, ",");
      List<String> permList = new ArrayList<>();
      while (stCom.hasMoreTokens()) {
        permList.add(stCom.nextToken().trim());
      }
      perms.put(role, permList);
    }
    return perms;
  }

  boolean isUserMemberOfDynamicGroup(LdapName userLdapDn, String memberUrl,
      final LdapContextFactory ldapContextFactory) throws NamingException {
    // ldap://host:port/dn?attributes?scope?filter?extensions
    if (memberUrl == null) {
      return false;
    }
    String[] tokens = memberUrl.split("\\?");
    if (tokens.length < 4) {
      return false;
    }

    String searchBaseString = tokens[0].substring(tokens[0].lastIndexOf('/') + 1);
    String searchScope = tokens[2];
    String searchFilter = tokens[3];

    LdapName searchBaseDn = new LdapName(searchBaseString);

    // do scope test
    if ("base".equalsIgnoreCase(searchScope)) {
      LOGGER.debug("DynamicGroup SearchScope base");
      return false;
    }
    if (!userLdapDn.toString().endsWith(searchBaseDn.toString())) {
      return false;
    }
    if ("one".equalsIgnoreCase(searchScope) && (userLdapDn.size() != searchBaseDn.size() - 1)) {
      LOGGER.debug("DynamicGroup SearchScope one");
      return false;
    }
    // search for the filter, substituting base with userDn
    // search for base_dn=userDn, scope=base, filter=filter
    LdapContext systemLdapCtx;
    systemLdapCtx = ldapContextFactory.getSystemLdapContext();
    NamingEnumeration<SearchResult> searchResultEnum = null;
    try {
      searchResultEnum = systemLdapCtx.search(userLdapDn, searchFilter,
                                              "sub".equalsIgnoreCase(searchScope) ? SUBTREE_SCOPE : ONELEVEL_SCOPE);
      if (searchResultEnum.hasMore()) {
        return true;
      }
    } finally {
      try {
        if (searchResultEnum != null) {
          searchResultEnum.close();
        }
      } finally {
        LdapUtils.closeContext(systemLdapCtx);
      }
    }
    return false;
  }

  public String getPrincipalRegex() {
    return principalRegex;
  }

  /**
  * Set Regex for Principal LDAP.
  *
  * @param regex
  *            regex to use to search for principal in shiro.
  */
  public void setPrincipalRegex(String regex) {
    if (regex == null || regex.trim().isEmpty()) {
      principalPattern = Pattern.compile(DEFAULT_PRINCIPAL_REGEX);
      principalRegex = DEFAULT_PRINCIPAL_REGEX;
    } else {
      regex = regex.trim();
      principalPattern = Pattern.compile(regex);
      principalRegex = regex;
    }
  }

  public String getUserSearchAttributeTemplate() {
    return userSearchAttributeTemplate;
  }

  public void setUserSearchAttributeTemplate(final String template) {
    this.userSearchAttributeTemplate = (template == null ? null : template.trim());
  }

  public String getUserSearchFilter() {
    return userSearchFilter;
  }

  public void setUserSearchFilter(final String filter) {
    this.userSearchFilter = (filter == null ? null : filter.trim());
  }

  public String getGroupSearchFilter() {
    return groupSearchFilter;
  }

  public void setGroupSearchFilter(final String filter) {
    this.groupSearchFilter = (filter == null ? null : filter.trim());
  }

  public boolean getUserLowerCase() {
    return userLowerCase;
  }

  public void setUserLowerCase(boolean userLowerCase) {
    this.userLowerCase = userLowerCase;
  }

  public String getUserSearchScope() {
    return userSearchScope;
  }

  public void setUserSearchScope(final String scope) {
    this.userSearchScope = (scope == null ? null : scope.trim().toLowerCase());
  }

  public String getGroupSearchScope() {
    return groupSearchScope;
  }

  public void setGroupSearchScope(final String scope) {
    this.groupSearchScope = (scope == null ? null : scope.trim().toLowerCase());
  }

  public boolean isGroupSearchEnableMatchingRuleInChain() {
    return groupSearchEnableMatchingRuleInChain;
  }

  public void setGroupSearchEnableMatchingRuleInChain(
      boolean groupSearchEnableMatchingRuleInChain) {
    this.groupSearchEnableMatchingRuleInChain = groupSearchEnableMatchingRuleInChain;
  }

  private SearchControls getUserSearchControls() {
    SearchControls searchControls = SUBTREE_SCOPE;
    if ("onelevel".equalsIgnoreCase(userSearchScope)) {
      searchControls = ONELEVEL_SCOPE;
    } else if ("object".equalsIgnoreCase(userSearchScope)) {
      searchControls = OBJECT_SCOPE;
    }
    return searchControls;
  }

  protected SearchControls getGroupSearchControls() {
    SearchControls searchControls = SUBTREE_SCOPE;
    if ("onelevel".equalsIgnoreCase(groupSearchScope)) {
      searchControls = ONELEVEL_SCOPE;
    } else if ("object".equalsIgnoreCase(groupSearchScope)) {
      searchControls = OBJECT_SCOPE;
    }
    return searchControls;
  }

  @Override
  public void setUserDnTemplate(final String template) throws IllegalArgumentException {
    userDnTemplate = template;
  }

  private String matchPrincipal(final String principal) {
    Matcher matchedPrincipal = principalPattern.matcher(principal);
    if (!matchedPrincipal.matches()) {
      throw new IllegalArgumentException("Principal "
          + principal + " does not match " + principalRegex);
    }
    return matchedPrincipal.group();
  }

  /**
  * Returns the LDAP User Distinguished Name (DN) to use when acquiring an
  * {@link javax.naming.ldap.LdapContext LdapContext} from the
  * {@link LdapContextFactory}.
  * <p/>
  * If the the {@link #getUserDnTemplate() userDnTemplate} property has been
  * set, this implementation will construct the User DN by substituting the
  * specified {@code principal} into the configured template. If the
  * {@link #getUserDnTemplate() userDnTemplate} has not been set, the method
  * argument will be returned directly (indicating that the submitted
  * authentication token principal <em>is</em> the User DN).
  *
  * @param principal
  *            the principal to substitute into the configured
  *            {@link #getUserDnTemplate() userDnTemplate}.
  * @return the constructed User DN to use at runtime when acquiring an
  *         {@link javax.naming.ldap.LdapContext}.
  * @throws IllegalArgumentException
  *             if the method argument is null or empty
  * @throws IllegalStateException
  *             if the {@link #getUserDnTemplate userDnTemplate} has not been
  *             set.
  * @see LdapContextFactory#getLdapContext(Object, Object)
  */
  @Override
  protected String getUserDn(final String principal) throws IllegalArgumentException,
      IllegalStateException {
    String userDn;
    String matchedPrincipal = matchPrincipal(principal);
    String userSearchBase = getUserSearchBase();
    String userSearchAttributeName = getUserSearchAttributeName();

    // If not searching use the userDnTemplate and return.
    if ((userSearchBase == null || userSearchBase.isEmpty()) || (userSearchAttributeName == null
        && userSearchFilter == null && !"object".equalsIgnoreCase(userSearchScope))) {
      // Bare-placeholder template: principal is already a full DN; escaping
      // its commas would collapse the RDNs and break binds.
      if (MEMBER_SUBSTITUTION_TOKEN.equals(userDnTemplate)) {
        userDn = matchedPrincipal;
      } else {
        userDn = expandDnTemplate(userDnTemplate, matchedPrincipal);
      }
      LOGGER.debug("LDAP UserDN and Principal: {},{}", userDn, principal);
      return userDn;
    }

    // Create the searchBase and searchFilter from config.
    String searchBase = expandDnTemplate(getUserSearchBase(), matchedPrincipal);
    String searchFilter;
    if (userSearchFilter == null) {
      if (userSearchAttributeName == null) {
        searchFilter = String.format("(objectclass=%1$s)",
            LdapFilterEncoder.escapeFilterValue(getUserObjectClass()));
      } else {
        searchFilter = String.format("(&(objectclass=%1$s)(%2$s=%3$s))",
            LdapFilterEncoder.escapeFilterValue(getUserObjectClass()),
            LdapFilterEncoder.escapeFilterValue(userSearchAttributeName),
            expandFilterTemplate(getUserSearchAttributeTemplate(), matchedPrincipal));
      }
    } else {
      searchFilter = expandFilterTemplate(userSearchFilter, matchedPrincipal);
    }
    SearchControls searchControls = getUserSearchControls();

    // Search for userDn and return.
    LdapContext systemLdapCtx = null;
    NamingEnumeration<SearchResult> searchResultEnum = null;
    try {
      systemLdapCtx = getContextFactory().getSystemLdapContext();
      LOGGER.debug("SearchBase,SearchFilter,UserSearchScope: {},{},{}", searchBase, searchFilter, userSearchScope);
      searchResultEnum = systemLdapCtx.search(searchBase, searchFilter, searchControls);
      // SearchResults contains all the entries in search scope
      if (searchResultEnum.hasMore()) {
        SearchResult searchResult = searchResultEnum.next();
        userDn = searchResult.getNameInNamespace();
        LOGGER.debug("UserDN Returned,Principal: {},{}", userDn, principal);
        return userDn;
      } else {
        throw new IllegalArgumentException("Illegal principal name: " + principal);
      }
    } catch (AuthenticationException ne) {
      LOGGER.error("AuthenticationException in getUserDn", ne);
      throw new IllegalArgumentException("Illegal principal name: " + principal);
    } catch (NamingException ne) {
      throw new IllegalArgumentException("Hit NamingException: " + ne.getMessage());
    } finally {
      try {
        if (searchResultEnum != null) {
          searchResultEnum.close();
        }
      } catch (NamingException ne) {
        // Ignore exception on close.
      } finally {
        LdapUtils.closeContext(systemLdapCtx);
      }
    }
  }

  // Implements the necessary escaping to represent an attribute value as a String as per RFC 4514.
  // https://github.com/apache/tomcat/blob/main/java/org/apache/catalina/realm/JNDIRealm.java#L2921
  protected String escapeAttributeValue(String input) {
    if (input == null) {
      return null;
    }
    int len = input.length();
    StringBuilder result = new StringBuilder();

    for (int i = 0; i < len; i++) {
      char c = input.charAt(i);
      switch (c) {
        case ' ': {
          if (i == 0 || i == (len - 1)) {
            result.append("\\20");
          } else {
            result.append(c);
          }
          break;
        }
        case '#': {
          if (i == 0) {
            result.append("\\23");
          } else {
            result.append(c);
          }
          break;
        }
        case '\"': {
          result.append("\\22");
          break;
        }
        case '+': {
          result.append("\\2B");
          break;
        }
        case ',': {
          result.append("\\2C");
          break;
        }
        case ';': {
          result.append("\\3B");
          break;
        }
        case '<': {
          result.append("\\3C");
          break;
        }
        case '>': {
          result.append("\\3E");
          break;
        }
        case '\\': {
          result.append("\\5C");
          break;
        }
        case '\u0000': {
          result.append("\\00");
          break;
        }
        default:
          result.append(c);
      }
    }

    return result.toString();
  }


  @Override
  protected AuthenticationInfo createAuthenticationInfo(AuthenticationToken token,
      Object ldapPrincipal, Object ldapCredentials, LdapContext ldapContext)
      throws NamingException {
    HashRequest.Builder builder = new HashRequest.Builder();
    Hash credentialsHash = hashService
        .computeHash(builder.setSource(token.getCredentials())
            .setAlgorithmName(HASHING_ALGORITHM).build());
    return new SimpleAuthenticationInfo(token.getPrincipal(),
        credentialsHash.toHex(), credentialsHash.getSalt(),
        getName());
  }

  protected static final String expandTemplate(final String template, final String input) {
    return template.replace(MEMBER_SUBSTITUTION_TOKEN, input);
  }

  /**
   * Expands a template that will be embedded in an LDAP search filter.
   * The {@code input} value is RFC 4515 escaped before substitution so that
   * user-controlled metacharacters cannot inject filter clauses.
   */
  protected static final String expandFilterTemplate(final String template, final String input) {
    String escaped = LdapFilterEncoder.escapeFilterValue(input);
    return template.replace(MEMBER_SUBSTITUTION_TOKEN, escaped == null ? "" : escaped);
  }

  /**
   * Expands a template that produces a Distinguished Name or DN component
   * (e.g. {@code userDnTemplate}, {@code userSearchBase}). The {@code input}
   * value is RFC 4514 escaped via the existing {@link #escapeAttributeValue}
   * before substitution so that user-controlled DN metacharacters such as
   * {@code ,}, {@code +} or leading {@code #} cannot break out of their RDN
   * or inject additional RDNs.
   */
  protected final String expandDnTemplate(final String template, final String input) {
    String escaped = escapeAttributeValue(input);
    return template.replace(MEMBER_SUBSTITUTION_TOKEN, escaped == null ? "" : escaped);
  }
}
