/*
 * 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.Collection;
import java.util.List;

import org.apache.ambari.server.configuration.Configuration;
import org.apache.ambari.server.ldap.service.AmbariLdapConfigurationProvider;
import org.apache.ambari.server.orm.entities.UserAuthenticationEntity;
import org.apache.ambari.server.orm.entities.UserEntity;
import org.apache.ambari.server.security.ClientSecurityType;
import org.apache.ambari.server.security.authentication.AccountDisabledException;
import org.apache.ambari.server.security.authentication.AmbariAuthenticationProvider;
import org.apache.ambari.server.security.authentication.AmbariUserAuthentication;
import org.apache.ambari.server.security.authentication.AmbariUserDetails;
import org.apache.ambari.server.security.authentication.AmbariUserDetailsImpl;
import org.apache.ambari.server.security.authentication.InvalidUsernamePasswordCombinationException;
import org.apache.ambari.server.security.authentication.TooManyLoginFailuresException;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.ldap.core.support.LdapContextSource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.ldap.authentication.LdapAuthenticationProvider;
import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
import org.springframework.security.ldap.userdetails.LdapUserDetails;

import com.google.inject.Inject;


/**
 * Provides LDAP user authorization logic for Ambari Server
 */
public class AmbariLdapAuthenticationProvider extends AmbariAuthenticationProvider {
  private static final String SYSTEM_PROPERTY_DISABLE_ENDPOINT_IDENTIFICATION = "com.sun.jndi.ldap.object.disableEndpointIdentification";
  private static Logger LOG = LoggerFactory.getLogger(AmbariLdapAuthenticationProvider.class);

  final AmbariLdapConfigurationProvider ldapConfigurationProvider;

  private AmbariLdapAuthoritiesPopulator authoritiesPopulator;

  private ThreadLocal<LdapServerProperties> ldapServerProperties = new ThreadLocal<>();
  private ThreadLocal<LdapAuthenticationProvider> providerThreadLocal = new ThreadLocal<>();
  private ThreadLocal<String> ldapUserSearchFilterThreadLocal = new ThreadLocal<>();

  @Inject
  public AmbariLdapAuthenticationProvider(Users users, Configuration configuration, AmbariLdapConfigurationProvider ldapConfigurationProvider,
                                          AmbariLdapAuthoritiesPopulator authoritiesPopulator) {
    super(users, configuration);
    this.ldapConfigurationProvider = ldapConfigurationProvider;
    this.authoritiesPopulator = authoritiesPopulator;
  }

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    if (isLdapEnabled()) {
      if (authentication.getName() == null) {
        LOG.info("Authentication failed: no username provided");
        throw new InvalidUsernamePasswordCombinationException("");
      }

      String username = authentication.getName().trim();

      if (authentication.getCredentials() == null) {
        LOG.info("Authentication failed: no credentials provided: {}", username);
        throw new InvalidUsernamePasswordCombinationException(username);
      }

      try {
        Authentication auth = loadLdapAuthenticationProvider(username).authenticate(authentication);
        UserEntity userEntity = getUserEntity(auth);

        if (userEntity == null) {
          // TODO: If we were automatically importing accounts from the LDAP server, we should
          // TODO: probably do it here.
          LOG.debug("user not found ('{}')", username);
          throw new InvalidUsernamePasswordCombinationException(username);
        } else {
          Users users = getUsers();

          // Ensure the user is allowed to login....
          try {
            users.validateLogin(userEntity, username);
          } catch (AccountDisabledException | TooManyLoginFailuresException e) {
            if (getConfiguration().showLockedOutUserMessage()) {
              throw e;
            } else {
              // Do not give away information about the existence or status of a user
              throw new InvalidUsernamePasswordCombinationException(username, false, e);
            }
          }

          AmbariUserDetails userDetails = new AmbariUserDetailsImpl(users.getUser(userEntity), null, users.getUserAuthorities(userEntity));
          return new AmbariUserAuthentication(null, userDetails, true);
        }
      } catch (AuthenticationException e) {
        LOG.debug("Got exception during LDAP authentication attempt", e);
        // Try to help in troubleshooting
        Throwable cause = e.getCause();
        if ((cause != null) && (cause != e)) {
          // Below we check the cause of an AuthenticationException to see what the actual cause is
          // and then send an appropriate message to the caller.
          if (cause instanceof org.springframework.ldap.CommunicationException) {
            if (LOG.isDebugEnabled()) {
              LOG.warn("Failed to communicate with the LDAP server: " + cause.getMessage(), e);
            } else {
              LOG.warn("Failed to communicate with the LDAP server: " + cause.getMessage());
            }
          } else if (cause instanceof org.springframework.ldap.AuthenticationException) {
            LOG.warn("Looks like LDAP manager credentials (that are used for " +
                "connecting to LDAP server) are invalid.", e);
          }
        }
        throw new InvalidUsernamePasswordCombinationException(username, e);
      } catch (IncorrectResultSizeDataAccessException multipleUsersFound) {
        String message = ldapConfigurationProvider.get().isLdapAlternateUserSearchEnabled()
            ? String.format("Login Failed: Please append your domain to your username and try again.  Example: %s@domain", username)
            : "Login Failed: More than one user with that username found, please work with your Ambari Administrator to adjust your LDAP configuration";

        throw new DuplicateLdapUserFoundAuthenticationException(message);
      }
    } else {
      return null;
    }
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
  }

  /**
   * Reloads LDAP Context Source and depending objects if properties were changed
   *
   * @return corresponding LDAP authentication provider
   */
  LdapAuthenticationProvider loadLdapAuthenticationProvider(String userName) {
    boolean ldapConfigPropertiesChanged = reloadLdapServerProperties();

    String ldapUserSearchFilter = getLdapUserSearchFilter(userName);

    if (ldapConfigPropertiesChanged || !ldapUserSearchFilter.equals(ldapUserSearchFilterThreadLocal.get())) {

      LOG.info("Either LDAP Properties or user search filter changed - rebuilding Context");
      LdapContextSource springSecurityContextSource = new LdapContextSource();
      List<String> ldapUrls = ldapServerProperties.get().getLdapUrls();
      springSecurityContextSource.setUrls(ldapUrls.toArray(new String[ldapUrls.size()]));
      springSecurityContextSource.setBase(ldapServerProperties.get().getBaseDN());

      if (!ldapServerProperties.get().isAnonymousBind()) {
        springSecurityContextSource.setUserDn(ldapServerProperties.get().getManagerDn());
        springSecurityContextSource.setPassword(ldapServerProperties.get().getManagerPassword());
      }

      if (ldapServerProperties.get().isUseSsl() && ldapServerProperties.get().isDisableEndpointIdentification()) {
        System.setProperty(SYSTEM_PROPERTY_DISABLE_ENDPOINT_IDENTIFICATION, "true");
        LOG.info("Disabled endpoint identification");
      } else {
        System.clearProperty(SYSTEM_PROPERTY_DISABLE_ENDPOINT_IDENTIFICATION);
        LOG.info("Removed endpoint identification disabling");
      }

      try {
        springSecurityContextSource.afterPropertiesSet();
      } catch (Exception e) {
        LOG.error("LDAP Context Source not loaded ", e);
        throw new UsernameNotFoundException("LDAP Context Source not loaded", e);
      }

      //TODO change properties
      String userSearchBase = ldapServerProperties.get().getUserSearchBase();
      FilterBasedLdapUserSearch userSearch = new FilterBasedLdapUserSearch(userSearchBase, ldapUserSearchFilter, springSecurityContextSource);

      AmbariLdapBindAuthenticator bindAuthenticator = new AmbariLdapBindAuthenticator(springSecurityContextSource, ldapConfigurationProvider.get());
      bindAuthenticator.setUserSearch(userSearch);

      LdapAuthenticationProvider authenticationProvider = new LdapAuthenticationProvider(bindAuthenticator, authoritiesPopulator);
      providerThreadLocal.set(authenticationProvider);
    }

    ldapUserSearchFilterThreadLocal.set(ldapUserSearchFilter);

    return providerThreadLocal.get();
  }


  /**
   * Check if LDAP authentication is enabled in server properties
   *
   * @return true if enabled
   */
  boolean isLdapEnabled() {
    return getConfiguration().getClientSecurityType() == ClientSecurityType.LDAP;
  }

  /**
   * Reloads LDAP Server properties from configuration
   *
   * @return true if properties were reloaded
   */
  private boolean reloadLdapServerProperties() {
    LdapServerProperties properties = ldapConfigurationProvider.get().getLdapServerProperties();
    if (!properties.equals(ldapServerProperties.get())) {
      LOG.info("Reloading properties");
      ldapServerProperties.set(properties);
      return true;
    }
    return false;
  }


  private String getLdapUserSearchFilter(String userName) {
    return ldapServerProperties.get()
        .getUserSearchFilter(ldapConfigurationProvider.get().isLdapAlternateUserSearchEnabled() && AmbariLdapUtils.isUserPrincipalNameFormat(userName));
  }

  /**
   * Gets the {@link UserEntity} related to the authentication information
   * <p>
   * First the DN is retrieved from the user authentication information and a {@link UserAuthenticationEntity}
   * is queried for where the type value is LDAP and key value case-insensitively matches the DN.
   * If a record is found, the related {@link UserEntity} is returned.
   * <p>
   * Else, a {@link UserEntity} with the user name is queried. If one is found and it has a
   * {@link UserAuthenticationEntity} where the type value is LDAP and key is empty, the related
   * {@link UserEntity} is returned
   * <p>
   * Else, <code>null</code> is returned.
   *
   * @param authentication the user's authentication data
   * @return a {@link UserEntity}
   */
  private UserEntity getUserEntity(Authentication authentication) {
    UserEntity userEntity = null;

    // Find user with the matching DN
    String dn = getUserDN(authentication);
    if (!StringUtils.isEmpty(dn)) {
      userEntity = getUserEntityForDN(dn);
    }

    // If a user was not found with the exact authentication properties (LDAP/dn), look up the user
    // using the configured LDAP username attribute and ensure that user has an empty-keyed LDAP
    // authentication entity record.
    if (userEntity == null) {
      String userName = AuthorizationHelper.resolveLoginAliasToUserName(authentication.getName());
      userEntity = getUsers().getUserEntity(userName);

      if (userEntity != null) {
        Collection<UserAuthenticationEntity> authenticationEntities = getAuthenticationEntities(userEntity, UserAuthenticationType.LDAP);
        UserEntity _userEntity = userEntity; // Hold on to the user entity value for now.
        userEntity = null;  // Guilty until proven innocent

        if (!CollectionUtils.isEmpty(authenticationEntities)) {
          for (UserAuthenticationEntity entity : authenticationEntities) {
            if (StringUtils.isEmpty(entity.getAuthenticationKey())) {
              // Proven innocent!
              userEntity = _userEntity;
              break;
            }
          }
        }
      }
    }

    return userEntity;
  }

  /**
   * Given a DN from the LDAP server, find the owning UserEntity.
   * <p>
   * DNs are case sensitive. Internally they are execpted to be stored as the bytes of the lowercase
   * string.
   * <p>
   * DN's are expected to be unique across all {@link UserAuthenticationEntity} records for type
   * UserAuthenticationType.LDAP.
   *
   * @param dn the DN to search for
   * @return a {@link UserEntity}, if found
   */
  private UserEntity getUserEntityForDN(String dn) {
    Collection<UserAuthenticationEntity> authenticationEntities = getAuthenticationEntities(UserAuthenticationType.LDAP, StringUtils.lowerCase(dn));
    return ((authenticationEntities == null) || (authenticationEntities.size() != 1))
        ? null
        : authenticationEntities.iterator().next().getUser();
  }

  /**
   * Given the authentication object, attempt to retrieve the user's DN value from it.
   *
   * @param authentication the authentication data
   * @return the relative DN; else <code>null</code> if not available
   */
  private String getUserDN(Authentication authentication) {
    Object objectPrincipal = (authentication == null) ? null : authentication.getPrincipal();
    if (objectPrincipal instanceof LdapUserDetails) {
      return ((LdapUserDetails) objectPrincipal).getDn();
    }

    return null;
  }
}
