IRT-1786, IRT-1791: check stored passwords against policy at login - #194
dsvanstedt wants to merge 7 commits into
Conversation
…6, IRT-1791) Password requirements were only applied when a password was set. doesPasswordMeetRequirements had a single production caller, the set-password path, and authorizeUser never called it. So when the defaults were hardened for 26.6.0 every existing account kept its old password indefinitely, including passwords the server would now refuse to accept. Upgrading installs got the new policy in name only. Login is the one point where the server holds the plaintext, so authorizeUser now evaluates it against the current requirements and returns SUCCESS_GRACE_PERIOD when it fails. Login still succeeds, so a non-compliant account is prompted rather than locked out of its own server, and API clients keep working. The check passes a null user id on purpose. The checker runs its reuse-history rules when given a real id and a reuse policy is enabled, and a user's current password is by definition in their own credential history, so a real id would report every compliant password as reused on every login. Null also keeps the call free of database access, which matters because authorizeUser runs on every Basic auth REST request. Governed by password.enforceatlogin, default true. Also rejects "admin" explicitly. The hardened defaults already reject it for length and character class, so this is a message-quality change: the operator is told the actual problem instead of reading a list of composition failures. It lives in the checker rather than the servlet because the checker is on every path. The multi-factor auth plugin returns its own LoginStatus rather than modifying the one it is given, which would drop the grace period for anyone using it. Restore it when the plugin has finished and simply passed the login through.
Two additions on top of the login-time policy check. First, a server event when a login is allowed but the password no longer meets requirements. Stored passwords are hashed, so there is no way to audit which accounts are affected; this event is the only way an administrator can find them, and it is what makes the restriction below safe to turn on later. It is dispatched from UserServlet.login rather than authorizeUser because authorizeUser also runs on every Basic auth REST request, which would write an event per request. Second, the restriction itself. A SUCCESS_GRACE_PERIOD session was fully privileged, so the change-password prompt was enforced by the clients only and could be ignored over REST. The session is now marked at login, and per request on the Basic auth path, and refused for anything outside the operations needed to complete the change. Extension endpoints authorize through their own method, so the check is in both. The bypass user is unaffected, and the 403 carries an explanation rather than being bare. The flag is cleared when the user successfully changes their own password. Governed by password.restrictgracesessions, default false. It is a breaking change for automation: any account whose stored password does not meet the requirements starts receiving 403, and there is no way to identify those accounts before they log in. Shipping it dormant gives operators a release in which the event above reveals them first.
…CLI (IRT-1791) The CLI accepted only a plain SUCCESS, so a user whose stored password no longer meets requirements would be told "Could not login to server", misreporting a working credential as a bad one. This breaks at the shipped defaults, since the login-time check alone produces the status. It now connects and prints the reason as a warning. The Administrator handled the grace period after starting up: handleSuccess built the whole main window and only then showed the change-password dialog. With grace-period sessions restricted, every request made while loading would be refused before the user ever saw the prompt. The dialog now runs first, talking to the server directly since there is no Frame yet, and the login is abandoned if it is dismissed. That also makes the prompt binding, where before it had a Cancel button that left the user logged in having changed nothing. A fresh install hits both paths at once, because the default password fails the requirements and it is also the user's first login. The first login dialog therefore no longer demands a password when one has already been set during the same login, rather than asking twice. Also drops the placeholder grace-period text that shipped in the dialog's generated form code, which was only ever overwritten at runtime.
…estricted logins end (IRT-1791) Three defects found reviewing the earlier commits on this branch. MirthDialog dereferences PlatformUI.MIRTH_FRAME on both setVisible and dispose, and that field is only assigned when the Administrator window is built. Showing the change-password dialog before that point — the whole purpose of moving it ahead of the Frame — therefore threw a NullPointerException, which LoginPanel swallowed as a generic connection error. On a default install the stored "admin" password now fails the requirements, so this made the Swing client unable to log in at all. The two calls are now guarded; there is nothing to suppress saving on when no window exists yet. logout and inactivityLogout call isUserAuthorized purely to audit and ignore the result, but that method now throws for a restricted session, so it aborted before invalidating. A user who declined the change was left holding a session they could neither use nor end, and the Swing abort path leaked it because it discards logout failures. Both are now allowed through the grace check; they only destroy the session. The first login dialog's register checkbox reset the password field to required, undoing the flag added so a fresh install is not asked twice. Self-scoped operations annotated auditCurrentUser=false still bypass the grace check, because isCurrentUser short-circuits ahead of it. Left as is: Web Admin reads the firstlogin preference through that path while routing a grace-period login, and preferences carry no privilege.
|
CI run 31752617577 failed on Not from this PR — it is the known poll-timing flake that PR #193 (IRT-1788) fixes:
Re-running the failed job. Merging #193 first would remove the flake permanently rather than relying on a re-run. |
…ture/IRT-1786-1791-password-policy-at-login
|
Resolved. #193 was merged, and CI green on the updated branch: run 31754951873 — The merge was clean and brought across only |
…798) The grace-period allowlist matched on operation name only, so a login confined to changing its own password could call updateUserPassword against any user id, then log back in as that user with a session under no restriction at all. Reproducible over Basic auth with no session. Split the allowlist: operations that name no user stay unscoped, while updateUserPassword is now permitted only when the request targets the current user. The target id is recorded in checkUserAuthorized, which already receives it from the @CheckAuthorizedUserId parameter, and a self-only operation that arrives without one is refused rather than allowed on the strength of its name. Nothing changes at shipped defaults: password.restrictgracesessions is off, so a grace login keeps full privileges on both the session and Basic auth paths.
…-1798) The scoping was only exercised through the session flag, with small user ids, and never through the annotation the invocation handler relies on. Adds the Basic auth path, which is where the bypass was originally demonstrated and which reaches the restriction through the login result rather than a session attribute. Adds a check that updateUserPassword still carries @CheckAuthorizedUserId and that its paramName resolves to a real @PARAM on the interface. Without the annotation the handler falls back to the no-argument overload, the target user is unknown, and every grace-restricted user is refused their own password change — locking them out of the one operation that clears the restriction. Nothing else in the suite covered that. Moves the fixture ids outside the Integer cache so the comparison in isCurrentUser cannot silently degrade to reference identity and still pass. Mutation-checked: reinstating updateUserPassword on the unscoped allowlist fails 3, removing the annotation fails 1.
|
Closing this PR. The change has been re-landed on the 26.9.x development line with a net diff identical to this PR's — same files, same insertion and deletion counts — and is merged there, with CI green on JDK 17 and 21. It will reach this repository with the 26.9.0 release. Nothing is lost. |
Closes the Core half of IRT-1786, part A of IRT-1791, and IRT-1798. The Web Admin half of IRT-1786 already merged as d70585a (BridgeLink-Web-UI nextgenhealthcare#720); everything here is Core.
The gap
Password requirements were enforced only when a password was set.
doesPasswordMeetRequirementshad exactly one production caller — the set-password path — andauthorizeUsernever called it. So when the defaults were hardened for 26.6.0 (minlength=8, one upper/lower/digit/special), every existing account kept its old password indefinitely, including passwords the server would now refuse to accept. Upgrading installs got the new policy in name only.The existing expiration machinery does not cover this: it asks how old a password is, never whether it meets the rules, and it is dormant by default (
password.expiration = 0).What changed
Policy is evaluated at login. Login is the one point where the server holds the plaintext.
authorizeUsernow checks it against the current requirements and returnsSUCCESS_GRACE_PERIODwhen it fails, so the user is prompted rather than locked out and API clients keep working. Governed bypassword.enforceatlogin, default true.The check passes a null user id deliberately. The checker runs its reuse-history rules when given a real id and a reuse policy is enabled, and a user's current password is by definition in their own credential history — a real id would report every compliant password as "reused" on every login, a violation the user cannot clear. Null also keeps the call free of database access, which matters because
authorizeUserruns on every Basic auth REST request (MirthServlet:175), not just interactive logins.adminis rejected by name. The hardened defaults already reject it for length and character class, so this is a message-quality change — the operator is told the actual problem instead of reading four composition failures. It lives in the checker, not the servlet, because the checker is on every path.A server event is raised when a login is allowed but the password fails policy. Stored passwords are hashed, so there is no way to audit which accounts are affected — this event is the only way to find them. It is dispatched from
UserServlet.login, notauthorizeUser, so a non-compliant polling account does not write an event every 60 seconds.Grace-period sessions can be confined. They were fully privileged, so the change-password prompt was client-side UX only and could be ignored over REST — step 1 of the disclosure PoC. The session is now marked at login (and per-request on the Basic auth path) and refused for anything outside the operations needed to complete the change. Extension endpoints authorize through their own method, so the check is in both; the bypass user is unaffected; the 403 carries an explanation instead of being bare.
Why the restriction ships off
password.restrictgracesessionsdefaults to false. It is a breaking change for automation — any account whose stored password fails the requirements starts getting 403 until rotated — and because passwords are hashed there is no way to identify those accounts before they log in. Note Fleet authenticates viaPOST /users/_loginand caches a JSESSIONID, so it is on the session path and is not spared by any variant of the restriction.Shipping it dormant gives operators one release in which the event above reveals affected accounts before the boundary that breaks them turns on. IRT-1791 AC4 therefore holds at the shipped defaults; enabling the flag deliberately overrides it. A follow-up should flip the default once operators have had a release to clean up.
Collateral fixes
SUCCESS, so a weak-password user was told "Could not login to server" — a working credential misreported as a bad one. This breaks at the shipped defaults, so it was not optional.LoginPanel:567vs:610). Under restriction every request made while loading would be refused before the user saw the prompt. The dialog now runs first, talking to the server directly since there is no Frame yet, and the login is abandoned if dismissed — which also makes it binding, where before Cancel left the user logged in having changed nothing.FirstLoginDialogno longer demands a password when one was already set during the same login.LoginStatusrather than modifying the one it is given, which would silently drop the grace period. Restored when the plugin has finished and simply passed the login through.Verification
Full
ant -f mirth-build.xmlgreen: 220 test classes, 0 failures, 0 errors. New tests mutation-checked — disabling theadmincheck fails 3, disabling the restriction fails 2.PasswordRequirementsCheckerTest(new, 7) —adminrejected in any case, exact-match not substring, defaults, property overridesMirthServletGracePeriodTest(new, 14) — restriction on/off, allowlisted operations, extension path, self-identification, and the IRT-1798 scoping cases belowDefaultUserControllerTest(+3) — the reuse false-positive guard (AC5), non-compliant reporting, message formatLive against a dev server, both at shipped defaults and with the restriction enabled:
admin/adminloginSUCCESS_GRACE_PERIOD,"admin" is not allowed as a passwordlisted firstgetCurrentUser/ password change under restrictionSUCCESS; old password 401SUCCESS— no false "cannot reuse" (AC5)adminNot in this PR
password == username(IRT-1791 part B, AC8/AC9). Dropped by decision — it needs ausernameparameter threaded through the checker,UserController,UserServletandUserServletInterface(a REST contract change) for a rule that is near-redundant under the shipped defaults: forpassword == usernameto be reachable the username itself must be 8+ chars with upper, lower, digit and special.webadmintreatsSUCCESS_GRACE_PERIODas plain success with no prompt, so its users would see failures with no explanation when the restriction is enabled. Documented in the property comment, not fixed.Release note
password.enforceatloginprompts non-compliant users at login starting this release.password.restrictgracesessionsis available and off; enabling it will 403 non-compliant automation accounts, so rotate service-account passwords first.Review round 1 — two blockers found and fixed
An adversarial review of the first three commits turned up three defects, fixed in
22ce0cda.Blocker: the Swing client could not log in at all on a default install.
MirthDialog.setVisibleanddisposedereferencePlatformUI.MIRTH_FRAME, which is only assigned insidenew Mirth(client). Moving the change-password dialog ahead of that call — the entire point of the client change — meant it NPE'd onsetVisible(true), andLoginPanelswallowed it as a generic connection error. Since the defaultadminpassword now fails policy, every fresh install would have hit this. Both calls are now null-guarded. Regression test:MirthDialogNoFrameTest(skips headless viaAssume), which reproduces the exact NPE without the fix.Blocker: a restricted session could not log out.
logoutandinactivityLogoutare@DontCheckAuthorized, but callisUserAuthorized()purely to audit and ignore the answer — and that method now throws for a restricted session, aborting beforesession.invalidate(). A user who declined the change was left with a session they could neither use nor end, and the Swing abort path leaked it because it discards logout failures. Both operations are now in the allowlist; they only destroy the session. Regression test added.Minor: the first-login register checkbox reset the password field to required, undoing the flag that prevents double-prompting.
Known limitations, deliberately accepted
UserServlet:82) authenticates through the plugin and never reachesauthorizeUser, so for those deploymentsenforceatloginis a no-op — no grace status and no login event, which also means the discovery mechanism shows nothing for MFA users. Needs its own ticket.checkUserPassword,getPasswordRequirements,getCurrentUser,logout, orinactivityLogoutwould pass the grace gate, though it still faces its own extension permission check. (updateUserPasswordis no longer in that set — see round 2.)Full build re-run after the fixes: 221 test classes, 0 failures.
Review round 2 — IRT-1798, privilege escalation through the password write
The confinement had a hole that made it worse than useless where it was enabled: a grace-restricted login could set any user's password and then log in as that user with a session under no restriction at all. Fixed in
da979d1f.The allowlist matched on operation name only.
updateUserPasswordwas on it so a confined user could repair their own password, but nothing scoped it to their own account. Reproduced over Basic auth with no session at all:createUserwas correctly refused throughout, so the allowlist mechanism itself was sound — the hole was specifically the unscoped write.The fix. The allowlist is split in two. Operations that name no user stay unscoped:
checkUserPassword(it takes only a plaintext string),getPasswordRequirements,getCurrentUser,logout,inactivityLogout.updateUserPasswordmoves to a self-only set and is permitted only when the request targets the current user. The target id is recorded incheckUserAuthorized(Integer, boolean), which already receives it from the@CheckAuthorizedUserIdparameter and previously used it only for the "or it's yourself" fallback. A self-only operation that reaches the check without a resolved target is refused rather than allowed on the strength of its name — which also closes the extension-operation case noted above for this one operation.Nothing changes at the shipped defaults: with
password.restrictgracesessions = falsea grace login keeps full privileges, including changing other users' passwords, on both the session and Basic auth paths.Regression tests in
MirthServletGracePeriodTest(7 -> 14): another user's id refused, own id still allowed, no-target refused, and the same cross-user write still permitted with the restriction off — each on both the session and the Basic auth path. Plus a check thatupdateUserPasswordstill carries@CheckAuthorizedUserIdand that itsparamNameresolves to a real@Param: without the annotation the handler falls back to the no-argument overload, the target is unknown, and every grace-restricted user is refused their own password change, locking them out of the one operation that clears the restriction.Fixture user ids sit deliberately outside the
Integercache, so theInteger-to-intcomparison inisCurrentUsercannot silently degrade to reference identity and still pass.Mutation-checked: reinstating
updateUserPasswordon the unscoped allowlist fails 3 of them, removing the annotation fails 1.Re-verified live on a fresh install across both configurations — 31 checks, 0 failures, where the pre-fix branch scored 30/1 with the bypass as the single failure. Specifically confirmed: the cross-user write is 403 on both the session and Basic auth paths and the victim's password is genuinely unchanged; a restricted session can still set its own password (204) and the same session is unrestricted immediately afterward with no re-login; logout from a restricted session still returns 204 and the session is genuinely dead; the entire shipped-defaults phase is unaffected.
Correction to round 1
The round-1 note claiming that self-scoped operations annotated
auditCurrentUser = falsebypass the grace check, and that Web Admin depends on that to read thefirstloginpreference, was wrong on both halves and has been removed. Observed: GET and PUT/users/1/preferences/firstloginfor self both return 403 under restriction, and Web Admin is blocked a step earlier atGET /usersanyway.The mechanism:
UserServletis constructed withinitLogin = false, socurrentUserIdis still 0 when theauditCurrentUser = falsebranch evaluatesisCurrentUser(userId)first. The self-check never matches and control always falls through toisUserAuthorized()and the grace check. Every@CheckAuthorizedUserIdmethod in the codebase lives inUserServlet, so the exemption does not exist anywhere. It has been left non-existent rather than made real — nothing needs it, and relying on thatinitLoginaccident would be fragile.