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 }