feat(security): periodic job to encrypt plaintext passwords in user_ table#35767
feat(security): periodic job to encrypt plaintext passwords in user_ table#35767wezell wants to merge 6 commits into
Conversation
Adds EncryptPlainPasswordsJob — a Quartz StatefulJob that runs every minute,
scans user_ for rows where passwordEncrypted=false, hashes the password via
PasswordFactoryProxy.generateHash, and flips the encrypted flag.
Defense-in-depth against plaintext passwords landing in the column via
migrations, bulk imports, or manual SQL inserts.
Configurable via:
ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOB (default true) — kill switch enforced
at both scheduler-startup and per-firing so the flag can be flipped
without a JVM restart
ENCRYPT_PLAIN_PASSWORDS_CRON_EXPRESSION (default \"0 0/1 * * * ?\")
Integration tests cover: hash + auth roundtrip, already-encrypted row left
alone, null-password row skipped, multi-row pass, disabled-flag no-op.
Refs #35766
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @wezell's task in 2m 2s —— View job Claude finished @wezell's task in 2m 17s —— View job Rollback Safety Analysis — ✅ Safe To Rollback
The PR is purely additive Java code with no schema, mapping, or API-contract changes. Files reviewed
Category check
Why H-1 does not apply here Additionally, both a startup-time kill switch ( |
- UPDATE now WHERE-clauses on (passwordEncrypted=false AND password_=?) so a concurrent password change between SELECT and UPDATE cannot be clobbered. Zero-rows-affected is logged at debug and counted as skipped. - Add ENCRYPT_PLAIN_PASSWORDS_BATCH_SIZE (default 500) so a bulk import of tens of thousands of plaintext rows cannot pin a Quartz worker thread; remaining rows are caught on subsequent ticks. - Default cron lowered from every-minute to every-5-minutes — defense-in-depth does not need 525k firings/year. - Test: delete the cron-scheduled job in @BeforeClass so the real scheduler cannot race the explicit job.execute() calls. Use the default company id via PublicCompanyFactory instead of hardcoding "dotcms.org". Add a concurrent-writer test for the new race guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the review — all four substantive findings addressed in 1 · UPDATE race → 2 · No batch limit → added 3 · Test/scheduler race → 5 · Cron frequency → default lowered from Minor nits → all three applied: |
Without this entry the test class compiles but CI never executes it. MainSuite2b is the existing home for the other quartz job tests (DotStatefulJobTest, CleanUpFieldReferencesJobTest, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validates the real-world claim: the hash format produced by the cron job is interchangeable with hashes produced by the standard user-create / change-password flows. Calls LoginFactory.doLogin(userId, plaintext) against a row that the job hashed and asserts the user can actually log in (and that a wrong password is rejected). Replaces the previous direct PasswordFactoryProxy.authPassword assertion, which was circular validation (same library that produced the hash). Adds emailAddress to the test insert because LoginFactory.doLogin rejects users without one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The end-to-end auth test was failing in CI because the default seeded company uses authType=emailAddress (AUTH_TYPE_EA), so LoginFactory.doLogin routes through loadByUserByEmail — but the test was passing the userId. Resolve the right identifier at runtime based on the company auth type so the test passes for either AUTH_TYPE_EA or AUTH_TYPE_ID configurations. Extracted emailFor(userId) helper so the email convention lives in one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LoginFactory.doLogin loads the user via UserAPI, which auto-assigns default roles — rows in users_cms_roles.user_id FK back to user_. The naive delete-from-user_ in @after then violates fkusers_cms_roles2. Clear users_cms_roles for the user first, then user_. The auth test itself was passing (Failures: 0, Errors: 1) — only the @after tearDown was failing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Proposed Changes
Adds
EncryptPlainPasswordsJob— a QuartzStatefulJobthat runs every 5 minutes, scans theuser_table for rows whosepasswordEncryptedflag isfalse, hashes the cleartext value viaPasswordFactoryProxy.generateHash, and flips the flag totrue.Defense-in-depth against any code path that lands a plaintext password in
user_.password_— migrations, bulk imports, manual SQL recovery, or older code that set the password without the encrypted flag. Once the job ticks, the row is hashed using the same utility as the rest of the platform, so existing login (authPassword) continues to work transparently.Files Touched
dotCMS/src/main/java/com/dotmarketing/quartz/job/EncryptPlainPasswordsJob.javaStatefulJobimplementation.dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.javaFreeServerFromClusterJobpattern).dotcms-integration/src/test/java/com/dotmarketing/quartz/job/EncryptPlainPasswordsJobTest.javaConfiguration
ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOBtruefalse) and at every firing — flip at runtime without a restart.ENCRYPT_PLAIN_PASSWORDS_CRON_EXPRESSION0 0/5 * * * ?Why a periodic job rather than a one-off migration
The risk of a fresh plaintext row landing in
user_is non-zero on a live system (admin tooling, recovery scripts, bulk imports that bypassUserAPI). A standing sweep catches them within one minute regardless of how they got there.The query is cheap:
passwordEncrypted = falseis an extremely selective predicate, so in steady state the job does an index/sequential check that finds zero rows and returns immediately. A partial indexCREATE INDEX ... ON user_ (userId) WHERE passwordEncrypted = falsewould be the right escalation if perf ever becomes a concern, but is unnecessary today.Test Plan
Integration tests (
EncryptPlainPasswordsJobTest):authPassword(plaintext, storedHash)returnsAUTHENTICATED.ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOB=falsemakes the firing a no-op.Run locally:
Rollback safety
Pure additive — new class, new scheduler registration, new test. No schema change, no API contract change, no frontend touched. If anything misbehaves in production, flip
ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOB=falseand the job becomes a no-op immediately; revert the commit for a full backout.Refs #35766
🤖 Generated with Claude Code