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.web.admin.user;
17  
18  import java.util.Base64;
19  import java.util.HashMap;
20  import java.util.Map;
21  import java.util.function.Consumer;
22  
23  import org.apache.commons.lang3.ArrayUtils;
24  import org.apache.logging.log4j.LogManager;
25  import org.apache.logging.log4j.Logger;
26  import org.codelibs.core.lang.StringUtil;
27  import org.codelibs.fess.Constants;
28  import org.codelibs.fess.annotation.Secured;
29  import org.codelibs.fess.app.pager.UserPager;
30  import org.codelibs.fess.app.service.GroupService;
31  import org.codelibs.fess.app.service.RoleService;
32  import org.codelibs.fess.app.service.UserService;
33  import org.codelibs.fess.app.web.CrudMode;
34  import org.codelibs.fess.app.web.base.FessAdminAction;
35  import org.codelibs.fess.mylasta.action.FessMessages;
36  import org.codelibs.fess.opensearch.user.exentity.User;
37  import org.codelibs.fess.util.ComponentUtil;
38  import org.codelibs.fess.util.RenderDataUtil;
39  import org.dbflute.optional.OptionalEntity;
40  import org.dbflute.optional.OptionalThing;
41  import org.lastaflute.web.Execute;
42  import org.lastaflute.web.response.HtmlResponse;
43  import org.lastaflute.web.response.render.RenderData;
44  import org.lastaflute.web.ruts.process.ActionRuntime;
45  import org.lastaflute.web.validation.VaErrorHook;
46  import org.lastaflute.web.validation.VaMessenger;
47  
48  import jakarta.annotation.Resource;
49  
50  /**
51   * Admin action for User management.
52   *
53   */
54  public class AdminUserAction extends FessAdminAction {
55  
56      /**
57       * Default constructor.
58       */
59      public AdminUserAction() {
60          super();
61      }
62  
63      /** Role name for admin user operations */
64      public static final String ROLE = "admin-user";
65  
66      private static final Logger logger = LogManager.getLogger(AdminUserAction.class);
67  
68      // ===================================================================================
69      //                                                                           Attribute
70      //                                                                           =========
71      @Resource
72      private UserService userService;
73      @Resource
74      private RoleService roleService;
75      @Resource
76      private GroupService groupService;
77      @Resource
78      private UserPager userPager;
79  
80      // ===================================================================================
81      //                                                                               Hook
82      //                                                                              ======
83      @Override
84      protected void setupHtmlData(final ActionRuntime runtime) {
85          super.setupHtmlData(runtime);
86          runtime.registerData("helpLink", systemHelper.getHelpLink(fessConfig.getOnlineHelpNameUser()));
87          runtime.registerData("ldapAdminEnabled", fessConfig.isLdapAdminEnabled());
88      }
89  
90      @Override
91      protected String getActionRole() {
92          return ROLE;
93      }
94  
95      // ===================================================================================
96      //                                                                      Search Execute
97      //                                                                      ==============
98      /**
99       * Displays the user management index page.
100      *
101      * @return HTML response for the user list page
102      */
103     @Execute
104     @Secured({ ROLE, ROLE + VIEW })
105     public HtmlResponse index() {
106         return asListHtml();
107     }
108 
109     /**
110      * Displays a paginated list of users.
111      *
112      * @param pageNumber the page number to display (optional)
113      * @param form the search form containing filter criteria
114      * @return HTML response with the user list
115      */
116     @Execute
117     @Secured({ ROLE, ROLE + VIEW })
118     public HtmlResponse list(final OptionalThing<Integer> pageNumber, final SearchForm form) {
119         pageNumber.ifPresent(num -> {
120             userPager.setCurrentPageNumber(pageNumber.get());
121         }).orElse(() -> {
122             userPager.setCurrentPageNumber(0);
123         });
124         return asHtml(path_AdminUser_AdminUserJsp).renderWith(data -> {
125             searchPaging(data, form);
126         });
127     }
128 
129     /**
130      * Searches for users based on the provided search criteria.
131      *
132      * @param form the search form containing search criteria
133      * @return HTML response with filtered user results
134      */
135     @Execute
136     @Secured({ ROLE, ROLE + VIEW })
137     public HtmlResponse search(final SearchForm form) {
138         copyBeanToBean(form, userPager, op -> op.exclude(Constants.PAGER_CONVERSION_RULE));
139         return asHtml(path_AdminUser_AdminUserJsp).renderWith(data -> {
140             searchPaging(data, form);
141         });
142     }
143 
144     /**
145      * Resets the search criteria and displays all users.
146      *
147      * @param form the search form to reset
148      * @return HTML response with the reset user list
149      */
150     @Execute
151     @Secured({ ROLE, ROLE + VIEW })
152     public HtmlResponse reset(final SearchForm form) {
153         userPager.clear();
154         return asHtml(path_AdminUser_AdminUserJsp).renderWith(data -> {
155             searchPaging(data, form);
156         });
157     }
158 
159     /**
160      * Registers pagination and user list data for rendering the user search results.
161      *
162      * @param data the render data container to populate
163      * @param form the search form containing pagination parameters
164      */
165     protected void searchPaging(final RenderData data, final SearchForm form) {
166         RenderDataUtil.register(data, "userItems", userService.getUserList(userPager)); // page navi
167         // restore from pager
168         copyBeanToBean(userPager, form, op -> op.include("id"));
169     }
170 
171     private void registerForms(final RenderData data) {
172         RenderDataUtil.register(data, "roleItems", roleService.getAvailableRoleList());
173         RenderDataUtil.register(data, "groupItems", groupService.getAvailableGroupList());
174     }
175 
176     // ===================================================================================
177     //                                                                        Edit Execute
178     //                                                                        ============
179     // -----------------------------------------------------
180     //                                            Entry Page
181     //                                            ----------
182     /**
183      * Displays the form for creating a new user.
184      *
185      * @return HTML response for the user creation form
186      */
187     @Execute
188     @Secured({ ROLE })
189     public HtmlResponse createnew() {
190         saveToken();
191         return asHtml(path_AdminUser_AdminUserEditJsp).useForm(CreateForm.class, op -> {
192             op.setup(form -> {
193                 form.initialize();
194                 form.crudMode = CrudMode.CREATE;
195             });
196         }).renderWith(data -> {
197             registerForms(data);
198         });
199     }
200 
201     /**
202      * Displays the form for editing an existing user.
203      *
204      * @param form the edit form containing user ID
205      * @return HTML response for the user edit form
206      */
207     @Execute
208     @Secured({ ROLE })
209     public HtmlResponse edit(final EditForm form) {
210         validate(form, messages -> {}, this::asListHtml);
211         final String id = form.id;
212         userService.getUser(id).ifPresent(entity -> {
213             copyBeanToBean(entity, form, op -> {});
214         }).orElse(() -> {
215             throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id), this::asListHtml);
216         });
217         resetPassword(form);
218         saveToken();
219         if (form.crudMode.intValue() == CrudMode.EDIT) {
220             // back
221             form.crudMode = CrudMode.DETAILS;
222             return asDetailsHtml();
223         }
224         form.crudMode = CrudMode.EDIT;
225         return asEditHtml();
226     }
227 
228     // -----------------------------------------------------
229     //                                               Details
230     //                                               -------
231     /**
232      * Displays the details of a user.
233      *
234      * @param crudMode the CRUD mode for the operation
235      * @param id the ID of the user to display
236      * @return HTML response for the user details page
237      */
238     @Execute
239     @Secured({ ROLE, ROLE + VIEW })
240     public HtmlResponse details(final int crudMode, final String id) {
241         verifyCrudMode(crudMode, CrudMode.DETAILS, this::asListHtml);
242         saveToken();
243         return asHtml(path_AdminUser_AdminUserDetailsJsp).useForm(EditForm.class, op -> {
244             op.setup(form -> {
245                 userService.getUser(id).ifPresent(entity -> {
246                     copyBeanToBean(entity, form, copyOp -> {
247                         copyOp.excludeNull();
248                     });
249                     form.crudMode = crudMode;
250                 }).orElse(() -> {
251                     throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id), this::asListHtml);
252                 });
253                 resetPassword(form);
254             });
255         }).renderWith(data -> {
256             registerForms(data);
257         });
258     }
259 
260     // -----------------------------------------------------
261     //                                         Actually Crud
262     //                                         -------------
263     /**
264      * Creates a new user.
265      *
266      * @param form the create form containing the new user data
267      * @return HTML response redirecting to the list page after creation
268      */
269     @Execute
270     @Secured({ ROLE })
271     public HtmlResponse create(final CreateForm form) {
272         verifyCrudMode(form.crudMode, CrudMode.CREATE, this::asListHtml);
273         validate(form, messages -> {}, this::asEditHtml);
274         validateAttributes(form.attributes, v -> throwValidationError(v, this::asEditHtml));
275         verifyPassword(form, this::asEditHtml);
276         verifyToken(this::asEditHtml);
277         getUser(form).ifPresent(entity -> {
278             try {
279                 userService.store(entity);
280                 logger.info("Created user: {}", entity.getName());
281                 saveInfo(messages -> messages.addSuccessCrudCreateCrudTable(GLOBAL));
282             } catch (final Exception e) {
283                 logger.warn("Failed to create user: {}", form.name, e);
284                 throwValidationError(messages -> messages.addErrorsCrudFailedToCreateCrudTable(GLOBAL, buildThrowableMessage(e)),
285                         this::asEditHtml);
286             }
287         }).orElse(() -> {
288             throwValidationError(messages -> messages.addErrorsCrudFailedToCreateInstance(GLOBAL), this::asEditHtml);
289         });
290         return redirect(getClass());
291     }
292 
293     /**
294      * Updates an existing user.
295      *
296      * @param form the edit form containing the updated user data
297      * @return HTML response redirecting to the list page after update
298      */
299     @Execute
300     @Secured({ ROLE })
301     public HtmlResponse update(final EditForm form) {
302         verifyCrudMode(form.crudMode, CrudMode.EDIT, this::asListHtml);
303         validate(form, messages -> {}, this::asEditHtml);
304         validateAttributes(form.attributes, v -> throwValidationError(v, this::asEditHtml));
305         verifyPassword(form, this::asEditHtml);
306         verifyToken(this::asEditHtml);
307         getUser(form).ifPresent(entity -> {
308             try {
309                 userService.store(entity);
310                 logger.info("Updated user: {}", entity.getName());
311                 saveInfo(messages -> messages.addSuccessCrudUpdateCrudTable(GLOBAL));
312             } catch (final Exception e) {
313                 logger.warn("Failed to update user: {}", form.name, e);
314                 throwValidationError(messages -> messages.addErrorsCrudFailedToUpdateCrudTable(GLOBAL, buildThrowableMessage(e)),
315                         this::asEditHtml);
316             }
317         }).orElse(() -> {
318             throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, form.id), this::asEditHtml);
319         });
320         return redirect(getClass());
321     }
322 
323     /**
324      * Deletes a user.
325      *
326      * @param form the edit form containing the ID of the user to delete
327      * @return HTML response redirecting to the list page after deletion
328      */
329     @Execute
330     @Secured({ ROLE })
331     public HtmlResponse delete(final EditForm form) {
332         verifyCrudMode(form.crudMode, CrudMode.DETAILS, this::asListHtml);
333         validate(form, messages -> {}, this::asDetailsHtml);
334         getUserBean().ifPresent(u -> {
335             if (u.getFessUser() instanceof User && form.name.equals(u.getUserId())) {
336                 throwValidationError(messages -> messages.addErrorsCouldNotDeleteLoggedInUser(GLOBAL), this::asDetailsHtml);
337             }
338         });
339         verifyToken(this::asDetailsHtml);
340         final String id = form.id;
341         userService.getUser(id).ifPresent(entity -> {
342             try {
343                 userService.delete(entity);
344                 logger.info("Deleted user: {}", entity.getName());
345                 saveInfo(messages -> messages.addSuccessCrudDeleteCrudTable(GLOBAL));
346             } catch (final Exception e) {
347                 logger.warn("Failed to delete user: {}", form.name, e);
348                 throwValidationError(messages -> messages.addErrorsCrudFailedToDeleteCrudTable(GLOBAL, buildThrowableMessage(e)),
349                         this::asDetailsHtml);
350             }
351         }).orElse(() -> {
352             throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id), this::asDetailsHtml);
353         });
354         return redirect(getClass());
355     }
356 
357     //===================================================================================
358     //                                                                       Assist Logic
359     //                                                                       ============
360     private static OptionalEntity<User> getEntity(final CreateForm form) {
361         switch (form.crudMode) {
362         case CrudMode.CREATE:
363             return OptionalEntity.of(new User()).map(entity -> {
364                 entity.setId(Base64.getUrlEncoder().encodeToString(form.name.getBytes(Constants.CHARSET_UTF_8)));
365                 return entity;
366             });
367         case CrudMode.EDIT:
368             if (form instanceof EditForm) {
369                 return ComponentUtil.getComponent(UserService.class).getUser(((EditForm) form).id);
370             }
371             break;
372         default:
373             break;
374         }
375         return OptionalEntity.empty();
376     }
377 
378     /**
379      * Returns the user entity based on the provided form data, applying any necessary transformations.
380      *
381      * @param form the form containing user data for retrieval and update
382      * @return optional user entity populated from the form
383      */
384     public static OptionalEntity<User> getUser(final CreateForm form) {
385         return getEntity(form).map(entity -> {
386             copyMapToBean(form.attributes, entity, op -> op.exclude(Constants.COMMON_CONVERSION_RULE));
387             copyBeanToBean(form, entity, op -> op.exclude(ArrayUtils.addAll(Constants.COMMON_CONVERSION_RULE, "password")));
388             if (form.crudMode.intValue() == CrudMode.CREATE || StringUtil.isNotBlank(form.password)) {
389                 final String encodedPassword = ComponentUtil.getPasswordHashHelper().encode(form.password);
390                 entity.setOriginalPassword(form.password);
391                 entity.setPassword(encodedPassword);
392             }
393             return entity;
394         });
395     }
396 
397     /**
398      * Creates a label/value map item for dropdowns or list displays.
399      *
400      * @param label the display label for the item
401      * @param value the value associated with the item
402      * @return a map containing the label and value entries
403      */
404     protected Map<String, String> createItem(final String label, final String value) {
405         final Map<String, String> map = new HashMap<>(2);
406         map.put(Constants.ITEM_LABEL, label);
407         map.put(Constants.ITEM_VALUE, value);
408         return map;
409     }
410 
411     // ===================================================================================
412     //                                                                        Small Helper
413     //                                                                        ============
414 
415     /**
416      * Validates the password and confirmation fields in the form for user creation and update.
417      *
418      * @param form the form containing password and confirmation fields
419      * @param validationErrorLambda callback to report validation errors
420      */
421     protected void verifyPassword(final CreateForm form, final VaErrorHook validationErrorLambda) {
422         if (form.crudMode == CrudMode.CREATE && StringUtil.isBlank(form.password)) {
423             resetPassword(form);
424             throwValidationError(messages -> {
425                 messages.addErrorsBlankPassword("password");
426             }, validationErrorLambda);
427         }
428         if (form.password != null && !form.password.equals(form.confirmPassword)) {
429             form.confirmPassword = null;
430             throwValidationError(messages -> {
431                 messages.addErrorsInvalidConfirmPassword("confirmPassword");
432             }, validationErrorLambda);
433         }
434         if (StringUtil.isNotBlank(form.password)) {
435             final String validationError = ComponentUtil.getSystemHelper().validatePassword(form.password);
436             if (StringUtil.isNotBlank(validationError)) {
437                 resetPassword(form);
438                 throwValidationError(messages -> {
439                     addPasswordValidationError(messages, validationError);
440                 }, validationErrorLambda);
441             }
442         }
443     }
444 
445     /**
446      * Adds a password validation error message to the messages object based on the error key.
447      *
448      * @param messages the FessMessages object to add the error to
449      * @param errorKey the error key identifying the type of password validation error
450      */
451     protected void addPasswordValidationError(final FessMessages messages, final String errorKey) {
452         switch (errorKey) {
453         case "errors.password_length":
454             messages.addErrorsPasswordLength("password", String.valueOf(ComponentUtil.getFessConfig().getPasswordMinLengthAsInteger()));
455             break;
456         case "errors.password_no_uppercase":
457             messages.addErrorsPasswordNoUppercase("password");
458             break;
459         case "errors.password_no_lowercase":
460             messages.addErrorsPasswordNoLowercase("password");
461             break;
462         case "errors.password_no_digit":
463             messages.addErrorsPasswordNoDigit("password");
464             break;
465         case "errors.password_no_special_char":
466             messages.addErrorsPasswordNoSpecialChar("password");
467             break;
468         case "errors.password_is_blacklisted":
469             messages.addErrorsPasswordIsBlacklisted("password");
470             break;
471         default:
472             messages.addErrorsBlankPassword("password");
473             break;
474         }
475     }
476 
477     /**
478      * Resets the password and confirmation fields in the user form.
479      *
480      * @param form the form whose password fields should be reset
481      */
482     public static void resetPassword(final CreateForm form) {
483         form.password = null;
484         form.confirmPassword = null;
485     }
486 
487     /**
488      * Validates LDAP user attribute types using the configured LDAP manager.
489      *
490      * @param attributes the map of attributes to validate
491      * @param throwError callback to report any validation errors
492      */
493     public static void validateAttributes(final Map<String, String> attributes, final Consumer<VaMessenger<FessMessages>> throwError) {
494         ComponentUtil.getLdapManager()
495                 .validateUserAttributes(Long.class, attributes,
496                         s -> throwError.accept(messages -> messages.addErrorsPropertyTypeLong("attributes." + s, "attributes." + s)));
497     }
498 
499     // ===================================================================================
500     //                                                                              JSP
501     //                                                                           =========
502 
503     private HtmlResponse asListHtml() {
504         return asHtml(path_AdminUser_AdminUserJsp).renderWith(data -> {
505             RenderDataUtil.register(data, "userItems", userService.getUserList(userPager)); // page navi
506         }).useForm(SearchForm.class, setup -> {
507             setup.setup(form -> {
508                 copyBeanToBean(userPager, form, op -> op.include("id"));
509             });
510         });
511     }
512 
513     private HtmlResponse asEditHtml() {
514         return asHtml(path_AdminUser_AdminUserEditJsp).renderWith(data -> {
515             registerForms(data);
516         });
517     }
518 
519     private HtmlResponse asDetailsHtml() {
520         return asHtml(path_AdminUser_AdminUserDetailsJsp).renderWith(data -> {
521             registerForms(data);
522         });
523     }
524 }