LdaptiveAuthenticationManager.java

/*
 * Copyright 2014 the original author or authors.
 *
 * Licensed 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.bremersee.spring.security.ldaptive.authentication;

import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import static java.util.Objects.requireNonNullElseGet;
import static org.springframework.util.ObjectUtils.isEmpty;

import java.util.Collection;
import java.util.Optional;
import java.util.stream.Stream;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.bremersee.ldaptive.LdaptiveException;
import org.bremersee.ldaptive.LdaptiveTemplate;
import org.bremersee.spring.security.core.EmailToUsernameResolver;
import org.bremersee.spring.security.ldaptive.authentication.provider.NoAccountControlEvaluator;
import org.bremersee.spring.security.ldaptive.userdetails.LdaptiveRememberMeTokenProvider;
import org.bremersee.spring.security.ldaptive.userdetails.LdaptiveUserDetails;
import org.bremersee.spring.security.ldaptive.userdetails.LdaptiveUserDetailsService;
import org.ldaptive.BindConnectionInitializer;
import org.ldaptive.CompareRequest;
import org.ldaptive.ConnectionConfig;
import org.ldaptive.ConnectionFactory;
import org.ldaptive.DefaultConnectionFactory;
import org.ldaptive.LdapException;
import org.ldaptive.ResultCode;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.core.convert.converter.Converter;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.RememberMeAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.Assert;

/**
 * The ldaptive authentication manager.
 *
 * @author Christian Bremer
 */
public class LdaptiveAuthenticationManager
    implements AuthenticationManager, AuthenticationProvider, MessageSourceAware {

  /**
   * The Logger.
   */
  protected final Log logger = LogFactory.getLog(this.getClass());

  /**
   * The authentication properties.
   */
  @Getter(AccessLevel.PROTECTED)
  private final LdaptiveAuthenticationProperties authenticationProperties;

  @Getter(AccessLevel.PROTECTED)
  private final String rememberMeKey;

  /**
   * The application ldaptive template.
   */
  @Getter(AccessLevel.PROTECTED)
  private final LdaptiveTemplate applicationLdaptiveTemplate;

  /**
   * The email to username resolver.
   */
  @Getter(AccessLevel.PROTECTED)
  private EmailToUsernameResolver emailToUsernameResolver;

  /**
   * The username to bind-dn converter.
   */
  @Getter(AccessLevel.PROTECTED)
  private UsernameToBindDnConverter usernameToBindDnConverter;

  /**
   * The password encoder.
   */
  @Getter(AccessLevel.PROTECTED)
  @Setter
  private PasswordEncoder passwordEncoder;

  /**
   * The account control evaluator.
   */
  @Getter(AccessLevel.PROTECTED)
  private AccountControlEvaluator accountControlEvaluator;

  /**
   * The granted authorities mapper.
   */
  @Getter(AccessLevel.PROTECTED)
  @Setter
  private GrantedAuthoritiesMapper grantedAuthoritiesMapper;

  /**
   * The remember-me token provider.
   */
  @Getter(AccessLevel.PROTECTED)
  @Setter
  private LdaptiveRememberMeTokenProvider passwordProvider;

  /**
   * The token converter.
   */
  @Getter(AccessLevel.PROTECTED)
  @Setter
  private Converter<LdaptiveUserDetails, LdaptiveAuthentication> tokenConverter;

  @Getter(AccessLevel.PROTECTED)
  private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

  /**
   * Instantiates a new ldaptive authentication manager.
   *
   * @param connectionConfig the connection config
   * @param authenticationProperties the authentication properties
   * @param rememberMeKey the remember me key
   */
  public LdaptiveAuthenticationManager(
      ConnectionConfig connectionConfig,
      LdaptiveAuthenticationProperties authenticationProperties,
      String rememberMeKey) {
    this(new DefaultConnectionFactory(connectionConfig), authenticationProperties, rememberMeKey);
  }

  /**
   * Instantiates a new ldaptive authentication manager.
   *
   * @param connectionFactory the connection factory
   * @param authenticationProperties the authentication properties
   * @param rememberMeKey the remember me key
   */
  public LdaptiveAuthenticationManager(
      ConnectionFactory connectionFactory,
      LdaptiveAuthenticationProperties authenticationProperties,
      String rememberMeKey) {
    this(new LdaptiveTemplate(connectionFactory), authenticationProperties, rememberMeKey);
  }

  /**
   * Instantiates a new ldaptive authentication manager.
   *
   * @param applicationLdaptiveTemplate the application ldaptive template
   * @param authenticationProperties the authentication properties
   * @param rememberMeKey the remember me key
   */
  public LdaptiveAuthenticationManager(
      LdaptiveTemplate applicationLdaptiveTemplate,
      LdaptiveAuthenticationProperties authenticationProperties,
      String rememberMeKey) {

    this.applicationLdaptiveTemplate = applicationLdaptiveTemplate;
    Assert.notNull(getApplicationLdaptiveTemplate(), "Application ldaptive template is required.");
    this.authenticationProperties = authenticationProperties;
    Assert.notNull(getAuthenticationProperties(), "Authentication properties are required.");
    this.rememberMeKey = rememberMeKey;

    // emailToUsernameResolver
    setEmailToUsernameResolver(new EmailToUsernameResolverByLdapAttribute(
        getAuthenticationProperties(), getApplicationLdaptiveTemplate()));

    // usernameToBindDnConverter
    Assert.notNull(getAuthenticationProperties().getUsernameToBindDnConverter(),
        "Username to bind dn converter is required.");
    setUsernameToBindDnConverter(getAuthenticationProperties().getUsernameToBindDnConverter()
        .apply(getAuthenticationProperties()));

    // accountControlEvaluator
    if (isNull(getAuthenticationProperties().getAccountControlEvaluator())) {
      setAccountControlEvaluator(new NoAccountControlEvaluator());
    } else {
      setAccountControlEvaluator(getAuthenticationProperties().getAccountControlEvaluator().get());
    }
  }

  /**
   * Sets email to username resolver.
   *
   * @param emailToUsernameResolver the email to username resolver
   */
  public void setEmailToUsernameResolver(
      EmailToUsernameResolver emailToUsernameResolver) {
    if (nonNull(emailToUsernameResolver)) {
      this.emailToUsernameResolver = emailToUsernameResolver;
    }
  }

  /**
   * Sets username to bind dn converter.
   *
   * @param usernameToBindDnConverter the username to bind dn converter
   */
  public void setUsernameToBindDnConverter(
      UsernameToBindDnConverter usernameToBindDnConverter) {
    if (nonNull(usernameToBindDnConverter)) {
      this.usernameToBindDnConverter = usernameToBindDnConverter;
    }
  }

  /**
   * Sets account control evaluator.
   *
   * @param accountControlEvaluator the account control evaluator
   */
  public void setAccountControlEvaluator(
      AccountControlEvaluator accountControlEvaluator) {
    if (nonNull(accountControlEvaluator)) {
      this.accountControlEvaluator = accountControlEvaluator;
    }
  }

  @Override
  public void setMessageSource(@NonNull MessageSource messageSource) {
    this.messages = new MessageSourceAccessor(messageSource);
  }

  /**
   * Init.
   */
  public void init() {
    if (!bindWithAuthentication() && isNull(getPasswordEncoder())) {
      throw new IllegalStateException(String.format("A password attribute is set (%s) but no "
              + "password encoder is present. Either delete the password attribute to perform a "
              + "bind to authenticate or set a password encoder.",
          getAuthenticationProperties().getPasswordAttribute()));
    }
  }

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

  private boolean isRememberMeAuthentication(Class<?> authentication) {
    return !isEmpty(getRememberMeKey())
        && RememberMeAuthenticationToken.class.isAssignableFrom(authentication);
  }

  /**
   * Remember me key matches given authentication.
   *
   * @param authentication the authentication
   * @return the boolean
   */
  protected boolean rememberMeKeyMatches(RememberMeAuthenticationToken authentication) {
    return Optional.ofNullable(getRememberMeKey())
        .filter(key -> key.hashCode() == authentication.getKeyHash())
        .isPresent();
  }

  @Override
  public LdaptiveAuthentication authenticate(Authentication authentication)
      throws AuthenticationException {
    if (!supports(authentication.getClass())) {
      logger.debug(String.format("Authentication [%s] is not supported.",
          authentication.getClass().getName()));
      return null;
    }
    if (authentication instanceof RememberMeAuthenticationToken rma && !rememberMeKeyMatches(rma)) {
      throw new BadCredentialsException(getMessages().getMessage(
          "RememberMeAuthenticationProvider.incorrectKey",
          "The presented RememberMeAuthenticationToken does not contain the expected key"));
    }
    String name = getName(authentication);
    logger.debug("Authenticating user '" + name + "' ...");
    String username = getEmailToUsernameResolver()
        .getUsernameByEmail(name)
        .orElse(name);
    if (isRefusedUsername(username)) {
      throw new DisabledException(String
          .format("Username '%s' is refused by configuration.", username));
    }
    String password = Optional.ofNullable(authentication.getCredentials())
        .map(String::valueOf)
        .orElse(null);
    LdaptiveTemplate ldaptiveTemplate = getLdapTemplate(username, password);
    LdaptiveUserDetails userDetails = getUserDetails(ldaptiveTemplate, username);
    checkPassword(ldaptiveTemplate, userDetails, password);
    checkAccountControl(userDetails);
    if (nonNull(getTokenConverter())) {
      return getTokenConverter().convert(userDetails);
    }
    return new LdaptiveAuthenticationToken(userDetails);
  }

  /**
   * Determines whether the username is refused by configuration.
   *
   * @param username the username
   * @return {@code true} if the username is refused, otherwise {@code false}
   */
  protected boolean isRefusedUsername(String username) {
    if (isEmpty(username)) {
      return true;
    }
    return Stream.ofNullable(getAuthenticationProperties().getRefusedUsernames())
        .flatMap(Collection::stream)
        .filter(refusedUsername -> !isEmpty(refusedUsername))
        .anyMatch(refusedUsername -> refusedUsername.equalsIgnoreCase(username));
  }

  /**
   * Gets name.
   *
   * @param authentication the authentication
   * @return the name
   */
  protected String getName(Authentication authentication) {
    Object principal = authentication.getPrincipal();
    if (principal instanceof LdaptiveUserDetails ldaptiveUserDetails) {
      return requireNonNullElseGet(ldaptiveUserDetails.getDn(), authentication::getName);
    }
    return authentication.getName();
  }

  /**
   * Determines whether to bind with username and password or with the application ldaptive
   * template.
   *
   * @return the boolean
   */
  protected boolean bindWithAuthentication() {
    return isNull(getAuthenticationProperties().getPasswordAttribute())
        || getAuthenticationProperties().getPasswordAttribute().isBlank();
  }

  /**
   * Gets ldap template.
   *
   * @param username the username
   * @param password the password
   * @return the ldap template
   */
  protected LdaptiveTemplate getLdapTemplate(String username, String password) {
    if (bindWithAuthentication()) {
      if (isNull(password)) {
        throw new BadCredentialsException("Password is required.");
      }
      ConnectionConfig authConfig = ConnectionConfig
          .copy(getApplicationLdaptiveTemplate().getConnectionFactory().getConnectionConfig());
      String bindDn = getUsernameToBindDnConverter().convert(username);
      authConfig.setConnectionInitializers(BindConnectionInitializer.builder()
          .dn(bindDn)
          .credential(password)
          .build());
      return new LdaptiveTemplate(new DefaultConnectionFactory(authConfig));
    }
    return getApplicationLdaptiveTemplate();
  }

  /**
   * Gets user details.
   *
   * @param ldaptiveTemplate the ldaptive template
   * @param username the username
   * @return the user details
   */
  protected LdaptiveUserDetails getUserDetails(LdaptiveTemplate ldaptiveTemplate, String username) {
    try {
      return getUserDetailsService(ldaptiveTemplate).loadUserByUsername(username);
    } catch (LdaptiveException le) {
      throw getBindException(le);
    }
  }

  /**
   * Gets user details service.
   *
   * @return the user details service
   */
  public LdaptiveUserDetailsService getUserDetailsService() {
    return getUserDetailsService(getApplicationLdaptiveTemplate());
  }

  /**
   * Gets user details service.
   *
   * @param ldaptiveTemplate the ldaptive template
   * @return the user details service
   */
  protected LdaptiveUserDetailsService getUserDetailsService(LdaptiveTemplate ldaptiveTemplate) {
    LdaptiveUserDetailsService userDetailsService = new LdaptiveUserDetailsService(
        getAuthenticationProperties(), ldaptiveTemplate);
    userDetailsService.setAccountControlEvaluator(getAccountControlEvaluator());
    userDetailsService.setGrantedAuthoritiesMapper(getGrantedAuthoritiesMapper());
    userDetailsService.setRememberMeTokenProvider(getPasswordProvider());
    return userDetailsService;
  }

  private RuntimeException getBindException(LdaptiveException exception) {
    BadCredentialsException badCredentials = new BadCredentialsException("Password doesn't match.");
    if (isInvalidCredentialsException(exception.getLdapException())) {
      return badCredentials;
    }
    return exception;
  }

  private boolean isInvalidCredentialsException(LdapException exception) {
    if (isNull(exception)) {
      return false;
    }
    if (ResultCode.INVALID_CREDENTIALS.equals(exception.getResultCode())) {
      return true;
    }
    String message = Optional.ofNullable(exception.getMessage())
        .map(String::toLowerCase)
        .orElse("");
    String text = ("resultCode=" + ResultCode.INVALID_CREDENTIALS).toLowerCase();
    if (ResultCode.CONNECT_ERROR.equals(exception.getResultCode()) && message.contains(text)) {
      return true;
    }
    if (exception.getCause() instanceof LdapException cause) {
      return isInvalidCredentialsException(cause);
    }
    return false;
  }

  /**
   * Check password.
   *
   * @param ldaptiveTemplate the ldaptive template
   * @param user the user
   * @param password the password
   */
  protected void checkPassword(
      LdaptiveTemplate ldaptiveTemplate,
      LdaptiveUserDetails user,
      String password) {

    if (!bindWithAuthentication()) {
      Assert.notNull(getPasswordEncoder(), "No password encoder is present.");
      boolean matches = ldaptiveTemplate.compare(CompareRequest.builder()
          .dn(user.getDn())
          .name(getAuthenticationProperties().getPasswordAttribute())
          .value(getPasswordEncoder().encode(password))
          .build());
      if (!matches) {
        throw new BadCredentialsException("Password doesn't match.");
      }
    }
  }

  /**
   * Check account control.
   *
   * @param user the user
   */
  protected void checkAccountControl(LdaptiveUserDetails user) {
    if (!user.isEnabled()) {
      throw new DisabledException("Account is disabled.");
    }
    if (!user.isAccountNonLocked()) {
      throw new LockedException("Account is locked.");
    }
    if (!user.isAccountNonExpired()) {
      throw new AccountExpiredException("Account is expired.");
    }
    if (!user.isCredentialsNonExpired()) {
      throw new CredentialsExpiredException("Credentials are expired.");
    }
  }

}