View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.ldap;
17  
18  import static org.codelibs.core.stream.StreamUtil.stream;
19  
20  import java.util.ArrayList;
21  import java.util.Base64;
22  import java.util.Collections;
23  import java.util.HashSet;
24  import java.util.Hashtable;
25  import java.util.List;
26  import java.util.Locale;
27  import java.util.Map;
28  import java.util.Set;
29  import java.util.function.BiConsumer;
30  import java.util.function.Consumer;
31  import java.util.function.Supplier;
32  import java.util.stream.Collectors;
33  import java.util.stream.Stream;
34  
35  import javax.naming.Context;
36  import javax.naming.NamingEnumeration;
37  import javax.naming.NamingException;
38  import javax.naming.directory.Attribute;
39  import javax.naming.directory.Attributes;
40  import javax.naming.directory.BasicAttribute;
41  import javax.naming.directory.BasicAttributes;
42  import javax.naming.directory.DirContext;
43  import javax.naming.directory.InitialDirContext;
44  import javax.naming.directory.ModificationItem;
45  import javax.naming.directory.SearchControls;
46  import javax.naming.directory.SearchResult;
47  
48  import org.apache.logging.log4j.LogManager;
49  import org.apache.logging.log4j.Logger;
50  import org.codelibs.core.lang.StringUtil;
51  import org.codelibs.core.timer.TimeoutManager;
52  import org.codelibs.fess.Constants;
53  import org.codelibs.fess.entity.FessUser;
54  import org.codelibs.fess.exception.LdapConfigurationException;
55  import org.codelibs.fess.exception.LdapOperationException;
56  import org.codelibs.fess.helper.SystemHelper;
57  import org.codelibs.fess.mylasta.direction.FessConfig;
58  import org.codelibs.fess.opensearch.user.exentity.Group;
59  import org.codelibs.fess.opensearch.user.exentity.Role;
60  import org.codelibs.fess.opensearch.user.exentity.User;
61  import org.codelibs.fess.util.ComponentUtil;
62  import org.codelibs.fess.util.OptionalUtil;
63  import org.dbflute.optional.OptionalEntity;
64  import org.dbflute.util.DfTypeUtil;
65  
66  import jakarta.annotation.PostConstruct;
67  
68  /**
69   * Manages LDAP connections and operations.
70   */
71  public class LdapManager {
72      private static final Logger logger = LogManager.getLogger(LdapManager.class);
73  
74      /** A thread-local variable to hold the directory context. */
75      protected ThreadLocal<DirContextHolder> contextLocal = new ThreadLocal<>();
76  
77      /** A flag to indicate if the LDAP connection is bound. */
78      protected volatile boolean isBind = false;
79  
80      /** The Fess configuration. */
81      protected FessConfig fessConfig;
82  
83      /**
84       * Default constructor.
85       */
86      public LdapManager() {
87          // do nothing
88      }
89  
90      /**
91       * Initializes the LDAP manager.
92       */
93      @PostConstruct
94      public void init() {
95          if (logger.isDebugEnabled()) {
96              logger.debug("Initializing {}", this.getClass().getSimpleName());
97          }
98          fessConfig = ComponentUtil.getFessConfig();
99      }
100 
101     /**
102      * Creates the environment for LDAP connection.
103      *
104      * @param initialContextFactory The initial context factory.
105      * @param securityAuthentication The security authentication.
106      * @param providerUrl The provider URL.
107      * @param principal The principal.
108      * @param credntials The credentials.
109      * @return The environment for LDAP connection.
110      */
111     protected Hashtable<String, String> createEnvironment(final String initialContextFactory, final String securityAuthentication,
112             final String providerUrl, final String principal, final String credntials) {
113         final Hashtable<String, String> env = new Hashtable<>();
114         putEnv(env, Context.INITIAL_CONTEXT_FACTORY, initialContextFactory);
115         putEnv(env, Context.SECURITY_AUTHENTICATION, securityAuthentication);
116         putEnv(env, Context.PROVIDER_URL, providerUrl);
117         putEnv(env, Context.SECURITY_PRINCIPAL, principal);
118         putEnv(env, Context.SECURITY_CREDENTIALS, credntials);
119         if (providerUrl != null && providerUrl.startsWith("ldaps://")) {
120             putEnv(env, Context.SECURITY_PROTOCOL, "ssl");
121         }
122         return env;
123     }
124 
125     /**
126      * Puts a key-value pair to the environment.
127      *
128      * @param env The environment.
129      * @param key The key.
130      * @param value The value.
131      */
132     protected void putEnv(final Hashtable<String, String> env, final String key, final String value) {
133         if (value == null) {
134             throw new LdapConfigurationException(key + " is null.");
135         }
136         env.put(key, value);
137     }
138 
139     /**
140      * Creates the admin environment for LDAP connection.
141      *
142      * @return The admin environment for LDAP connection.
143      */
144     protected Hashtable<String, String> createAdminEnv() {
145         return createEnvironment(//
146                 fessConfig.getLdapInitialContextFactory(), //
147                 fessConfig.getLdapSecurityAuthentication(), fessConfig.getLdapProviderUrl(), //
148                 fessConfig.getLdapAdminSecurityPrincipal(), //
149                 fessConfig.getLdapAdminSecurityCredentials());
150     }
151 
152     /**
153      * Creates the search environment for LDAP connection.
154      *
155      * @param username The username.
156      * @param password The password.
157      * @return The search environment for LDAP connection.
158      */
159     protected Hashtable<String, String> createSearchEnv(final String username, final String password) {
160         return createEnvironment(//
161                 fessConfig.getLdapInitialContextFactory(), //
162                 fessConfig.getLdapSecurityAuthentication(), //
163                 fessConfig.getLdapProviderUrl(), //
164                 fessConfig.getLdapSecurityPrincipal(username), password);
165     }
166 
167     /**
168      * Creates the search environment for LDAP connection.
169      *
170      * @return The search environment for LDAP connection.
171      */
172     protected Hashtable<String, String> createSearchEnv() {
173         return createEnvironment(//
174                 fessConfig.getLdapInitialContextFactory(), //
175                 fessConfig.getLdapSecurityAuthentication(), fessConfig.getLdapProviderUrl(), //
176                 fessConfig.getLdapAdminSecurityPrincipal(), //
177                 fessConfig.getLdapAdminSecurityCredentials());
178     }
179 
180     /**
181      * Updates the LDAP configuration.
182      */
183     public void updateConfig() {
184         isBind = false;
185     }
186 
187     /**
188      * Validates the LDAP connection.
189      *
190      * @return True if the LDAP connection is valid, otherwise false.
191      */
192     protected boolean validate() {
193         if (!isBind) {
194             if (fessConfig.getLdapAdminSecurityPrincipal() == null || fessConfig.getLdapAdminSecurityCredentials() == null) {
195                 // no credentials
196                 return !fessConfig.isLdapAuthValidation();
197             }
198             try {
199                 final Hashtable<String, String> env = createAdminEnv();
200                 try (DirContextHolder holder = getDirContext(() -> env)) {
201                     final DirContext context = holder.get();
202                     if (logger.isDebugEnabled()) {
203                         logger.debug("Logged in as Bind DN. {}", context);
204                     }
205                     isBind = true;
206                 }
207             } catch (final LdapConfigurationException e) {
208                 logger.warn("LDAP configuration error: {}", e.getMessage(), e);
209             } catch (final LdapOperationException e) {
210                 logger.warn("LDAP connection failed: {}", e.getMessage(), e);
211             } catch (final Exception e) {
212                 logger.warn("Unexpected error during LDAP validation: {}", e.getMessage(), e);
213             }
214         }
215         return isBind;
216     }
217 
218     /**
219      * Authenticates a user with the specified username and password against LDAP.
220      *
221      * @param username the username for authentication
222      * @param password the password for authentication
223      * @return an optional containing the authenticated user if successful, empty otherwise
224      */
225     public OptionalEntity<FessUser> login(final String username, final String password) {
226         // Add defensive null/blank checks
227         if (StringUtil.isBlank(username) || StringUtil.isBlank(password)) {
228             if (logger.isDebugEnabled()) {
229                 logger.debug("Login failed: username or password is blank");
230             }
231             return OptionalEntity.empty();
232         }
233 
234         if (StringUtil.isBlank(fessConfig.getLdapProviderUrl()) || !validate()) {
235             return OptionalEntity.empty();
236         }
237 
238         try {
239             final Hashtable<String, String> env = createSearchEnv(username, password);
240             try (DirContextHolder holder = getDirContext(() -> env)) {
241                 final DirContext context = holder.get();
242                 final LdapUser ldapUser = createLdapUser(username, env);
243                 if (!allowEmptyGroupAndRole(ldapUser)) {
244                     if (logger.isDebugEnabled()) {
245                         logger.debug("Login failed. No permissions. {}", context);
246                     }
247                     return OptionalEntity.empty();
248                 }
249                 if (logger.isDebugEnabled()) {
250                     logger.debug("Logged in. {}", context);
251                 }
252                 return OptionalEntity.of(ldapUser);
253             }
254         } catch (final LdapOperationException e) {
255             logger.debug("LDAP operation failed during login for user: {}", username, e);
256         } catch (final Exception e) {
257             logger.debug("Login failed for user: {}", username, e);
258         }
259         return OptionalEntity.empty();
260     }
261 
262     /**
263      * Authenticates a user with the specified username without password validation.
264      *
265      * @param username the username for authentication
266      * @return an optional containing the authenticated user if successful, empty otherwise
267      */
268     public OptionalEntity<FessUser> login(final String username) {
269         // Add defensive null/blank check
270         if (StringUtil.isBlank(username)) {
271             if (logger.isDebugEnabled()) {
272                 logger.debug("Login failed: username is blank");
273             }
274             return OptionalEntity.empty();
275         }
276 
277         try {
278             final Hashtable<String, String> env = createSearchEnv();
279             try (DirContextHolder holder = getDirContext(() -> env)) {
280                 final DirContext context = holder.get();
281                 final LdapUser ldapUser = createLdapUser(username, env);
282                 if (!allowEmptyGroupAndRole(ldapUser)) {
283                     if (logger.isDebugEnabled()) {
284                         logger.debug("Login failed. No permissions. {}", context);
285                     }
286                     return OptionalEntity.empty();
287                 }
288                 if (logger.isDebugEnabled()) {
289                     logger.debug("Logged in. {}", context);
290                 }
291                 return OptionalEntity.of(ldapUser);
292             }
293         } catch (final LdapOperationException e) {
294             logger.debug("LDAP operation failed during login for user: {}", username, e);
295         } catch (final Exception e) {
296             logger.debug("Login failed for user: {}", username, e);
297         }
298         return OptionalEntity.empty();
299     }
300 
301     /**
302      * Checks if the specified LDAP user is allowed to have empty group and role permissions.
303      *
304      * @param ldapUser the LDAP user to check
305      * @return true if empty permissions are allowed, false otherwise
306      */
307     protected boolean allowEmptyGroupAndRole(final LdapUser ldapUser) {
308         if (fessConfig.isLdapAllowEmptyPermission()) {
309             return true;
310         }
311 
312         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
313         for (final String permission : ldapUser.getPermissions()) {
314             if (!systemHelper.isUserPermission(permission)) {
315                 return true;
316             }
317         }
318         return false;
319     }
320 
321     /**
322      * Creates a new LDAP user instance with the specified username and environment.
323      *
324      * @param username the username for the LDAP user
325      * @param env the environment configuration for LDAP connection
326      * @return a new LdapUser instance
327      */
328     protected LdapUser createLdapUser(final String username, final Hashtable<String, String> env) {
329         return new LdapUser(env, username);
330     }
331 
332     /**
333      * Retrieves roles for the specified LDAP user based on the provided filters.
334      *
335      * @param ldapUser the LDAP user to retrieve roles for
336      * @param bindDn the bind DN for LDAP connection
337      * @param accountFilter the account filter pattern
338      * @param groupFilter the group filter pattern
339      * @param lazyLoading the lazy loading consumer for roles
340      * @return an array of role names
341      */
342     public String[] getRoles(final LdapUser ldapUser, final String bindDn, final String accountFilter, final String groupFilter,
343             final Consumer<String[]> lazyLoading) {
344         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
345         final Set<String> roleSet = new HashSet<>();
346 
347         if (fessConfig.isLdapRoleSearchUserEnabled()) {
348             roleSet.add(normalizePermissionName(systemHelper.getSearchRoleByUser(ldapUser.getName())));
349         }
350 
351         // LDAP: cn=%s
352         // AD: (&(objectClass=user)(sAMAccountName=%s))
353         final String filter = String.format(accountFilter, escapeLDAPSearchFilter(ldapUser.getName()));
354         if (logger.isDebugEnabled()) {
355             logger.debug("Account filter: {}", filter);
356         }
357         final Set<String> subRoleSet = new HashSet<>();
358         final Set<String> sAMAccountGroupNameSet = new HashSet<>();
359         search(bindDn, filter, new String[] { fessConfig.getLdapMemberofAttribute() }, () -> ldapUser.getEnvironment(), result -> {
360             processSearchRoles(result, entryDn -> {
361                 final String roleName = getSearchRoleName(entryDn);
362                 final String roleType = updateSearchRoles(roleSet, entryDn, roleName);
363                 if (fessConfig.getRoleSearchGroupPrefix().equals(roleType) && fessConfig.isLdapSamaccountnameGroup()) {
364                     sAMAccountGroupNameSet.add(roleName);
365                 }
366                 if (StringUtil.isNotBlank(groupFilter)) {
367                     subRoleSet.add(entryDn);
368                 }
369             });
370         });
371 
372         if (logger.isDebugEnabled()) {
373             logger.debug("Roles: {}", roleSet);
374         }
375         final String[] roles = roleSet.toArray(new String[roleSet.size()]);
376 
377         if (!subRoleSet.isEmpty()) {
378             TimeoutManager.getInstance().addTimeoutTarget(() -> {
379                 sAMAccountGroupNameSet.stream().forEach(groupName -> {
380                     getSAMAccountGroupName(bindDn, groupName).ifPresent(sAMAccountGroupName -> {
381                         roleSet.add(systemHelper.getSearchRoleByGroup(normalizePermissionName(sAMAccountGroupName)));
382                     });
383                 });
384                 processSubRoles(ldapUser, bindDn, subRoleSet, groupFilter, roleSet);
385                 if (logger.isDebugEnabled()) {
386                     logger.debug("Roles (lazy loading): {}", roleSet);
387                 }
388                 lazyLoading.accept(roleSet.toArray(new String[roleSet.size()]));
389             }, 0, false);
390         }
391 
392         return roles;
393     }
394 
395     /**
396      * Gets the sAMAccountName for a group from the LDAP directory.
397      *
398      * @param bindDn the bind DN to search within
399      * @param groupName the name of the group to search for
400      * @return an optional containing the sAMAccountName if found, empty otherwise
401      */
402     protected OptionalEntity<String> getSAMAccountGroupName(final String bindDn, final String groupName) {
403         // Add defensive null/blank checks
404         if (StringUtil.isBlank(bindDn) || StringUtil.isBlank(groupName)) {
405             if (logger.isDebugEnabled()) {
406                 logger.debug("bindDn or groupName is blank: bindDn={}, groupName={}", bindDn, groupName);
407             }
408             return OptionalEntity.empty();
409         }
410 
411         final Hashtable<String, String> env = createSearchEnv();
412         try (DirContextHolder holder = getDirContext(() -> env)) {
413             final DirContext context = holder.get();
414             final SearchControls searchControls = new SearchControls();
415             searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE);
416             if (logger.isDebugEnabled()) {
417                 logger.debug("Searching for sAMAccountName of group: {} on {}", groupName, bindDn);
418             }
419             final NamingEnumeration<SearchResult> results =
420                     context.search(bindDn, "(name=" + escapeLDAPSearchFilter(groupName) + ")", searchControls);
421             if (results.hasMore()) {
422                 final SearchResult searchResult = results.next();
423                 final Attribute attribute = searchResult.getAttributes().get("sAMAccountName");
424                 if (logger.isDebugEnabled()) {
425                     logger.debug("sAMAccountName: {}", attribute);
426                 }
427                 if (attribute != null && attribute.get() instanceof String sAMAccountName) {
428                     return OptionalEntity.of(sAMAccountName);
429                 }
430             }
431         } catch (final NamingException e) {
432             logger.warn("LDAP naming exception while getting sAMAccountName for group: {}", groupName, e);
433         } catch (final Exception e) {
434             logger.warn("Unexpected exception while getting sAMAccountName for group: {}", groupName, e);
435         }
436         return OptionalEntity.empty();
437     }
438 
439     /**
440      * Processes sub-roles for the specified LDAP user.
441      *
442      * @param ldapUser the LDAP user to process sub-roles for
443      * @param bindDn the bind DN for LDAP connection
444      * @param subRoleSet the set of sub-roles to process
445      * @param groupFilter the group filter pattern
446      * @param roleSet the set of roles to update
447      */
448     protected void processSubRoles(final LdapUser ldapUser, final String bindDn, final Set<String> subRoleSet, final String groupFilter,
449             final Set<String> roleSet) {
450         // (member:1.2.840.113556.1.4.1941:=%s)
451         if (subRoleSet.isEmpty()) {
452             return;
453         }
454         String filter = subRoleSet.stream().map(s -> String.format(groupFilter, s)).collect(Collectors.joining());
455         if (subRoleSet.size() > 1) {
456             filter = "(|" + filter + ")";
457         }
458 
459         if (logger.isDebugEnabled()) {
460             logger.debug("Group filter: {}", filter);
461         }
462         final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
463         search(bindDn, filter, null, () -> ldapUser.getEnvironment(), result -> {
464             for (final SearchResult srcrslt : result) {
465                 final String groupDn = srcrslt.getNameInNamespace();
466                 if (logger.isDebugEnabled()) {
467                     logger.debug("Group DN: {}", groupDn);
468                 }
469                 final String groupName = getSearchRoleName(groupDn);
470                 final String roleType = updateSearchRoles(roleSet, groupDn, groupName);
471                 if (fessConfig.getRoleSearchGroupPrefix().equals(roleType) && fessConfig.isLdapSamaccountnameGroup()) {
472                     getSAMAccountGroupName(bindDn, groupName).ifPresent(sAMAccountGroupName -> {
473                         roleSet.add(systemHelper.getSearchRoleByGroup(normalizePermissionName(sAMAccountGroupName)));
474                     });
475                 }
476             }
477         });
478     }
479 
480     /**
481      * Updates the role set with search roles based on the entry DN and name.
482      *
483      * @param roleSet the set of roles to update
484      * @param entryDn the entry DN to check
485      * @param name the role name
486      * @return the role type prefix if successful, null otherwise
487      */
488     protected String updateSearchRoles(final Set<String> roleSet, final String entryDn, final String name) {
489         if (StringUtil.isNotBlank(name)) {
490             final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
491             final boolean isRole = entryDn.toLowerCase(Locale.ROOT).indexOf("ou=role") != -1;
492             if (isRole) {
493                 if (fessConfig.isLdapRoleSearchRoleEnabled()) {
494                     roleSet.add(systemHelper.getSearchRoleByRole(normalizePermissionName(name)));
495                     return fessConfig.getRoleSearchRolePrefix();
496                 }
497             } else if (fessConfig.isLdapRoleSearchGroupEnabled()) {
498                 roleSet.add(systemHelper.getSearchRoleByGroup(normalizePermissionName(name)));
499                 return fessConfig.getRoleSearchGroupPrefix();
500             }
501         }
502         return null;
503     }
504 
505     /**
506      * Escapes special characters in an LDAP search filter to prevent LDAP injection attacks.
507      *
508      * @param filter the LDAP search filter to escape (null is treated as empty string)
509      * @return the escaped filter string safe for use in LDAP queries (empty string if filter is null)
510      * @see <a href="https://tools.ietf.org/html/rfc4515">RFC 4515 - LDAP String Representation of Search Filters</a>
511      * @deprecated Use {@link LdapUtil#escapeValue(String)} instead
512      */
513     @Deprecated
514     protected String escapeLDAPSearchFilter(final String filter) {
515         return LdapUtil.escapeValue(filter);
516     }
517 
518     /**
519      * Normalizes a permission name based on configuration settings.
520      *
521      * @param name the permission name to normalize
522      * @return the normalized permission name
523      */
524     public String normalizePermissionName(final String name) {
525         if (fessConfig.isLdapLowercasePermissionName()) {
526             return name.toLowerCase(Locale.ROOT);
527         }
528         return name;
529     }
530 
531     /**
532      * Processes search results to extract roles using a BiConsumer.
533      *
534      * @param result the list of search results
535      * @param consumer the BiConsumer to process entry DN and role name
536      * @throws NamingException if LDAP naming exception occurs
537      */
538     protected void processSearchRoles(final List<SearchResult> result, final BiConsumer<String, String> consumer) throws NamingException {
539         processSearchRoles(result, entryDn -> {
540             final String name = getSearchRoleName(entryDn);
541             if (name != null) {
542                 consumer.accept(entryDn, name);
543             }
544         });
545     }
546 
547     /**
548      * Processes search results to extract roles using a Consumer.
549      *
550      * @param result the list of search results
551      * @param consumer the Consumer to process entry DN
552      * @throws NamingException if LDAP naming exception occurs
553      */
554     protected void processSearchRoles(final List<SearchResult> result, final Consumer<String> consumer) throws NamingException {
555         for (final SearchResult srcrslt : result) {
556             final Attributes attrs = srcrslt.getAttributes();
557 
558             //get group attr
559             final Attribute attr = attrs.get(fessConfig.getLdapMemberofAttribute());
560             if (attr == null) {
561                 continue;
562             }
563 
564             for (int i = 0; i < attr.size(); i++) {
565                 final Object attrValue = attr.get(i);
566                 if (attrValue != null) {
567                     final String entryDn = attrValue.toString();
568 
569                     if (logger.isDebugEnabled()) {
570                         logger.debug("entryDn: {}", entryDn);
571                     }
572                     consumer.accept(entryDn);
573                 }
574             }
575         }
576     }
577 
578     /**
579      * Extracts the role name from an LDAP entry DN.
580      *
581      * @param entryDn the LDAP entry DN
582      * @return the extracted role name, or null if not found
583      */
584     protected String getSearchRoleName(final String entryDn) {
585         if (entryDn == null) {
586             return null;
587         }
588         int start = entryDn.toLowerCase(Locale.ROOT).indexOf("cn=");
589         if (start == -1) {
590             return null;
591         }
592         start += 3;
593 
594         final int end = entryDn.indexOf(',', start);
595         final String value = end == -1 ? entryDn.substring(start) : entryDn.substring(start, end);
596         if (fessConfig.isLdapGroupNameWithUnderscores()) {
597             return replaceWithUnderscores(value);
598         }
599         return value;
600     }
601 
602     /**
603      * Replaces special characters in a string with underscores for group names.
604      *
605      * @param value the string to process
606      * @return the string with special characters replaced by underscores
607      */
608     protected String replaceWithUnderscores(final String value) {
609         return value.replaceAll("[/\\\\\\[\\]:;|=,+\\*?<>]", "_");
610     }
611 
612     /**
613      * Sets an attribute value from search results using a Consumer.
614      *
615      * @param result the list of search results
616      * @param name the attribute name
617      * @param consumer the Consumer to process the attribute value
618      */
619     protected void setAttributeValue(final List<SearchResult> result, final String name, final Consumer<Object> consumer) {
620         final List<Object> attrList = getAttributeValueList(result, name);
621         if (!attrList.isEmpty()) {
622             consumer.accept(attrList.get(0));
623         }
624     }
625 
626     /**
627      * Gets a list of attribute values from search results.
628      *
629      * @param result the list of search results
630      * @param name the attribute name
631      * @return a list of attribute values
632      */
633     protected List<Object> getAttributeValueList(final List<SearchResult> result, final String name) {
634         try {
635             for (final SearchResult srcrslt : result) {
636                 final Attributes attrs = srcrslt.getAttributes();
637 
638                 final Attribute attr = attrs.get(name);
639                 if (attr == null) {
640                     continue;
641                 }
642 
643                 final List<Object> attrList = new ArrayList<>();
644                 for (int i = 0; i < attr.size(); i++) {
645                     final Object attrValue = attr.get(i);
646                     if (attrValue != null) {
647                         attrList.add(attrValue);
648                     }
649                 }
650                 return attrList;
651             }
652             return Collections.emptyList();
653         } catch (final NamingException e) {
654             throw new LdapOperationException("Failed to parse attribute values for " + name, e);
655         }
656     }
657 
658     /**
659      * Applies LDAP attributes to a user object.
660      *
661      * @param user the user object to populate with LDAP attributes
662      */
663     public void apply(final User user) {
664         if (!fessConfig.isLdapAdminEnabled(user.getName())) {
665             return;
666         }
667 
668         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
669         search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(user.getName()), null, adminEnv, result -> {
670             if (!result.isEmpty()) {
671                 setAttributeValue(result, fessConfig.getLdapAttrSurname(), o -> user.setSurname(o.toString()));
672                 setAttributeValue(result, fessConfig.getLdapAttrGivenName(), o -> user.setGivenName(o.toString()));
673                 setAttributeValue(result, fessConfig.getLdapAttrMail(), o -> user.setMail(o.toString()));
674                 setAttributeValue(result, fessConfig.getLdapAttrEmployeeNumber(), o -> user.setEmployeeNumber(o.toString()));
675                 setAttributeValue(result, fessConfig.getLdapAttrTelephoneNumber(), o -> user.setTelephoneNumber(o.toString()));
676                 setAttributeValue(result, fessConfig.getLdapAttrHomePhone(), o -> user.setHomePhone(o.toString()));
677                 setAttributeValue(result, fessConfig.getLdapAttrHomePostalAddress(), o -> user.setHomePostalAddress(o.toString()));
678                 setAttributeValue(result, fessConfig.getLdapAttrLabeledURI(), o -> user.setLabeledURI(o.toString()));
679                 setAttributeValue(result, fessConfig.getLdapAttrRoomNumber(), o -> user.setRoomNumber(o.toString()));
680                 setAttributeValue(result, fessConfig.getLdapAttrDescription(), o -> user.setDescription(o.toString()));
681                 setAttributeValue(result, fessConfig.getLdapAttrTitle(), o -> user.setTitle(o.toString()));
682                 setAttributeValue(result, fessConfig.getLdapAttrPager(), o -> user.setPager(o.toString()));
683                 setAttributeValue(result, fessConfig.getLdapAttrStreet(), o -> user.setStreet(o.toString()));
684                 setAttributeValue(result, fessConfig.getLdapAttrPostalCode(), o -> user.setPostalCode(o.toString()));
685                 setAttributeValue(result, fessConfig.getLdapAttrPhysicalDeliveryOfficeName(),
686                         o -> user.setPhysicalDeliveryOfficeName(o.toString()));
687                 setAttributeValue(result, fessConfig.getLdapAttrDestinationIndicator(), o -> user.setDestinationIndicator(o.toString()));
688                 setAttributeValue(result, fessConfig.getLdapAttrInternationaliSDNNumber(),
689                         o -> user.setInternationaliSDNNumber(o.toString()));
690                 setAttributeValue(result, fessConfig.getLdapAttrState(), o -> user.setState(o.toString()));
691                 setAttributeValue(result, fessConfig.getLdapAttrEmployeeType(), o -> user.setEmployeeType(o.toString()));
692                 setAttributeValue(result, fessConfig.getLdapAttrFacsimileTelephoneNumber(),
693                         o -> user.setFacsimileTelephoneNumber(o.toString()));
694                 setAttributeValue(result, fessConfig.getLdapAttrPostOfficeBox(), o -> user.setPostOfficeBox(o.toString()));
695                 setAttributeValue(result, fessConfig.getLdapAttrInitials(), o -> user.setInitials(o.toString()));
696                 setAttributeValue(result, fessConfig.getLdapAttrCarLicense(), o -> user.setCarLicense(o.toString()));
697                 setAttributeValue(result, fessConfig.getLdapAttrMobile(), o -> user.setMobile(o.toString()));
698                 setAttributeValue(result, fessConfig.getLdapAttrPostalAddress(), o -> user.setPostalAddress(o.toString()));
699                 setAttributeValue(result, fessConfig.getLdapAttrCity(), o -> user.setCity(o.toString()));
700                 setAttributeValue(result, fessConfig.getLdapAttrTeletexTerminalIdentifier(),
701                         o -> user.setTeletexTerminalIdentifier(o.toString()));
702                 setAttributeValue(result, fessConfig.getLdapAttrX121Address(), o -> user.setX121Address(o.toString()));
703                 setAttributeValue(result, fessConfig.getLdapAttrBusinessCategory(), o -> user.setBusinessCategory(o.toString()));
704                 setAttributeValue(result, fessConfig.getLdapAttrRegisteredAddress(), o -> user.setRegisteredAddress(o.toString()));
705                 setAttributeValue(result, fessConfig.getLdapAttrDisplayName(), o -> user.setDisplayName(o.toString()));
706                 setAttributeValue(result, fessConfig.getLdapAttrPreferredLanguage(), o -> user.setPreferredLanguage(o.toString()));
707                 setAttributeValue(result, fessConfig.getLdapAttrDepartmentNumber(), o -> user.setDepartmentNumber(o.toString()));
708                 setAttributeValue(result, fessConfig.getLdapAttrUidNumber(), o -> user.setUidNumber(DfTypeUtil.toLong(o)));
709                 setAttributeValue(result, fessConfig.getLdapAttrGidNumber(), o -> user.setGidNumber(DfTypeUtil.toLong(o)));
710                 setAttributeValue(result, fessConfig.getLdapAttrHomeDirectory(), o -> user.setHomeDirectory(o.toString()));
711             }
712         });
713 
714         // groups and roles
715         search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(user.getName()),
716                 new String[] { fessConfig.getLdapMemberofAttribute() }, adminEnv, result -> {
717                     if (!result.isEmpty()) {
718                         final List<String> groupList = new ArrayList<>();
719                         final List<String> roleList = new ArrayList<>();
720                         final String lowerGroupDn = fessConfig.getLdapAdminGroupBaseDn().toLowerCase(Locale.ROOT);
721                         final String lowerRoleDn = fessConfig.getLdapAdminRoleBaseDn().toLowerCase(Locale.ROOT);
722                         processSearchRoles(result, (entryDn, name) -> {
723                             final String lowerEntryDn = entryDn.toLowerCase(Locale.ROOT);
724                             if (lowerEntryDn.indexOf(lowerGroupDn) != -1) {
725                                 groupList.add(Base64.getUrlEncoder().encodeToString(name.getBytes(Constants.CHARSET_UTF_8)));
726                             } else if (lowerEntryDn.indexOf(lowerRoleDn) != -1) {
727                                 roleList.add(Base64.getUrlEncoder().encodeToString(name.getBytes(Constants.CHARSET_UTF_8)));
728                             }
729                         });
730                         user.setGroups(groupList.toArray(new String[groupList.size()]));
731                         user.setRoles(roleList.toArray(new String[roleList.size()]));
732                     }
733                 });
734 
735     }
736 
737     /**
738      * Inserts or updates a user in LDAP directory.
739      *
740      * @param user the user object to insert or update
741      */
742     public void insert(final User user) {
743         if (!fessConfig.isLdapAdminEnabled(user.getName())) {
744             return;
745         }
746 
747         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
748         final String userDN = fessConfig.getLdapAdminUserSecurityPrincipal(user.getName());
749         // attributes
750         search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(user.getName()), null, adminEnv, result -> {
751             if (!result.isEmpty()) {
752                 modifyUserAttributes(user, adminEnv, userDN, result);
753             } else {
754                 final BasicAttributes entry = new BasicAttributes();
755                 addUserAttributes(entry, user);
756                 final Attribute oc = fessConfig.getLdapAdminUserObjectClassAttribute();
757                 entry.put(oc);
758                 insert(userDN, entry, adminEnv);
759             }
760         });
761 
762         // groups and roles
763         search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(user.getName()),
764                 new String[] { fessConfig.getLdapMemberofAttribute() }, adminEnv, result -> {
765                     if (!result.isEmpty()) {
766                         final List<String> oldGroupList = new ArrayList<>();
767                         final List<String> oldRoleList = new ArrayList<>();
768                         final String lowerGroupDn = fessConfig.getLdapAdminGroupBaseDn().toLowerCase(Locale.ROOT);
769                         final String lowerRoleDn = fessConfig.getLdapAdminRoleBaseDn().toLowerCase(Locale.ROOT);
770                         processSearchRoles(result, (entryDn, name) -> {
771                             final String lowerEntryDn = entryDn.toLowerCase(Locale.ROOT);
772                             if (lowerEntryDn.indexOf(lowerGroupDn) != -1) {
773                                 oldGroupList.add(name);
774                             } else if (lowerEntryDn.indexOf(lowerRoleDn) != -1) {
775                                 oldRoleList.add(name);
776                             }
777                         });
778                         final List<String> newGroupList = stream(user.getGroupNames()).get(stream -> stream.collect(Collectors.toList()));
779                         stream(user.getGroupNames()).of(stream -> stream.forEach(name -> {
780                             if (oldGroupList.contains(name)) {
781                                 oldGroupList.remove(name);
782                                 newGroupList.remove(name);
783                             }
784                         }));
785                         oldGroupList.stream().forEach(name -> {
786                             search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(name), null, adminEnv,
787                                     subResult -> {
788                                         if (!subResult.isEmpty()) {
789                                             final List<ModificationItem> modifyList = new ArrayList<>();
790                                             modifyDeleteEntry(modifyList, "member", userDN);
791                                             modify(fessConfig.getLdapAdminGroupSecurityPrincipal(name), modifyList, adminEnv);
792                                         }
793                                     });
794                         });
795                         newGroupList.stream().forEach(name -> {
796                             search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(name), null, adminEnv,
797                                     subResult -> {
798                                         if (subResult.isEmpty()) {
799                                             final Group group = new Group();
800                                             group.setName(name);
801                                             insert(group);
802                                         }
803                                         final List<ModificationItem> modifyList = new ArrayList<>();
804                                         modifyAddEntry(modifyList, "member", userDN);
805                                         modify(fessConfig.getLdapAdminGroupSecurityPrincipal(name), modifyList, adminEnv);
806                                     });
807                         });
808 
809                         final List<String> newRoleList = stream(user.getRoleNames()).get(stream -> stream.collect(Collectors.toList()));
810                         stream(user.getRoleNames()).of(stream -> stream.forEach(name -> {
811                             if (oldRoleList.contains(name)) {
812                                 oldRoleList.remove(name);
813                                 newRoleList.remove(name);
814                             }
815                         }));
816                         oldRoleList.stream().forEach(name -> {
817                             search(fessConfig.getLdapAdminRoleBaseDn(), fessConfig.getLdapAdminRoleFilter(name), null, adminEnv,
818                                     subResult -> {
819                                         if (!subResult.isEmpty()) {
820                                             final List<ModificationItem> modifyList = new ArrayList<>();
821                                             modifyDeleteEntry(modifyList, "member", userDN);
822                                             modify(fessConfig.getLdapAdminRoleSecurityPrincipal(name), modifyList, adminEnv);
823                                         }
824                                     });
825                         });
826                         newRoleList.stream().forEach(name -> {
827                             search(fessConfig.getLdapAdminRoleBaseDn(), fessConfig.getLdapAdminRoleFilter(name), null, adminEnv,
828                                     subResult -> {
829                                         if (subResult.isEmpty()) {
830                                             final Role role = new Role();
831                                             role.setName(name);
832                                             insert(role);
833                                         }
834                                         final List<ModificationItem> modifyList = new ArrayList<>();
835                                         modifyAddEntry(modifyList, "member", userDN);
836                                         modify(fessConfig.getLdapAdminRoleSecurityPrincipal(name), modifyList, adminEnv);
837                                     });
838                         });
839                     } else {
840                         stream(user.getGroupNames()).of(stream -> stream.forEach(name -> {
841                             search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(name), null, adminEnv,
842                                     subResult -> {
843                                         if (subResult.isEmpty()) {
844                                             final Group group = new Group();
845                                             group.setName(name);
846                                             insert(group);
847                                         }
848                                         final List<ModificationItem> modifyList = new ArrayList<>();
849                                         modifyAddEntry(modifyList, "member", userDN);
850                                         modify(fessConfig.getLdapAdminGroupSecurityPrincipal(name), modifyList, adminEnv);
851                                     });
852                         }));
853 
854                         stream(user.getRoleNames()).of(stream -> stream.forEach(name -> {
855                             search(fessConfig.getLdapAdminRoleBaseDn(), fessConfig.getLdapAdminRoleFilter(name), null, adminEnv,
856                                     subResult -> {
857                                         if (subResult.isEmpty()) {
858                                             final Role role = new Role();
859                                             role.setName(name);
860                                             insert(role);
861                                         }
862                                         final List<ModificationItem> modifyList = new ArrayList<>();
863                                         modifyAddEntry(modifyList, "member", userDN);
864                                         modify(fessConfig.getLdapAdminRoleSecurityPrincipal(name), modifyList, adminEnv);
865                                     });
866                         }));
867                     }
868                 });
869 
870     }
871 
872     /**
873      * Modifies user attributes in the LDAP directory.
874      *
875      * @param user the user object with new attribute values
876      * @param adminEnv the supplier for admin environment
877      * @param userDN the DN of the user entry
878      * @param result the search results containing current attributes
879      */
880     protected void modifyUserAttributes(final User user, final Supplier<Hashtable<String, String>> adminEnv, final String userDN,
881             final List<SearchResult> result) {
882         final List<ModificationItem> modifyList = new ArrayList<>();
883         if (user.getOriginalPassword() != null) {
884             modifyReplaceEntry(modifyList, "userPassword", user.getOriginalPassword());
885         }
886 
887         final String attrSurname = fessConfig.getLdapAttrSurname();
888         OptionalUtil.ofNullable(user.getSurname())
889                 .filter(StringUtil::isNotBlank)
890                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrSurname, s))
891                 .orElse(() -> getAttributeValueList(result, attrSurname).stream()
892                         .forEach(v -> modifyDeleteEntry(modifyList, attrSurname, v)));
893         final String attrGivenName = fessConfig.getLdapAttrGivenName();
894         OptionalUtil.ofNullable(user.getGivenName())
895                 .filter(StringUtil::isNotBlank)
896                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrGivenName, s))
897                 .orElse(() -> getAttributeValueList(result, attrGivenName).stream()
898                         .forEach(v -> modifyDeleteEntry(modifyList, attrGivenName, v)));
899         final String attrMail = fessConfig.getLdapAttrMail();
900         OptionalUtil.ofNullable(user.getMail())
901                 .filter(StringUtil::isNotBlank)
902                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrMail, s))
903                 .orElse(() -> getAttributeValueList(result, attrMail).stream().forEach(v -> modifyDeleteEntry(modifyList, attrMail, v)));
904         final String attrEmployeeNumber = fessConfig.getLdapAttrEmployeeNumber();
905         OptionalUtil.ofNullable(user.getEmployeeNumber())
906                 .filter(StringUtil::isNotBlank)
907                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrEmployeeNumber, s))
908                 .orElse(() -> getAttributeValueList(result, attrEmployeeNumber).stream()
909                         .forEach(v -> modifyDeleteEntry(modifyList, attrEmployeeNumber, v)));
910         final String attrTelephoneNumber = fessConfig.getLdapAttrTelephoneNumber();
911         OptionalUtil.ofNullable(user.getTelephoneNumber())
912                 .filter(StringUtil::isNotBlank)
913                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrTelephoneNumber, s))
914                 .orElse(() -> getAttributeValueList(result, attrTelephoneNumber).stream()
915                         .forEach(v -> modifyDeleteEntry(modifyList, attrTelephoneNumber, v)));
916         final String attrHomePhone = fessConfig.getLdapAttrHomePhone();
917         OptionalUtil.ofNullable(user.getHomePhone())
918                 .filter(StringUtil::isNotBlank)
919                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrHomePhone, s))
920                 .orElse(() -> getAttributeValueList(result, attrHomePhone).stream()
921                         .forEach(v -> modifyDeleteEntry(modifyList, attrHomePhone, v)));
922         final String attrHomePostalAddress = fessConfig.getLdapAttrHomePostalAddress();
923         OptionalUtil.ofNullable(user.getHomePostalAddress())
924                 .filter(StringUtil::isNotBlank)
925                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrHomePostalAddress, s))
926                 .orElse(() -> getAttributeValueList(result, attrHomePostalAddress).stream()
927                         .forEach(v -> modifyDeleteEntry(modifyList, attrHomePostalAddress, v)));
928         final String attrLabeledURI = fessConfig.getLdapAttrLabeledURI();
929         OptionalUtil.ofNullable(user.getLabeledURI())
930                 .filter(StringUtil::isNotBlank)
931                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrLabeledURI, s))
932                 .orElse(() -> getAttributeValueList(result, attrLabeledURI).stream()
933                         .forEach(v -> modifyDeleteEntry(modifyList, attrLabeledURI, v)));
934         final String attrRoomNumber = fessConfig.getLdapAttrRoomNumber();
935         OptionalUtil.ofNullable(user.getRoomNumber())
936                 .filter(StringUtil::isNotBlank)
937                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrRoomNumber, s))
938                 .orElse(() -> getAttributeValueList(result, attrRoomNumber).stream()
939                         .forEach(v -> modifyDeleteEntry(modifyList, attrRoomNumber, v)));
940         final String attrDescription = fessConfig.getLdapAttrDescription();
941         OptionalUtil.ofNullable(user.getDescription())
942                 .filter(StringUtil::isNotBlank)
943                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrDescription, s))
944                 .orElse(() -> getAttributeValueList(result, attrDescription).stream()
945                         .forEach(v -> modifyDeleteEntry(modifyList, attrDescription, v)));
946         final String attrTitle = fessConfig.getLdapAttrTitle();
947         OptionalUtil.ofNullable(user.getTitle())
948                 .filter(StringUtil::isNotBlank)
949                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrTitle, s))
950                 .orElse(() -> getAttributeValueList(result, attrTitle).stream().forEach(v -> modifyDeleteEntry(modifyList, attrTitle, v)));
951         final String attrPager = fessConfig.getLdapAttrPager();
952         OptionalUtil.ofNullable(user.getPager())
953                 .filter(StringUtil::isNotBlank)
954                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrPager, s))
955                 .orElse(() -> getAttributeValueList(result, attrPager).stream().forEach(v -> modifyDeleteEntry(modifyList, attrPager, v)));
956         final String attrStreet = fessConfig.getLdapAttrStreet();
957         OptionalUtil.ofNullable(user.getStreet())
958                 .filter(StringUtil::isNotBlank)
959                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrStreet, s))
960                 .orElse(() -> getAttributeValueList(result, attrStreet).stream()
961                         .forEach(v -> modifyDeleteEntry(modifyList, attrStreet, v)));
962         final String attrPostalCode = fessConfig.getLdapAttrPostalCode();
963         OptionalUtil.ofNullable(user.getPostalCode())
964                 .filter(StringUtil::isNotBlank)
965                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrPostalCode, s))
966                 .orElse(() -> getAttributeValueList(result, attrPostalCode).stream()
967                         .forEach(v -> modifyDeleteEntry(modifyList, attrPostalCode, v)));
968         final String attrPhysicalDeliveryOfficeName = fessConfig.getLdapAttrPhysicalDeliveryOfficeName();
969         OptionalUtil.ofNullable(user.getPhysicalDeliveryOfficeName())
970                 .filter(StringUtil::isNotBlank)
971                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrPhysicalDeliveryOfficeName, s))
972                 .orElse(() -> getAttributeValueList(result, attrPhysicalDeliveryOfficeName).stream()
973                         .forEach(v -> modifyDeleteEntry(modifyList, attrPhysicalDeliveryOfficeName, v)));
974         final String attrDestinationIndicator = fessConfig.getLdapAttrDestinationIndicator();
975         OptionalUtil.ofNullable(user.getDestinationIndicator())
976                 .filter(StringUtil::isNotBlank)
977                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrDestinationIndicator, s))
978                 .orElse(() -> getAttributeValueList(result, attrDestinationIndicator).stream()
979                         .forEach(v -> modifyDeleteEntry(modifyList, attrDestinationIndicator, v)));
980         final String attrInternationaliSDNNumber = fessConfig.getLdapAttrInternationaliSDNNumber();
981         OptionalUtil.ofNullable(user.getInternationaliSDNNumber())
982                 .filter(StringUtil::isNotBlank)
983                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrInternationaliSDNNumber, s))
984                 .orElse(() -> getAttributeValueList(result, attrInternationaliSDNNumber).stream()
985                         .forEach(v -> modifyDeleteEntry(modifyList, attrInternationaliSDNNumber, v)));
986         final String attrState = fessConfig.getLdapAttrState();
987         OptionalUtil.ofNullable(user.getState())
988                 .filter(StringUtil::isNotBlank)
989                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrState, s))
990                 .orElse(() -> getAttributeValueList(result, attrState).stream().forEach(v -> modifyDeleteEntry(modifyList, attrState, v)));
991         final String attrEmployeeType = fessConfig.getLdapAttrEmployeeType();
992         OptionalUtil.ofNullable(user.getEmployeeType())
993                 .filter(StringUtil::isNotBlank)
994                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrEmployeeType, s))
995                 .orElse(() -> getAttributeValueList(result, attrEmployeeType).stream()
996                         .forEach(v -> modifyDeleteEntry(modifyList, attrEmployeeType, v)));
997         final String attrFacsimileTelephoneNumber = fessConfig.getLdapAttrFacsimileTelephoneNumber();
998         OptionalUtil.ofNullable(user.getFacsimileTelephoneNumber())
999                 .filter(StringUtil::isNotBlank)
1000                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrFacsimileTelephoneNumber, s))
1001                 .orElse(() -> getAttributeValueList(result, attrFacsimileTelephoneNumber).stream()
1002                         .forEach(v -> modifyDeleteEntry(modifyList, attrFacsimileTelephoneNumber, v)));
1003         final String attrPostOfficeBox = fessConfig.getLdapAttrPostOfficeBox();
1004         OptionalUtil.ofNullable(user.getPostOfficeBox())
1005                 .filter(StringUtil::isNotBlank)
1006                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrPostOfficeBox, s))
1007                 .orElse(() -> getAttributeValueList(result, attrPostOfficeBox).stream()
1008                         .forEach(v -> modifyDeleteEntry(modifyList, attrPostOfficeBox, v)));
1009         final String attrInitials = fessConfig.getLdapAttrInitials();
1010         OptionalUtil.ofNullable(user.getInitials())
1011                 .filter(StringUtil::isNotBlank)
1012                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrInitials, s))
1013                 .orElse(() -> getAttributeValueList(result, attrInitials).stream()
1014                         .forEach(v -> modifyDeleteEntry(modifyList, attrInitials, v)));
1015         final String attrCarLicense = fessConfig.getLdapAttrCarLicense();
1016         OptionalUtil.ofNullable(user.getCarLicense())
1017                 .filter(StringUtil::isNotBlank)
1018                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrCarLicense, s))
1019                 .orElse(() -> getAttributeValueList(result, attrCarLicense).stream()
1020                         .forEach(v -> modifyDeleteEntry(modifyList, attrCarLicense, v)));
1021         final String attrMobile = fessConfig.getLdapAttrMobile();
1022         OptionalUtil.ofNullable(user.getMobile())
1023                 .filter(StringUtil::isNotBlank)
1024                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrMobile, s))
1025                 .orElse(() -> getAttributeValueList(result, attrMobile).stream()
1026                         .forEach(v -> modifyDeleteEntry(modifyList, attrMobile, v)));
1027         final String attrPostalAddress = fessConfig.getLdapAttrPostalAddress();
1028         OptionalUtil.ofNullable(user.getPostalAddress())
1029                 .filter(StringUtil::isNotBlank)
1030                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrPostalAddress, s))
1031                 .orElse(() -> getAttributeValueList(result, attrPostalAddress).stream()
1032                         .forEach(v -> modifyDeleteEntry(modifyList, attrPostalAddress, v)));
1033         final String attrCity = fessConfig.getLdapAttrCity();
1034         OptionalUtil.ofNullable(user.getCity())
1035                 .filter(StringUtil::isNotBlank)
1036                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrCity, s))
1037                 .orElse(() -> getAttributeValueList(result, attrCity).stream().forEach(v -> modifyDeleteEntry(modifyList, attrCity, v)));
1038         final String attrTeletexTerminalIdentifier = fessConfig.getLdapAttrTeletexTerminalIdentifier();
1039         OptionalUtil.ofNullable(user.getTeletexTerminalIdentifier())
1040                 .filter(StringUtil::isNotBlank)
1041                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrTeletexTerminalIdentifier, s))
1042                 .orElse(() -> getAttributeValueList(result, attrTeletexTerminalIdentifier).stream()
1043                         .forEach(v -> modifyDeleteEntry(modifyList, attrTeletexTerminalIdentifier, v)));
1044         final String attrX121Address = fessConfig.getLdapAttrX121Address();
1045         OptionalUtil.ofNullable(user.getX121Address())
1046                 .filter(StringUtil::isNotBlank)
1047                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrX121Address, s))
1048                 .orElse(() -> getAttributeValueList(result, attrX121Address).stream()
1049                         .forEach(v -> modifyDeleteEntry(modifyList, attrX121Address, v)));
1050         final String attrBusinessCategory = fessConfig.getLdapAttrBusinessCategory();
1051         OptionalUtil.ofNullable(user.getBusinessCategory())
1052                 .filter(StringUtil::isNotBlank)
1053                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrBusinessCategory, s))
1054                 .orElse(() -> getAttributeValueList(result, attrBusinessCategory).stream()
1055                         .forEach(v -> modifyDeleteEntry(modifyList, attrBusinessCategory, v)));
1056         final String attrRegisteredAddress = fessConfig.getLdapAttrRegisteredAddress();
1057         OptionalUtil.ofNullable(user.getRegisteredAddress())
1058                 .filter(StringUtil::isNotBlank)
1059                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrRegisteredAddress, s))
1060                 .orElse(() -> getAttributeValueList(result, attrRegisteredAddress).stream()
1061                         .forEach(v -> modifyDeleteEntry(modifyList, attrRegisteredAddress, v)));
1062         final String attrDisplayName = fessConfig.getLdapAttrDisplayName();
1063         OptionalUtil.ofNullable(user.getDisplayName())
1064                 .filter(StringUtil::isNotBlank)
1065                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrDisplayName, s))
1066                 .orElse(() -> getAttributeValueList(result, attrDisplayName).stream()
1067                         .forEach(v -> modifyDeleteEntry(modifyList, attrDisplayName, v)));
1068         final String attrPreferredLanguage = fessConfig.getLdapAttrPreferredLanguage();
1069         OptionalUtil.ofNullable(user.getPreferredLanguage())
1070                 .filter(StringUtil::isNotBlank)
1071                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrPreferredLanguage, s))
1072                 .orElse(() -> getAttributeValueList(result, attrPreferredLanguage).stream()
1073                         .forEach(v -> modifyDeleteEntry(modifyList, attrPreferredLanguage, v)));
1074         final String attrDepartmentNumber = fessConfig.getLdapAttrDepartmentNumber();
1075         OptionalUtil.ofNullable(user.getDepartmentNumber())
1076                 .filter(StringUtil::isNotBlank)
1077                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrDepartmentNumber, s))
1078                 .orElse(() -> getAttributeValueList(result, attrDepartmentNumber).stream()
1079                         .forEach(v -> modifyDeleteEntry(modifyList, attrDepartmentNumber, v)));
1080         final String attrUidNumber = fessConfig.getLdapAttrUidNumber();
1081         OptionalUtil.ofNullable(user.getUidNumber())
1082                 .filter(s -> StringUtil.isNotBlank(s.toString()))
1083                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrUidNumber, s.toString()))
1084                 .orElse(() -> getAttributeValueList(result, attrUidNumber).stream()
1085                         .forEach(v -> modifyDeleteEntry(modifyList, attrUidNumber, v)));
1086         final String attrGidNumber = fessConfig.getLdapAttrGidNumber();
1087         OptionalUtil.ofNullable(user.getGidNumber())
1088                 .filter(s -> StringUtil.isNotBlank(s.toString()))
1089                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrGidNumber, s.toString()))
1090                 .orElse(() -> getAttributeValueList(result, attrGidNumber).stream()
1091                         .forEach(v -> modifyDeleteEntry(modifyList, attrGidNumber, v)));
1092         final String attrHomeDirectory = fessConfig.getLdapAttrHomeDirectory();
1093         OptionalUtil.ofNullable(user.getHomeDirectory())
1094                 .filter(StringUtil::isNotBlank)
1095                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrHomeDirectory, s))
1096                 .orElse(() -> getAttributeValueList(result, attrHomeDirectory).stream()
1097                         .forEach(v -> modifyDeleteEntry(modifyList, attrHomeDirectory, v)));
1098 
1099         modify(userDN, modifyList, adminEnv);
1100     }
1101 
1102     /**
1103      * Adds user attributes to the LDAP entry for user creation.
1104      *
1105      * @param entry the BasicAttributes to add user attributes to
1106      * @param user the user object containing attribute values
1107      */
1108     protected void addUserAttributes(final BasicAttributes entry, final User user) {
1109         entry.put(new BasicAttribute("cn", user.getName()));
1110         entry.put(new BasicAttribute("userPassword", user.getOriginalPassword()));
1111 
1112         OptionalUtil.ofNullable(user.getSurname())
1113                 .filter(StringUtil::isNotBlank)
1114                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrSurname(), s)));
1115         OptionalUtil.ofNullable(user.getGivenName())
1116                 .filter(StringUtil::isNotBlank)
1117                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrGivenName(), s)));
1118         OptionalUtil.ofNullable(user.getMail())
1119                 .filter(StringUtil::isNotBlank)
1120                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrMail(), s)));
1121         OptionalUtil.ofNullable(user.getEmployeeNumber())
1122                 .filter(StringUtil::isNotBlank)
1123                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrEmployeeNumber(), s)));
1124         OptionalUtil.ofNullable(user.getTelephoneNumber())
1125                 .filter(StringUtil::isNotBlank)
1126                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrTelephoneNumber(), s)));
1127         OptionalUtil.ofNullable(user.getHomePhone())
1128                 .filter(StringUtil::isNotBlank)
1129                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrHomePhone(), s)));
1130         OptionalUtil.ofNullable(user.getHomePostalAddress())
1131                 .filter(StringUtil::isNotBlank)
1132                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrHomePostalAddress(), s)));
1133         OptionalUtil.ofNullable(user.getLabeledURI())
1134                 .filter(StringUtil::isNotBlank)
1135                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrLabeledURI(), s)));
1136         OptionalUtil.ofNullable(user.getRoomNumber())
1137                 .filter(StringUtil::isNotBlank)
1138                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrRoomNumber(), s)));
1139         OptionalUtil.ofNullable(user.getDescription())
1140                 .filter(StringUtil::isNotBlank)
1141                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrDescription(), s)));
1142         OptionalUtil.ofNullable(user.getTitle())
1143                 .filter(StringUtil::isNotBlank)
1144                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrTitle(), s)));
1145         OptionalUtil.ofNullable(user.getPager())
1146                 .filter(StringUtil::isNotBlank)
1147                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrPager(), s)));
1148         OptionalUtil.ofNullable(user.getStreet())
1149                 .filter(StringUtil::isNotBlank)
1150                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrStreet(), s)));
1151         OptionalUtil.ofNullable(user.getPostalCode())
1152                 .filter(StringUtil::isNotBlank)
1153                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrPostalCode(), s)));
1154         OptionalUtil.ofNullable(user.getPhysicalDeliveryOfficeName())
1155                 .filter(StringUtil::isNotBlank)
1156                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrPhysicalDeliveryOfficeName(), s)));
1157         OptionalUtil.ofNullable(user.getDestinationIndicator())
1158                 .filter(StringUtil::isNotBlank)
1159                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrDestinationIndicator(), s)));
1160         OptionalUtil.ofNullable(user.getInternationaliSDNNumber())
1161                 .filter(StringUtil::isNotBlank)
1162                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrInternationaliSDNNumber(), s)));
1163         OptionalUtil.ofNullable(user.getState())
1164                 .filter(StringUtil::isNotBlank)
1165                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrState(), s)));
1166         OptionalUtil.ofNullable(user.getEmployeeType())
1167                 .filter(StringUtil::isNotBlank)
1168                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrEmployeeType(), s)));
1169         OptionalUtil.ofNullable(user.getFacsimileTelephoneNumber())
1170                 .filter(StringUtil::isNotBlank)
1171                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrFacsimileTelephoneNumber(), s)));
1172         OptionalUtil.ofNullable(user.getPostOfficeBox())
1173                 .filter(StringUtil::isNotBlank)
1174                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrPostOfficeBox(), s)));
1175         OptionalUtil.ofNullable(user.getInitials())
1176                 .filter(StringUtil::isNotBlank)
1177                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrInitials(), s)));
1178         OptionalUtil.ofNullable(user.getCarLicense())
1179                 .filter(StringUtil::isNotBlank)
1180                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrCarLicense(), s)));
1181         OptionalUtil.ofNullable(user.getMobile())
1182                 .filter(StringUtil::isNotBlank)
1183                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrMobile(), s)));
1184         OptionalUtil.ofNullable(user.getPostalAddress())
1185                 .filter(StringUtil::isNotBlank)
1186                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrPostalAddress(), s)));
1187         OptionalUtil.ofNullable(user.getCity())
1188                 .filter(StringUtil::isNotBlank)
1189                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrCity(), s)));
1190         OptionalUtil.ofNullable(user.getTeletexTerminalIdentifier())
1191                 .filter(StringUtil::isNotBlank)
1192                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrTeletexTerminalIdentifier(), s)));
1193         OptionalUtil.ofNullable(user.getX121Address())
1194                 .filter(StringUtil::isNotBlank)
1195                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrX121Address(), s)));
1196         OptionalUtil.ofNullable(user.getBusinessCategory())
1197                 .filter(StringUtil::isNotBlank)
1198                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrBusinessCategory(), s)));
1199         OptionalUtil.ofNullable(user.getRegisteredAddress())
1200                 .filter(StringUtil::isNotBlank)
1201                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrRegisteredAddress(), s)));
1202         OptionalUtil.ofNullable(user.getDisplayName())
1203                 .filter(StringUtil::isNotBlank)
1204                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrDisplayName(), s)));
1205         OptionalUtil.ofNullable(user.getPreferredLanguage())
1206                 .filter(StringUtil::isNotBlank)
1207                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrPreferredLanguage(), s)));
1208         OptionalUtil.ofNullable(user.getDepartmentNumber())
1209                 .filter(StringUtil::isNotBlank)
1210                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrDepartmentNumber(), s)));
1211         OptionalUtil.ofNullable(user.getUidNumber())
1212                 .filter(s -> StringUtil.isNotBlank(s.toString()))
1213                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrUidNumber(), s)));
1214         OptionalUtil.ofNullable(user.getGidNumber())
1215                 .filter(s -> StringUtil.isNotBlank(s.toString()))
1216                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrGidNumber(), s)));
1217         OptionalUtil.ofNullable(user.getHomeDirectory())
1218                 .filter(StringUtil::isNotBlank)
1219                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrHomeDirectory(), s)));
1220     }
1221 
1222     /**
1223      * Validates user attributes for the specified type.
1224      *
1225      * @param type the class type to validate for
1226      * @param attributes the map of attribute names to values
1227      * @param consumer the consumer to handle validation errors
1228      */
1229     public void validateUserAttributes(final Class<?> type, final Map<String, String> attributes, final Consumer<String> consumer) {
1230         if (type == Long.class) {
1231             // Long type attributes
1232             final String attrUidNumber = fessConfig.getLdapAttrUidNumber();
1233             final String attrGidNumber = fessConfig.getLdapAttrGidNumber();
1234 
1235             Stream.of(attrUidNumber, attrGidNumber)
1236                     .forEach(attrName -> OptionalUtil.ofNullable(attributes.get(attrName)).filter(StringUtil::isNotBlank).ifPresent(s -> {
1237                         try {
1238                             DfTypeUtil.toLong(s);
1239                         } catch (final NumberFormatException e) {
1240                             consumer.accept(attrName);
1241                         }
1242                     }));
1243         } else {
1244             // do nothing
1245         }
1246     }
1247 
1248     /**
1249      * Deletes a user from the LDAP directory.
1250      *
1251      * @param user the user object to delete
1252      */
1253     public void delete(final User user) {
1254         if (!fessConfig.isLdapAdminEnabled(user.getName())) {
1255             return;
1256         }
1257 
1258         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1259         final String userDN = fessConfig.getLdapAdminUserSecurityPrincipal(user.getName());
1260 
1261         stream(user.getGroupNames()).of(stream -> stream.forEach(name -> {
1262             search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(name), null, adminEnv, subResult -> {
1263                 if (subResult.isEmpty()) {
1264                     final Group group = new Group();
1265                     group.setName(name);
1266                     insert(group);
1267                 }
1268                 final List<ModificationItem> modifyList = new ArrayList<>();
1269                 modifyDeleteEntry(modifyList, "member", userDN);
1270                 modify(fessConfig.getLdapAdminGroupSecurityPrincipal(name), modifyList, adminEnv);
1271             });
1272         }));
1273         stream(user.getRoleNames()).of(stream -> stream.forEach(name -> {
1274             search(fessConfig.getLdapAdminRoleBaseDn(), fessConfig.getLdapAdminRoleFilter(name), null, adminEnv, subResult -> {
1275                 if (subResult.isEmpty()) {
1276                     final Role role = new Role();
1277                     role.setName(name);
1278                     insert(role);
1279                 }
1280                 final List<ModificationItem> modifyList = new ArrayList<>();
1281                 modifyDeleteEntry(modifyList, "member", userDN);
1282                 modify(fessConfig.getLdapAdminRoleSecurityPrincipal(name), modifyList, adminEnv);
1283             });
1284         }));
1285 
1286         search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(user.getName()), null, adminEnv, result -> {
1287             if (!result.isEmpty()) {
1288                 delete(userDN, adminEnv);
1289             } else {
1290                 logger.info("User does not exist in LDAP server: name={}", user.getName());
1291             }
1292         });
1293 
1294     }
1295 
1296     /**
1297      * Inserts or updates a role in the LDAP directory.
1298      *
1299      * @param role the role object to insert or update
1300      */
1301     public void insert(final Role role) {
1302         if (!fessConfig.isLdapAdminEnabled()) {
1303             return;
1304         }
1305 
1306         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1307         search(fessConfig.getLdapAdminRoleBaseDn(), fessConfig.getLdapAdminRoleFilter(role.getName()), null, adminEnv, result -> {
1308             if (!result.isEmpty()) {
1309                 logger.info("Role already exists in LDAP server: name={}", role.getName());
1310             } else {
1311                 final String entryDN = fessConfig.getLdapAdminRoleSecurityPrincipal(role.getName());
1312                 final BasicAttributes entry = new BasicAttributes();
1313                 addRoleAttributes(entry, role);
1314                 final Attribute oc = fessConfig.getLdapAdminRoleObjectClassAttribute();
1315                 entry.put(oc);
1316                 insert(entryDN, entry, adminEnv);
1317             }
1318         });
1319 
1320     }
1321 
1322     /**
1323      * Adds role attributes to the LDAP entry for role creation.
1324      *
1325      * @param entry the BasicAttributes to add role attributes to
1326      * @param user the role object containing attribute values
1327      */
1328     protected void addRoleAttributes(final BasicAttributes entry, final Role user) {
1329         // nothing
1330     }
1331 
1332     /**
1333      * Deletes a role from the LDAP directory.
1334      *
1335      * @param role the role object to delete
1336      */
1337     public void delete(final Role role) {
1338         if (!fessConfig.isLdapAdminEnabled()) {
1339             return;
1340         }
1341 
1342         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1343         search(fessConfig.getLdapAdminRoleBaseDn(), fessConfig.getLdapAdminRoleFilter(role.getName()), null, adminEnv, result -> {
1344             if (!result.isEmpty()) {
1345                 final String entryDN = fessConfig.getLdapAdminRoleSecurityPrincipal(role.getName());
1346                 delete(entryDN, adminEnv);
1347             } else {
1348                 logger.info("Role does not exist in LDAP server: name={}", role.getName());
1349             }
1350         });
1351 
1352     }
1353 
1354     /**
1355      * Applies LDAP attributes to a group object.
1356      *
1357      * @param group the group object to populate with LDAP attributes
1358      */
1359     public void apply(final Group group) {
1360         if (!fessConfig.isLdapAdminEnabled()) {
1361             return;
1362         }
1363 
1364         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1365         search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(group.getName()), null, adminEnv, result -> {
1366             if (!result.isEmpty()) {
1367                 setAttributeValue(result, fessConfig.getLdapAttrGidNumber(), o -> group.setGidNumber(DfTypeUtil.toLong(o)));
1368             }
1369         });
1370     }
1371 
1372     /**
1373      * Inserts or updates a group in the LDAP directory.
1374      *
1375      * @param group the group object to insert or update
1376      */
1377     public void insert(final Group group) {
1378         if (!fessConfig.isLdapAdminEnabled()) {
1379             return;
1380         }
1381 
1382         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1383         final String entryDN = fessConfig.getLdapAdminGroupSecurityPrincipal(group.getName());
1384         search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(group.getName()), null, adminEnv, result -> {
1385             if (!result.isEmpty()) {
1386                 logger.info("Group already exists in LDAP server: name={}", group.getName());
1387                 modifyGroupAttributes(group, adminEnv, entryDN, result);
1388             } else {
1389                 final BasicAttributes entry = new BasicAttributes();
1390                 addGroupAttributes(entry, group);
1391                 final Attribute oc = fessConfig.getLdapAdminGroupObjectClassAttribute();
1392                 entry.put(oc);
1393                 insert(entryDN, entry, adminEnv);
1394             }
1395         });
1396     }
1397 
1398     /**
1399      * Modifies group attributes in the LDAP directory.
1400      *
1401      * @param group the group object with new attribute values
1402      * @param adminEnv the supplier for admin environment
1403      * @param entryDN the DN of the group entry
1404      * @param result the search results containing current attributes
1405      */
1406     protected void modifyGroupAttributes(final Group group, final Supplier<Hashtable<String, String>> adminEnv, final String entryDN,
1407             final List<SearchResult> result) {
1408         final List<ModificationItem> modifyList = new ArrayList<>();
1409 
1410         final String attrGidNumber = fessConfig.getLdapAttrGidNumber();
1411         OptionalUtil.ofNullable(group.getGidNumber())
1412                 .filter(s -> StringUtil.isNotBlank(s.toString()))
1413                 .ifPresent(s -> modifyReplaceEntry(modifyList, attrGidNumber, s.toString()))
1414                 .orElse(() -> getAttributeValueList(result, attrGidNumber).stream()
1415                         .forEach(v -> modifyDeleteEntry(modifyList, attrGidNumber, v)));
1416 
1417         modify(entryDN, modifyList, adminEnv);
1418     }
1419 
1420     /**
1421      * Adds group attributes to the LDAP entry for group creation.
1422      *
1423      * @param entry the BasicAttributes to add group attributes to
1424      * @param group the group object containing attribute values
1425      */
1426     protected void addGroupAttributes(final BasicAttributes entry, final Group group) {
1427         OptionalUtil.ofNullable(group.getGidNumber())
1428                 .filter(s -> StringUtil.isNotBlank(s.toString()))
1429                 .ifPresent(s -> entry.put(new BasicAttribute(fessConfig.getLdapAttrGidNumber(), s)));
1430     }
1431 
1432     /**
1433      * Validates group attributes for the specified type.
1434      *
1435      * @param type the class type to validate for
1436      * @param attributes the map of attribute names to values
1437      * @param consumer the consumer to handle validation errors
1438      */
1439     public void validateGroupAttributes(final Class<?> type, final Map<String, String> attributes, final Consumer<String> consumer) {
1440         if (type == Long.class) {
1441             // Long type attributes
1442             final String attrGidNumber = fessConfig.getLdapAttrGidNumber();
1443 
1444             Stream.of(attrGidNumber)
1445                     .forEach(attrName -> OptionalUtil.ofNullable(attributes.get(attrName)).filter(StringUtil::isNotBlank).ifPresent(s -> {
1446                         try {
1447                             DfTypeUtil.toLong(s);
1448                         } catch (final NumberFormatException e) {
1449                             consumer.accept(attrName);
1450                         }
1451                     }));
1452         } else {
1453             // do nothing
1454         }
1455     }
1456 
1457     /**
1458      * Deletes a group from the LDAP directory.
1459      *
1460      * @param group the group object to delete
1461      */
1462     public void delete(final Group group) {
1463         if (!fessConfig.isLdapAdminEnabled()) {
1464             return;
1465         }
1466 
1467         final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1468         search(fessConfig.getLdapAdminGroupBaseDn(), fessConfig.getLdapAdminGroupFilter(group.getName()), null, adminEnv, result -> {
1469             if (!result.isEmpty()) {
1470                 final String entryDN = fessConfig.getLdapAdminGroupSecurityPrincipal(group.getName());
1471                 delete(entryDN, adminEnv);
1472             } else {
1473                 logger.info("Group does not exist in LDAP server: name={}", group.getName());
1474             }
1475         });
1476     }
1477 
1478     /**
1479      * Changes the password for a user in the LDAP directory.
1480      *
1481      * <p>This method performs the following validations:
1482      * <ul>
1483      * <li>Checks if username and password are not blank</li>
1484      * <li>Verifies LDAP admin is enabled for the user</li>
1485      * <li>Confirms the user exists in LDAP directory</li>
1486      * </ul>
1487      *
1488      * @param username the username of the user (must not be null or blank)
1489      * @param password the new password (must not be null or blank)
1490      * @return true if the password was changed successfully, false otherwise
1491      * @throws LdapOperationException if the user is not found in LDAP
1492      */
1493     public boolean changePassword(final String username, final String password) {
1494         // Add defensive null/blank checks
1495         if (StringUtil.isBlank(username) || StringUtil.isBlank(password)) {
1496             logger.warn("Cannot change password: username or password is blank");
1497             return false;
1498         }
1499 
1500         if (!fessConfig.isLdapAdminEnabled(username)) {
1501             if (logger.isDebugEnabled()) {
1502                 logger.debug("LDAP admin not enabled for user: {}", username);
1503             }
1504             return false;
1505         }
1506 
1507         try {
1508             final Supplier<Hashtable<String, String>> adminEnv = this::createAdminEnv;
1509             final String userDN = fessConfig.getLdapAdminUserSecurityPrincipal(username);
1510             search(fessConfig.getLdapAdminUserBaseDn(), fessConfig.getLdapAdminUserFilter(username), null, adminEnv, result -> {
1511                 if (result.isEmpty()) {
1512                     throw new LdapOperationException("User is not found: " + username);
1513                 }
1514                 final List<ModificationItem> modifyList = new ArrayList<>();
1515                 modifyReplaceEntry(modifyList, "userPassword", password);
1516                 modify(userDN, modifyList, adminEnv);
1517             });
1518             return true;
1519         } catch (final LdapOperationException e) {
1520             logger.warn("Failed to change password for user: {}", username, e);
1521             throw e;
1522         } catch (final Exception e) {
1523             logger.warn("Unexpected error while changing password for user: {}", username, e);
1524             return false;
1525         }
1526     }
1527 
1528     /**
1529      * Inserts a new entry into the LDAP directory.
1530      *
1531      * @param entryDN the DN of the entry to insert
1532      * @param entry the attributes of the entry
1533      * @param envSupplier the supplier for environment configuration
1534      */
1535     protected void insert(final String entryDN, final Attributes entry, final Supplier<Hashtable<String, String>> envSupplier) {
1536         try (DirContextHolder holder = getDirContext(envSupplier)) {
1537             logger.debug("Inserting LDAP entry: dn={}", entryDN);
1538             holder.get().createSubcontext(entryDN, entry);
1539         } catch (final NamingException e) {
1540             throw new LdapOperationException("Failed to add " + entryDN, e);
1541         }
1542     }
1543 
1544     /**
1545      * Deletes an entry from the LDAP directory.
1546      *
1547      * @param entryDN the DN of the entry to delete
1548      * @param envSupplier the supplier for environment configuration
1549      */
1550     protected void delete(final String entryDN, final Supplier<Hashtable<String, String>> envSupplier) {
1551         try (DirContextHolder holder = getDirContext(envSupplier)) {
1552             logger.debug("Deleting LDAP entry: dn={}", entryDN);
1553             holder.get().destroySubcontext(entryDN);
1554         } catch (final NamingException e) {
1555             throw new LdapOperationException("Failed to delete " + entryDN, e);
1556         }
1557     }
1558 
1559     /**
1560      * Searches the LDAP directory with the specified parameters.
1561      *
1562      * @param baseDn the base DN for the search
1563      * @param filter the search filter
1564      * @param returningAttrs the attributes to return from the search
1565      * @param envSupplier the supplier for environment configuration
1566      * @param consumer the consumer to handle search results
1567      */
1568     protected void search(final String baseDn, final String filter, final String[] returningAttrs,
1569             final Supplier<Hashtable<String, String>> envSupplier, final SearchConsumer consumer) {
1570         try (DirContextHolder holder = getDirContext(envSupplier)) {
1571             final SearchControls controls = new SearchControls();
1572             controls.setSearchScope(SearchControls.SUBTREE_SCOPE);
1573             if (returningAttrs != null) {
1574                 controls.setReturningAttributes(returningAttrs);
1575             }
1576 
1577             final SystemHelper systemHelper = ComponentUtil.getSystemHelper();
1578             final long startTime = systemHelper.getCurrentTimeAsLong();
1579             final List<SearchResult> list = Collections.list(holder.get().search(baseDn, filter, controls));
1580             if (logger.isDebugEnabled()) {
1581                 logger.debug("LDAP search completed: time={}ms, baseDn={}, filter={}", systemHelper.getCurrentTimeAsLong() - startTime,
1582                         baseDn, filter);
1583             }
1584             consumer.accept(list);
1585         } catch (final NamingException e) {
1586             throw new LdapOperationException("Failed to search " + baseDn + " with " + filter, e);
1587         }
1588     }
1589 
1590     /**
1591      * Modifies an entry by adding a new attribute.
1592      *
1593      * @param modifyList The list of modification items.
1594      * @param name The name of the attribute.
1595      * @param value The value of the attribute.
1596      */
1597     protected void modifyAddEntry(final List<ModificationItem> modifyList, final String name, final String value) {
1598         final Attribute attr = new BasicAttribute(name, value);
1599         final ModificationItem mod = new ModificationItem(DirContext.ADD_ATTRIBUTE, attr);
1600         modifyList.add(mod);
1601     }
1602 
1603     /**
1604      * Modifies an entry by replacing an attribute.
1605      *
1606      * @param modifyList The list of modification items.
1607      * @param name The name of the attribute.
1608      * @param value The value of the attribute.
1609      */
1610     protected void modifyReplaceEntry(final List<ModificationItem> modifyList, final String name, final String value) {
1611         final Attribute attr = new BasicAttribute(name, value);
1612         final ModificationItem mod = new ModificationItem(DirContext.REPLACE_ATTRIBUTE, attr);
1613         modifyList.add(mod);
1614     }
1615 
1616     /**
1617      * Modifies an entry by deleting an attribute.
1618      *
1619      * @param modifyList The list of modification items.
1620      * @param name The name of the attribute.
1621      * @param value The value of the attribute.
1622      */
1623     protected void modifyDeleteEntry(final List<ModificationItem> modifyList, final String name, final Object value) {
1624         final Attribute attr = new BasicAttribute(name, value);
1625         final ModificationItem mod = new ModificationItem(DirContext.REMOVE_ATTRIBUTE, attr);
1626         modifyList.add(mod);
1627     }
1628 
1629     /**
1630      * Modifies an entry.
1631      *
1632      * @param dn The DN of the entry.
1633      * @param modifyList The list of modification items.
1634      * @param envSupplier The environment supplier.
1635      */
1636     protected void modify(final String dn, final List<ModificationItem> modifyList, final Supplier<Hashtable<String, String>> envSupplier) {
1637         if (modifyList.isEmpty()) {
1638             return;
1639         }
1640         try (DirContextHolder holder = getDirContext(envSupplier)) {
1641             holder.get().modifyAttributes(dn, modifyList.toArray(new ModificationItem[modifyList.size()]));
1642         } catch (final NamingException e) {
1643             throw new LdapOperationException("Failed to search " + dn, e);
1644         }
1645     }
1646 
1647     /**
1648      * An interface for consuming search results.
1649      */
1650     interface SearchConsumer {
1651         /**
1652          * Accepts a list of search results.
1653          *
1654          * @param t The list of search results.
1655          * @throws NamingException If a naming exception occurs.
1656          */
1657         void accept(List<SearchResult> t) throws NamingException;
1658     }
1659 
1660     /**
1661      * Gets the directory context.
1662      *
1663      * @param envSupplier The environment supplier.
1664      * @return The directory context holder.
1665      */
1666     protected DirContextHolder getDirContext(final Supplier<Hashtable<String, String>> envSupplier) {
1667         DirContextHolder holder = contextLocal.get();
1668         if (holder != null) {
1669             holder.inc();
1670             return holder;
1671         }
1672         final Hashtable<String, String> env = envSupplier.get();
1673         try {
1674             holder = new DirContextHolder(new InitialDirContext(env));
1675             contextLocal.set(holder);
1676             return holder;
1677         } catch (final NamingException e) {
1678             throw new LdapOperationException("Failed to create DirContext.", e);
1679         }
1680     }
1681 
1682     /**
1683      * A holder for the directory context.
1684      */
1685     protected class DirContextHolder implements AutoCloseable {
1686         private final DirContext context;
1687 
1688         private int counter = 1;
1689 
1690         /**
1691          * Constructs a new directory context holder.
1692          *
1693          * @param context The directory context.
1694          */
1695         protected DirContextHolder(final DirContext context) {
1696             this.context = context;
1697         }
1698 
1699         /**
1700          * Returns the directory context.
1701          *
1702          * @return The directory context.
1703          */
1704         public DirContext get() {
1705             return context;
1706         }
1707 
1708         /**
1709          * Increments the counter.
1710          */
1711         public void inc() {
1712             counter++;
1713         }
1714 
1715         @Override
1716         public void close() {
1717             if (counter > 1) {
1718                 counter--;
1719             } else {
1720                 try {
1721                     if (context != null) {
1722                         try {
1723                             context.close();
1724                         } catch (final NamingException e) {
1725                             if (logger.isDebugEnabled()) {
1726                                 logger.debug("Failed to close LDAP context", e);
1727                             }
1728                         }
1729                     }
1730                 } finally {
1731                     // Ensure ThreadLocal is always cleaned up, even if context.close() fails
1732                     contextLocal.remove();
1733                 }
1734             }
1735         }
1736     }
1737 }