diff --git a/api/src/org/labkey/api/security/AuthenticationManager.java b/api/src/org/labkey/api/security/AuthenticationManager.java index 4d498671541..18a7961679b 100644 --- a/api/src/org/labkey/api/security/AuthenticationManager.java +++ b/api/src/org/labkey/api/security/AuthenticationManager.java @@ -263,6 +263,41 @@ public static boolean isAutoCreateAccountsEnabled() public static boolean isSelfServiceEmailChangesEnabled() { return getAuthSetting(SELF_SERVICE_EMAIL_CHANGES_KEY, false);} + public static boolean isLoginAttemptControlEnabled() + { + return getAuthSetting(LOGIN_ATTEMPT_ENABLED_KEY, false); + } + + public static int getLoginAttemptLimit() + { + return getAuthenticationProperty(LOGIN_ATTEMPT_LIMIT_KEY, 3); + } + + public static int getLoginAttemptPeriod() + { + return getAuthenticationProperty(LOGIN_ATTEMPT_PERIOD_KEY, 30); + } + + public static int getLoginAttemptResetTime() + { + return getAuthenticationProperty(LOGIN_ATTEMPT_RESET_TIME_KEY, 5); + } + + // Convenience method that returns the default value on missing or bad value + private static int getAuthenticationProperty(@NotNull String key, int defaultValue) + { + Map props = PropertyManager.getProperties(AUTHENTICATION_CATEGORY); + String value = props.get(key); + try + { + return value == null ? defaultValue : Integer.parseInt(value); + } + catch (NumberFormatException e) + { + return defaultValue; + } + } + public static @NotNull String getDefaultDomain() { Map props = PropertyManager.getProperties(AUTHENTICATION_CATEGORY); @@ -291,7 +326,7 @@ public static void saveAuthSetting(User user, String key, boolean value) saveAuthSetting(user, key, Boolean.toString(value), value ? "enabled" : "disabled"); } - private static void saveAuthSetting(User user, String key, String value, String action) + public static void saveAuthSetting(User user, String key, String value, String action) { WritablePropertyMap props = PropertyManager.getWritableProperties(AUTHENTICATION_CATEGORY, true); props.put(key, value); @@ -308,6 +343,37 @@ public static void saveAuthSettings(User user, Map map) .forEach(e->saveAuthSetting(user, e.getKey(), e.getValue())); } + // Returns true if any setting changed + public static boolean saveLoginAttemptSettings(User user, boolean enabled, int limit, int period, int resetTime) + { + if (limit < 1 || period < 1 || resetTime < 1) + throw new IllegalArgumentException("limit, period, and resetTime values must be positive!"); + + // Use standard saveAuthSetting() methods to ensure audit logging + boolean changed = false; + if (enabled != isLoginAttemptControlEnabled()) + { + saveAuthSetting(user, LOGIN_ATTEMPT_ENABLED_KEY, enabled); + changed = true; + } + if (limit != getLoginAttemptLimit()) + { + saveAuthSetting(user, LOGIN_ATTEMPT_LIMIT_KEY, String.valueOf(limit), "set to " + limit); + changed = true; + } + if (period != getLoginAttemptPeriod()) + { + saveAuthSetting(user, LOGIN_ATTEMPT_PERIOD_KEY, String.valueOf(period), "set to " + period); + changed = true; + } + if (resetTime != getLoginAttemptResetTime()) + { + saveAuthSetting(user, LOGIN_ATTEMPT_RESET_TIME_KEY, String.valueOf(resetTime), "set to " + resetTime); + changed = true; + } + return changed; + } + public static void reorderConfigurations(User user, String name, int[] rowIds) { if (null != rowIds && rowIds.length != 0) @@ -539,7 +605,7 @@ public static void registerProvider(AuthenticationProvider authProvider) public static void registerProvider(AuthenticationProvider authProvider, Priority priority) { if (Priority.High == priority) - _allProviders.add(0, authProvider); + _allProviders.addFirst(authProvider); else _allProviders.add(authProvider); @@ -588,7 +654,8 @@ private static void addAuthSettingAuditEvent(User user, String name, String acti return AuthenticationProviderCache.getProvider(ResetPasswordProvider.class, name); } - public static @Nullable DisableLoginProvider getEnabledDisableLoginProviderForUser(String id) + // Return a DisableLoginProvider if it's enabled and applicable to this user + public static @Nullable DisableLoginProvider getDisableLoginProviderForUser(String id) { for (DisableLoginProvider provider : AuthenticationProviderCache.getProviders(DisableLoginProvider.class)) if (provider.isEnabledForUser(id)) @@ -635,8 +702,8 @@ public static void setAcceptOnlyFicamProviders(User user, boolean enable) AuthenticationConfigurationCache.clear(); } - // Used by start-up properties - private static final String AUTHENTICATION_CATEGORY = "Authentication"; + // Used by start-up properties and upgrade code + public static final String AUTHENTICATION_CATEGORY = "Authentication"; public static final String SELF_REGISTRATION_KEY = "SelfRegistration"; public static final String AUTO_CREATE_ACCOUNTS_KEY = "AutoCreateAccounts"; @@ -644,6 +711,11 @@ public static void setAcceptOnlyFicamProviders(User user, boolean enable) public static final String SELF_SERVICE_EMAIL_CHANGES_KEY = "SelfServiceEmailChanges"; public static final String ACCEPT_ONLY_FICAM_PROVIDERS_KEY = "AcceptOnlyFicamProviders"; + public static final String LOGIN_ATTEMPT_ENABLED_KEY = "LoginAttemptEnabled"; + public static final String LOGIN_ATTEMPT_LIMIT_KEY = "LoginAttemptLimit"; + public static final String LOGIN_ATTEMPT_PERIOD_KEY = "LoginAttemptPeriod"; + public static final String LOGIN_ATTEMPT_RESET_TIME_KEY = "LoginAttemptResetTime"; + public enum AuthenticationSettings implements StartupProperty { SelfRegistration("Allow self sign up"), @@ -931,7 +1003,7 @@ public URLHelper getRedirectURL() { BindException errors = new BindException(new Object(), "dummy"); getStatus().addUserErrorMessage(errors, this, null, null, location); - return errors.hasErrors() ? errors.getAllErrors().get(0).getDefaultMessage() : null; + return errors.hasErrors() ? errors.getAllErrors().getFirst().getDefaultMessage() : null; } } @@ -1165,17 +1237,23 @@ public static PrimaryAuthenticationResult finalizePrimaryAuthentication(HttpServ // limit one bad login per second averaged out over 60sec private static final Cache addrLimiter = CacheManager.getCache(1001, TimeUnit.MINUTES.toMillis(5), "Login limiter"); - private static final Cache userLimiter = CacheManager.getCache(1001, TimeUnit.MINUTES.toMillis(5), "User limiter"); private static final Cache pwdLimiter = CacheManager.getCache(1001, TimeUnit.MINUTES.toMillis(5), "Password limiter"); - private static final CacheLoader addrLoader = (key, request) -> new RateLimiter("Addr limiter: " + key, new Rate(60, TimeUnit.MINUTES)); - private static final CacheLoader pwdLoader = (key, request) -> new RateLimiter("Pwd limiter: " + key, new Rate(20, TimeUnit.MINUTES)); - private static final CacheLoader userLoader = (key, request) -> new RateLimiter("User limiter: " + key, new Rate(20, TimeUnit.MINUTES)); + private static final CacheLoader addrLoader = (key, _) -> new RateLimiter("Addr limiter: " + key, new Rate(60, TimeUnit.MINUTES)); + private static final CacheLoader pwdLoader = (key, _) -> new RateLimiter("Pwd limiter: " + key, new Rate(20, TimeUnit.MINUTES)); - private static Integer _toKey(String s) + private static final Cache userLimiter = CacheManager.getCache(10000, TimeUnit.MINUTES.toMillis(5), "User limiter"); + private static final CacheLoader userLoader = (key, _) -> new RateLimiter("User limiter: " + key, new Rate(20, TimeUnit.MINUTES)); + + private static Integer getIntCacheKey(String s) { return null==s ? 0 : s.toLowerCase().hashCode() % 1000; } + public static String getEmailCacheKey(String s) + { + return StringUtils.trimToEmpty(s).toLowerCase(); + } + private static PrimaryAuthenticationResult _beforeAuthenticate(HttpServletRequest request, String id, String pwd) { if (null == id || null == pwd) @@ -1184,10 +1262,10 @@ private static PrimaryAuthenticationResult _beforeAuthenticate(HttpServletReques long delay = 0; // slow down login attempts when we detect more than 20/minute bad attempts per user, password, or ip address - rl = addrLimiter.get(_toKey(request == null ? null : request.getRemoteAddr())); + rl = addrLimiter.get(getIntCacheKey(request == null ? null : request.getRemoteAddr())); if (null != rl) delay = Math.max(delay,rl.add(0, false)); - rl = pwdLimiter.get(_toKey(pwd)); + rl = pwdLimiter.get(getIntCacheKey(pwd)); if (null != rl) delay = Math.max(delay, rl.add(0, false)); @@ -1209,7 +1287,7 @@ private static PrimaryAuthenticationResult _beforeAuthenticate(HttpServletReques private static long getUserLoginDelay(String id) throws LoginDisabledException { - DisableLoginProvider provider = AuthenticationManager.getEnabledDisableLoginProviderForUser(id); + DisableLoginProvider provider = AuthenticationManager.getDisableLoginProviderForUser(id); if (provider != null) return provider.getUserDelay(id); return getDefaultUserLoginDelay(id); @@ -1217,7 +1295,7 @@ private static long getUserLoginDelay(String id) throws LoginDisabledException private static long getDefaultUserLoginDelay(String id) { - RateLimiter rl = userLimiter.get(_toKey(id)); + RateLimiter rl = userLimiter.get(getEmailCacheKey(id)); if (null != rl) return rl.add(0, false); return 0; @@ -1230,9 +1308,9 @@ private static void _afterAuthenticate(HttpServletRequest request, String id, St if (result.getStatus() == AuthenticationStatus.BadCredentials || result.getStatus() == AuthenticationStatus.InactiveUser) { RateLimiter rl; - rl = addrLimiter.get(_toKey(request.getRemoteAddr()),request, addrLoader); + rl = addrLimiter.get(getIntCacheKey(request.getRemoteAddr()),request, addrLoader); rl.add(1, false); - rl = pwdLimiter.get(_toKey(pwd),request, pwdLoader); + rl = pwdLimiter.get(getIntCacheKey(pwd),request, pwdLoader); rl.add(1, false); addUserLoginDelay(request, id); @@ -1245,14 +1323,14 @@ else if (result.getStatus() == AuthenticationStatus.Success) private static void resetModuleUserLoginDelay(String id) { - DisableLoginProvider provider = AuthenticationManager.getEnabledDisableLoginProviderForUser(id); + DisableLoginProvider provider = AuthenticationManager.getDisableLoginProviderForUser(id); if (provider != null) provider.resetUserDelay(id); } private static void addUserLoginDelay(HttpServletRequest request, String id) { - DisableLoginProvider provider = AuthenticationManager.getEnabledDisableLoginProviderForUser(id); + DisableLoginProvider provider = AuthenticationManager.getDisableLoginProviderForUser(id); if (provider != null) provider.addUserDelay(request, id, 1); else @@ -1261,7 +1339,7 @@ private static void addUserLoginDelay(HttpServletRequest request, String id) private static void addDefaultUserLoginDelay(HttpServletRequest request, String id) { - RateLimiter rl = userLimiter.get(_toKey(id),request, userLoader); + RateLimiter rl = userLimiter.get(getEmailCacheKey(id),request, userLoader); rl.add(1, false); } diff --git a/core/module.properties b/core/module.properties index 756c5aea7e0..d4c7dec2745 100644 --- a/core/module.properties +++ b/core/module.properties @@ -1,6 +1,6 @@ Name: Core ModuleClass: org.labkey.core.CoreModule -SchemaVersion: 26.004 +SchemaVersion: 26.005 Label: Administration and Essential Services Description: The Core module provides central services such as login, \ security, administration, folder management, user management, \ diff --git a/core/resources/schemas/dbscripts/postgresql/core-26.004-26.005.sql b/core/resources/schemas/dbscripts/postgresql/core-26.004-26.005.sql new file mode 100644 index 00000000000..fbeac64a91e --- /dev/null +++ b/core/resources/schemas/dbscripts/postgresql/core-26.004-26.005.sql @@ -0,0 +1,2 @@ +-- Migrate login attempt settings from the compliance module's property store to core authentication settings. +SELECT core.executeJavaUpgradeCode('migrateLoginAttemptSettings'); diff --git a/core/resources/schemas/dbscripts/sqlserver/core-26.004-26.005.sql b/core/resources/schemas/dbscripts/sqlserver/core-26.004-26.005.sql new file mode 100644 index 00000000000..5e512f7015f --- /dev/null +++ b/core/resources/schemas/dbscripts/sqlserver/core-26.004-26.005.sql @@ -0,0 +1,2 @@ +-- Migrate login attempt settings from the compliance module's property store to core authentication settings. +EXEC core.executeJavaUpgradeCode 'migrateLoginAttemptSettings'; diff --git a/core/src/client/components/GlobalSettings.tsx b/core/src/client/components/GlobalSettings.tsx index 312775ae408..d74e76cb509 100644 --- a/core/src/client/components/GlobalSettings.tsx +++ b/core/src/client/components/GlobalSettings.tsx @@ -9,6 +9,10 @@ interface GlobalSettingFieldData { tip: string; } +const LOGIN_ATTEMPT_LIMIT_OPTIONS = ['3', '5', '10', '100']; +const LOGIN_ATTEMPT_PERIOD_OPTIONS = ['5', '15', '30', '60']; +const LOGIN_ATTEMPT_RESET_TIME_OPTIONS = ['5', '10', '30', '60']; + const FIELD_DATA: GlobalSettingFieldData[] = [ { id: 'SelfRegistration', @@ -76,6 +80,25 @@ export const GlobalSettings: FC = memo(({ canEdit, authCount, onChange, g [onChange] ); + const onLoginAttemptEnabledChange = useCallback( + (event: ChangeEvent) => { + onChange('LoginAttemptEnabled', event.target.checked); + }, + [onChange] + ); + + const onLoginAttemptSelectChange = useCallback( + (event: ChangeEvent) => { + onChange(event.target.name, event.target.value); + }, + [onChange] + ); + + const loginAttemptEnabled = !!globalSettings?.LoginAttemptEnabled; + const loginAttemptLimit = globalSettings?.LoginAttemptLimit ?? '3'; + const loginAttemptPeriod = globalSettings?.LoginAttemptPeriod ?? '30'; + const loginAttemptResetTime = globalSettings?.LoginAttemptResetTime ?? '5'; + return (
@@ -83,18 +106,6 @@ export const GlobalSettings: FC = memo(({ canEdit, authCount, onChange, g
- {fieldData.map(data => ( - - ))} -
System Default Domain @@ -117,6 +128,67 @@ export const GlobalSettings: FC = memo(({ canEdit, authCount, onChange, g />
+ +
+ + {fieldData.map(data => ( + + ))} + +
+ +
+ Disable user login if + + consecutive invalid logins are attempted in a + + second period. Automatically allow users to login again after + + minutes. +
+
); diff --git a/core/src/client/components/models.ts b/core/src/client/components/models.ts index af64099db69..15bc8712a54 100644 --- a/core/src/client/components/models.ts +++ b/core/src/client/components/models.ts @@ -33,6 +33,10 @@ export interface AuthConfigProvider { export interface GlobalSettingsOptions { AutoCreateAccounts?: boolean; DefaultDomain?: string; + LoginAttemptEnabled?: boolean; + LoginAttemptLimit?: string; + LoginAttemptPeriod?: string; + LoginAttemptResetTime?: string; SelfRegistration?: boolean; SelfServiceEmailChanges?: boolean; } diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 803404e97ce..02dc923cf1b 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -253,6 +253,7 @@ import org.labkey.core.dialect.PostgreSqlVersion; import org.labkey.core.junit.JunitController; import org.labkey.core.login.DbLoginAuthenticationProvider; +import org.labkey.core.login.LoginAttemptDisableLoginProvider; import org.labkey.core.login.DbLoginManager; import org.labkey.core.login.LoginController; import org.labkey.core.metrics.SimpleMetricsServiceImpl; @@ -446,6 +447,8 @@ protected void init() addController("notification", NotificationController.class); addController("product", ProductController.class); + WarningService.setInstance(new WarningServiceImpl()); + AuthenticationManager.registerProvider(new DbLoginAuthenticationProvider(), Priority.Low); AttachmentService.setInstance(new AttachmentServiceImpl()); AnalyticsService.setInstance(new AnalyticsServiceImpl()); @@ -453,8 +456,6 @@ protected void init() CacheManager.addListener(RhinoService::clearCaches); NotificationService.setInstance(NotificationServiceImpl.getInstance()); - WarningService.setInstance(new WarningServiceImpl()); - ViewService.setInstance(ViewServiceImpl.getInstance()); OptionalFeatureService.setInstance(new OptionalFeatureServiceImpl()); ThumbnailService.setInstance(new ThumbnailServiceImpl()); @@ -975,6 +976,7 @@ public void startupAfterSpringConfig(ModuleContext moduleContext) FolderTypeManager.get().registerFolderType(this, FolderType.NONE); FolderTypeManager.get().registerFolderType(this, new CollaborationFolderType()); + AuthenticationManager.registerProvider(new LoginAttemptDisableLoginProvider()); AnalyticsServiceImpl.get().resetCSP(); if (moduleContext.isNewInstall() && ModuleLoader.getInstance().shouldInsertData()) diff --git a/core/src/org/labkey/core/CoreUpgradeCode.java b/core/src/org/labkey/core/CoreUpgradeCode.java index 4001279f522..1c4406bb4f3 100644 --- a/core/src/org/labkey/core/CoreUpgradeCode.java +++ b/core/src/org/labkey/core/CoreUpgradeCode.java @@ -19,8 +19,10 @@ import org.apache.logging.log4j.Logger; import org.labkey.api.attachments.AttachmentParentType; import org.labkey.api.attachments.AttachmentService; +import org.labkey.api.data.ContainerManager; import org.labkey.api.data.CoreSchema; import org.labkey.api.data.DeferredUpgrade; +import org.labkey.api.data.PropertyManager; import org.labkey.api.data.SQLFragment; import org.labkey.api.data.SqlExecutor; import org.labkey.api.data.SqlSelector; @@ -28,7 +30,9 @@ import org.labkey.api.data.dialect.TestUpgradeCodeCounter; import org.labkey.api.module.ModuleContext; import org.labkey.api.module.ModuleLoader; +import org.labkey.api.security.AuthenticationManager; import org.labkey.api.security.Directive; +import org.labkey.api.security.User; import org.labkey.api.settings.AppProps; import org.labkey.api.util.ContextListener; import org.labkey.api.util.StartupListener; @@ -36,8 +40,15 @@ import org.labkey.core.security.AllowedExternalResourceHosts; import org.labkey.core.security.AllowedExternalResourceHosts.AllowedHost; +import java.util.Map; import java.util.List; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_ENABLED_KEY; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_LIMIT_KEY; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_PERIOD_KEY; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_RESET_TIME_KEY; +import static org.labkey.api.settings.AbstractSettingsGroup.SITE_CONFIG_USER; + public class CoreUpgradeCode implements UpgradeCode { private static final Logger LOG = LogHelper.getLogger(CoreUpgradeCode.class, "Custom core upgrade steps"); @@ -146,4 +157,47 @@ public static void deleteOrphanedAttachments(ModuleContext context) svc.deleteOrphanedAttachments(); } } + + /** + * Called from core-26.004-26.005.sql. Migrates login attempt settings that were previously stored in a + * compliance module property group to the core authentication property category. + */ + @SuppressWarnings("unused") + @DeferredUpgrade // Make sure property schema is up-to-date before migrating settings + public static void migrateLoginAttemptSettings(ModuleContext context) + { + if (context.isNewInstall()) + return; + + String complianceCategory = "complianceSettingPropLoginAttempt"; + String keyPrefix = complianceCategory + "/"; + Map complianceProps = PropertyManager.getProperties(SITE_CONFIG_USER, ContainerManager.getRoot(), complianceCategory); + + // Important: The old compliance property set used a prefix with the property names. The new property set will not. + String enabledVal = complianceProps.get(keyPrefix + "attemptEnabled"); + String limitVal = complianceProps.get(keyPrefix + "attemptLimit"); + String periodVal = complianceProps.get(keyPrefix + "attemptPeriod"); + String resetVal = complianceProps.get(keyPrefix + "resetTime"); + + if (enabledVal == null && limitVal == null && periodVal == null && resetVal == null) + { + // Nothing to migrate + LOG.info("No existing unsuccessful login attempt settings were found"); + } + else + { + LOG.info("Migrating existing unsuccessful login attempt settings: {}", complianceProps); + User user = context.getUpgradeUser(); + + // TODO: change saveAuthSetting() visibility back to private after deleting this upgrade code + if (enabledVal != null) + AuthenticationManager.saveAuthSetting(user, LOGIN_ATTEMPT_ENABLED_KEY, Boolean.valueOf(enabledVal)); + if (limitVal != null) + AuthenticationManager.saveAuthSetting(user, LOGIN_ATTEMPT_LIMIT_KEY, limitVal, "set to " + limitVal); + if (periodVal != null) + AuthenticationManager.saveAuthSetting(user, LOGIN_ATTEMPT_PERIOD_KEY, periodVal, "set to " + periodVal); + if (resetVal != null) + AuthenticationManager.saveAuthSetting(user, LOGIN_ATTEMPT_RESET_TIME_KEY, resetVal, "set to " + resetVal); + } + } } diff --git a/core/src/org/labkey/core/login/LoginAttemptDisableLoginProvider.java b/core/src/org/labkey/core/login/LoginAttemptDisableLoginProvider.java new file mode 100644 index 00000000000..79be269dd73 --- /dev/null +++ b/core/src/org/labkey/core/login/LoginAttemptDisableLoginProvider.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2016-2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.core.login; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheLoader; +import org.labkey.api.cache.CacheManager; +import org.labkey.api.security.AuthenticationManager; +import org.labkey.api.security.AuthenticationProvider; +import org.labkey.api.security.LoginDisabledException; +import org.labkey.api.security.User; +import org.labkey.api.security.UserManager; +import org.labkey.api.security.ValidEmail; +import org.labkey.api.security.ValidEmail.InvalidEmailException; +import org.labkey.api.util.CountLimiter; +import org.labkey.api.util.StringUtilsLabKey; +import org.labkey.api.util.logging.LogHelper; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.TimeUnit; + +import static org.labkey.api.security.AuthenticationManager.getEmailCacheKey; + +public class LoginAttemptDisableLoginProvider implements AuthenticationProvider.DisableLoginProvider +{ + private static final Logger _log = LogHelper.getLogger(LoginAttemptDisableLoginProvider.class, "Warnings about disabled logins due to too many failures"); + + private static final String NAME = "loginAttemptDisableLogin"; + private static final String DESCRIPTION = "Disable unsuccessful login provider"; + private static final Cache userLimiter = CacheManager.getCache(10000, CacheManager.DAY, "User login attempt limiter"); + + private static volatile CacheLoader userLoader; + + static + { + reloadCache(); + } + + @NotNull + @Override + public String getName() + { + return NAME; + } + + @NotNull + @Override + public String getDescription() + { + return DESCRIPTION; + } + + @Override + public boolean isEnabledForUser(String id) + { + User user = getUserFromEmailStr(id); + if (user == null || user.hasRootAdminPermission()) + return false; + + return AuthenticationManager.isLoginAttemptControlEnabled(); + } + + @Override + public long getUserDelay(String id) throws LoginDisabledException + { + CountLimiter rl = userLimiter.get(getEmailCacheKey(id)); + if (rl != null && isLoginDisabled(rl.getLimitReachedTimeStamp())) + { + int resetTime = AuthenticationManager.getLoginAttemptResetTime(); + User user = getUserFromEmailStr(id); + if (user != null) + { + String errorMessage = getUserLoginDisabledMsg(user.getEmail()); + AuthenticationManager.addAuditEvent(user, null, errorMessage); + _log.warn(errorMessage); + throw new LoginDisabledException("Your login has been disabled. Please try again in " + StringUtilsLabKey.pluralize(resetTime, "minute") + "."); + } + } + return 0; + } + + public boolean isLoginDisabled(long lastLimitReachedTimestamp) + { + long resetMinutes = AuthenticationManager.getLoginAttemptResetTime(); + long now = System.currentTimeMillis(); + return lastLimitReachedTimestamp > 0 && (now - lastLimitReachedTimestamp) < TimeUnit.MINUTES.toMillis(resetMinutes); + } + + @Override + public void addUserDelay(HttpServletRequest request, String id, int add) + { + CountLimiter rl = userLimiter.get(getEmailCacheKey(id), request, userLoader); + rl.add(add); + } + + @Override + public void resetUserDelay(String id) + { + CountLimiter rl = userLimiter.get(getEmailCacheKey(id)); + if (rl != null) + rl.reset(); + } + + public static void reloadCache() + { + if (!AuthenticationManager.isLoginAttemptControlEnabled()) + { + userLimiter.clear(); + return; + } + + long attemptSeconds = AuthenticationManager.getLoginAttemptPeriod(); + long attemptCount = AuthenticationManager.getLoginAttemptLimit(); + + userLimiter.clear(); + userLoader = (key, _) -> new CountLimiter("User login attempt limiter: " + key, TimeUnit.SECONDS.toMillis(attemptSeconds), 0, attemptCount); + } + + private User getUserFromEmailStr(String emailStr) + { + if (StringUtils.isBlank(emailStr)) + return null; + ValidEmail email = null; + + try + { + email = new ValidEmail(emailStr); + } + catch (InvalidEmailException _) + { + } + + if (null == email) + return null; + + return UserManager.getUser(email); + } + + private String getUserLoginDisabledMsg(String email) + { + int resetTime = AuthenticationManager.getLoginAttemptResetTime(); + int attemptLimit = AuthenticationManager.getLoginAttemptLimit(); + int attemptPeriod = AuthenticationManager.getLoginAttemptPeriod(); + + return email + + " disabled from login for " + StringUtilsLabKey.pluralize(resetTime, "minute") + ": " + + "incorrect password entered " + StringUtilsLabKey.pluralize(attemptLimit, "time") + " in " + + StringUtilsLabKey.pluralize(attemptPeriod, "second") + "."; + } +} diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index d9f0349a3bf..731ea7ed8e3 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -135,6 +135,10 @@ import static org.labkey.api.security.AuthenticationManager.AUTO_CREATE_ACCOUNTS_KEY; import static org.labkey.api.security.AuthenticationManager.AuthenticationStatus.Success; import static org.labkey.api.security.AuthenticationManager.DEFAULT_DOMAIN; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_ENABLED_KEY; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_LIMIT_KEY; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_PERIOD_KEY; +import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_RESET_TIME_KEY; import static org.labkey.api.security.AuthenticationManager.SELF_REGISTRATION_KEY; import static org.labkey.api.security.AuthenticationManager.SELF_SERVICE_EMAIL_CHANGES_KEY; @@ -2244,6 +2248,8 @@ public Object execute(SaveSettingsForm form, BindException errors) throws Except )); AuthenticationManager.setDefaultDomain(getUser(), form.getDefaultDomain()); + if (AuthenticationManager.saveLoginAttemptSettings(getUser(), form.isLoginAttemptEnabled(), form.getLoginAttemptLimit(), form.getLoginAttemptPeriod(), form.getLoginAttemptResetTime())) + LoginAttemptDisableLoginProvider.reloadCache(); // rowId arrays will be posted only if they are dirty AuthenticationManager.reorderConfigurations(getUser(), "LDAP", form.getFormConfigurations()); @@ -2260,6 +2266,10 @@ public static class SaveSettingsForm private boolean _selfServiceEmailChanges; private boolean _autoCreateAccounts; private String _defaultDomain; + private boolean _loginAttemptEnabled; + private int _loginAttemptLimit = 3; + private int _loginAttemptPeriod = 30; + private int _loginAttemptResetTime = 5; private int[] _formConfigurations; private int[] _ssoConfigurations; private int[] _secondaryConfigurations; @@ -2308,6 +2318,50 @@ public void setDefaultDomain(String defaultDomain) _defaultDomain = defaultDomain; } + public boolean isLoginAttemptEnabled() + { + return _loginAttemptEnabled; + } + + @SuppressWarnings("unused") + public void setLoginAttemptEnabled(boolean loginAttemptEnabled) + { + _loginAttemptEnabled = loginAttemptEnabled; + } + + public int getLoginAttemptLimit() + { + return _loginAttemptLimit; + } + + @SuppressWarnings("unused") + public void setLoginAttemptLimit(int loginAttemptLimit) + { + _loginAttemptLimit = loginAttemptLimit; + } + + public int getLoginAttemptPeriod() + { + return _loginAttemptPeriod; + } + + @SuppressWarnings("unused") + public void setLoginAttemptPeriod(int loginAttemptPeriod) + { + _loginAttemptPeriod = loginAttemptPeriod; + } + + public int getLoginAttemptResetTime() + { + return _loginAttemptResetTime; + } + + @SuppressWarnings("unused") + public void setLoginAttemptResetTime(int loginAttemptResetTime) + { + _loginAttemptResetTime = loginAttemptResetTime; + } + public int[] getFormConfigurations() { return _formConfigurations; @@ -2529,7 +2583,11 @@ public ApiResponse execute(Object o, BindException errors) SELF_REGISTRATION_KEY, AuthenticationManager.isRegistrationEnabled(), SELF_SERVICE_EMAIL_CHANGES_KEY, AuthenticationManager.isSelfServiceEmailChangesEnabled(), AUTO_CREATE_ACCOUNTS_KEY, AuthenticationManager.isAutoCreateAccountsEnabled(), - DEFAULT_DOMAIN, AuthenticationManager.getDefaultDomain() + DEFAULT_DOMAIN, AuthenticationManager.getDefaultDomain(), + LOGIN_ATTEMPT_ENABLED_KEY, AuthenticationManager.isLoginAttemptControlEnabled(), + LOGIN_ATTEMPT_LIMIT_KEY, String.valueOf(AuthenticationManager.getLoginAttemptLimit()), + LOGIN_ATTEMPT_PERIOD_KEY, String.valueOf(AuthenticationManager.getLoginAttemptPeriod()), + LOGIN_ATTEMPT_RESET_TIME_KEY, String.valueOf(AuthenticationManager.getLoginAttemptResetTime()) ); // Primary providers diff --git a/core/test/src/org/labkey/test/tests/LoginAttemptLimitTest.java b/core/test/src/org/labkey/test/tests/LoginAttemptLimitTest.java new file mode 100644 index 00000000000..47a81f9009e --- /dev/null +++ b/core/test/src/org/labkey/test/tests/LoginAttemptLimitTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2016-2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.test.tests; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.experimental.categories.Category; +import org.labkey.test.BaseWebDriverTest; +import org.labkey.test.TestTimeoutException; +import org.labkey.test.categories.Git; +import org.labkey.test.pages.core.login.LoginConfigurePage; + +import java.util.Collections; +import java.util.List; + +@Category({Git.class}) +public class LoginAttemptLimitTest extends BaseWebDriverTest +{ + private static final String TEST_USER = "testuser@test.test"; + private static final String TEST_USER2 = "testuser2@test.test"; + + @BeforeClass + public static void doSetup() throws Exception + { + LoginAttemptLimitTest initTest = getCurrentTest(); + initTest.setupUsers(); + } + + private void setupUsers() + { + enableEmailRecorder(); + _userHelper.createUser(TEST_USER, true, true); + _userHelper.createUser(TEST_USER2, true, true); + + goToEmailRecord(); + waitForTextWithRefresh(longWaitForPage, TEST_USER, TEST_USER2); + + setInitialPassword(TEST_USER); + setInitialPassword(TEST_USER2); + } + + @Override + protected void doCleanup(boolean afterTest) throws TestTimeoutException + { + LoginConfigurePage.beginAt(this) + .setLoginAttemptEnabled(false) + .clickSaveAndFinish(); + + _userHelper.deleteUsers(afterTest, TEST_USER, TEST_USER2); + } + + @Override + public List getAssociatedModules() + { + return Collections.singletonList("Core"); + } + + @Override + protected String getProjectName() + { + return null; + } + + @Override + protected BrowserType bestBrowser() + { + return BrowserType.CHROME; + } + + @Test + public void testLimit() + { + setupLoginAttemptLimit(); + log("Signing out"); + simpleSignOut(); + log("sign in fail number 1"); + signInShouldFail(TEST_USER, "foo", "The email address and password you entered did not match any accounts on file. Note: Passwords are case sensitive; make sure your Caps Lock is off."); + simpleSignOut(); + log("sign in fail number 2"); + signInShouldFail(TEST_USER, "bar", "The email address and password you entered did not match any accounts on file. Note: Passwords are case sensitive; make sure your Caps Lock is off."); + simpleSignOut(); + log("sign in fail number 3"); + signInShouldFail(TEST_USER, "pug", "The email address and password you entered did not match any accounts on file. Note: Passwords are case sensitive; make sure your Caps Lock is off."); + simpleSignOut(); + log("sign in fail number 4, should be disabled now"); + attemptSignIn(TEST_USER, "dog"); + waitForText("Your login has been disabled. Please try again in 5 minutes."); + // Re-authenticate as admin; waiting for the 5-minute reset to verify re-enable is impractically slow. + signIn(); + } + + @Test + public void testBehaviorWithFeatureOff() + { + LoginConfigurePage.beginAt(this) + .setLoginAttemptEnabled(false) + .clickSaveAndFinish(); + signOut(); + for (int i = 0; i < 10; i++) + { + signInShouldFail(TEST_USER2, "pug", "The email address and password you entered did not match any accounts on file. Note: Passwords are case sensitive; make sure your Caps Lock is off."); + goToHome(); + } + } + + private void setupLoginAttemptLimit() + { + LoginConfigurePage.beginAt(this) + .setLoginAttemptEnabled(true) + .setLoginAttemptLimit("3") + .setLoginAttemptPeriod("30") + .setLoginAttemptResetTime("5") + .clickSaveAndFinish(); + } +}