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.app.service;
17  
18  import java.util.List;
19  
20  import org.apache.logging.log4j.LogManager;
21  import org.apache.logging.log4j.Logger;
22  import org.codelibs.core.beans.util.BeanUtil;
23  import org.codelibs.core.lang.StringUtil;
24  import org.codelibs.fess.Constants;
25  import org.codelibs.fess.app.pager.UserPager;
26  import org.codelibs.fess.exception.FessUserNotFoundException;
27  import org.codelibs.fess.mylasta.direction.FessConfig;
28  import org.codelibs.fess.opensearch.user.cbean.UserCB;
29  import org.codelibs.fess.opensearch.user.exbhv.UserBhv;
30  import org.codelibs.fess.opensearch.user.exentity.User;
31  import org.codelibs.fess.util.ComponentUtil;
32  import org.dbflute.cbean.result.PagingResultBean;
33  import org.dbflute.optional.OptionalEntity;
34  
35  import jakarta.annotation.Resource;
36  
37  /**
38   * Service class for managing user operations in the Fess search system.
39   * This service provides CRUD operations for user management, including user authentication,
40   * password management, and user listing with pagination support.
41   *
42   */
43  public class UserService {
44  
45      private static final Logger logger = LogManager.getLogger(UserService.class);
46  
47      /**
48       * Default constructor for UserService.
49       */
50      public UserService() {
51          // Default constructor
52      }
53  
54      /** User behavior for database operations */
55      @Resource
56      protected UserBhv userBhv;
57  
58      /** Fess configuration for system settings */
59      @Resource
60      protected FessConfig fessConfig;
61  
62      /**
63       * Retrieves a paginated list of users based on the provided pager criteria.
64       * Updates the pager with pagination information including total count and page navigation.
65       *
66       * @param userPager the pager containing search criteria and pagination settings
67       * @return a list of users matching the criteria
68       */
69      public List<User> getUserList(final UserPager userPager) {
70  
71          final PagingResultBean<User> userList = userBhv.selectPage(cb -> {
72              cb.paging(userPager.getPageSize(), userPager.getCurrentPageNumber());
73              setupListCondition(cb, userPager);
74          });
75  
76          // update pager
77          BeanUtil.copyBeanToBean(userList, userPager, option -> option.include(Constants.PAGER_CONVERSION_RULE));
78          userPager.setPageNumberList(userList.pageRange(op -> {
79              op.rangeSize(fessConfig.getPagingPageRangeSizeAsInteger());
80          }).createPageNumberList());
81  
82          return userList;
83      }
84  
85      /**
86       * Retrieves a user by their unique identifier.
87       * Loads the user through the authentication manager for complete user data.
88       *
89       * @param id the unique identifier of the user
90       * @return an OptionalEntity containing the user if found
91       */
92      public OptionalEntity<User> getUser(final String id) {
93          return userBhv.selectByPK(id).map(u -> ComponentUtil.getAuthenticationManager().load(u));
94      }
95  
96      /**
97       * Retrieves a user by their username.
98       *
99       * @param username the username to search for
100      * @return an OptionalEntity containing the user if found
101      */
102     public OptionalEntity<User> getUserByName(final String username) {
103         return userBhv.selectEntity(cb -> {
104             cb.query().setName_Equal(username);
105         });
106     }
107 
108     /**
109      * Stores (inserts or updates) a user in the system.
110      * Handles user authentication setup and database persistence.
111      * If the surname is blank, it will be set to the user's name.
112      *
113      * @param user the user entity to store
114      */
115     public void store(final User user) {
116         final String username = user.getName();
117         final boolean isUpdate = StringUtil.isNotBlank(user.getId());
118 
119         if (logger.isDebugEnabled()) {
120             logger.debug("User {} operation initiated: username={}, id={}", isUpdate ? "update" : "create", username,
121                     user.getId() != null ? user.getId() : "new");
122         }
123 
124         try {
125             if (StringUtil.isBlank(user.getSurname())) {
126                 user.setSurname(user.getName());
127             }
128 
129             ComponentUtil.getAuthenticationManager().insert(user);
130 
131             userBhv.insertOrUpdate(user, op -> {
132                 op.setRefreshPolicy(Constants.TRUE);
133             });
134 
135             if (logger.isInfoEnabled()) {
136                 logger.info("User {} completed successfully: username={}, id={}", isUpdate ? "update" : "create", username, user.getId());
137             }
138         } catch (final Exception e) {
139             logger.warn("Failed to {} user: username={}, id={}, error={}", isUpdate ? "update" : "create", username, user.getId(),
140                     e.getMessage(), e);
141             throw e;
142         } finally {
143             user.clearOriginalPassword();
144         }
145     }
146 
147     /**
148      * Changes the password for a user identified by username.
149      * Updates both the authentication manager and the database with the new encrypted password.
150      *
151      * @param username the username of the user
152      * @param password the new password in plain text
153      * @throws FessUserNotFoundException if the user is not found
154      */
155     public void changePassword(final String username, final String password) {
156         if (logger.isDebugEnabled()) {
157             logger.debug("Password change initiated for user: username={}", username);
158         }
159 
160         try {
161             final boolean changed = ComponentUtil.getAuthenticationManager().changePassword(username, password);
162             if (changed) {
163                 userBhv.selectEntity(cb -> cb.query().setName_Equal(username)).ifPresent(entity -> {
164                     final String encodedPassword = ComponentUtil.getPasswordHashHelper().encode(password);
165                     entity.setPassword(encodedPassword);
166                     userBhv.insertOrUpdate(entity, op -> op.setRefreshPolicy(Constants.TRUE));
167 
168                     if (logger.isInfoEnabled()) {
169                         logger.info("Password changed successfully for user: username={}, id={}", username, entity.getId());
170                     }
171                 }).orElse(() -> {
172                     if (logger.isDebugEnabled()) {
173                         logger.debug("Failed to change password - user not found: username={}", username);
174                     }
175                     throw new FessUserNotFoundException(username);
176                 });
177             } else {
178                 logger.warn("Password change not applied by authentication manager: username={}", username);
179             }
180         } catch (final FessUserNotFoundException e) {
181             throw e;
182         } catch (final Exception e) {
183             logger.warn("Failed to change password for user: username={}, error={}", username, e.getMessage(), e);
184             throw e;
185         }
186     }
187 
188     /**
189      * Updates the stored password hash directly, bypassing AuthenticationManager chain.
190      * Used by lazy re-hashing on successful login when the current stored hash
191      * is in a legacy format. The input value MUST already be an encoded hash
192      * (e.g., "{bcrypt}$2a$10$...") - this method does NOT hash it again.
193      *
194      * Does NOT propagate to LDAP/SSO backends because we do not have the plaintext
195      * (and LDAP manages its own credential).
196      *
197      * <p>This overload performs an unconditional update. Prefer
198      * {@link #updateStoredPasswordHash(String, String, String)} when a race with
199      * concurrent password changes must be avoided.</p>
200      *
201      * @param username target user (must exist)
202      * @param encodedPassword already-hashed password value
203      * @return true if updated; false if user not found or update failed
204      */
205     public boolean updateStoredPasswordHash(final String username, final String encodedPassword) {
206         return updateStoredPasswordHash(username, null, encodedPassword);
207     }
208 
209     /**
210      * Updates the stored password hash with an optional compare-and-set guard.
211      * When {@code expectedCurrentHash} is non-null, the update is only applied
212      * if the latest stored value equals that expected hash, mitigating a race
213      * where another code path (e.g. explicit password change) writes a new hash
214      * while this lazy re-hash is computing the new encoded value.
215      *
216      * @param username target user (must exist)
217      * @param expectedCurrentHash the hash value observed at match time, or
218      *        {@code null} to perform an unconditional update
219      * @param newEncodedPassword already-hashed password value to store
220      * @return {@code true} if the update was applied; {@code false} if the user
221      *         was not found, the guard did not match, or the update failed
222      */
223     public boolean updateStoredPasswordHash(final String username, final String expectedCurrentHash, final String newEncodedPassword) {
224         if (StringUtil.isBlank(username) || StringUtil.isBlank(newEncodedPassword)) {
225             return false;
226         }
227 
228         try {
229             final OptionalEntity<User> optEntity = userBhv.selectEntity(cb -> cb.query().setName_Equal(username));
230             if (!optEntity.isPresent()) {
231                 if (logger.isDebugEnabled()) {
232                     logger.debug("User not found for stored password hash update: username={}", username);
233                 }
234                 return false;
235             }
236             final User entity = optEntity.get();
237             if (expectedCurrentHash != null && !expectedCurrentHash.equals(entity.getPassword())) {
238                 if (logger.isDebugEnabled()) {
239                     logger.debug("Stored password hash changed concurrently; skipping lazy upgrade: username={}", username);
240                 }
241                 return false;
242             }
243             entity.setPassword(newEncodedPassword);
244             // Propagate the seqNo/primaryTerm observed at select time into the
245             // index request itself so that OpenSearch rejects the update with a
246             // version_conflict_engine_exception if another writer (e.g. an
247             // explicit password change) landed between our select and update.
248             // DBFlute's ESBhv update path does NOT do this automatically for
249             // us; see EsAbstractBehavior.createUpdateRequest which only copies
250             // the values back onto the entity. This is the atomic half of the
251             // CAS — the in-memory equals() check above remains as a first-line
252             // filter that avoids a wasted round-trip on the common case.
253             final Long seqNo = entity.asDocMeta().seqNo();
254             final Long primaryTerm = entity.asDocMeta().primaryTerm();
255             userBhv.update(entity, op -> {
256                 op.setRefreshPolicy(Constants.TRUE);
257                 if (seqNo != null && seqNo.longValue() >= 0L) {
258                     op.setIfSeqNo(seqNo.longValue());
259                 }
260                 if (primaryTerm != null && primaryTerm.longValue() >= 0L) {
261                     op.setIfPrimaryTerm(primaryTerm.longValue());
262                 }
263             });
264             if (logger.isDebugEnabled()) {
265                 logger.debug("Upgraded stored password hash for user: username={}", username);
266             }
267             return true;
268         } catch (final Exception e) {
269             if (isVersionConflict(e)) {
270                 // A concurrent writer won the race; lazy rehash is best-effort
271                 // and the next successful login will retry, so a noisy WARN is
272                 // not warranted here.
273                 if (logger.isDebugEnabled()) {
274                     logger.debug("Version conflict while upgrading password hash; skipping. username={}", username, e);
275                 }
276                 return false;
277             }
278             logger.warn("Failed to upgrade password hash for user. username={}", username, e);
279             return false;
280         }
281     }
282 
283     /**
284      * Tests whether the given throwable (or any of its causes) represents an
285      * OpenSearch optimistic-concurrency conflict. Walks the cause chain and
286      * inspects class name / message so we do not have to depend on a specific
287      * OpenSearch exception type from the DBFlute layer above.
288      *
289      * @param t the throwable to inspect
290      * @return {@code true} if {@code t} looks like a version-conflict
291      */
292     protected boolean isVersionConflict(final Throwable t) {
293         Throwable cur = t;
294         while (cur != null) {
295             final String name = cur.getClass().getName();
296             if (name.endsWith("VersionConflictEngineException")) {
297                 return true;
298             }
299             final String msg = cur.getMessage();
300             if (msg != null && msg.contains("version_conflict_engine_exception")) {
301                 return true;
302             }
303             final Throwable next = cur.getCause();
304             if (next == cur) {
305                 break;
306             }
307             cur = next;
308         }
309         return false;
310     }
311 
312     /**
313      * Deletes a user from the system.
314      * Removes the user from both the authentication manager and the database.
315      *
316      * @param user the user entity to delete
317      */
318     public void delete(final User user) {
319         final String username = user.getName();
320         final String userId = user.getId();
321 
322         if (logger.isDebugEnabled()) {
323             logger.debug("User deletion initiated: username={}, id={}", username, userId);
324         }
325 
326         try {
327             ComponentUtil.getAuthenticationManager().delete(user);
328 
329             userBhv.delete(user, op -> {
330                 op.setRefreshPolicy(Constants.TRUE);
331             });
332 
333             if (logger.isInfoEnabled()) {
334                 logger.info("User deleted successfully: username={}, id={}", username, userId);
335             }
336         } catch (final Exception e) {
337             logger.warn("Failed to delete user: username={}, id={}, error={}", username, userId, e.getMessage(), e);
338             throw e;
339         }
340     }
341 
342     /**
343      * Sets up the search conditions for user list queries based on pager criteria.
344      * Configures the condition bean with search filters and ordering.
345      *
346      * @param cb the condition bean for the user query
347      * @param userPager the pager containing search criteria
348      */
349     protected void setupListCondition(final UserCB cb, final UserPager userPager) {
350         if (userPager.id != null) {
351             cb.query().docMeta().setId_Equal(userPager.id);
352         }
353         // TODO Long, Integer, String supported only.
354 
355         // setup condition
356         cb.query().addOrderBy_Name_Asc();
357 
358         // search
359 
360     }
361 
362     /**
363      * Retrieves a list of all available users in the system.
364      * Returns up to the maximum configured number of users.
365      *
366      * @return a list of all available users
367      */
368     public List<User> getAvailableUserList() {
369         return userBhv.selectList(cb -> {
370             cb.query().matchAll();
371             cb.fetchFirst(fessConfig.getPageUserMaxFetchSizeAsInteger());
372         });
373     }
374 
375 }