LdaptiveUserDetailsService.java

/*
 * Copyright 2024-2026 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.userdetails;

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

import java.util.Collection;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.AccessLevel;
import lombok.Getter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.bremersee.ldaptive.LdaptiveAttribute;
import org.bremersee.ldaptive.LdaptiveTemplate;
import org.bremersee.spring.security.ldaptive.authentication.AccountControlEvaluator;
import org.bremersee.spring.security.ldaptive.authentication.LdaptiveAuthenticationProperties;
import org.bremersee.spring.security.ldaptive.authentication.provider.NoAccountControlEvaluator;
import org.ldaptive.FilterTemplate;
import org.ldaptive.LdapAttribute;
import org.ldaptive.LdapEntry;
import org.ldaptive.SearchRequest;
import org.ldaptive.dn.Dn;
import org.ldaptive.dn.NameValue;
import org.ldaptive.dn.RDn;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.SimpleAuthorityMapper;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.util.Assert;

/**
 * The ldaptive user details service.
 *
 * @author Christian Bremer
 */
@Getter(AccessLevel.PROTECTED)
public class LdaptiveUserDetailsService implements UserDetailsService {

  /**
   * The constant USERNAME_PLACEHOLDER.
   */
  protected static final String USERNAME_PLACEHOLDER = "${username}";

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

  /**
   * The authentication properties.
   */
  private final LdaptiveAuthenticationProperties authenticationProperties;

  /**
   * The ldaptive template.
   */
  private final LdaptiveTemplate ldaptiveTemplate;

  /**
   * The groups mapper.
   */
  private GrantedAuthoritiesMapper grantedAuthoritiesMapper;

  /**
   * The account control evaluator.
   */
  private AccountControlEvaluator accountControlEvaluator = new NoAccountControlEvaluator();

  /**
   * The remember-me token provider.
   */
  private LdaptiveRememberMeTokenProvider rememberMeTokenProvider;

  /**
   * Instantiates a ldaptive user details service.
   *
   * @param authenticationProperties the authentication properties
   * @param ldaptiveTemplate the ldaptive template
   */
  public LdaptiveUserDetailsService(
      LdaptiveAuthenticationProperties authenticationProperties,
      LdaptiveTemplate ldaptiveTemplate) {

    this.authenticationProperties = authenticationProperties;
    this.ldaptiveTemplate = ldaptiveTemplate;
    Assert.notNull(getAuthenticationProperties(), "Authentication properties are required.");
    Assert.notNull(getLdaptiveTemplate(), "Ldaptive template is required.");
    setGrantedAuthoritiesMapper(new SimpleAuthorityMapper());
    if (nonNull(authenticationProperties.getAccountControlEvaluator())) {
      setAccountControlEvaluator(authenticationProperties.getAccountControlEvaluator().get());
    }
    if (isEmpty(getAuthenticationProperties().getPasswordLastSetAttribute())) {
      setRememberMeTokenProvider(
          new LdaptiveEvaluatedRememberMeTokenProvider(getAccountControlEvaluator()));
    } else {
      setRememberMeTokenProvider(
          new LdaptivePwdLastSetRememberMeTokenProvider(getAccountControlEvaluator(),
              getAuthenticationProperties().getPasswordLastSetAttribute()));
    }
  }

  /**
   * Sets groups mapper.
   *
   * @param grantedAuthoritiesMapper the groups mapper
   */
  public void setGrantedAuthoritiesMapper(
      GrantedAuthoritiesMapper grantedAuthoritiesMapper) {
    if (nonNull(grantedAuthoritiesMapper)) {
      this.grantedAuthoritiesMapper = grantedAuthoritiesMapper;
    }
  }

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

  /**
   * Sets remember-me token provider.
   *
   * @param rememberMeTokenProvider the remember-me token provider
   */
  public void setRememberMeTokenProvider(LdaptiveRememberMeTokenProvider rememberMeTokenProvider) {
    if (nonNull(rememberMeTokenProvider)) {
      this.rememberMeTokenProvider = rememberMeTokenProvider;
    }
  }

  @Override
  public LdaptiveUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    logger.debug(String.format("Loading user '%s' ...", username));
    LdapEntry ldapEntry = findUser(username)
        .orElseThrow(() -> new UsernameNotFoundException(String
            .format("User '%s' was  not found.", username)));
    Collection<? extends GrantedAuthority> authorities = getAuthorities(ldapEntry);
    var userDetailsBuilder = LdaptiveUserDetails.builder()
        .dn(ldapEntry.getDn())
        .username(LdaptiveAttribute.define(getAuthenticationProperties().getUsernameAttribute())
            .getValue(ldapEntry)
            .orElse(username))
        .password(getRememberMeTokenProvider().getRememberMeToken(ldapEntry))
        .accountNonExpired(getAccountControlEvaluator().isAccountNonExpired(ldapEntry))
        .accountNonLocked(getAccountControlEvaluator().isAccountNonLocked(ldapEntry))
        .credentialsNonExpired(getAccountControlEvaluator().isCredentialsNonExpired(ldapEntry))
        .enabled(getAccountControlEvaluator().isEnabled(ldapEntry))
        .authorities(authorities);
    LdaptiveAttribute.define(getAuthenticationProperties().getFirstNameAttribute())
        .getValue(ldapEntry)
        .ifPresent(userDetailsBuilder::firstName);
    LdaptiveAttribute.define(getAuthenticationProperties().getLastNameAttribute())
        .getValue(ldapEntry)
        .ifPresent(userDetailsBuilder::lastName);
    LdaptiveAttribute.define(getAuthenticationProperties().getEmailAttribute())
        .getValue(ldapEntry)
        .ifPresent(userDetailsBuilder::email);
    return userDetailsBuilder.build();
  }

  /**
   * Determines whether the given username is a distinguished name or not.
   *
   * @param username the username
   * @return {@code true} if the username is a distinguished name, otherwise {@code false}
   */
  protected boolean isDn(String username) {
    try {
      Dn baseDn = new Dn(getAuthenticationProperties().getUserBaseDn());
      return Optional.ofNullable(username)
          .map(Dn::new)
          .filter(baseDn::isAncestor)
          .isPresent();

    } catch (RuntimeException e) {
      return false;
    }
  }

  /**
   * Find user.
   *
   * @param username the username
   * @return the user
   */
  public Optional<LdapEntry> findUser(String username) {
    SearchRequest searchRequest = isDn(username)
        ? SearchRequest.objectScopeSearchRequest(username)
        : SearchRequest.builder()
            .dn(getAuthenticationProperties().getUserBaseDn())
            .filter(FilterTemplate.builder()
                .filter(getAuthenticationProperties().getUserFindOneFilter())
                .parameters(username)
                .build())
            .scope(getAuthenticationProperties().getUserFindOneSearchScope())
            .sizeLimit(1)
            .build();
    return getLdaptiveTemplate().findOne(searchRequest);
  }

  /**
   * Gets authorities.
   *
   * @param user the user
   * @return the authorities
   */
  public Collection<GrantedAuthority> getAuthorities(LdapEntry user) {

    return switch (getAuthenticationProperties().getGroupFetchStrategy()) {
      case NONE -> Set.of();
      case USER_CONTAINS_GROUPS -> getAuthoritiesByGroupsInUser(user);
      case GROUP_CONTAINS_USERS -> getAuthoritiesByGroupsWithUser(user);
    };
  }

  /**
   * Gets roles by groups in user.
   *
   * @param user the user
   * @return the roles by groups in user
   */
  protected Collection<GrantedAuthority> getAuthoritiesByGroupsInUser(LdapEntry user) {
    Collection<GrantedAuthority> authorities = Stream.ofNullable(user)
        .map(entry -> entry.getAttribute(getAuthenticationProperties().getMemberAttribute()))
        .filter(Objects::nonNull)
        .map(LdapAttribute::getStringValues)
        .filter(Objects::nonNull)
        .flatMap(Collection::stream)
        .flatMap(memberOfValue -> getGroupName(memberOfValue).stream())
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toSet());
    return Set.copyOf(getGrantedAuthoritiesMapper().mapAuthorities(authorities));
  }

  private static Optional<String> getGroupName(String groupNameOrGroupDn) {
    return getDn(groupNameOrGroupDn)
        .map(Dn::getRDn)
        .map(RDn::getNameValue)
        .map(NameValue::getStringValue)
        .or(() -> Optional.ofNullable(groupNameOrGroupDn))
        .filter(Predicate.not(String::isBlank));
  }

  private static Optional<Dn> getDn(String dn) {
    if (isEmpty(dn)) {
      return Optional.empty();
    }
    try {
      return Optional.of(new Dn(dn));
    } catch (RuntimeException e) {
      return Optional.empty();
    }
  }

  /**
   * Gets roles by groups with user.
   *
   * @param user the user
   * @return the roles by groups with user
   */
  protected Collection<GrantedAuthority> getAuthoritiesByGroupsWithUser(LdapEntry user) {
    Collection<GrantedAuthority> authorities = getLdaptiveTemplate()
        .findAll(
            SearchRequest.builder()
                .dn(getAuthenticationProperties().getGroupBaseDn())
                .filter(FilterTemplate.builder()
                    .filter(getAuthorityFilter(user))
                    .build())
                .scope(getAuthenticationProperties().getGroupSearchScope())
                .build())
        .stream()
        .map(this::getAuthorityName)
        .map(SimpleGrantedAuthority::new)
        .collect(Collectors.toSet());
    return Set.copyOf(getGrantedAuthoritiesMapper().mapAuthorities(authorities));
  }

  /**
   * Gets group filter.
   *
   * @param user the user
   * @return the group filter
   */
  protected String getAuthorityFilter(LdapEntry user) {
    String groupObjectClass = getAuthenticationProperties().getGroupObjectClass();
    String groupMemberAttribute = getAuthenticationProperties().getGroupMemberAttribute();
    String groupMemberValue;
    String groupMemberFormat = getAuthenticationProperties().getGroupMemberFormat();
    if (isEmpty(groupMemberFormat)) {
      groupMemberValue = user.getDn();
    } else {
      String username = user.getAttribute(getAuthenticationProperties().getUsernameAttribute())
          .getStringValue();
      groupMemberValue = groupMemberFormat
          .replaceFirst(Pattern.quote(USERNAME_PLACEHOLDER), username);
    }
    return String.format("(&(objectClass=%s)(%s=%s))",
        groupObjectClass, groupMemberAttribute, groupMemberValue);
  }

  /**
   * Gets group name.
   *
   * @param group the group
   * @return the group name
   */
  protected String getAuthorityName(LdapEntry group) {
    String groupIdAttribute = getAuthenticationProperties().getGroupIdAttribute();
    String fallback = getGroupName(group.getDn()).orElse(null);
    if (isEmpty(groupIdAttribute)) {
      return fallback;
    }
    return Optional.ofNullable(group.getAttribute(groupIdAttribute))
        .map(LdapAttribute::getStringValue)
        .orElse(fallback);
  }

}