diff --git a/docs/README.md b/docs/README.md index ee9c0b3ecffd..e2d4ef7d2a34 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,7 @@ Descriptions say **when to load** the doc, not what it contains. | [CONFIGURATION_PATTERNS.md](backend/CONFIGURATION_PATTERNS.md) | Reading configuration — `Config.getProperty()` and the resolution order | | [DATABASE_PATTERNS.md](backend/DATABASE_PATTERNS.md) | Writing SQL, using `DotConnect`, or reasoning about transactions | | [SECURITY_BACKEND.md](backend/SECURITY_BACKEND.md) | Input validation, auth, SQL/XSS prevention, secure logging on the backend specifically | +| [LOGIN_LOCKOUT.md](backend/LOGIN_LOCKOUT.md) | Working on login lockout, configuring it, or unlocking a locked-out user (support runbook) | | [VIRTUAL_THREADS.md](backend/VIRTUAL_THREADS.md) | Deciding whether a workload belongs on a virtual thread — socket I/O yes, file I/O no | | [JANDEX_METADATA_SCANNING.md](backend/JANDEX_METADATA_SCANNING.md) | Looking up classes or annotations at runtime — prefer this over reflection | | [HEALTH_MONITORING.md](backend/HEALTH_MONITORING.md) | Working on health endpoints or runtime log levels | diff --git a/docs/backend/LOGIN_LOCKOUT.md b/docs/backend/LOGIN_LOCKOUT.md new file mode 100644 index 000000000000..9632f94c80d5 --- /dev/null +++ b/docs/backend/LOGIN_LOCKOUT.md @@ -0,0 +1,72 @@ +# Login Lockout + +Repeated failed logins lock the failing source out of the account for a fixed time. The code is +`com.dotcms.auth.LoginLockout`; state lives in the `login_lockout` table, so every node of a cluster sees +the same counts and locks. + +## How it works + +- **Per account and client address.** Each address gets `auth.max.failures.limit` password attempts + (default 5) within the counting window. When they are used up without a success, that address is locked + out of that account. Other addresses, including the account owner's, are not affected. + - IPv6 addresses are grouped by their /64 network, since one host can usually use any address in it. + - WebDAV is counted on its own (`webdav:
`), so a mounted drive retrying a stale password locks + only WebDAV from that machine, not backend logins. +- **Per account, all addresses together (opt-in, off by default).** When `AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES` + is set above 0 and failures from every address add up to it within the window, the account is locked for + every address. It caps guessing spread over many addresses, but anyone who knows a login name can trigger + it and keep that account, administrators included, locked for everyone. Enable it only if that trade-off + suits the site. +- **Fixed length.** Locks last `AUTH_LOCKOUT_DURATION_SECONDS` and do not grow. A count that ages out of + the window, or whose lock has run out, starts over. +- **Counted before the check.** Each attempt is counted before its password is checked, so parallel + requests get at most the limit between them. +- **While locked,** the password is still checked (same timing) and the login fails exactly like a wrong + password. Nothing is counted. +- **Clearing.** A successful login clears the count for its own address. A password reset, or a new + password set by an administrator, clears everything for the account. Deleting a user deletes their rows, + and rows that no longer affect any login are purged periodically. + +Every password check goes through it: the backend and front-end login, REST basic auth (and so the +authentication endpoints and the CLI), WebDAV, the legacy `LoginFactory.doLogin` endpoints, Login As, and +the current-password check when users change their own password. Failures rejected by LDAP or a WebDAV +pre-authenticator are counted too. API tokens, SAML, OAuth and remember-me cookies check no password, so +they are neither counted nor blocked. + +## Configuration + +| Key | Default | Meaning | +|---|---|---| +| `FEATURE_FLAG_AUTH_LOGIN_LOCKOUT` | `true` | Turns lockout on or off. Env var: `DOT_FEATURE_FLAG_AUTH_LOGIN_LOCKOUT`. | +| `auth.max.failures.limit` | `5` | Attempts per address and account in a window (portal property). `0` also turns lockout off. | +| `AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES` | `0` (off) | Failures per account from all addresses in a window before the account is locked for everyone. Set above 0 to enable. | +| `AUTH_LOCKOUT_WINDOW_SECONDS` | `3600` | How long attempts are counted together. | +| `AUTH_LOCKOUT_DURATION_SECONDS` | `900` | Lock length. | + +## Behind a proxy or load balancer + +The client address is `HttpServletRequest.getRemoteAddr()`. Tomcat's `RemoteIpValve` replaces it with the +`X-Forwarded-For` client only when the request comes from a trusted proxy. The bundled `server.xml` trusts +private address ranges by default; set `CMS_REMOTEIP_INTERNALPROXIES` for anything else. + +If the proxy isn't trusted, every user behind it shares one address. Five wrong passwords from anyone then +lock everyone behind that proxy out of the account. When one address is locked out of five or more +accounts at once, dotCMS logs a warning that names it. That warning means either one client is trying many +accounts, or the address is an untrusted proxy. + +## Support runbook: a user is locked out + +1. **Try another network, or wait.** Locks are per address and last 15 minutes by default, so the user can + log in from another network or wait. Only the account-wide limit, if a site has enabled it, blocks every + address. +2. **Unlock now:** reset the password through the "Forgot password" email, or have an administrator set a + new password in Users. Either clears all lockout state for the account. +3. **Unlock without changing the password:** with database access, clear the state directly. + - Everything for the user: `DELETE FROM login_lockout WHERE user_id = '';` + - One address only: `DELETE FROM login_lockout WHERE user_id = '' AND source = '
';` +4. **Diagnose:** `SELECT source, failures, to_timestamp(locked_until / 1000) FROM login_lockout WHERE + user_id = '';` shows each source's count and lock. The security log records every lock with + its source. +5. **Every lock shows the same proxy or load-balancer address:** Tomcat isn't seeing real client + addresses. Set `CMS_REMOTEIP_INTERNALPROXIES` (see above). +6. **Turn lockout off:** set `DOT_FEATURE_FLAG_AUTH_LOGIN_LOCKOUT=false`. diff --git a/dotCMS/src/main/java/com/dotcms/auth/LoginLockout.java b/dotCMS/src/main/java/com/dotcms/auth/LoginLockout.java new file mode 100644 index 000000000000..5987c1e23a12 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/auth/LoginLockout.java @@ -0,0 +1,636 @@ +package com.dotcms.auth; + +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.business.CloseDBIfOpened; +import com.dotcms.featureflag.FeatureFlagName; +import com.dotmarketing.cms.login.factories.LoginFactory; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; +import com.dotmarketing.util.UtilMethods; +import com.google.common.net.InetAddresses; +import com.liferay.portal.model.User; +import com.liferay.portal.util.PropsUtil; +import com.liferay.util.GetterUtil; + +import javax.servlet.http.HttpServletRequest; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiPredicate; +import java.util.function.LongSupplier; + +/** + * Login lockout keyed on the account and the client address, so failed logins from one place cannot + * lock the account owner out from another. Two counts are kept in the {@code login_lockout} table, + * shared by every node of a cluster: + *
    + *
  • Per account and client address. Each address gets {@code auth.max.failures.limit} + * password attempts within the counting window. When they are used up without a success, that + * address is locked out of that account. Other addresses, including the owner's, are not affected. + * IPv6 addresses are grouped by their /64 prefix, which one host can rotate through. WebDAV is + * counted apart from other logins ({@value #WEBDAV_SOURCE_PREFIX}address), so a mounted drive + * retrying a stale password locks only WebDAV from that machine.
  • + *
  • Per account, all addresses together (source {@value #ACCOUNT_WIDE}). An optional, looser + * backstop, off by default: once failures from every address add up to + * {@code AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES} within the window, the account is locked for everyone. It + * caps guessing spread over many addresses, but anyone who knows a login name can then trigger it, so + * it is opt-in.
  • + *
+ * An attempt is counted against its address before the password is checked ({@link #begin}), so + * parallel requests cannot all pass a check made before any of them was counted. Locks last a fixed + * time and do not grow. While a lock applies every password check fails, including the correct + * password, and nothing is counted. A successful login clears the count for its own address; a password + * reset or a new password set by an administrator clears everything for the account. + * + *

The client address is {@link HttpServletRequest#getRemoteAddr()}. Behind a proxy it is the real + * client only when Tomcat's {@code RemoteIpValve} trusts that proxy (the bundled {@code server.xml} + * trusts private ranges by default); otherwise every login shares the proxy's address. A warning is + * logged, at most once a minute per node, for any address locked out of several accounts, which is what + * that looks like. + * + *

The main login flow ({@code UserManagerImpl#authenticate}) uses {@link #begin}, + * {@link #succeeded} and {@link #failed} directly. Other places that check a password (WebDAV, the + * legacy {@code LoginFactory.doLogin} methods, Login As and the current-password check on a user update) + * go through {@link #passwordMatch(String, User, String, BiPredicate)}. Passwords checked by an external + * authenticator such as LDAP are counted with {@link #countFailure(String, String)}. + * + *

Configuration: + *

    + *
  • {@code FEATURE_FLAG_AUTH_LOGIN_LOCKOUT} (default {@code true})
  • + *
  • {@code auth.max.failures.limit}: attempts per address (portal property, default 5)
  • + *
  • {@code AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES}: failures per account from all addresses + * (default 0, which leaves the account-wide lock off)
  • + *
  • {@code AUTH_LOCKOUT_WINDOW_SECONDS}: how long attempts are counted together (default 3600)
  • + *
  • {@code AUTH_LOCKOUT_DURATION_SECONDS}: lock length (default 900)
  • + *
+ */ +public final class LoginLockout { + + public static final String LOCKOUT_ENABLED = FeatureFlagName.FEATURE_FLAG_AUTH_LOGIN_LOCKOUT; + public static final String LOCKOUT_DURATION_SECONDS = "AUTH_LOCKOUT_DURATION_SECONDS"; + public static final String LOCKOUT_WINDOW_SECONDS = "AUTH_LOCKOUT_WINDOW_SECONDS"; + public static final String LOCKOUT_ACCOUNT_MAX_FAILURES = "AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES"; + + /** Source of the account-wide row. Never a client address. */ + public static final String ACCOUNT_WIDE = "*"; + + /** Source used when there is no request to take an address from. */ + public static final String UNKNOWN_SOURCE = "unknown"; + + /** Prefix that keeps WebDAV attempts apart from other logins from the same address. */ + public static final String WEBDAV_SOURCE_PREFIX = "webdav:"; + + private static final int MAX_ADDRESS_LENGTH = 64; + + /** Bytes of an IPv6 address that identify its /64 network. */ + private static final int IPV6_PREFIX_BYTES = 8; + + /** An address locked out of this many accounts at once is reported as a likely shared proxy address. */ + private static final int SHARED_SOURCE_WARNING_ACCOUNTS = 5; + + private static final long MAINTENANCE_INTERVAL_MILLIS = 60_000L; + + /** + * Counts one attempt for an account and source and returns the new count and the source's lock. + * Both CASE conditions mean the count starts over: it is older than the window, or its lock has run + * out. Parameters: user, source, now, mod date, then window cutoff and now (failures), window + * cutoff, now and now (window start), now (lock), mod date. + */ + private static final String COUNT_SQL = + "INSERT INTO login_lockout (user_id, source, failures, window_start, locked_until, mod_date)" + + " VALUES (?, ?, 1, ?, 0, ?) ON CONFLICT (user_id, source) DO UPDATE SET" + + " failures = CASE WHEN (login_lockout.window_start < ? OR (login_lockout.locked_until > 0" + + " AND login_lockout.locked_until <= ?)) THEN 1 ELSE login_lockout.failures + 1 END," + + " window_start = CASE WHEN (login_lockout.window_start < ? OR (login_lockout.locked_until > 0" + + " AND login_lockout.locked_until <= ?)) THEN ? ELSE login_lockout.window_start END," + + " locked_until = CASE WHEN login_lockout.locked_until <= ? THEN 0" + + " ELSE login_lockout.locked_until END," + + " mod_date = ? RETURNING failures, locked_until"; + + /** Hands back one attempt that turned out not to be a password failure. */ + private static final String UNCOUNT_SQL = + "UPDATE login_lockout SET failures = failures - 1 WHERE user_id = ? AND source = ? AND failures > 0"; + + /** Locks an account and source. Parameters: user, source, now, locked until, mod date, then locked until, now, mod date. */ + private static final String LOCK_SQL = + "INSERT INTO login_lockout (user_id, source, failures, window_start, locked_until, mod_date)" + + " VALUES (?, ?, 0, ?, ?, ?) ON CONFLICT (user_id, source) DO UPDATE SET" + + " locked_until = ?, failures = 0, window_start = ?, mod_date = ?"; + + /** Clears one source's count after a success, but never a lock in force. Parameters: user, source, now. */ + private static final String CLEAR_SOURCE_SQL = + "DELETE FROM login_lockout WHERE user_id = ? AND source = ? AND locked_until <= ?"; + + /** + * Sources locked out of at least a given number of accounts right now, with that number. Parameters: now, + * account-wide source, minimum accounts. + */ + private static final String SHARED_SOURCES_SQL = + "SELECT source, COUNT(DISTINCT user_id) AS accounts FROM login_lockout" + + " WHERE locked_until > ? AND source <> ? GROUP BY source HAVING COUNT(DISTINCT user_id) >= ?"; + + /** + * Removes rows that no longer affect any login: their lock has run out (or never existed) and their + * count is older than the window. Parameters: now, window cutoff. + */ + private static final String PURGE_SQL = + "DELETE FROM login_lockout WHERE locked_until <= ? AND window_start < ?"; + + /** + * Lock state of one account as seen from one source. + */ + public enum Status { + /** Nothing is recorded for this source and nothing is locked. */ + NONE, + /** Attempts from this source are recorded but nothing is locked. */ + RECORDED, + /** This source, or the whole account, is locked. */ + LOCKED + } + + /** + * One login attempt, counted against its source before the password is checked. Get it from + * {@link #begin(String, String)} and finish it with {@link #succeeded}, {@link #failed} or + * {@link #notAPasswordLogin}. + */ + public static final class Attempt { + + private static final Attempt UNTRACKED = new Attempt(null, null, false, false, 0); + + private final String userId; + private final String source; + private final boolean refused; + private final boolean counted; + private final int number; + + private Attempt(final String userId, final String source, final boolean refused, final boolean counted, + final int number) { + this.userId = userId; + this.source = source; + this.refused = refused; + this.counted = counted; + this.number = number; + } + + /** + * @return {@code true} when the password must not be accepted: a lock applies, or this source has + * used up its attempts for the window + */ + public boolean isRefused() { + return refused; + } + } + + private static final LoginLockout INSTANCE = new LoginLockout(System::currentTimeMillis); + + private final LongSupplier clock; + private final AtomicLong lastMaintenance = new AtomicLong(); + + LoginLockout(final LongSupplier clock) { + this.clock = clock; + } + + public static LoginLockout getInstance() { + return INSTANCE; + } + + /** + * @return the attempts allowed per source in a window ({@code auth.max.failures.limit}), 0 when unset + */ + public static int maxFailures() { + return GetterUtil.get(PropsUtil.get(PropsUtil.AUTH_MAX_FAILURES_LIMIT), 0); + } + + /** + * @return the failure limit for an account from all addresses together; 0 or less (the default) leaves the + * account-wide lock off + */ + public static int accountMaxFailures() { + return Config.getIntProperty(LOCKOUT_ACCOUNT_MAX_FAILURES, 0); + } + + /** + * @return the source the current request's logins are counted under, or {@value #UNKNOWN_SOURCE} + * outside a request + */ + public static String clientAddress() { + return clientAddress(HttpServletRequestThreadLocal.INSTANCE.getRequest()); + } + + /** + * Returns the source a request's logins are counted under: its remote address, with an IPv6 + * address reduced to its /64 network ({@code 2001:db8:1:2::/64}), since a single host can usually + * pick any address in its /64. An IPv4-mapped IPv6 address counts as the IPv4 address. + * + * @return the source, or {@value #UNKNOWN_SOURCE} when there is no request or address + */ + public static String clientAddress(final HttpServletRequest request) { + final String address = null != request ? request.getRemoteAddr() : null; + if (!UtilMethods.isSet(address)) { + return UNKNOWN_SOURCE; + } + final String source = address.indexOf(':') >= 0 ? ipv6Network(address) : address; + return source.length() > MAX_ADDRESS_LENGTH ? source.substring(0, MAX_ADDRESS_LENGTH) : source; + } + + /** + * @return the /64 network of an IPv6 literal, the IPv4 address of an IPv4-mapped one, or the input + * unchanged when it is not an IP literal. Parsing never does a DNS lookup. + */ + private static String ipv6Network(final String address) { + final InetAddress parsed; + try { + // Tomcat may report a zone, as in fe80::1%eth0; the zone does not identify the host + final int zone = address.indexOf('%'); + parsed = InetAddresses.forString(zone >= 0 ? address.substring(0, zone) : address); + } catch (final IllegalArgumentException e) { + return address; + } + if (!(parsed instanceof Inet6Address)) { + return parsed.getHostAddress(); + } + final byte[] network = Arrays.copyOf(Arrays.copyOf(parsed.getAddress(), IPV6_PREFIX_BYTES), 16); + try { + // Built from raw bytes, so no lookup either + return InetAddresses.toAddrString(InetAddress.getByAddress(network)) + "/64"; + } catch (final UnknownHostException e) { + return address; + } + } + + /** + * @return the source WebDAV logins from the current request are counted under + */ + public static String webdavSource() { + return WEBDAV_SOURCE_PREFIX + clientAddress(); + } + + /** + * @return {@code true} when lockout is switched on and a failure limit is configured + */ + public boolean isEnabled() { + return isEnabled(maxFailures()); + } + + /** + * @return {@code true} when lockout is switched on and a failure limit is configured + */ + public boolean isEnabled(final int maxFailures) { + return maxFailures > 0 && Config.getBooleanProperty(LOCKOUT_ENABLED, true); + } + + /** + * Returns the lock state to enforce for a login to the account from the given source: + * {@link Status#NONE} when lockout is switched off, otherwise what is stored. + */ + public Status check(final String userId, final String source) { + return isEnabled() ? status(userId, source) : Status.NONE; + } + + /** + * Returns the stored lock state for the account and source, whether or not lockout is switched on. + * A database error is treated as {@link Status#NONE} so an outage of this table never blocks every + * login; the error is logged. + */ + @CloseDBIfOpened + public Status status(final String userId, final String source) { + try { + final List> rows = new DotConnect() + .setSQL("SELECT source, locked_until FROM login_lockout WHERE user_id = ? AND source IN (?, ?)") + .addParam(userId).addParam(source).addParam(ACCOUNT_WIDE) + .loadObjectResults(); + final long now = clock.getAsLong(); + boolean recorded = false; + for (final Map row : rows) { + if (((Number) row.get("locked_until")).longValue() > now) { + return Status.LOCKED; + } + recorded |= source.equals(row.get("source")); + } + return recorded ? Status.RECORDED : Status.NONE; + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to read login lockout state for user " + userId + ": " + e.getMessage(), e); + return Status.NONE; + } + } + + /** + * Returns whether a login to the account from the given source would be refused right now, whether + * or not lockout is switched on. + */ + public boolean isLocked(final String userId, final String source) { + return status(userId, source) == Status.LOCKED; + } + + /** + * Returns whether any lock on the account is in force right now: for some source, or account-wide. + */ + @CloseDBIfOpened + public boolean isLocked(final String userId) { + try { + return !new DotConnect() + .setSQL("SELECT source FROM login_lockout WHERE user_id = ? AND locked_until > ?") + .addParam(userId).addParam(clock.getAsLong()) + .loadObjectResults().isEmpty(); + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to read login lockout state for user " + userId + ": " + e.getMessage(), e); + return false; + } + } + + /** + * Starts a login attempt from the given source, before its password is checked. When a lock is in + * force the attempt is refused and nothing is counted. Otherwise the attempt is counted now, and + * refused if that takes the source past its limit for the window, or if a lock was set on the source + * between the check and the count; counting first means parallel attempts get at most the limit + * between them. With lockout switched off, or on a database error, the attempt is neither counted + * nor refused. + */ + @CloseDBIfOpened + public Attempt begin(final String userId, final String source) { + final int maxFailures = maxFailures(); + if (!isEnabled(maxFailures)) { + return Attempt.UNTRACKED; + } + if (status(userId, source) == Status.LOCKED) { + return new Attempt(userId, source, true, false, 0); + } + try { + final Map row = count(userId, source); + if (((Number) row.get("locked_until")).longValue() > clock.getAsLong()) { + // Locked by a parallel attempt after the check above: refused, and the lock left as it is + return new Attempt(userId, source, true, false, 0); + } + final int number = ((Number) row.get("failures")).intValue(); + return new Attempt(userId, source, number > maxFailures, true, number); + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to count login attempt for user " + userId + ": " + e.getMessage(), e); + return Attempt.UNTRACKED; + } + } + + /** + * Finishes an attempt whose password was accepted: earlier attempts from its source no longer count. + * A lock that a parallel attempt set on the source stays. Nothing is written unless the attempt was + * counted. + */ + @CloseDBIfOpened + public void succeeded(final Attempt attempt) { + if (!attempt.counted) { + return; + } + try { + new DotConnect().setSQL(CLEAR_SOURCE_SQL) + .addParam(attempt.userId).addParam(attempt.source).addParam(clock.getAsLong()) + .loadResult(); + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to clear login lockout state for user " + attempt.userId + ": " + + e.getMessage(), e); + } + } + + /** + * Finishes an attempt that was not a password failure (for example, WebDAV accepted an API token in + * the password field): its count is handed back. + */ + @CloseDBIfOpened + public void notAPasswordLogin(final Attempt attempt) { + if (!attempt.counted) { + return; + } + try { + new DotConnect().setSQL(UNCOUNT_SQL).addParam(attempt.userId).addParam(attempt.source).loadResult(); + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to update login lockout state for user " + attempt.userId + ": " + + e.getMessage(), e); + } + } + + /** + * Finishes an attempt whose password was refused or wrong. Locks the source when it has used up its + * attempts, and counts a wrong password toward the account-wide limit, locking the account when that + * is reached. An attempt refused under a lock, or never counted, changes nothing. Errors are logged, + * never thrown, so a problem with this table never breaks a login. + */ + @CloseDBIfOpened + public void failed(final Attempt attempt) { + if (!attempt.counted) { + return; + } + try { + if (attempt.number >= maxFailures()) { + lock(attempt.userId, attempt.source, attempt.number); + } + final int accountMax = accountMaxFailures(); + if (!attempt.refused && accountMax > 0) { + final int accountFailures = ((Number) count(attempt.userId, ACCOUNT_WIDE).get("failures")).intValue(); + if (accountFailures >= accountMax) { + lock(attempt.userId, ACCOUNT_WIDE, accountFailures); + } + } + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to record failed login for user " + attempt.userId + ": " + e.getMessage(), e); + } + } + + /** + * Counts a failed login whose password was checked by an external authenticator, such as LDAP or a + * WebDAV pre-authenticator, rather than by dotCMS. Same effect as a wrong password through + * {@link #begin} and {@link #failed}; nothing is counted while a lock is in force. + */ + public void countFailure(final String userId, final String source) { + failed(begin(userId, source)); + } + + /** + * Clears the count for one source. The account-wide count is left to run out with its window. + */ + @CloseDBIfOpened + public void clear(final String userId, final String source) { + try { + new DotConnect().setSQL("DELETE FROM login_lockout WHERE user_id = ? AND source = ?") + .addParam(userId).addParam(source).loadResult(); + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to clear login lockout state for user " + userId + ": " + e.getMessage(), e); + } + } + + /** + * Clears all counts and locks for the account, from every source. Used when a new password is set. + */ + @CloseDBIfOpened + public void clear(final String userId) { + try { + new DotConnect().setSQL("DELETE FROM login_lockout WHERE user_id = ?").addParam(userId).loadResult(); + } catch (final DotDataException | RuntimeException e) { + Logger.error(this, "Unable to clear login lockout state for user " + userId + ": " + e.getMessage(), e); + } + } + + /** + * Same as {@link #passwordMatch(String, User, String, BiPredicate)} for a password-only login from + * the current request's address. + */ + public boolean passwordMatch(final String password, final User user) { + return passwordMatch(password, user, clientAddress(), (secret, candidate) -> false); + } + + /** + * Password check for places that do not go through {@code UserManagerImpl#authenticate}: WebDAV, the + * legacy {@code LoginFactory.doLogin} methods, Login As and the current-password check on a user + * update. It answers the way that path does: + *
    + *
  • while a lock applies to this source or the whole account, or the source has used up its + * attempts, every password is refused after the same hashing work;
  • + *
  • a wrong password counts as a failed login from this source;
  • + *
  • a right password clears the count for this source.
  • + *
+ * With lockout switched off this is a plain {@link LoginFactory#passwordMatch(String, User)}. + * + * @param source what attempts are counted under: the client address, or + * {@link #webdavSource()} for WebDAV + * @param otherCredential checked when the password does not match, for callers that also accept + * another secret in the password field (WebDAV takes an API token). A match + * there is not a password login, so it is accepted while locked and its count + * is handed back. + * @return whether the caller should treat the user as authenticated + */ + public boolean passwordMatch(final String password, final User user, final String source, + final BiPredicate otherCredential) { + if (!isEnabled()) { + return LoginFactory.passwordMatch(password, user) || otherCredential.test(password, user); + } + final Attempt attempt = begin(user.getUserId(), source); + // Check the password even when refused, so a refused login takes the same time to answer and + // gives no signal about whether the guess was right. + final boolean match = LoginFactory.passwordMatch(password, user) && !attempt.isRefused(); + if (match) { + succeeded(attempt); + return true; + } + if (otherCredential.test(password, user)) { + notAPasswordLogin(attempt); + return true; + } + if (attempt.isRefused()) { + SecurityLogger.logInfo(LoginLockout.class, "Login refused, locked out: user " + user.getUserId() + + " from " + source); + } + failed(attempt); + return false; + } + + /** + * Counts one attempt for the account and source in a single statement, so concurrent attempts on + * several nodes all count. + * + * @return the row after counting: {@code failures} (the count this attempt brought it to) and + * {@code locked_until} + */ + private Map count(final String userId, final String source) throws DotDataException { + final long now = clock.getAsLong(); + final long windowCutoff = now - windowSeconds() * 1000L; + final Date modDate = new Date(now); + final List> rows = new DotConnect() + .setSQL(COUNT_SQL) + .addParam(userId).addParam(source).addParam(now).addParam(modDate) + .addParam(windowCutoff).addParam(now) + .addParam(windowCutoff).addParam(now).addParam(now) + .addParam(now) + .addParam(modDate) + .loadObjectResults(); + if (rows.isEmpty()) { + throw new DotDataException("No count returned for login attempt of user " + userId); + } + return rows.get(0); + } + + private void lock(final String userId, final String source, final int attempts) throws DotDataException { + final long now = clock.getAsLong(); + final long seconds = lockSeconds(); + final long lockedUntil = now + seconds * 1000L; + final Date modDate = new Date(now); + new DotConnect() + .setSQL(LOCK_SQL) + .addParam(userId).addParam(source).addParam(now).addParam(lockedUntil).addParam(modDate) + .addParam(lockedUntil).addParam(now).addParam(modDate) + .loadResult(); + SecurityLogger.logInfo(LoginLockout.class, ACCOUNT_WIDE.equals(source) + ? "Account locked for all addresses after " + attempts + " failed logins: user " + userId + + " for " + seconds + "s, last attempt from " + clientAddress() + : "Source locked out of account after " + attempts + " login attempts: user " + userId + + " from " + source + " for " + seconds + "s"); + maintain(now); + } + + /** + * Housekeeping run when a lock is set, at most once a minute per node, so it costs nothing on most + * locks while still running whenever failures are frequent: deletes rows that no longer affect a login, + * and reports sources that look shared. + */ + private void maintain(final long now) { + final long last = lastMaintenance.get(); + if (now - last < MAINTENANCE_INTERVAL_MILLIS || !lastMaintenance.compareAndSet(last, now)) { + return; + } + purgeExpired(now); + warnAboutSharedSources(now); + } + + /** + * Deletes rows, for any account, that no longer affect a login. Sources that fail and never succeed + * would otherwise stay in the table for good. + */ + private void purgeExpired(final long now) { + try { + new DotConnect().setSQL(PURGE_SQL) + .addParam(now).addParam(now - windowSeconds() * 1000L) + .loadResult(); + } catch (final DotDataException | RuntimeException e) { + Logger.warn(this, "Unable to remove expired login lockout rows: " + e.getMessage()); + } + } + + /** + * Logs a warning for each source locked out of several accounts at once. Either one client is trying + * many accounts, or the source is a proxy or load balancer that Tomcat does not trust, so every user + * behind it is counted as one address. + */ + private void warnAboutSharedSources(final long now) { + try { + final List> rows = new DotConnect().setSQL(SHARED_SOURCES_SQL) + .addParam(now).addParam(ACCOUNT_WIDE).addParam(SHARED_SOURCE_WARNING_ACCOUNTS) + .loadObjectResults(); + for (final Map row : rows) { + Logger.warn(this, "Login lockout: " + row.get("source") + " is locked out of " + row.get("accounts") + + " accounts. Either one client is trying many accounts, or this is a proxy or load balancer" + + " address and every user behind it shares one lockout. If it is a proxy, add it to" + + " CMS_REMOTEIP_INTERNALPROXIES so Tomcat uses the real client address."); + } + } catch (final DotDataException | RuntimeException e) { + Logger.warn(this, "Unable to check login lockout sources: " + e.getMessage()); + } + } + + /** + * @return the lock length in seconds; fixed, so repeated locks do not grow + */ + long lockSeconds() { + return Math.max(1, Config.getLongProperty(LOCKOUT_DURATION_SECONDS, 900)); + } + + /** + * @return how long attempts are counted together, in seconds + */ + long windowSeconds() { + return Math.max(1, Config.getLongProperty(LOCKOUT_WINDOW_SECONDS, 3600)); + } +} diff --git a/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPI.java b/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPI.java index e8f98cd17689..807118a69e4b 100644 --- a/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPI.java +++ b/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPI.java @@ -2,6 +2,7 @@ import static com.dotcms.util.FunctionUtils.ifTrue; +import com.dotcms.auth.LoginLockout; import com.dotmarketing.util.Config; import io.vavr.Lazy; import java.io.Serializable; @@ -211,11 +212,13 @@ default void doLogout(final HttpServletRequest request, final HttpServletRespons * MUST be loaded from db with all the information because we are * going to save this object if rehash process required * @return If passwords match, returns {@code true}. Otherwise, returns - * {@code false}. + * {@code false}. While login lockout applies to the request's address or the account, returns + * {@code false} for any password; a wrong password counts toward the lockout (see + * {@link LoginLockout}). */ default boolean passwordMatch(final String password, final User user) { - return LoginFactory.passwordMatch(password, user); + return LoginLockout.getInstance().passwordMatch(password, user); } /** diff --git a/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPIFactory.java b/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPIFactory.java index 70be8221274e..59e3af1db04b 100644 --- a/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPIFactory.java +++ b/dotCMS/src/main/java/com/dotcms/cms/login/LoginServiceAPIFactory.java @@ -10,6 +10,7 @@ import com.dotcms.api.system.event.message.SystemMessageEventUtil; import com.dotcms.api.system.event.message.builder.SystemMessageBuilder; import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.auth.LoginLockout; import com.dotcms.auth.providers.jwt.JsonWebTokenUtils; import com.dotcms.auth.providers.jwt.beans.ApiToken; import com.dotcms.auth.providers.jwt.factories.ApiTokenAPI; @@ -684,7 +685,7 @@ public void doLogout(final HttpServletRequest request, final HttpServletResponse @CloseDBIfOpened @Override public boolean passwordMatch(String password, User user) { - return LoginFactory.passwordMatch(password, user); + return LoginLockout.getInstance().passwordMatch(password, user); } /** diff --git a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java index 0850b21b6c97..e5dad58376ef 100644 --- a/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java +++ b/dotCMS/src/main/java/com/dotcms/featureflag/FeatureFlagName.java @@ -147,4 +147,11 @@ public interface FeatureFlagName { * customer data has been migrated to CAEM. */ String FEATURE_FLAG_CAEM_EXPERIMENT_RESULTS = "FEATURE_FLAG_CAEM_EXPERIMENT_RESULTS"; + + /** + * Login lockout after repeated failed logins ({@link com.dotcms.auth.LoginLockout}): per account and + * client address, plus an account-wide limit. The tuning values ({@code AUTH_LOCKOUT_*}) are read + * separately. + */ + String FEATURE_FLAG_AUTH_LOGIN_LOCKOUT = "FEATURE_FLAG_AUTH_LOGIN_LOCKOUT"; } diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResourceHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResourceHelper.java index 6bf0d78474d2..d650bb1db218 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResourceHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/user/UserResourceHelper.java @@ -5,6 +5,7 @@ import com.dotcms.api.system.event.message.SystemMessageEventUtil; import com.dotcms.api.system.event.message.builder.SystemMessage; import com.dotcms.api.system.event.message.builder.SystemMessageBuilder; +import com.dotcms.auth.LoginLockout; import com.dotcms.cms.login.LoginServiceAPI; import com.google.common.annotations.VisibleForTesting; import com.dotcms.rest.ResponseEntityView; @@ -25,7 +26,6 @@ import com.dotmarketing.business.web.HostWebAPI; import com.dotmarketing.business.web.UserWebAPI; import com.dotmarketing.business.web.WebAPILocator; -import com.dotmarketing.cms.login.factories.LoginFactory; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; import com.dotmarketing.util.ActivityLogger; @@ -215,7 +215,7 @@ public Map doLoginAs(final User currentUser, final String loginA com.dotmarketing.business.APILocator.getRoleAPI().loadCMSAdminRole())) { if (!UtilMethods.isSet(loginAsUserPwd)) { throw new DotDataException("The 'Login As' user password is required.", "loginas.error.missingloginaspwd"); - } else if (!LoginFactory.passwordMatch(loginAsUserPwd, currentUser)) { + } else if (!LoginLockout.getInstance().passwordMatch(loginAsUserPwd, currentUser)) { throw new DotDataException("The 'Login As' user password is invalid.", "loginas.error.invalidloginascredentials"); } @@ -421,6 +421,7 @@ public User updateUser(final UpdateCurrentUserForm updateUserForm, final User mo if (userToSave.getUserId().equalsIgnoreCase(modUser.getUserId())) { + // Goes through login lockout: counted, and refused under a lock, like any other password check boolean passwordMatch = loginService.passwordMatch(updateUserForm.getCurrentPassword(), modUser); if (!passwordMatch){ diff --git a/dotCMS/src/main/java/com/dotmarketing/business/UserAPIImpl.java b/dotCMS/src/main/java/com/dotmarketing/business/UserAPIImpl.java index e51efbdb8ac1..0eefe2825b14 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/UserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/UserAPIImpl.java @@ -1,5 +1,6 @@ package com.dotmarketing.business; +import com.dotcms.auth.LoginLockout; import com.dotcms.business.CloseDBIfOpened; import com.dotcms.business.WrapInTransaction; import com.dotcms.enterprise.PasswordFactoryProxy; @@ -494,8 +495,13 @@ public void save(User userToSave, User user, boolean validatePassword, Logger.error(UserAPIImpl.class, "An error occurred generating the hashed password for userId: " + userToSave.getUserId(), e); throw new DotDataException("An error occurred generating the hashed password."); } + userToSave.setFailedLoginAttempts(0); } save(userToSave, user, respectFrontEndRoles); + if (validatePassword) { + // A new password set by an administrator (or the user) unlocks an account locked out by failed logins + LoginLockout.getInstance().clear(userToSave.getUserId()); + } } @Override @@ -680,7 +686,10 @@ public void updatePassword(User user, String newpass, User currentUser, boolean user.setIcqId(""); user.setPasswordReset(GetterUtil.getBoolean( PropsUtil.get(PropsUtil.PASSWORDS_CHANGE_ON_FIRST_USE))); + user.setFailedLoginAttempts(0); save(user, currentUser, respectFrontEndRoles); + // A new password (reset token flow or set by an administrator) unlocks an account locked out by failed logins + LoginLockout.getInstance().clear(user.getUserId()); } diff --git a/dotCMS/src/main/java/com/dotmarketing/business/UserFactoryImpl.java b/dotCMS/src/main/java/com/dotmarketing/business/UserFactoryImpl.java index 1e7dd4fcf93b..d4e7a08853f4 100644 --- a/dotCMS/src/main/java/com/dotmarketing/business/UserFactoryImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/business/UserFactoryImpl.java @@ -927,5 +927,8 @@ public void delete(final User userToDelete) throws DotDataException { dotConnect.setSQL("delete from user_ where userid = ?"); dotConnect.addParam(userToDelete.getUserId()); dotConnect.loadResult(); + // Login lockout state (see com.dotcms.auth.LoginLockout) belongs to the user and goes with them + new DotConnect().setSQL("delete from login_lockout where user_id = ?") + .addParam(userToDelete.getUserId()).loadResult(); } } diff --git a/dotCMS/src/main/java/com/dotmarketing/cms/login/factories/LoginFactory.java b/dotCMS/src/main/java/com/dotmarketing/cms/login/factories/LoginFactory.java index fe7d0f093eeb..5e41df572f9c 100755 --- a/dotCMS/src/main/java/com/dotmarketing/cms/login/factories/LoginFactory.java +++ b/dotCMS/src/main/java/com/dotmarketing/cms/login/factories/LoginFactory.java @@ -1,5 +1,6 @@ package com.dotmarketing.cms.login.factories; +import com.dotcms.auth.LoginLockout; import com.dotcms.cms.login.PreventSessionFixationUtil; import com.dotcms.concurrent.lock.DotKeyLockManager; import com.dotcms.concurrent.lock.DotKeyLockManagerBuilder; @@ -191,7 +192,12 @@ public static boolean doLogin(String userName, String password, boolean remember auth = Authenticator.SUCCESS; } - match = auth == Authenticator.SUCCESS; + match = auth == Authenticator.SUCCESS + && (skipPasswordCheck || !isLockedOut(user, LoginLockout.clientAddress(request))); + if (!skipPasswordCheck && auth != Authenticator.SUCCESS && null != user) { + // LDAP refused the password; it still counts toward the login lockout + LoginLockout.getInstance().countFailure(user.getUserId(), LoginLockout.clientAddress(request)); + } } else { if (comp.getAuthType().equals(Company.AUTH_TYPE_EA)) { user = APILocator.getUserAPI().loadByUserByEmail(userName, APILocator.getUserAPI().getSystemUser(), false); @@ -213,8 +219,10 @@ public static boolean doLogin(String userName, String password, boolean remember } match = true; if (!skipPasswordCheck) { - // Validate password and rehash when is needed - match = passwordMatch(password, user); + // Validate password and rehash when is needed. Refused while a lockout applies to this + // address or account; a failure counts toward the lock. + match = LoginLockout.getInstance().passwordMatch(password, user, + LoginLockout.clientAddress(request), (secret, candidate) -> false); } if (match) { @@ -225,9 +233,6 @@ public static boolean doLogin(String userName, String password, boolean remember } else { - match = false; - user.setFailedLoginAttempts(user.getFailedLoginAttempts()+1); - APILocator.getUserAPI().save(user,APILocator.getUserAPI().getSystemUser(),false); SecurityLogger.logInfo(LoginFactory.class,"An invalid attempt to login as " + userName + " from IP: " + request.getRemoteAddr()); } @@ -267,6 +272,15 @@ public static boolean doLogin(String userName, String password, boolean remember return false; } + /** + * LDAP checked the password; a login that a lockout applies to (this address, or the whole account) + * is still refused. + */ + private static boolean isLockedOut(final User user, final String source) { + return null != user + && LoginLockout.getInstance().check(user.getUserId(), source) == LoginLockout.Status.LOCKED; + } + private static void syncPassword(User user) { try { @@ -364,7 +378,11 @@ public static boolean doLogin(String userName, String password) throws NoSuchUse Logger.debug(LoginFactory.class, "syncPassword not set or unable to load user", e); } - match = auth == Authenticator.SUCCESS; + match = auth == Authenticator.SUCCESS && !isLockedOut(user, LoginLockout.clientAddress()); + if (auth != Authenticator.SUCCESS && null != user) { + // LDAP refused the password; it still counts toward the login lockout + LoginLockout.getInstance().countFailure(user.getUserId(), LoginLockout.clientAddress()); + } } else { // Liferay authentication if (comp.getAuthType().equals(Company.AUTH_TYPE_EA)) { @@ -386,11 +404,8 @@ public static boolean doLogin(String userName, String password) throws NoSuchUse return false; } - match = passwordMatch(password, user); - if (match == false) { - user.setFailedLoginAttempts(user.getFailedLoginAttempts() + 1); - APILocator.getUserAPI().save(user, APILocator.getUserAPI().getSystemUser(), false); - } + // Refused while a lockout applies to this address or account; a failure counts toward the lock. + match = LoginLockout.getInstance().passwordMatch(password, user); } // if passwords match diff --git a/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task261015CreateLoginLockoutTable.java b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task261015CreateLoginLockoutTable.java new file mode 100644 index 000000000000..6046742bd18a --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task261015CreateLoginLockoutTable.java @@ -0,0 +1,51 @@ +package com.dotmarketing.startup.runonce; + +import com.dotmarketing.common.db.DotDatabaseMetaData; +import com.dotmarketing.db.DbConnectionFactory; +import com.dotmarketing.startup.AbstractJDBCStartupTask; +import com.dotmarketing.util.Logger; + +import java.sql.SQLException; + +/** + * Creates {@code login_lockout}, which holds login attempt counts and locks for + * {@link com.dotcms.auth.LoginLockout}: one row per account and source (client address, or WebDAV from + * that address), plus one account-wide row (source {@code *}). A separate table keeps the migration + * additive: nothing on {@code user_} changes, and an older release rolled back onto this schema never + * reads the new table. + */ +public class Task261015CreateLoginLockoutTable extends AbstractJDBCStartupTask { + + private static final String TABLE_NAME = "login_lockout"; + + /** + * Runs when the table is missing, or when it has the earlier per-account layout (no {@code source} + * column) from a pre-release build. Lockout state is short-lived, so that table is simply rebuilt. + */ + @Override + public boolean forceRun() { + try { + final DotDatabaseMetaData metaData = new DotDatabaseMetaData(); + return !metaData.tableExists(DbConnectionFactory.getConnection(), TABLE_NAME) + || !metaData.hasColumn(TABLE_NAME, "source"); + } catch (final SQLException e) { + Logger.error(this, e.getMessage(), e); + return false; + } + } + + @Override + public String getPostgresScript() { + return "DROP TABLE IF EXISTS login_lockout;" // nosemgrep: gitlab.find_sec_bugs.CUSTOM_INJECTION-2 -- fully hardcoded DDL, no user input + + "CREATE TABLE login_lockout (" + + " user_id varchar(100) not null," + + " source varchar(100) not null," + + " failures int not null," + + " window_start bigint not null," + + " locked_until bigint not null," + + " mod_date timestamptz not null," + + " constraint pk_login_lockout primary key (user_id, source)" + + ");" + + "CREATE INDEX idx_login_lockout_window ON login_lockout (window_start);"; + } +} diff --git a/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java b/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java index 7db50e61777c..7c1ba8a0579d 100644 --- a/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java +++ b/dotCMS/src/main/java/com/dotmarketing/util/TaskLocatorUtil.java @@ -272,6 +272,7 @@ import com.dotmarketing.startup.runonce.Task260420AddDotAuthPortletToMenu; import com.dotmarketing.startup.runonce.Task260720AddDefaultBaseTypeToFolderTable; import com.dotmarketing.startup.runonce.Task260826CreateSystemEventCursorTable; +import com.dotmarketing.startup.runonce.Task261015CreateLoginLockoutTable; import com.google.common.collect.ImmutableList; import java.util.ArrayList; @@ -617,6 +618,7 @@ public static List> getStartupRunOnceTaskClasses() { .add(Task260615AlterClusterIdLength.class) .add(Task260720AddDefaultBaseTypeToFolderTable.class) .add(Task260826CreateSystemEventCursorTable.class) + .add(Task261015CreateLoginLockoutTable.class) .build(); return ret.stream().sorted(classNameComparator).collect(Collectors.toList()); diff --git a/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java b/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java index 43327b506d36..7045b6813012 100644 --- a/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java +++ b/dotCMS/src/main/java/com/dotmarketing/webdav/DotWebdavHelper.java @@ -4,6 +4,7 @@ import static com.dotmarketing.business.PermissionAPI.PERMISSION_READ; import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.auth.LoginLockout; import com.dotcms.auth.providers.jwt.beans.JWToken; import com.dotcms.rendering.velocity.services.DotResourceCache; import com.bradmcevoy.http.CollectionResource; @@ -29,7 +30,6 @@ import com.dotmarketing.business.UserAPI; import com.dotmarketing.business.Versionable; import com.dotmarketing.cache.FolderCache; -import com.dotmarketing.cms.login.factories.LoginFactory; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotRuntimeException; import com.dotmarketing.exception.DotSecurityException; @@ -192,6 +192,7 @@ public User authorizePrincipal(final String username, final String passwd) throw } catch (AuthException ae) { Logger.debug(this, "Username : " + username + " failed to login", ae); + this.countPreAuthenticatorFailure(username, useEmailAsLogin); throw new DotSecurityException(ae.getMessage(),ae); } catch (Exception e) { @@ -209,8 +210,11 @@ public User authorizePrincipal(final String username, final String passwd) throw throw new DotSecurityException("The user was returned NULL"); } - // Validate password and rehash when is needed - if (!LoginFactory.passwordMatch(passwd, user) && !this.tryMatchJsonWebToken(passwd, user)) { + // Validate password and rehash when is needed. Refused while locked out; WebDAV attempts are + // counted apart from other logins from this address, so a client retrying a stale password does + // not lock its user out of the backend. + if (!LoginLockout.getInstance().passwordMatch(passwd, user, LoginLockout.webdavSource(), + this::tryMatchJsonWebToken)) { Logger.debug(this, ()-> "The user's passwords didn't match"); throw new DotSecurityException("The user's passwords didn't match"); @@ -219,6 +223,27 @@ public User authorizePrincipal(final String username, final String passwd) throw return user; } + /** + * Counts a password that a pre-authenticator such as LDAP refused before dotCMS checked it, as a failed + * WebDAV login, so the login lockout applies to those failures too. + * + * @param username The login name sent by the WebDAV client. + * @param useEmailAsLogin Whether the login name is an email address rather than a user ID. + */ + private void countPreAuthenticatorFailure(final String username, final boolean useEmailAsLogin) { + try { + final UserAPI userAPI = APILocator.getUserAPI(); + final User user = useEmailAsLogin + ? userAPI.loadByUserByEmail(username, userAPI.getSystemUser(), false) + : userAPI.loadUserById(username, userAPI.getSystemUser(), false); + if (null != user) { + LoginLockout.getInstance().countFailure(user.getUserId(), LoginLockout.webdavSource()); + } + } catch (final Exception e) { + Logger.debug(this, () -> "No login lockout count for WebDAV user " + username + ": " + e.getMessage()); + } + } + private boolean tryMatchJsonWebToken (final String jsonWebToken, final User user) { final HttpServletRequest request = HttpServletRequestThreadLocal.INSTANCE.getRequest(); diff --git a/dotCMS/src/main/java/com/liferay/portal/ejb/UserManagerImpl.java b/dotCMS/src/main/java/com/liferay/portal/ejb/UserManagerImpl.java index 7aead8d4ce22..28485ceffbfa 100644 --- a/dotCMS/src/main/java/com/liferay/portal/ejb/UserManagerImpl.java +++ b/dotCMS/src/main/java/com/liferay/portal/ejb/UserManagerImpl.java @@ -20,6 +20,7 @@ package com.liferay.portal.ejb; import com.dotcms.api.system.user.UserService; +import com.dotcms.auth.LoginLockout; import com.dotcms.business.CloseDBIfOpened; import com.dotcms.enterprise.AuthPipeProxy; import com.dotcms.enterprise.PasswordFactoryProxy; @@ -41,6 +42,7 @@ import com.dotmarketing.util.Config; import com.dotmarketing.util.EmailUtils; import com.dotmarketing.util.Logger; +import com.dotmarketing.util.SecurityLogger; import com.dotmarketing.util.UtilMethods; import com.dotmarketing.util.WebKeys; import com.liferay.portal.NoSuchUserException; @@ -547,6 +549,18 @@ private int authenticate(final String companyId, String login, final String pass return Authenticator.DNE; } this.checkPasswordExpiration(login, user); + // Counted before the password is checked, so parallel attempts get at most the limit between them + final LoginLockout.Attempt attempt = LoginLockout.getInstance().begin(user.getUserId(), + LoginLockout.clientAddress()); + if (attempt.isRefused()) { + // Same work, delay, failure handlers and result as a wrong password, so a refused login gives + // no signal about whether the guess was right. + LoginFactory.passwordMatch(password, user); + SecurityLogger.logInfo(this.getClass(), "Login refused, locked out: user " + user.getUserId() + + " from " + LoginLockout.clientAddress()); + this.processFailedLogin(user, login, companyId, byEmailAddress, attempt, true); + return Authenticator.FAILURE; + } if (authResult == Authenticator.SUCCESS) { authResult = LoginFactory.passwordMatch(password, user) ? Authenticator.SUCCESS : Authenticator.FAILURE; Logger.debug(this, String.format("Does password for User '%s' match? %s", login, @@ -564,10 +578,16 @@ private int authenticate(final String companyId, String login, final String pass user.setFailedLoginAttempts(0); UserUtil.update(user); } + if (authResult == Authenticator.SUCCESS) { + // Earlier attempts from this address no longer count toward a lock + LoginLockout.getInstance().succeeded(attempt); + } } } if (authResult == Authenticator.FAILURE) { - this.processFailedLogin(user, login, companyId, byEmailAddress); + this.processFailedLogin(user, login, companyId, byEmailAddress, attempt, false); + } else if (authResult != Authenticator.SUCCESS) { + LoginLockout.getInstance().failed(attempt); } return authResult; } @@ -695,13 +715,23 @@ private void checkUserStatus(final User user) throws UserActiveException { * @param companyId The current Company ID. * @param byEmailAddress If the current authentication method is via email, set this to {@code true}. If it's * done via User ID, set to {@code false}. + * @param attempt The login lockout attempt for this login; see {@link LoginLockout#begin(String, String)}. + * @param refused If the login was refused by login lockout, set this to {@code true}: the request is + * delayed the same way, but the User's failure counter is not changed. */ private void processFailedLogin(final User user, final String login, final String companyId, - final boolean byEmailAddress) { + final boolean byEmailAddress, final LoginLockout.Attempt attempt, + final boolean refused) { Logger.debug(this, String.format("Authentication for user '%s' has failed.", login)); + // Recorded first, before the delay below, so the lock state is up to date as soon as possible + LoginLockout.getInstance().failed(attempt); try { this.runCustomOnFailureHandlers(companyId, login, byEmailAddress); - this.handleFailedLoginAttempt(user, login, companyId, byEmailAddress); + if (refused) { + this.delayFailedLogin(user, login); + } else { + this.handleFailedLoginAttempt(user, login, companyId, byEmailAddress); + } } catch (final Exception e) { final String errorMsg = String.format("An error occurred when handling failed login for User '%s': " + "%s", login, e.getMessage()); @@ -747,27 +777,62 @@ private void runCustomOnFailureHandlers(String companyId, String login, boolean * @throws SystemException User failed attempts data could not be saved. * @throws AuthException An error occurred when executing the handlers for maximum failed loin attempts. */ - private void handleFailedLoginAttempt(final User user, final String login, final String companyId, final boolean byEmailAddress) throws SystemException, AuthException { + private void handleFailedLoginAttempt(final User user, final String login, final String companyId, + final boolean byEmailAddress) throws SystemException, AuthException { int failedLoginAttempts = user.getFailedLoginAttempts(); Logger.debug(this, String.format("Current failed login attempts for user '%s' is %s", login, failedLoginAttempts)); - if (Config.getBooleanProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, true)) { - Logger.debug(this, String.format("Delaying login attempts for user '%s'", login)); - this.delayRequest(failedLoginAttempts); - } + this.delayFailedLogin(user, login); user.setFailedLoginAttempts(++failedLoginAttempts); Logger.debug(this, String.format("Setting failed login attempts for user '%s' to %s", login, user.getFailedLoginAttempts())); UserUtil.update(user); final int maxFailures = GetterUtil.get(PropsUtil.get(PropsUtil.AUTH_MAX_FAILURES_LIMIT), 0); Logger.debug(this, String.format("Maximum failed login attempts is %s", maxFailures)); + final boolean lockoutEnabled = LoginLockout.getInstance().isEnabled(maxFailures); if ((failedLoginAttempts >= maxFailures) && (maxFailures != 0)) { Logger.debug(this, String.format("User '%s' failed to authenticate via '%s' after %s failed attempts", login, byEmailAddress ? "email" : "user ID", failedLoginAttempts)); + if (lockoutEnabled) { + // Locks are decided per address by LoginLockout. This account-wide count only paces the delay and + // the handlers below, so it starts over rather than growing with every failure from anywhere. + user.setFailedLoginAttempts(0); + UserUtil.update(user); + } this.runCustomMaxFailuresHandlers(login, companyId, byEmailAddress); } } + /** + * Applies the login-delaying strategy for a failed login, based on the User's current failed login count. + * + * @param user The {@link User} that failed to log in. + * @param login The User's email or ID. + */ + private void delayFailedLogin(final User user, final String login) { + if (Config.getBooleanProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, true)) { + Logger.debug(this, String.format("Delaying login attempts for user '%s'", login)); + this.delayRequest(this.delaySeed(user)); + } + } + + /** + * Returns the failed login count that paces the delay. With login lockout on, the count starts over at the + * limit, so a larger stored value can only be left from before lockout (it used to reset only on a successful + * login). Such a value is capped below the limit here rather than reset in the database, so the first failed + * login of a dormant account does not sleep for the square of an old count. + * + * @param user The {@link User} that failed to log in. + * + * @return The seed for {@link #delayRequest(int)}. + */ + private int delaySeed(final User user) { + final int maxFailures = LoginLockout.maxFailures(); + return LoginLockout.getInstance().isEnabled(maxFailures) + ? Math.min(user.getFailedLoginAttempts(), maxFailures - 1) + : user.getFailedLoginAttempts(); + } + /** * Executes the list of custom failure handlers when the maximum number of login attempts has been reached. It can * be specified via the following property: {@link PropsUtil#AUTH_MAX_FAILURES}. @@ -825,6 +890,7 @@ public void resetPassword(String userId, String token, String newPassword) throw try { final User user = APILocator.getUserAPI().loadUserById(userId); ResetPasswordTokenUtil.checkToken(user, token); + // Also clears any login lockout on the account. APILocator.getUserAPI().updatePassword(user, newPassword, APILocator.getUserAPI().getSystemUser(), false); } catch (DotDataException e) { throw new IllegalArgumentException(); diff --git a/dotCMS/src/main/resources/postgres.sql b/dotCMS/src/main/resources/postgres.sql index 5ffd8b7d9e99..1a28d0ed97dc 100644 --- a/dotCMS/src/main/resources/postgres.sql +++ b/dotCMS/src/main/resources/postgres.sql @@ -2472,6 +2472,19 @@ CREATE TABLE system_event_cursor ( CONSTRAINT pk_system_event_cursor PRIMARY KEY (server_id) ); +-- Login attempt counts and locks per account and source (client address, 'webdav:' + address, or +-- '*' for the account-wide count), shared by every node of a cluster. +CREATE TABLE login_lockout ( + user_id VARCHAR(100) NOT NULL, + source VARCHAR(100) NOT NULL, + failures INT NOT NULL, + window_start BIGINT NOT NULL, + locked_until BIGINT NOT NULL, + mod_date TIMESTAMPTZ NOT NULL, + CONSTRAINT pk_login_lockout PRIMARY KEY (user_id, source) +); +CREATE INDEX idx_login_lockout_window ON login_lockout (window_start); + --Content Types improvement CREATE INDEX idx_lower_structure_name ON structure (LOWER(velocity_var_name)); diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java index 1f3f692541a5..3c3b744a23ae 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite1b.java @@ -108,7 +108,8 @@ com.dotcms.rest.StoryBlockMarkdownPopulatorTest.class, com.dotcms.content.elasticsearch.business.ContentletDestroyIndexRemovalTest.class, com.dotcms.content.elasticsearch.business.ContentletIndexPartialFailurePhaseTest.class, - com.dotcms.content.elasticsearch.business.ContentletIndexProviderSkipTest.class + com.dotcms.content.elasticsearch.business.ContentletIndexProviderSkipTest.class, + com.dotcms.auth.LoginLockoutTest.class }) public class MainSuite1b { diff --git a/dotcms-integration/src/test/java/com/dotcms/auth/LoginLockoutTest.java b/dotcms-integration/src/test/java/com/dotcms/auth/LoginLockoutTest.java new file mode 100644 index 000000000000..a8f6f91e1b60 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/auth/LoginLockoutTest.java @@ -0,0 +1,1015 @@ +package com.dotcms.auth; + +import com.dotcms.api.web.HttpServletRequestThreadLocal; +import com.dotcms.auth.providers.jwt.beans.ApiToken; +import com.dotcms.datagen.UserDataGen; +import com.dotcms.enterprise.cluster.ClusterFactory; +import com.dotcms.rest.api.v1.authentication.IncorrectPasswordException; +import com.dotcms.rest.api.v1.authentication.ResetPasswordTokenUtil; +import com.dotcms.rest.api.v1.user.UpdateCurrentUserForm; +import com.dotcms.rest.api.v1.user.UserResourceHelper; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.business.CacheLocator; +import com.dotmarketing.cms.factories.PublicCompanyFactory; +import com.dotmarketing.cms.login.factories.LoginFactory; +import com.dotmarketing.common.db.DotConnect; +import com.dotmarketing.db.DbConnectionFactory; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.startup.runonce.Task261015CreateLoginLockoutTable; +import com.dotmarketing.util.Config; +import com.dotmarketing.util.WebKeys; +import com.dotmarketing.webdav.DotWebdavHelper; +import com.liferay.portal.auth.Authenticator; +import com.liferay.portal.ejb.UserManagerFactory; +import com.liferay.portal.ejb.UserManagerUtil; +import com.liferay.portal.ejb.UserUtil; +import com.liferay.portal.model.Company; +import com.liferay.portal.model.User; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Integration tests for {@link LoginLockout} through the real authentication paths: the main login flow, WebDAV, + * the legacy {@link LoginFactory} logins, and the password reset and update flows. Logins come from a client + * address set on the request thread-local, as the servlet container does for real requests. + */ +public class LoginLockoutTest { + + private static final String PASSWORD = "Lockout-Test-9!xQ-correct"; + private static final String NEW_PASSWORD = "Lockout-Test-7#zR-renewed"; + private static final int LIMIT = 5; + // Documentation address ranges (RFC 5737), so they never collide with a real client. + private static final String ADDRESS = "203.0.113.10"; + private static final String OTHER_ADDRESS = "198.51.100.20"; + private static final String THIRD_ADDRESS = "192.0.2.30"; + private static Object delayEnabledBefore; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + delayEnabledBefore = Config.getStringProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, "true"); + // The per-attempt sleep is not under test and would make each case take tens of seconds. + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, false); + } + + @AfterClass + public static void restore() { + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, delayEnabledBefore); + } + + @Before + public void fromDefaultAddress() { + from(ADDRESS); + } + + @After + public void closeConnection() { + DbConnectionFactory.closeSilently(); + HttpServletRequestThreadLocal.INSTANCE.setRequest(null); + } + + /** + * Makes the following logins come from the given client address. + */ + private static void from(final String address) { + HttpServletRequestThreadLocal.INSTANCE.setRequest(requestFrom(address)); + } + + private static HttpServletRequest requestFrom(final String address) { + final HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getRemoteAddr()).thenReturn(address); + return request; + } + + private static User newUser() throws Exception { + final User user = new UserDataGen().nextPersisted(); + APILocator.getUserAPI().updatePassword(user, PASSWORD, APILocator.systemUser(), false); + return user; + } + + /** + * Each login here stands for one request. A request ends by closing its DB connection and Hibernate session; + * without that, the main login path would keep reading the user row it loaded earlier on this thread and miss a + * password changed since through {@code UserAPI}. + */ + private static int login(final User user, final String password) throws Exception { + try { + return UserManagerUtil.authenticateByUserId( + PublicCompanyFactory.getDefaultCompany().getCompanyId(), user.getUserId(), password); + } finally { + DbConnectionFactory.closeSilently(); + } + } + + /** + * The legacy login used by the publishing and maintenance ajax endpoints, as one request. + */ + private static boolean legacyLogin(final User user, final String password) throws Exception { + try { + return LoginFactory.doLogin(loginName(user), password); + } finally { + DbConnectionFactory.closeSilently(); + } + } + + private static boolean webdavLogin(final User user, final String password) throws Exception { + try { + return null != new DotWebdavHelper().authorizePrincipal(loginName(user), password); + } catch (final DotSecurityException e) { + return false; + } finally { + DbConnectionFactory.closeSilently(); + } + } + + /** + * The name WebDAV and the legacy logins look the user up by: the user ID or the email, per the company setting. + */ + private static String loginName(final User user) { + return Company.AUTH_TYPE_ID.equals(PublicCompanyFactory.getDefaultCompany().getAuthType()) + ? user.getUserId() : user.getEmailAddress(); + } + + private static void failLogins(final User user, final int count) throws Exception { + for (int i = 0; i < count; i++) { + assertEquals(Authenticator.FAILURE, login(user, "wrong-" + i)); + } + } + + /** + * Locks the current address out of the account through the main login flow. + */ + private static void lockByFailedLogins(final User user) throws Exception { + failLogins(user, LIMIT); + assertTrue(LoginLockout.getInstance().isLocked(user.getUserId(), LoginLockout.clientAddress())); + } + + private static boolean lockedFrom(final User user, final String address) { + return LoginLockout.getInstance().isLocked(user.getUserId(), address); + } + + private static String webdav(final String address) { + return LoginLockout.WEBDAV_SOURCE_PREFIX + address; + } + + /** + * Runs the task on many threads at once, each as a request from the given address, and returns the results. + */ + private static List inParallel(final int threads, final String address, final Callable task) + throws Exception { + final ExecutorService pool = Executors.newFixedThreadPool(threads); + final CountDownLatch start = new CountDownLatch(1); + try { + final List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + futures.add(pool.submit(() -> { + from(address); + start.await(); + try { + return task.call(); + } finally { + DbConnectionFactory.closeSilently(); + HttpServletRequestThreadLocal.INSTANCE.setRequest(null); + } + })); + } + start.countDown(); + final List results = new ArrayList<>(); + for (final Future future : futures) { + results.add(future.get(2, TimeUnit.MINUTES)); + } + return results; + } finally { + pool.shutdownNow(); + } + } + + private static int failedLoginAttempts(final User user) throws Exception { + return APILocator.getUserAPI().loadUserById(user.getUserId()).getFailedLoginAttempts(); + } + + /** + * @return every lockout row of the account, keyed by source + */ + private static Map> lockoutRows(final User user) throws Exception { + try { + final List> rows = new DotConnect() + .setSQL("SELECT source, failures, window_start, locked_until FROM login_lockout WHERE user_id = ?") + .addParam(user.getUserId()) + .loadObjectResults(); + final Map> bySource = new HashMap<>(); + rows.forEach(row -> bySource.put((String) row.get("source"), row)); + return bySource; + } finally { + DbConnectionFactory.closeSilently(); + } + } + + private static long lockedUntil(final User user, final String source) throws Exception { + final Map row = lockoutRows(user).get(source); + assertNotNull("no lockout row for " + source, row); + return ((Number) row.get("locked_until")).longValue(); + } + + /** + * Makes every lock on the account run out now, as if the lock length had passed. + */ + private static void expireLocks(final User user) throws Exception { + try { + new DotConnect().setSQL("UPDATE login_lockout SET locked_until = ? WHERE user_id = ? AND locked_until > 0") + .addParam(System.currentTimeMillis() - 1000L).addParam(user.getUserId()).loadResult(); + } finally { + DbConnectionFactory.closeSilently(); + } + } + + private static void cleanUp(final User user) { + LoginLockout.getInstance().clear(user.getUserId()); + UserDataGen.remove(user); + } + + /** + * Given: an account and the default limit of 5 failures per address + * When: 5 wrong passwords are tried from one address + * Then: that address is locked out, and the correct password is refused from it with the same result as a wrong one + */ + @Test + public void test_lockout_after_limit_refuses_correct_password() throws Exception { + final User user = newUser(); + try { + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + lockByFailedLogins(user); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + assertEquals(Authenticator.FAILURE, login(user, "wrong-again")); + } finally { + cleanUp(user); + } + } + + /** + * Given: an address locked out of an account by failed logins + * When: the owner logs in from another address, on every login path + * Then: the owner gets in, and the locked address is still refused + */ + @Test + public void test_lock_applies_only_to_the_failing_address() throws Exception { + final User user = newUser(); + try { + lockByFailedLogins(user); + + from(OTHER_ADDRESS); + assertFalse(lockedFrom(user, OTHER_ADDRESS)); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + assertTrue(webdavLogin(user, PASSWORD)); + assertTrue(legacyLogin(user, PASSWORD)); + + from(ADDRESS); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + assertFalse(legacyLogin(user, PASSWORD)); + // WebDAV is counted on its own, so a lock from backend logins does not reach it + assertTrue(webdavLogin(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: failures from two addresses, each below the per-address limit + * When: one address reaches the limit + * Then: only that address is locked; the other keeps its own count and can still log in + */ + @Test + public void test_each_address_counts_separately() throws Exception { + final User user = newUser(); + try { + failLogins(user, LIMIT - 1); + from(OTHER_ADDRESS); + failLogins(user, LIMIT - 1); + assertFalse(lockedFrom(user, ADDRESS)); + assertFalse(lockedFrom(user, OTHER_ADDRESS)); + + from(ADDRESS); + failLogins(user, 1); + assertTrue(lockedFrom(user, ADDRESS)); + assertFalse(lockedFrom(user, OTHER_ADDRESS)); + + from(OTHER_ADDRESS); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: the default configuration, where the account-wide limit is off + * When: two addresses each use up their attempts + * Then: only those addresses are locked out; nothing is counted account-wide, and an address that never + * failed still gets in + */ + @Test + public void test_account_wide_limit_is_off_by_default() throws Exception { + assertEquals(0, LoginLockout.accountMaxFailures()); + final User user = newUser(); + try { + failLogins(user, LIMIT); + from(OTHER_ADDRESS); + failLogins(user, LIMIT); + assertTrue(lockedFrom(user, ADDRESS)); + assertTrue(lockedFrom(user, OTHER_ADDRESS)); + assertFalse(lockoutRows(user).containsKey(LoginLockout.ACCOUNT_WIDE)); + + from(THIRD_ADDRESS); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: an account-wide limit of 8 and a per-address limit of 5 + * When: two addresses each fail 4 times, so no single address reaches its limit + * Then: the account is locked for every address, including one that never failed, until a new password is set + */ + @Test + public void test_account_wide_limit_locks_every_address() throws Exception { + final int accountLimitBefore = LoginLockout.accountMaxFailures(); + Config.setProperty(LoginLockout.LOCKOUT_ACCOUNT_MAX_FAILURES, 8); + final User user = newUser(); + try { + failLogins(user, 4); + from(OTHER_ADDRESS); + failLogins(user, 4); + + assertTrue(LoginLockout.getInstance().isLocked(user.getUserId())); + assertTrue(lockedFrom(user, THIRD_ADDRESS)); + assertTrue(lockedUntil(user, LoginLockout.ACCOUNT_WIDE) > System.currentTimeMillis()); + + from(THIRD_ADDRESS); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + assertFalse(webdavLogin(user, PASSWORD)); + + APILocator.getUserAPI().updatePassword(APILocator.getUserAPI().loadUserById(user.getUserId()), + NEW_PASSWORD, APILocator.systemUser(), false); + assertTrue(lockoutRows(user).isEmpty()); + assertEquals(Authenticator.SUCCESS, login(user, NEW_PASSWORD)); + } finally { + Config.setProperty(LoginLockout.LOCKOUT_ACCOUNT_MAX_FAILURES, accountLimitBefore); + cleanUp(user); + } + } + + /** + * Given: an address that has been locked out once and whose lock has run out + * When: it fails once, and then reaches the limit again + * Then: one failure does not relock it, and the second lock is the same fixed length as the first + */ + @Test + public void test_lock_length_is_fixed_and_count_starts_over() throws Exception { + final User user = newUser(); + final long expectedMillis = LoginLockout.getInstance().lockSeconds() * 1000L; + try { + long before = System.currentTimeMillis(); + lockByFailedLogins(user); + long length = lockedUntil(user, ADDRESS) - before; + assertTrue("first lock " + length + "ms", length >= expectedMillis && length < expectedMillis + 60_000L); + + expireLocks(user); + failLogins(user, 1); + assertFalse(lockedFrom(user, ADDRESS)); + + failLogins(user, LIMIT - 1); + before = System.currentTimeMillis(); + assertTrue(lockedFrom(user, ADDRESS)); + length = lockedUntil(user, ADDRESS) - before; + assertTrue("second lock " + length + "ms", length <= expectedMillis && length > expectedMillis - 60_000L); + } finally { + cleanUp(user); + } + } + + /** + * Given: a locked address + * When: further logins are tried from it, with right and wrong passwords, on every login path + * Then: they fail without moving any failure count or extending the lock + */ + @Test + public void test_failure_counts_do_not_move_while_locked() throws Exception { + final User user = newUser(); + try { + lockByFailedLogins(user); + assertEquals(0, failedLoginAttempts(user)); + final Map> rowsAtLock = lockoutRows(user); + + assertEquals(Authenticator.FAILURE, login(user, "wrong-while-locked")); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + assertFalse(legacyLogin(user, "wrong-while-locked")); + + assertEquals(0, failedLoginAttempts(user)); + assertEquals(rowsAtLock, lockoutRows(user)); + } finally { + cleanUp(user); + } + } + + /** + * Given: a locked address + * When: the account's lockout state is cleared directly + * Then: the correct password works again from that address and leaves nothing recorded for it + */ + @Test + public void test_clear_unlocks_and_success_resets_state() throws Exception { + final User user = newUser(); + try { + lockByFailedLogins(user); + LoginLockout.getInstance().clear(user.getUserId()); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + assertEquals(LoginLockout.Status.NONE, LoginLockout.getInstance().status(user.getUserId(), ADDRESS)); + } finally { + cleanUp(user); + } + } + + /** + * Given: failures recorded for an address, first below the limit and then as a lock that has run out + * When: the correct password is used from that address + * Then: the check reports the recorded failures, the login succeeds, and that address's count is removed + */ + @Test + public void test_success_clears_the_count_for_its_address() throws Exception { + final User user = newUser(); + try { + assertEquals(LoginLockout.Status.NONE, LoginLockout.getInstance().check(user.getUserId(), ADDRESS)); + failLogins(user, 2); + assertEquals(LoginLockout.Status.RECORDED, LoginLockout.getInstance().check(user.getUserId(), ADDRESS)); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + assertEquals(LoginLockout.Status.NONE, LoginLockout.getInstance().status(user.getUserId(), ADDRESS)); + + lockByFailedLogins(user); + assertEquals(LoginLockout.Status.LOCKED, LoginLockout.getInstance().check(user.getUserId(), ADDRESS)); + expireLocks(user); + assertEquals(LoginLockout.Status.RECORDED, LoginLockout.getInstance().check(user.getUserId(), ADDRESS)); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + assertEquals(LoginLockout.Status.NONE, LoginLockout.getInstance().status(user.getUserId(), ADDRESS)); + } finally { + cleanUp(user); + } + } + + /** + * Given: fewer failures than the limit + * When: the correct password is used + * Then: login succeeds and no lock is set + */ + @Test + public void test_below_limit_does_not_lock() throws Exception { + final User user = newUser(); + try { + failLogins(user, LIMIT - 1); + assertFalse(LoginLockout.getInstance().isLocked(user.getUserId())); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: an account + * When: the limit of wrong passwords is sent over WebDAV from one address, as a client retrying a stale + * password does + * Then: WebDAV from that address is locked out and refuses even the correct password, without counting + * further attempts; backend logins from the same address still work; clearing the lock lets WebDAV in + */ + @Test + public void test_webdav_failures_lock_only_webdav() throws Exception { + final User user = newUser(); + try { + assertTrue(webdavLogin(user, PASSWORD)); + for (int i = 0; i < LIMIT; i++) { + assertFalse(webdavLogin(user, "wrong-" + i)); + } + assertTrue(lockedFrom(user, webdav(ADDRESS))); + assertFalse(lockedFrom(user, ADDRESS)); + + final Map> rowsAtLock = lockoutRows(user); + assertThrows(DotSecurityException.class, + () -> new DotWebdavHelper().authorizePrincipal(loginName(user), PASSWORD)); + DbConnectionFactory.closeSilently(); + assertFalse(webdavLogin(user, "wrong-while-locked")); + assertEquals(rowsAtLock, lockoutRows(user)); + + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + assertTrue(legacyLogin(user, PASSWORD)); + + LoginLockout.getInstance().clear(user.getUserId()); + assertTrue(webdavLogin(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: WebDAV from an address locked out of an account + * When: one of the user's API tokens is sent in the WebDAV password field + * Then: it is accepted, since a token is not a password, and nothing is counted for it + */ + @Test + public void test_webdav_token_accepted_while_locked() throws Exception { + final User user = newUser(); + try { + final ApiToken token = APILocator.getApiTokenAPI().persistApiToken(ApiToken.builder() + .withIssuer(ClusterFactory.getClusterId()) + .withExpires(Date.from(Instant.now().plus(Duration.ofDays(1)))) + .withUserId(user.getUserId()) + .withRequestingUserId(APILocator.systemUser().getUserId()) + .withRequestingIp(ADDRESS) + .build(), APILocator.systemUser()); + final String jwt = APILocator.getApiTokenAPI().getJWT(token, APILocator.systemUser()); + DbConnectionFactory.closeSilently(); + + assertTrue(webdavLogin(user, jwt)); + for (int i = 0; i < LIMIT; i++) { + assertFalse(webdavLogin(user, "wrong-" + i)); + } + assertTrue(lockedFrom(user, webdav(ADDRESS))); + final Map> rowsAtLock = lockoutRows(user); + + assertTrue(webdavLogin(user, jwt)); + assertFalse(webdavLogin(user, PASSWORD)); + assertEquals(rowsAtLock, lockoutRows(user)); + } finally { + cleanUp(user); + } + } + + /** + * Given: an address with no failures + * When: 20 login attempts from it start at the same moment, before any password is checked + * Then: exactly the limit of them may check a password, because each is counted before its check + */ + @Test + public void test_attempts_are_counted_before_the_password_check() throws Exception { + final User user = newUser(); + try { + final List refused = inParallel(20, ADDRESS, + () -> LoginLockout.getInstance().begin(user.getUserId(), ADDRESS).isRefused()); + assertEquals(LIMIT, refused.stream().filter(r -> !r).count()); + } finally { + cleanUp(user); + } + } + + /** + * Given: an account + * When: 10 wrong passwords arrive from one address at the same moment, on the main login flow + * Then: all fail and the address ends up locked out + */ + @Test + public void test_concurrent_wrong_logins_lock_the_address() throws Exception { + final User user = newUser(); + try { + final List results = inParallel(10, ADDRESS, () -> login(user, "wrong-in-parallel")); + assertTrue(results.stream().allMatch(r -> r == Authenticator.FAILURE)); + assertTrue(lockedFrom(user, ADDRESS)); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: an account whose stored failed-login count is far above the limit, as dormant accounts can carry + * from before this change, and a delay of one millisecond per counted failure + * When: one wrong password is tried, and the upgrade task runs + * Then: the wrong password does not lock the account and is delayed only as for a count below the limit, + * and the task leaves the stored count alone (the migration does not touch {@code user_}) + */ + @Test + public void test_counter_above_limit_from_before_upgrade() throws Exception { + final User user = newUser(); + final Object strategyBefore = Config.getStringProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY, "pow"); + try { + final User counted = APILocator.getUserAPI().loadUserById(user.getUserId()); + counted.setFailedLoginAttempts(10_000); + APILocator.getUserAPI().save(counted, APILocator.systemUser(), false); + + // Uncapped, this failure would sleep for 10 seconds + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, true); + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY, "time_mills"); + final long start = System.currentTimeMillis(); + assertEquals(Authenticator.FAILURE, login(user, "wrong-after-upgrade")); + final long elapsed = System.currentTimeMillis() - start; + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, false); + assertTrue("failed login took " + elapsed + "ms", elapsed < 5_000L); + + assertFalse(LoginLockout.getInstance().isLocked(user.getUserId())); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + + final User countedAgain = APILocator.getUserAPI().loadUserById(user.getUserId()); + countedAgain.setFailedLoginAttempts(50); + APILocator.getUserAPI().save(countedAgain, APILocator.systemUser(), false); + new Task261015CreateLoginLockoutTable().executeUpgrade(); + DbConnectionFactory.closeSilently(); + CacheLocator.getUserCache().remove(user.getUserId()); + assertEquals(50, failedLoginAttempts(user)); + assertTrue(lockoutRows(user).isEmpty()); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + } finally { + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY_ENABLED, false); + Config.setProperty(WebKeys.AUTH_FAILED_ATTEMPTS_DELAY_STRATEGY, strategyBefore); + cleanUp(user); + } + } + + /** + * Given: an address locked out of an account + * When: the correct password is sent from it to the legacy {@link LoginFactory} logins used by the ajax endpoints + * Then: both overloads refuse it, and accept it again once the lock is cleared + */ + @Test + public void test_legacy_doLogin_refuses_locked_address() throws Exception { + final User user = newUser(); + try { + assertTrue(legacyLogin(user, PASSWORD)); + lockByFailedLogins(user); + + assertFalse(legacyLogin(user, PASSWORD)); + assertFalse(LoginFactory.doLogin(loginName(user), PASSWORD, false, requestFrom(ADDRESS), + mock(HttpServletResponse.class))); + DbConnectionFactory.closeSilently(); + + LoginLockout.getInstance().clear(user.getUserId()); + assertTrue(legacyLogin(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: an account + * When: the limit of wrong passwords is sent to the legacy {@link LoginFactory} login from one address + * Then: that address is locked out + */ + @Test + public void test_legacy_doLogin_failures_count_toward_lock() throws Exception { + final User user = newUser(); + try { + for (int i = 0; i < LIMIT; i++) { + assertFalse(legacyLogin(user, "wrong-" + i)); + } + assertTrue(lockedFrom(user, ADDRESS)); + } finally { + cleanUp(user); + } + } + + /** + * Given: a locked address + * When: the user resets the password with an emailed reset token + * Then: all lockout state for the account is cleared and the new password works + */ + @Test + public void test_password_reset_clears_lock() throws Exception { + final Object tokenDelayBefore = Config.getStringProperty("RECOVER_PASSWORD_TOKEN_AUTH_DELAY", "2000"); + Config.setProperty("RECOVER_PASSWORD_TOKEN_AUTH_DELAY", 0); + final User user = newUser(); + try { + lockByFailedLogins(user); + final String token = ResetPasswordTokenUtil.createToken(); + final User withToken = APILocator.getUserAPI().loadUserById(user.getUserId()); + withToken.setIcqId(token); + UserUtil.update(withToken); + + UserManagerFactory.getManager().resetPassword(user.getUserId(), token, NEW_PASSWORD); + + assertTrue(lockoutRows(user).isEmpty()); + assertEquals(Authenticator.SUCCESS, login(user, NEW_PASSWORD)); + } finally { + Config.setProperty("RECOVER_PASSWORD_TOKEN_AUTH_DELAY", tokenDelayBefore); + cleanUp(user); + } + } + + /** + * Given: a locked address + * When: an administrator sets a new password through {@code UserAPI.updatePassword} + * Then: the lockout state and the failure count are cleared and the new password works + */ + @Test + public void test_admin_update_password_clears_lock() throws Exception { + final User user = newUser(); + try { + lockByFailedLogins(user); + // A count left over from failures before the lock; a new password resets it too. + final User counted = APILocator.getUserAPI().loadUserById(user.getUserId()); + counted.setFailedLoginAttempts(LIMIT - 1); + APILocator.getUserAPI().save(counted, APILocator.systemUser(), false); + assertEquals(LIMIT - 1, failedLoginAttempts(user)); + + APILocator.getUserAPI().updatePassword(APILocator.getUserAPI().loadUserById(user.getUserId()), + NEW_PASSWORD, APILocator.systemUser(), false); + + assertTrue(lockoutRows(user).isEmpty()); + assertEquals(0, failedLoginAttempts(user)); + assertEquals(Authenticator.SUCCESS, login(user, NEW_PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: a locked address + * When: an administrator saves the user with a new password, as the Users portlet and the REST user update do + * Then: the lockout state is cleared and the new password works + */ + @Test + public void test_admin_save_with_new_password_clears_lock() throws Exception { + final User user = newUser(); + try { + lockByFailedLogins(user); + + final User toSave = APILocator.getUserAPI().loadUserById(user.getUserId()); + toSave.setPassword(NEW_PASSWORD); + APILocator.getUserAPI().save(toSave, APILocator.systemUser(), true, false); + + assertTrue(lockoutRows(user).isEmpty()); + assertEquals(Authenticator.SUCCESS, login(user, NEW_PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: a user with lockout rows + * When: the user is deleted + * Then: the rows are deleted with them + */ + @Test + public void test_user_delete_removes_lockout_rows() throws Exception { + final User user = newUser(); + final User replacement = new UserDataGen().nextPersisted(); + try { + lockByFailedLogins(user); + assertFalse(lockoutRows(user).isEmpty()); + + APILocator.getUserAPI().delete(user, replacement, APILocator.systemUser(), false); + DbConnectionFactory.closeSilently(); + + assertTrue(lockoutRows(user).isEmpty()); + } finally { + LoginLockout.getInstance().clear(user.getUserId()); + UserDataGen.remove(replacement); + } + } + + /** + * Given: failures recorded for one account long ago, never locked and never cleared by a success + * When: any lock is set + * Then: those expired rows are removed, so failing addresses do not stay in the table for good + */ + @Test + public void test_expired_rows_are_purged_when_a_lock_is_set() throws Exception { + final User stale = newUser(); + final User user = newUser(); + try { + failLogins(stale, 2); + assertFalse(lockoutRows(stale).isEmpty()); + try { + new DotConnect().setSQL("UPDATE login_lockout SET window_start = ? WHERE user_id = ?") + .addParam(System.currentTimeMillis() - 2 * LoginLockout.getInstance().windowSeconds() * 1000L) + .addParam(stale.getUserId()).loadResult(); + } finally { + DbConnectionFactory.closeSilently(); + } + + // A fresh instance, since each one purges at most once a minute + final LoginLockout lockout = new LoginLockout(System::currentTimeMillis); + for (int i = 0; i < LIMIT; i++) { + lockout.failed(lockout.begin(user.getUserId(), ADDRESS)); + } + assertTrue(lockedFrom(user, ADDRESS)); + + assertTrue(lockoutRows(stale).isEmpty()); + assertFalse(lockoutRows(user).isEmpty()); + } finally { + cleanUp(stale); + cleanUp(user); + } + } + + /** + * Given: failures from one address below the limit + * When: the counting window passes with no lock ever set, and the address fails again + * Then: the old failures no longer count, so the lock comes only at the limit within the new window + */ + @Test + public void test_count_ages_out_of_the_window_without_a_lock() throws Exception { + final User user = newUser(); + final AtomicLong now = new AtomicLong(System.currentTimeMillis()); + final LoginLockout lockout = new LoginLockout(now::get); + try { + for (int i = 0; i < LIMIT - 1; i++) { + lockout.failed(lockout.begin(user.getUserId(), ADDRESS)); + } + assertFalse(lockout.isLocked(user.getUserId(), ADDRESS)); + + now.addAndGet((lockout.windowSeconds() + 1) * 1000L); + for (int i = 0; i < LIMIT - 1; i++) { + lockout.failed(lockout.begin(user.getUserId(), ADDRESS)); + } + assertFalse(lockout.isLocked(user.getUserId(), ADDRESS)); + + lockout.failed(lockout.begin(user.getUserId(), ADDRESS)); + assertTrue(lockout.isLocked(user.getUserId(), ADDRESS)); + } finally { + cleanUp(user); + } + } + + /** + * Given: an attempt that started before parallel failures from the same address locked it + * When: that attempt's password turns out to be right + * Then: its success does not remove the lock the others set + */ + @Test + public void test_lock_set_during_an_attempt_survives_its_success() throws Exception { + final User user = newUser(); + final LoginLockout lockout = LoginLockout.getInstance(); + try { + final LoginLockout.Attempt inFlight = lockout.begin(user.getUserId(), ADDRESS); + assertFalse(inFlight.isRefused()); + for (int i = 0; i < LIMIT - 1; i++) { + lockout.failed(lockout.begin(user.getUserId(), ADDRESS)); + } + assertTrue(lockedFrom(user, ADDRESS)); + + lockout.succeeded(inFlight); + assertTrue(lockedFrom(user, ADDRESS)); + } finally { + cleanUp(user); + } + } + + /** + * Given: a locked address, and an attempt whose lock check ran before the lock was set + * When: the attempt is counted + * Then: the count sees the lock and the attempt is refused + */ + @Test + public void test_attempt_counted_after_a_lock_is_refused() throws Exception { + final User user = newUser(); + try { + lockByFailedLogins(user); + // The first reading of the clock (the lock check) falls after the lock has run out, as if the check + // had happened before the lock was set; every later reading is the real time. + final Deque readings = new ArrayDeque<>( + List.of(System.currentTimeMillis() + (LoginLockout.getInstance().lockSeconds() + 60) * 1000L)); + final LongSupplier clock = () -> readings.isEmpty() ? System.currentTimeMillis() : readings.poll(); + final LoginLockout.Attempt attempt = new LoginLockout(clock).begin(user.getUserId(), ADDRESS); + + assertTrue(attempt.isRefused()); + assertTrue(lockedFrom(user, ADDRESS)); + } finally { + cleanUp(user); + } + } + + /** + * IPv6 addresses count under their /64 network, which one host can usually pick any address from; an + * IPv4-mapped address counts as the IPv4 address. + */ + @Test + public void test_ipv6_addresses_are_grouped_by_64() { + assertEquals("2001:db8:1:2::/64", LoginLockout.clientAddress(requestFrom("2001:db8:1:2:aaaa:bbbb:cccc:dddd"))); + assertEquals("2001:db8:1:2::/64", LoginLockout.clientAddress(requestFrom("2001:db8:1:2::1"))); + assertNotEquals("2001:db8:1:2::/64", LoginLockout.clientAddress(requestFrom("2001:db8:1:3::1"))); + assertEquals("fe80::/64", LoginLockout.clientAddress(requestFrom("fe80::1%eth0"))); + assertEquals("192.0.2.30", LoginLockout.clientAddress(requestFrom("::ffff:192.0.2.30"))); + assertEquals("not-an-address:x", LoginLockout.clientAddress(requestFrom("not-an-address:x"))); + } + + /** + * Given: an account + * When: wrong passwords come from different addresses in one IPv6 /64 + * Then: they share one count, so the /64 is locked out while another network still gets in + */ + @Test + public void test_ipv6_network_is_locked_as_one_source() throws Exception { + final User user = newUser(); + try { + for (int i = 0; i < LIMIT; i++) { + from("2001:db8:5:6::" + Integer.toHexString(i + 1)); + assertEquals(Authenticator.FAILURE, login(user, "wrong-" + i)); + } + from("2001:db8:5:6::ffff"); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + from("2001:db8:5:7::1"); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: a signed-in user changing their own details, which asks for the current password + * When: the wrong current password is sent up to the limit from one address + * Then: that address is locked out, and the correct current password is refused there too + */ + @Test + public void test_current_password_check_counts_and_refuses_while_locked() throws Exception { + final User user = newUser(); + try { + final HttpServletRequest request = requestFrom(ADDRESS); + when(request.getSession()).thenReturn(mock(HttpSession.class)); + for (int i = 0; i < LIMIT; i++) { + final UpdateCurrentUserForm form = new UpdateCurrentUserForm.Builder() + .userId(user.getUserId()).givenName(user.getFirstName()).surname(user.getLastName()) + .email(user.getEmailAddress()).currentPassword("wrong-" + i).build(); + assertThrows(IncorrectPasswordException.class, + () -> UserResourceHelper.getInstance().updateUser(form, user, request, Locale.US)); + DbConnectionFactory.closeSilently(); + } + assertTrue(lockedFrom(user, ADDRESS)); + + final UpdateCurrentUserForm correct = new UpdateCurrentUserForm.Builder() + .userId(user.getUserId()).givenName(user.getFirstName()).surname(user.getLastName()) + .email(user.getEmailAddress()).currentPassword(PASSWORD).build(); + assertThrows(IncorrectPasswordException.class, + () -> UserResourceHelper.getInstance().updateUser(correct, user, request, Locale.US)); + DbConnectionFactory.closeSilently(); + assertEquals(Authenticator.FAILURE, login(user, PASSWORD)); + } finally { + cleanUp(user); + } + } + + /** + * Given: lockout switched off with {@code FEATURE_FLAG_AUTH_LOGIN_LOCKOUT=false} + * When: more wrong passwords than the limit are tried, on the main login and over WebDAV + * Then: nothing is recorded or locked and the correct password still works on every path + */ + @Test + public void test_disabled_does_not_lock() throws Exception { + final boolean enabledBefore = Config.getBooleanProperty(LoginLockout.LOCKOUT_ENABLED, true); + Config.setProperty(LoginLockout.LOCKOUT_ENABLED, false); + final User user = newUser(); + try { + assertFalse(LoginLockout.getInstance().isEnabled()); + for (int i = 0; i <= LIMIT; i++) { + assertEquals(Authenticator.FAILURE, login(user, "wrong-" + i)); + assertFalse(webdavLogin(user, "wrong-" + i)); + } + assertTrue(lockoutRows(user).isEmpty()); + assertTrue(webdavLogin(user, PASSWORD)); + assertTrue(legacyLogin(user, PASSWORD)); + assertEquals(Authenticator.SUCCESS, login(user, PASSWORD)); + } finally { + Config.setProperty(LoginLockout.LOCKOUT_ENABLED, enabledBefore); + cleanUp(user); + } + } + + /** + * The client address falls back to a fixed source outside a request, and is cut to the column length. + */ + @Test + public void test_client_address() { + assertEquals(LoginLockout.UNKNOWN_SOURCE, LoginLockout.clientAddress((HttpServletRequest) null)); + assertEquals(ADDRESS, LoginLockout.clientAddress(requestFrom(ADDRESS))); + assertEquals(64, LoginLockout.clientAddress(requestFrom("f".repeat(100))).length()); + } +}