Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 98 additions & 20 deletions api/src/org/labkey/api/security/AuthenticationManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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<String, String> props = PropertyManager.getProperties(AUTHENTICATION_CATEGORY);
Expand Down Expand Up @@ -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);
Expand All @@ -308,6 +343,37 @@ public static void saveAuthSettings(User user, Map<String, Boolean> 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)
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -635,15 +702,20 @@ 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";
public static final String DEFAULT_DOMAIN = "DefaultDomain";
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"),
Expand Down Expand 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;
}
}

Expand Down Expand Up @@ -1165,17 +1237,23 @@ public static PrimaryAuthenticationResult finalizePrimaryAuthentication(HttpServ

// limit one bad login per second averaged out over 60sec
private static final Cache<Integer, RateLimiter> addrLimiter = CacheManager.getCache(1001, TimeUnit.MINUTES.toMillis(5), "Login limiter");
private static final Cache<Integer, RateLimiter> userLimiter = CacheManager.getCache(1001, TimeUnit.MINUTES.toMillis(5), "User limiter");
private static final Cache<Integer, RateLimiter> pwdLimiter = CacheManager.getCache(1001, TimeUnit.MINUTES.toMillis(5), "Password limiter");
private static final CacheLoader<Integer, RateLimiter> addrLoader = (key, request) -> new RateLimiter("Addr limiter: " + key, new Rate(60, TimeUnit.MINUTES));
private static final CacheLoader<Integer, RateLimiter> pwdLoader = (key, request) -> new RateLimiter("Pwd limiter: " + key, new Rate(20, TimeUnit.MINUTES));
private static final CacheLoader<Integer, RateLimiter> userLoader = (key, request) -> new RateLimiter("User limiter: " + key, new Rate(20, TimeUnit.MINUTES));
private static final CacheLoader<Integer, RateLimiter> addrLoader = (key, _) -> new RateLimiter("Addr limiter: " + key, new Rate(60, TimeUnit.MINUTES));
private static final CacheLoader<Integer, RateLimiter> pwdLoader = (key, _) -> new RateLimiter("Pwd limiter: " + key, new Rate(20, TimeUnit.MINUTES));

private static Integer _toKey(String s)
private static final Cache<String, RateLimiter> userLimiter = CacheManager.getCache(10000, TimeUnit.MINUTES.toMillis(5), "User limiter");
private static final CacheLoader<String, RateLimiter> 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)
Expand All @@ -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));

Expand All @@ -1209,15 +1287,15 @@ 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);
}

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;
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion core/module.properties
Original file line number Diff line number Diff line change
@@ -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, \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Migrate login attempt settings from the compliance module's property store to core authentication settings.
SELECT core.executeJavaUpgradeCode('migrateLoginAttemptSettings');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we consider an upgrade test?

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Migrate login attempt settings from the compliance module's property store to core authentication settings.
EXEC core.executeJavaUpgradeCode 'migrateLoginAttemptSettings';
96 changes: 84 additions & 12 deletions core/src/client/components/GlobalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -76,25 +80,32 @@ export const GlobalSettings: FC<Props> = memo(({ canEdit, authCount, onChange, g
[onChange]
);

const onLoginAttemptEnabledChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange('LoginAttemptEnabled', event.target.checked);
},
[onChange]
);

const onLoginAttemptSelectChange = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
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 (
<div className="panel panel-default">
<div className="panel-heading">
Global Settings
</div>

<div className="panel-body">
{fieldData.map(data => (
<GlobalSetting
key={data.id}
canEdit={canEdit}
id={data.id}
onChange={onChange}
value={globalSettings[data.id]}
text={data.text}
tip={data.tip}
/>
))}

<div className="global-settings__default-domain">
<span>System Default Domain</span>

Expand All @@ -117,6 +128,67 @@ export const GlobalSettings: FC<Props> = memo(({ canEdit, authCount, onChange, g
/>
</span>
</div>

<hr/>

{fieldData.map(data => (
<GlobalSetting
key={data.id}
canEdit={canEdit}
id={data.id}
onChange={onChange}
value={globalSettings[data.id]}
text={data.text}
tip={data.tip}
/>
))}

<div className="global-settings__text-row">
<label>
<input
checked={loginAttemptEnabled}
disabled={!canEdit}
onChange={onLoginAttemptEnabledChange}
type="checkbox"
/>
Limit unsuccessful login attempts
<LabelHelpTip title="Tip">
<div>
This does not apply to site and application administrators. <HelpLink topic="complianceSettings#Login">More info</HelpLink>
</div>
</LabelHelpTip>
</label>
<div style={{marginLeft: '17px', marginTop: '5px'}}>
<span>Disable user login if </span>
<select
disabled={!canEdit || !loginAttemptEnabled}
name="LoginAttemptLimit"
onChange={onLoginAttemptSelectChange}
value={loginAttemptLimit}
>
{LOGIN_ATTEMPT_LIMIT_OPTIONS.map(v => <option key={v} value={v}>{v}</option>)}
</select>
<span> consecutive invalid logins are attempted in a </span>
<select
disabled={!canEdit || !loginAttemptEnabled}
name="LoginAttemptPeriod"
onChange={onLoginAttemptSelectChange}
value={loginAttemptPeriod}
>
{LOGIN_ATTEMPT_PERIOD_OPTIONS.map(v => <option key={v} value={v}>{v}</option>)}
</select>
<span> second period. Automatically allow users to login again after </span>
<select
disabled={!canEdit || !loginAttemptEnabled}
name="LoginAttemptResetTime"
onChange={onLoginAttemptSelectChange}
value={loginAttemptResetTime}
>
{LOGIN_ATTEMPT_RESET_TIME_OPTIONS.map(v => <option key={v} value={v}>{v}</option>)}
</select>
<span> minutes.</span>
</div>
</div>
</div>
</div>
);
Expand Down
4 changes: 4 additions & 0 deletions core/src/client/components/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading