/*
 * 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.ambari.server.security.authorization;


import java.util.List;

import javax.naming.Name;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import javax.naming.directory.DirContext;

import org.apache.ambari.server.ldap.domain.AmbariLdapConfiguration;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ldap.core.AttributesMapper;
import org.springframework.ldap.core.ContextSource;
import org.springframework.ldap.core.DirContextAdapter;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
import org.springframework.ldap.support.LdapUtils;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.ldap.authentication.AbstractLdapAuthenticator;
import org.springframework.security.ldap.search.LdapUserSearch;


/**
 * An authenticator which binds as a user and checks if user should get ambari
 * admin authorities according to LDAP group membership
 */
public class AmbariLdapBindAuthenticator extends AbstractLdapAuthenticator {
  private static final Logger LOG = LoggerFactory.getLogger(AmbariLdapBindAuthenticator.class);
  private static final String AMBARI_ADMIN_LDAP_ATTRIBUTE_KEY = "ambari_admin";

  private final AmbariLdapConfiguration ldapConfiguration;


  public AmbariLdapBindAuthenticator(BaseLdapPathContextSource contextSource, AmbariLdapConfiguration ldapConfiguration) {
    super(contextSource);
    this.ldapConfiguration = ldapConfiguration;;
  }

  @Override
  public DirContextOperations authenticate(Authentication authentication) {

    if (!(authentication instanceof UsernamePasswordAuthenticationToken)) {
      LOG.info("Unexpected authentication token type encountered ({}) - failing authentication.", authentication.getClass().getName());
      throw new BadCredentialsException("Unexpected authentication token type encountered.");
    }

    DirContextOperations user = authenticate((UsernamePasswordAuthenticationToken) authentication);

    LdapServerProperties ldapServerProperties = ldapConfiguration.getLdapServerProperties();
    if (StringUtils.isNotEmpty(ldapServerProperties.getAdminGroupMappingRules())) {
      setAmbariAdminAttr(user, ldapServerProperties);
    }

    // Users stored locally in ambari are matched against LDAP users by the ldap attribute configured to be used as user name.
    // (e.g. uid, sAMAccount -> ambari user name )
    String ldapUserName = user.getStringAttribute(ldapServerProperties.getUsernameAttribute());
    String loginName = authentication.getName(); // user login name the user has logged in

    if (ldapUserName == null) {
      LOG.warn("The user data does not contain a value for {}.", ldapServerProperties.getUsernameAttribute());
    } else if (ldapUserName.isEmpty()) {
      LOG.warn("The user data contains an empty value for {}.", ldapServerProperties.getUsernameAttribute());
    } else {
      // if authenticated user name is different from ldap user name than user has logged in
      // with a login name that is different (e.g. user principal name) from the ambari user name stored in
      // ambari db. In this case add the user login name  as login alias for ambari user name.
      LOG.info("User with {}='{}' logged in with login alias '{}'", ldapServerProperties.getUsernameAttribute(), ldapUserName, loginName);

      // If the ldap username needs to be processed (like converted to all lowercase characters),
      // process it before setting it in the session via AuthorizationHelper#addLoginNameAlias
      String processedLdapUserName;
      if (ldapServerProperties.isForceUsernameToLowercase()) {
        processedLdapUserName = ldapUserName.toLowerCase();
        LOG.info("Forcing ldap username to be lowercase characters: {} ==> {}", ldapUserName, processedLdapUserName);
      } else {
        processedLdapUserName = ldapUserName;
      }
      if (!processedLdapUserName.equals(loginName.toLowerCase())) {
        AuthorizationHelper.addLoginNameAlias(processedLdapUserName, loginName.toLowerCase());
      }
    }

    return user;
  }

  /**
   * Authenticates a user with a configured LDAP server using the user's username and password.
   * <p>
   * To authenticate a user:
   * <ol>
   * <li>
   * The LDAP server is queried for the relevant user object where the
   * supplied username matches the configured LDAP attribute that represents the user's username
   * <ul><li>Example: (&(uid=user1)(objectClass=posixAccount))</li></ul>
   * </li>
   * <li>
   * If found, the distinguished name (DN) of the user object is obtained from returned data and then
   * used, along with the supplied password to perform an LDAP bind (see {@link #bind(DirContextOperations, String)})
   * </li>
   * </ol>
   * <p>
   * Failure to authenticate will result in a {@link BadCredentialsException} to be thrown.
   *
   * @param authentication the credentials to use for authentication
   * @return the authenticated user details
   * @see #bind(DirContextOperations, String)
   */
  private DirContextOperations authenticate(UsernamePasswordAuthenticationToken authentication) {
    DirContextOperations user = null;

    String username = authentication.getName();
    Object credentials = authentication.getCredentials();
    String password = (credentials instanceof String) ? (String) credentials : null;

    if (StringUtils.isEmpty(username)) {
      LOG.debug("Empty username encountered - failing authentication.");
      throw new BadCredentialsException("Empty username encountered.");
    }

    LOG.debug("Authenticating {}", username);

    if (StringUtils.isEmpty(password)) {
      LOG.debug("Empty password encountered - failing authentication.");
      throw new BadCredentialsException("Empty password encountered.");
    }

    LdapUserSearch userSearch = getUserSearch();
    if (userSearch == null) {
      LOG.debug("The user search facility has not been set - failing authentication.");
      throw new BadCredentialsException("The user search facility has not been set.");
    } else {
      if (LOG.isTraceEnabled()) {
        LOG.trace("Searching for user with username {}: {}", username, userSearch);
      }

      // Find the user data where the supplied username matches the value of the configured LDAP
      // attribute for the user's username. If a user is found, use the DN fro the returned data
      // and the supplied password to attempt authentication.
      DirContextOperations userFromSearch = userSearch.searchForUser(username);

      if (userFromSearch == null) {
        LOG.debug("LDAP user object not found for {}", username);
      } else {
        LOG.debug("Found LDAP user for {}: {}", username, userFromSearch.getDn());
        user = bind(userFromSearch, password);

        // If trace enabled, log the user's LDAP attributes.
        if (LOG.isTraceEnabled()) {
          Attributes attributes = user.getAttributes();
          if (attributes != null) {
            StringBuilder builder = new StringBuilder();
            NamingEnumeration<String> ids = attributes.getIDs();
            try {
              while (ids.hasMore()) {
                String id = ids.next();
                builder.append("\n\t");
                builder.append(attributes.get(id));
              }
            } catch (NamingException e) {
              // Ignore this...
            }
            LOG.trace("User Attributes: {}", builder);
          } else {
            LOG.trace("User Attributes: not available");
          }
        }
      }
    }

    // If a user was not authenticated, thrown a BadCredentialsException, else return the user data
    if (user == null) {
      LOG.debug("Invalid credentials for {} - failing authentication.", username);
      throw new BadCredentialsException("Invalid credentials.");
    } else {
      LOG.debug("Successfully authenticated {}", username);
    }

    return user;
  }

  /**
   * Attempt to authenticate a user with the configured LDAP server by performing an LDAP bind.
   * <p>
   * Using the distinguished name provided in the supplied user data and the supplied password,
   * attempt to authenticate with the configured LDAP server. If authentication is successful, use the
   * attributes from the supplied user data rather than the attributes associated with the bound context
   * because some scenarios result in missing data within the bound context due to LDAP server implementations.
   * <p>
   * If authentication is not successful, throw a {@link BadCredentialsException}.
   *
   * @param user     the user data containing the relevant DN and associated attributes
   * @param password the password
   * @return the authenticated user details
   * @throws BadCredentialsException if authentication fails
   */
  private DirContextOperations bind(DirContextOperations user, String password) {
    ContextSource contextSource = getContextSource();

    if (contextSource == null) {
      String message = "Missing ContextSource - failing authentication.";
      LOG.debug(message);
      throw new InternalAuthenticationServiceException(message);
    }

    if (!(contextSource instanceof BaseLdapPathContextSource)) {
      String message = String.format("Unexpected ContextSource type (%s) - failing authentication.", contextSource.getClass().getName());
      LOG.debug(message);
      throw new InternalAuthenticationServiceException(message);
    }

    BaseLdapPathContextSource baseLdapPathContextSource = (BaseLdapPathContextSource) contextSource;
    Name userDistinguishedName = user.getDn();
    Name fullDn = AmbariLdapUtils.getFullDn(userDistinguishedName, baseLdapPathContextSource.getBaseLdapName());

    LOG.debug("Attempting to bind as {}", fullDn);

    DirContext dirContext = null;

    try {
      // Perform the authentication.  The result is not used because it is expected that the supplied
      // user data has all of the attributes for the authenticated user. If authentication fails, it
      // expected that the supplied user data will be destroyed or orphaned.
      dirContext = baseLdapPathContextSource.getContext(fullDn.toString(), password);

      // Build a new DirContextAdapter using the attributes from the passed in user details since it
      // is expected these details will be more complete of querying for them from the bound context.
      // Some LDAP server implementations will no return all attributes to the bound context due to
      // the filter being used in the query.
      return new DirContextAdapter(user.getAttributes(), userDistinguishedName, baseLdapPathContextSource.getBaseLdapName());
    } catch (org.springframework.ldap.AuthenticationException e) {
      String message = String.format("Failed to bind as %s - %s", user.getDn().toString(), e.getMessage());
      if (LOG.isTraceEnabled()) {
        LOG.trace(message, e);
      } else if (LOG.isDebugEnabled()) {
        LOG.debug(message);
      }
      throw new BadCredentialsException("The username or password is incorrect.");
    } finally {
      LdapUtils.closeContext(dirContext);
    }
  }

  /**
   * Checks weather user is a member of ambari administrators group in LDAP. If
   * yes, sets user's ambari_admin attribute to true
   *
   * @param user the user details
   * @return the updated user details
   */
  private DirContextOperations setAmbariAdminAttr(DirContextOperations user, LdapServerProperties ldapServerProperties) {
    String baseDn = ldapServerProperties.getBaseDN().toLowerCase();
    String groupBase = ldapServerProperties.getGroupBase().toLowerCase();
    final String groupNamingAttribute =
        ldapServerProperties.getGroupNamingAttr();
    final String adminGroupMappingMemberAttr = ldapServerProperties.getAdminGroupMappingMemberAttr();

    //If groupBase is set incorrectly or isn't set - search in BaseDn
    int indexOfBaseDn = groupBase.indexOf(baseDn);
    groupBase = indexOfBaseDn <= 0 ? "" : groupBase.substring(0, indexOfBaseDn - 1);

    String memberValue = StringUtils.isNotEmpty(adminGroupMappingMemberAttr)
        ? user.getStringAttribute(adminGroupMappingMemberAttr) : user.getNameInNamespace();
    LOG.debug("LDAP login - set '{}' as member attribute for adminGroupMappingRules", memberValue);

    String setAmbariAdminAttrFilter = resolveAmbariAdminAttrFilter(ldapServerProperties, memberValue);
    LOG.debug("LDAP login - set admin attr filter: {}", setAmbariAdminAttrFilter);

    AttributesMapper attributesMapper = attrs -> attrs.get(groupNamingAttribute).get();

    LdapTemplate ldapTemplate = new LdapTemplate((getContextSource()));
    ldapTemplate.setIgnorePartialResultException(true);
    ldapTemplate.setIgnoreNameNotFoundException(true);

    @SuppressWarnings("unchecked")
    List<String> ambariAdminGroups = ldapTemplate.search(groupBase, setAmbariAdminAttrFilter, attributesMapper);

    //user has admin role granted, if user is a member of at least 1 group,
    // which matches the rules in configuration
    if (ambariAdminGroups.size() > 0) {
      user.setAttributeValue(AMBARI_ADMIN_LDAP_ATTRIBUTE_KEY, true);
    }

    return user;
  }

  private String resolveAmbariAdminAttrFilter(LdapServerProperties ldapServerProperties, String memberValue) {
    String groupMembershipAttr = ldapServerProperties.getGroupMembershipAttr();
    String groupObjectClass = ldapServerProperties.getGroupObjectClass();
    String adminGroupMappingRules =
        ldapServerProperties.getAdminGroupMappingRules();
    final String groupNamingAttribute =
        ldapServerProperties.getGroupNamingAttr();
    String groupSearchFilter = ldapServerProperties.getGroupSearchFilter();

    String setAmbariAdminAttrFilter;
    if (StringUtils.isEmpty(groupSearchFilter)) {
      String adminGroupMappingRegex = createAdminGroupMappingRegex(adminGroupMappingRules, groupNamingAttribute);
      setAmbariAdminAttrFilter = String.format("(&(%s=%s)(objectclass=%s)(|%s))",
          groupMembershipAttr,
          memberValue,
          groupObjectClass,
          adminGroupMappingRegex);
    } else {
      setAmbariAdminAttrFilter = String.format("(&(%s=%s)%s)",
          groupMembershipAttr,
          memberValue,
          groupSearchFilter);
    }
    return setAmbariAdminAttrFilter;
  }

  private String createAdminGroupMappingRegex(String adminGroupMappingRules, String groupNamingAttribute) {
    String[] adminGroupMappingRegexs = adminGroupMappingRules.split(",");
    StringBuilder builder = new StringBuilder("");
    for (String adminGroupMappingRegex : adminGroupMappingRegexs) {
      builder.append(String.format("(%s=%s)", groupNamingAttribute, adminGroupMappingRegex));
    }
    return builder.toString();
  }

}
