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.base.login;
17  
18  import java.lang.reflect.Method;
19  import java.util.function.Function;
20  
21  import org.apache.logging.log4j.LogManager;
22  import org.apache.logging.log4j.Logger;
23  import org.codelibs.fess.annotation.Secured;
24  import org.codelibs.fess.app.service.UserService;
25  import org.codelibs.fess.app.web.RootAction;
26  import org.codelibs.fess.app.web.base.FessAdminAction;
27  import org.codelibs.fess.app.web.login.LoginAction;
28  import org.codelibs.fess.entity.FessUser;
29  import org.codelibs.fess.exception.UserRoleLoginException;
30  import org.codelibs.fess.helper.PasswordHashHelper;
31  import org.codelibs.fess.mylasta.action.FessUserBean;
32  import org.codelibs.fess.mylasta.direction.FessConfig;
33  import org.codelibs.fess.opensearch.user.exbhv.UserBhv;
34  import org.codelibs.fess.opensearch.user.exentity.User;
35  import org.codelibs.fess.sso.SsoAuthenticator;
36  import org.codelibs.fess.util.ComponentUtil;
37  import org.dbflute.optional.OptionalEntity;
38  import org.dbflute.optional.OptionalThing;
39  import org.lastaflute.core.magic.async.AsyncManager;
40  import org.lastaflute.core.time.TimeManager;
41  import org.lastaflute.web.login.LoginHandlingResource;
42  import org.lastaflute.web.login.PrimaryLoginManager;
43  import org.lastaflute.web.login.TypicalLoginAssist;
44  import org.lastaflute.web.login.credential.LoginCredential;
45  import org.lastaflute.web.login.exception.LoginRequiredException;
46  import org.lastaflute.web.login.option.LoginSpecifiedOption;
47  import org.lastaflute.web.servlet.session.SessionManager;
48  
49  import jakarta.annotation.Resource;
50  
51  /**
52   * The assist for login handling in the Fess application.
53   * This class extends TypicalLoginAssist to provide Fess-specific login functionality
54   * including user authentication, permission checking, and login history management.
55   *
56   */
57  public class FessLoginAssist extends TypicalLoginAssist<String, FessUserBean, FessUser> // #change_it also UserBean
58          implements PrimaryLoginManager {
59  
60      /** Logger instance for this class. */
61      private static final Logger logger = LogManager.getLogger(FessLoginAssist.class);
62  
63      /**
64       * Default constructor.
65       */
66      public FessLoginAssist() {
67          super();
68      }
69  
70      // ===================================================================================
71      //                                                                           Attribute
72      //                                                                           =========
73      /** The time manager for handling time-related operations. */
74      @Resource
75      private TimeManager timeManager;
76  
77      /** The async manager for handling asynchronous operations. */
78      @Resource
79      private AsyncManager asyncManager;
80  
81      /** The session manager for handling user sessions. */
82      @Resource
83      private SessionManager sessionManager;
84  
85      /** The Fess configuration providing application settings. */
86      @Resource
87      private FessConfig fessConfig;
88  
89      /** The user behavior for database operations on user entities. */
90      @Resource
91      private UserBhv userBhv;
92  
93      // ===================================================================================
94      //                                                                           Find User
95      //                                                                           =========
96      /**
97       * Checks if a user can login with the given credential.
98       * This method is not supported in the Fess implementation.
99       *
100      * @param credential the login credential to check
101      * @return true if the user can login, false otherwise
102      * @throws UnsupportedOperationException always thrown as this method is not supported
103      */
104     @Override
105     public boolean checkUserLoginable(final LoginCredential credential) {
106         throw new UnsupportedOperationException("checkUserLoginable is not supported.");
107     }
108 
109     /**
110      * Checks the credential using the provided credential checker.
111      * This method is not supported in the Fess implementation.
112      *
113      * @param checker the credential checker to use
114      * @throws UnsupportedOperationException always thrown as this method is not supported
115      */
116     @Override
117     protected void checkCredential(final TypicalLoginAssist<String, FessUserBean, FessUser>.CredentialChecker checker) {
118         throw new UnsupportedOperationException("checkCredential is not supported.");
119     }
120 
121     /**
122      * Finds a login user by username.
123      *
124      * @param username the username to search for
125      * @return an optional entity containing the found user, or empty if not found
126      */
127     @Override
128     protected OptionalEntity<FessUser> doFindLoginUser(final String username) {
129         return userBhv.selectEntity(cb -> {
130             cb.query().setName_Equal(username);
131         }).map(user -> (FessUser) user);
132     }
133 
134     // ===================================================================================
135     //                                                                       Login Process
136     //                                                                       =============
137     /**
138      * Creates a user bean from the given user entity.
139      *
140      * @param user the user entity to create a bean from
141      * @return the created user bean
142      */
143     @Override
144     protected FessUserBean createUserBean(final FessUser user) {
145         return new FessUserBean(user);
146     }
147 
148     /**
149      * Gets the cookie remember-me key for persistent login.
150      * Currently returns empty as remember-me functionality is not enabled.
151      *
152      * @return an optional thing containing the remember-me key, or empty if not configured
153      */
154     @Override
155     protected OptionalThing<String> getCookieRememberMeKey() {
156         // example to use remember-me
157         //return OptionalThing.of(fessConfig.getCookieRememberMeFessKey());
158         return OptionalThing.empty();
159     }
160 
161     /**
162      * Saves the login history for the given user.
163      * This operation is performed asynchronously.
164      *
165      * @param user the user entity
166      * @param userBean the user bean
167      * @param option the login specified option
168      */
169     @Override
170     protected void saveLoginHistory(final FessUser user, final FessUserBean userBean, final LoginSpecifiedOption option) {
171         asyncManager.async(() -> {
172             insertLogin(user);
173         });
174     }
175 
176     /**
177      * Inserts a login record for the given member.
178      * Currently this method does nothing.
179      *
180      * @param member the member to insert a login record for
181      */
182     protected void insertLogin(final Object member) {
183         // nothing
184     }
185 
186     /**
187      * Checks if the current user has permission to access the given resource.
188      * For admin actions, verifies that the user has appropriate admin roles or
189      * meets the secured annotation requirements.
190      *
191      * @param resource the login handling resource to check permission for
192      * @throws LoginRequiredException if login is required
193      * @throws UserRoleLoginException if the user doesn't have required roles
194      */
195     @Override
196     protected void checkPermission(final LoginHandlingResource resource) throws LoginRequiredException {
197         if (FessAdminAction.class.isAssignableFrom(resource.getActionClass())) {
198             getSavedUserBean().ifPresent(user -> {
199                 if (user.hasRoles(fessConfig.getAuthenticationAdminRolesAsArray())) {
200                     return;
201                 }
202                 final Method executeMethod = resource.getExecuteMethod();
203                 final Secured secured = executeMethod.getAnnotation(Secured.class);
204                 if (secured != null && user.hasRoles(secured.value())) {
205                     return;
206                 }
207                 throw new UserRoleLoginException(RootAction.class);
208             });
209         }
210     }
211 
212     // ===================================================================================
213     //                                                                      Login Resource
214     //                                                                      ==============
215     /**
216      * Gets the user bean type class.
217      *
218      * @return the FessUserBean class
219      */
220     @Override
221     protected Class<FessUserBean> getUserBeanType() {
222         return FessUserBean.class;
223     }
224 
225     /**
226      * Gets the login action type class.
227      *
228      * @return the LoginAction class
229      */
230     @Override
231     protected Class<?> getLoginActionType() {
232         return LoginAction.class;
233     }
234 
235     /**
236      * Converts a user key to a typed user ID.
237      * In this implementation, returns the user key as-is.
238      *
239      * @param userKey the user key to convert
240      * @return the typed user ID
241      */
242     @Override
243     protected String toTypedUserId(final String userKey) {
244         return userKey;
245     }
246 
247     // ===================================================================================
248     //                                                                     Login Extension
249     //                                                                      ==============
250 
251     /**
252      * Resolves login credentials using various authentication methods.
253      * This method handles local user authentication, LDAP authentication,
254      * and SSO authentication through configured authenticators.
255      *
256      * @param resolver the credential resolver to use
257      */
258     @Override
259     protected void resolveCredential(final CredentialResolver resolver) {
260         resolver.resolve(LocalUserCredential.class, credential -> {
261             final LocalUserCredential userCredential = credential;
262             final String username = userCredential.getUser();
263             final String password = userCredential.getPassword();
264             if (!fessConfig.isAdminUser(username)) {
265                 final OptionalEntity<FessUser> ldapUser = ComponentUtil.getLdapManager().login(username, password);
266                 if (ldapUser.isPresent()) {
267                     return ldapUser;
268                 }
269             }
270             return doAuthenticateLocal(username, password);
271         });
272         final LoginCredentialResolver loginResolver = new LoginCredentialResolver(resolver);
273         for (final SsoAuthenticator auth : ComponentUtil.getSsoManager().getAuthenticators()) {
274             auth.resolveCredential(loginResolver);
275         }
276     }
277 
278     /**
279      * A resolver for login credentials that wraps the standard credential resolver
280      * to provide SSO authentication support.
281      */
282     public static class LoginCredentialResolver {
283         /** The wrapped credential resolver. */
284         private final TypicalLoginAssist<String, FessUserBean, FessUser>.CredentialResolver resolver;
285 
286         /**
287          * Creates a new login credential resolver.
288          *
289          * @param resolver the credential resolver to wrap
290          */
291         public LoginCredentialResolver(final CredentialResolver resolver) {
292             this.resolver = resolver;
293         }
294 
295         /**
296          * Resolves credentials of the specified type using the provided function.
297          *
298          * @param <CREDENTIAL> the credential type
299          * @param credentialType the class of the credential type
300          * @param oneArgLambda the function to apply for credential resolution
301          */
302         public <CREDENTIAL extends LoginCredential> void resolve(final Class<CREDENTIAL> credentialType,
303                 final Function<CREDENTIAL, OptionalEntity<FessUser>> oneArgLambda) {
304             resolver.resolve(credentialType, credential -> oneArgLambda.apply(credential));
305         }
306     }
307 
308     /**
309      * Authenticates a local user by username and plaintext password using the
310      * {@link PasswordHashHelper} (BCrypt with legacy hex-digest fallback) and
311      * performs best-effort lazy re-hashing for credentials stored in an older
312      * format.
313      *
314      * <p>Timing-attack countermeasure: every failure path must pay
315      * approximately one BCrypt verification worth of CPU, regardless of
316      * whether the user exists and regardless of the stored hash format.
317      * When {@link PasswordHashHelper#matches} already consumed a BCrypt cost
318      * (stored value carries a {@code {bcrypt}} prefix), no additional padding
319      * is applied — otherwise the failure branch would pay <em>two</em>
320      * BCrypt costs and become distinguishable from unknown-user failures.</p>
321      *
322      * @param username the login name to look up
323      * @param plainPassword the raw, user-supplied password
324      * @return an optional entity containing the found user on success, or empty
325      *         otherwise
326      */
327     protected OptionalEntity<FessUser> doAuthenticateLocal(final String username, final String plainPassword) {
328         final PasswordHashHelper passwordHashHelper = ComponentUtil.getPasswordHashHelper();
329         final OptionalEntity<FessUser> userOpt = doFindLoginUser(username);
330         if (userOpt.isPresent()) {
331             final FessUser user = userOpt.get();
332             final String stored = (user instanceof User) ? ((User) user).getPassword() : null;
333             if (stored != null && passwordHashHelper.matches(plainPassword, stored)) {
334                 lazyUpgradePassword(username, plainPassword, stored, passwordHashHelper);
335                 return userOpt;
336             }
337             // Failure path: pad with dummy BCrypt UNLESS the matches() call
338             // above already consumed a BCrypt cost (i.e., stored was in the
339             // {bcrypt} form). Paying it twice would make this branch visibly
340             // slower than the unknown-user branch and re-introduce the
341             // enumeration oracle we are trying to close.
342             if (!passwordHashHelper.isTimingSafeHash(stored)) {
343                 passwordHashHelper.applyTimingPadding();
344             }
345             return OptionalEntity.empty();
346         }
347         // User does not exist: pay one BCrypt pass to equalise timing.
348         passwordHashHelper.applyTimingPadding();
349         return OptionalEntity.empty();
350     }
351 
352     /**
353      * Best-effort upgrade of a legacy or obsolete-cost password hash to the
354      * currently configured algorithm/parameters. A failure here never fails
355      * the login and never propagates an exception to the caller.
356      *
357      * <p>Logs only the username (never the plaintext or hash values).</p>
358      *
359      * @param username the user whose stored hash is being upgraded
360      * @param plainPassword the plaintext password (already verified to match)
361      * @param currentStored the currently stored hash value
362      * @param passwordHashHelper the password manager to use
363      */
364     protected void lazyUpgradePassword(final String username, final String plainPassword, final String currentStored,
365             final PasswordHashHelper passwordHashHelper) {
366         if (!passwordHashHelper.upgradeEncoding(currentStored)) {
367             return;
368         }
369         try {
370             final String newEncoded = passwordHashHelper.encode(plainPassword);
371             final boolean updated =
372                     ComponentUtil.getComponent(UserService.class).updateStoredPasswordHash(username, currentStored, newEncoded);
373             if (updated) {
374                 if (logger.isInfoEnabled()) {
375                     logger.info("Upgraded password hash. username={}", username);
376                 }
377             } else if (logger.isWarnEnabled()) {
378                 logger.warn("Failed to upgrade password hash (update returned false). username={}", username);
379             }
380         } catch (final Exception e) {
381             if (logger.isWarnEnabled()) {
382                 logger.warn("Failed to upgrade password hash. username={}", username, e);
383             }
384         }
385     }
386 
387     /**
388      * Overrides the default cipher-based encryption to delegate to
389      * {@link PasswordHashHelper#encode}. This override exists solely so that any
390      * internal LastaFlute login path that still calls
391      * {@code encryptPassword} produces a hash in the new
392      * <code>{bcrypt}$2a$...</code> format. All Fess write paths (user
393      * creation, password change, initial admin bootstrap) call
394      * {@link PasswordHashHelper#encode(String)} directly via
395      * {@link ComponentUtil#getPasswordHashHelper()}; do not add new callers of
396      * this method from outside the login framework.
397      *
398      * @param plainText the plaintext password
399      * @return the encoded (hashed) password with prefix
400      */
401     @Override
402     public String encryptPassword(final String plainText) {
403         return ComponentUtil.getPasswordHashHelper().encode(plainText);
404     }
405 
406     /**
407      * Finds a login user by username and encrypted password.
408      *
409      * @param username the username to search for
410      * @param cipheredPassword ignored; retained only for source-level
411      *        backward compatibility. Local authentication now goes through
412      *        {@link #doAuthenticateLocal(String, String)}.
413      * @return an optional entity containing the found user when the stored
414      *         password exactly matches the supplied ciphered value, otherwise
415      *         empty
416      * @deprecated Use {@link #doAuthenticateLocal(String, String)} with the
417      *             plaintext password. BCrypt uses per-record salts, so an
418      *             exact-match DB lookup on a hashed value is no longer
419      *             meaningful. Retained for any subclass or legacy caller.
420      */
421     @Deprecated
422     protected OptionalEntity<FessUser> doFindLoginUser(final String username, final String cipheredPassword) {
423         final OptionalEntity<FessUser> userOpt = doFindLoginUser(username);
424         if (!userOpt.isPresent()) {
425             return OptionalEntity.empty();
426         }
427         final FessUser user = userOpt.get();
428         final String stored = (user instanceof User) ? ((User) user).getPassword() : null;
429         if (stored != null && stored.equals(cipheredPassword)) {
430             return userOpt;
431         }
432         return OptionalEntity.empty();
433     }
434 }