Repository navigation
Conversation
Refs dotCMS/private-issues#777 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0191fGSd1gBosfHHz1JCCHks
This comment has been minimized.
This comment has been minimized.
Refs dotCMS/private-issues#777 - WebDAV (DotWebdavHelper.authorizePrincipal) and both LoginFactory.doLogin overloads now check passwords through LoginLockout.passwordMatch. A locked account is refused like a wrong password, a failure counts toward the lock, and a success resets the count. The LDAP branches of doLogin also refuse a locked account. API tokens accepted by WebDAV are not subject to the lock. - Setting a new password (UserAPI.updatePassword, or UserAPI.save with validatePassword as the Users portlet and REST user update do) clears the lock and the failure count. The reset-token flow gets this through updatePassword. - A login refused because the account is locked now runs the auth.failure handlers and the same delay as a wrong password. - LoginLockout.check returns NONE / EXPIRED / LOCKED from a single read, so a successful login deletes a lockout row only when one exists. - LoginLockoutTest covers WebDAV and doLogin while locked and as failure sources, reset-token and admin password changes, AUTH_LOCKOUT_ENABLED=false, expired locks, and the failure count not moving while locked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs dotCMS/private-issues#777 - LoginLockout.status, lock and clear are @CloseDBIfOpened, so a caller outside a request or transaction does not leave a connection open on the thread. - LoginLockoutTest treats each login as one request and closes the thread's connection and Hibernate session afterwards. Without that, the main login path kept reading the user row it had cached while locking the account and missed a password changed afterwards through UserAPI, which failed the reset and admin-save cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs dotCMS/private-issues#777 Failed logins from one address no longer lock the account owner out from another, so knowing a login name is not enough to keep someone out. - login_lockout now holds one row per account and client address (getRemoteAddr, which Tomcat's RemoteIpValve resolves behind trusted proxies), plus an account-wide row (source '*'). - An address is locked out of an account after auth.max.failures.limit failures (default 5) within AUTH_LOCKOUT_WINDOW_SECONDS (default 3600). - Account-wide backstop: AUTH_LOCKOUT_ACCOUNT_MAX_FAILURES failures from all addresses in the window (default 100, 0 turns it off) lock the account for everyone. - Locks last a fixed AUTH_LOCKOUT_DURATION_SECONDS (default 900) and no longer double; AUTH_LOCKOUT_MAX_DURATION_SECONDS is gone. A count that has aged out of the window, or whose lock has run out, starts over. - Counting is a single upsert, so failures on several nodes all count. - A successful login clears only its own address's count. A password reset or a new password set by an administrator clears everything. - The startup task rebuilds a login_lockout table left by a pre-release build of this branch (per-account layout, no source column). - LoginLockoutTest covers per-address isolation, separate counts per address, the account-wide limit, the fixed lock length and the count starting over, alongside the earlier cases (18 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Semgrep found 11
🟡 Medium severity issue identified in your code: The method identified is susceptible to injection. The input should be validated and properly If this is a critical or high severity finding, please also link this issue in the #security channel in Slack. |
Refs dotCMS/private-issues#777 The failure upsert and the lock update were assembled inline in the setSQL call, which the semgrep CUSTOM_INJECTION-2 rule reports even though every piece is a literal. They now live in final String constants, the form the rule accepts. The SQL text is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fabrizzio-dotCMS
left a comment
There was a problem hiding this comment.
Solid change overall. Keeping lockout on by default makes sense given the internal tracking issue. A few points before merge:
1. failedLoginAttempts increment is not atomic (likely bypass, not verified). handleFailedLoginAttempt and LoginLockout.recordFailure do read → +1 → save on the User. If N parallel attempts read the same value, they all write the same result, so N guesses count as one failure and the lock may never trigger. This race predates the PR, but the lockout now depends on it. A concurrent-requests test against one account would settle it; the fix would be an UPDATE ... SET failedlogins = failedlogins + 1 RETURNING failedlogins, deciding the lock on the returned value. Same root cause as the lockout_count open point, but with higher impact.
2. WebDAV failures now lock the whole account. test_webdav_failures_count_toward_lock shows this is intended, but a mounted drive with a stale password (Finder / Windows) retries on its own. It will lock the user out of the UI within seconds, re-lock on every expiry, and escalate to 24h. Please test with a real WebDAV client and confirm we accept that trade-off.
3. Accumulated counters lock on the first typo after upgrade. failedLoginAttempts only resets on success, so dormant accounts can already be above the limit. Suggest resetting failedlogins to 0 in Task261015CreateLoginLockoutTable.
4. Recovery path. Anyone who knows a user's email can keep that account locked with 5 requests per window. Password reset recovers the account, but it depends on working mail. Could we document a support runbook (admin sets a new password, or delete the login_lockout row) and add a release note saying lockout is on by default?
Minor: the counter reset means delayRequest never grows past the 4-failure delay, while every locked attempt still does a full hash. Fine as a trade-off, just worth stating. Fail-open on a login_lockout read error looks right to me.
Missing tests: WebDAV token accepted while locked, LDAP path refuses a locked account, pre-existing counter above the limit after upgrade, concurrent failures.
Refs dotCMS/private-issues#777 Addresses review feedback on the lockout: - Each attempt is now counted against its source before its password is checked (LoginLockout.begin, one INSERT ... RETURNING). Parallel requests from one address get at most auth.max.failures.limit password checks per window between them; before, all of them could pass a check made before any was counted. A success clears its source's count, a failure locks the source once its attempts are used up and counts toward the account-wide limit, and an API token accepted by WebDAV hands its count back. user_.failedloginattempts no longer takes part in lock decisions. - WebDAV attempts are counted under their own source (webdav:<address>), so a client retrying a stale password locks only WebDAV from that machine, not backend logins. - The startup task restarts user_.failedloginattempts at zero. That count only reset on success, so dormant accounts can carry large values that set the delay on their next failed login. login_lockout.source is now varchar(100) to fit the WebDAV prefix. - New tests: attempts counted before the check (20 parallel attempts, 5 allowed), concurrent wrong logins, WebDAV failures locking only WebDAV, an API token accepted over WebDAV while locked, and a stale counter above the limit, including the task's reset (21 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, this was useful. Fixes are in a6ac29b. Some points describe the version before b168bb4, which made locks per address and fixed at 15 minutes (no 24h growth), so I've answered against the current code. 1. Atomic counting. Lock decisions no longer use
2. WebDAV. WebDAV is now counted under its own source ( 3. Old counters. These no longer decide locks, but they do set the delay on the next failed login. The startup task now resets 4. Recovery. With locks per address, knowing an email isn't enough to lock the owner out. Only the account-wide limit (100 failures an hour, by default) locks the account for everyone, and that takes failures from several addresses, since each address is stopped after 5. The runbook and release note are in the PR description. Minor: agreed. The delay stays bounded, and locked attempts still do the full hash on purpose, so timing doesn't show whether an account is locked. Tests: I added the WebDAV token, stale counter and concurrency cases (21 tests, all passing locally). The LDAP branch isn't covered. It only runs when |
wezell
left a comment
There was a problem hiding this comment.
I don't think we need a separate table for locking out. I think we just need a column in the user table called "lockout_date" that we write to and that we update when they are locked out. When they login successfully, we can delete the lockout date. Not sure what the extra table bring us?
|
Follow-up on point 1: the separate The failure count is a different story: |
The new table is not in cache. However, I don't see a cleanup operation anywhere in case of a user deletion |
Refs dotCMS/private-issues#777 - Deleting a user deletes their login_lockout rows in the same transaction (UserFactoryImpl.delete). - Whenever a lock is set, rows for any account that no longer affect a login (lock run out and count older than the window) are deleted. Sources that fail and never succeed no longer stay in the table for good. - Tests: test_user_delete_removes_lockout_rows, test_expired_rows_are_purged_when_a_lock_is_set (23 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@wezell The table is there because the lock is no longer per account. Since b168bb4 it is per account and client address: each address gets its own count and its own lock, WebDAV is counted separately from other logins, and a looser account-wide count sits on top. That state is several rows per user (one per address that has failed recently, plus the account-wide one), so it doesn't fit in a single column on A single Other reasons for a separate table:
If we'd rather have the simpler per-account lock, a |
|
@fabrizzio-dotCMS thanks. Failure count on the cached On transactions: a password change clears the lock in the same transaction, which is what we want. If a login runs inside a wider transaction, its count holds the row lock until commit. Parallel attempts on the same row then wait their turn rather than skip ahead, so the limit still holds. Cleanup: good catch, fixed in ef168f1.
|
Closes #38066
Internal tracking: dotCMS/private-issues#777
Proposed Changes
New
com.dotcms.auth.LoginLockout, keyed on the account and the client address:auth.max.failures.limitpassword attempts (default 5) withinAUTH_LOCKOUT_WINDOW_SECONDS(default 3600). When they're used up without a success, that address is locked out of that account. Logins to the same account from other addresses are not affected.webdav:<address>), so a client retrying a stale password locks only WebDAV from that machine, not backend logins.AUTH_LOCKOUT_ACCOUNT_MAX_FAILURESwithin the window (default 100;0turns it off), the account is locked for every address.AUTH_LOCKOUT_DURATION_SECONDS(default 900) and do not grow. A count that ages out of the window, or whose lock has run out, starts over.AUTH_LOCKOUT_ENABLED=falseturns locking off.Each attempt is counted before its password is checked, with a single
INSERT ... RETURNING, and the returned count decides whether it may check a password. Parallel requests therefore get at most the limit between them.While a lock applies, the password is still checked, and the login fails with the same result as a wrong password. The
auth.failurehandlers and the failed-login delay run as for a wrong password, and nothing is counted.Every password check goes through it:
UserManagerImpl.authenticate: login form, REST basic auth)DotWebdavHelper.authorizePrincipal)LoginFactory.doLoginmethods used by the publishing and maintenance ajax endpoints, including their LDAP branchesAPI tokens accepted by WebDAV are not subject to the lock and are not counted.
A successful login clears the count for its own address. A password reset, or a new password set by an administrator, clears all lockout state for the account and resets its failure count. Admin password changes cover
UserAPI.updatePasswordand a user save with a new password, as the Users portlet and REST user update do.The client address is
HttpServletRequest.getRemoteAddr(). The bundled TomcatRemoteIpValvetakesX-Forwarded-Foronly from trusted proxies (private ranges by default). Installs behind other proxies needCMS_REMOTEIP_INTERNALPROXIESset; otherwise all logins share the proxy's address.State lives in a new
login_lockouttable, so all nodes share it: one row per account and source, plus one account-wide row.Schema comes from startup task
Task261015CreateLoginLockoutTable(additive;postgres.sqlupdated). The task also:failedloginattemptsat 0, because dormant accounts can carry large values that set the delay on their next failed loginChecklist
Tests:
LoginLockoutTest(inMainSuite1b), 21 cases, all passing locally against Postgres and OpenSearch. They cover:doLoginoverloadsupdatePasswordand admin-save unlocksAUTH_LOCKOUT_ENABLED=falseNot covered: the LDAP branch of
doLogin. It only runs whenauth.pipeline.prenames the LDAP authenticator, whichLoginFactoryreads into a static final.Translations
Security Implications Contemplated
Release note
Logins now lock out after repeated failures. Five failed attempts from one address within an hour lock that address out of the account for 15 minutes. 100 failed attempts from all addresses within an hour lock the account for everyone for 15 minutes. This applies to backend login, REST basic auth, WebDAV (counted separately) and the legacy publishing endpoints. API tokens are not affected. Locks clear on their own, or when the password is reset or changed by an administrator. Set
DOT_AUTH_LOCKOUT_ENABLED=falseto turn lockout off. Behind a proxy, make sure Tomcat sees the real client address (CMS_REMOTEIP_INTERNALPROXIES).Support runbook: a user is locked out
DELETE FROM login_lockout WHERE user_id = '<user id>';DELETE FROM login_lockout WHERE user_id = '<user id>' AND source = '<address>';SELECT source, failures, to_timestamp(locked_until / 1000) FROM login_lockout WHERE user_id = '<user id>';shows each address's count and lock. The security log records each lock with its address.CMS_REMOTEIP_INTERNALPROXIES.DOT_AUTH_LOCKOUT_ENABLED=false.Additional Info
Open question: should lockout be on or off by default? The code ships with
AUTH_LOCKOUT_ENABLED=true; reviewers please weigh in.Other behavior to review:
user_.failedloginattemptsis still incremented by the main login flow and still drives the failed-login delay and theauth.max.failureshandlers. With lockout on, it starts over at the limit so the delay stays bounded; lock decisions come only fromlogin_lockout.login_lockout: the attempt is counted, then a success deletes the row. That is small next to the password hash each login already does.LoginFactory.doLoginpaths no longer incrementuser_.failedloginattempts(they did before this PR); WebDAV never did.🤖 Generated with Claude Code
https://claude.ai/code/session_0191fGSd1gBosfHHz1JCCHks