Skip to content

Commit 3139ad7

Browse files
wezellclaude
andauthored
feat(security): periodic job to encrypt plaintext passwords in user_ table (#35767)
## Proposed Changes Adds **`EncryptPlainPasswordsJob`** — a Quartz `StatefulJob` that runs every 5 minutes, scans the `user_` table for rows whose `passwordEncrypted` flag is `false`, hashes the cleartext value via `PasswordFactoryProxy.generateHash`, and flips the flag to `true`. 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 | File | Change | | --- | --- | | `dotCMS/src/main/java/com/dotmarketing/quartz/job/EncryptPlainPasswordsJob.java` | **New.** `StatefulJob` implementation. | | `dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.java` | Registers the new job (mirrors `FreeServerFromClusterJob` pattern). | | `dotcms-integration/src/test/java/com/dotmarketing/quartz/job/EncryptPlainPasswordsJobTest.java` | **New.** Five integration tests. | ## Configuration | Property | Default | Effect | | --- | --- | --- | | `ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOB` | `true` | Kill switch. Checked at startup (job not scheduled if `false`) **and** at every firing — flip at runtime without a restart. | | `ENCRYPT_PLAIN_PASSWORDS_CRON_EXPRESSION` | `0 0/5 * * * ?` | Standard Quartz cron. | ## 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 bypass `UserAPI`). A standing sweep catches them within one minute regardless of how they got there. The query is cheap: `passwordEncrypted = false` is an extremely selective predicate, so in steady state the job does an index/sequential check that finds zero rows and returns immediately. A partial index `CREATE INDEX ... ON user_ (userId) WHERE passwordEncrypted = false` would be the right escalation if perf ever becomes a concern, but is unnecessary today. ## Test Plan Integration tests (`EncryptPlainPasswordsJobTest`): - [x] **Happy path** — plaintext row gets hashed; `authPassword(plaintext, storedHash)` returns `AUTHENTICATED`. - [x] **Already encrypted row** — left untouched (no double-hashing). - [x] **Null password** — skipped (no UPDATE issued). - [x] **Multiple rows** — all hashed in a single pass. - [x] **Disabled flag** — `ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOB=false` makes the firing a no-op. Run locally: ```bash ./mvnw verify -pl :dotcms-integration -Dcoreit.test.skip=false -Dit.test=EncryptPlainPasswordsJobTest ``` ## 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=false` and the job becomes a no-op immediately; revert the commit for a full backout. Refs #35766 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 531ae0a commit 3139ad7

4 files changed

Lines changed: 457 additions & 0 deletions

File tree

dotCMS/src/main/java/com/dotmarketing/init/DotInitScheduler.java

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import com.dotmarketing.quartz.job.DeleteInactiveLiveWorkingIndicesJob;
1717
import com.dotmarketing.quartz.job.DeleteSiteSearchIndicesJob;
1818
import com.dotmarketing.quartz.job.DropOldContentVersionsJob;
19+
import com.dotmarketing.quartz.job.EncryptPlainPasswordsJob;
1920
import com.dotmarketing.quartz.job.FreeServerFromClusterJob;
2021
import com.dotmarketing.quartz.job.PruneTimeMachineBackupJob;
2122
import com.dotmarketing.quartz.job.ServerHeartbeatJob;
@@ -301,6 +302,50 @@ public static void start() throws Exception {
301302
}
302303
}
303304

305+
//Schedule EncryptPlainPasswordsJob
306+
final String EPPjobName = "EncryptPlainPasswordsJob";
307+
final String EPPjobGroup = DOTCMS_JOB_GROUP_NAME;
308+
final String EPPtriggerName = "trigger27";
309+
final String EPPtriggerGroup = "group27";
310+
311+
if (EncryptPlainPasswordsJob.isEnabled()) {
312+
try {
313+
isNew = false;
314+
315+
try {
316+
if ((job = sched.getJobDetail(EPPjobName, EPPjobGroup)) == null) {
317+
job = new JobDetail(EPPjobName, EPPjobGroup, EncryptPlainPasswordsJob.class);
318+
isNew = true;
319+
}
320+
} catch (SchedulerException se) {
321+
sched.deleteJob(EPPjobName, EPPjobGroup);
322+
job = new JobDetail(EPPjobName, EPPjobGroup, EncryptPlainPasswordsJob.class);
323+
isNew = true;
324+
}
325+
calendar = Calendar.getInstance();
326+
calendar.add(Calendar.MINUTE, 1);
327+
// By default, the job runs every minute
328+
trigger = new CronTrigger(EPPtriggerName, EPPtriggerGroup, EPPjobName, EPPjobGroup,
329+
calendar.getTime(), null,
330+
Config.getStringProperty(EncryptPlainPasswordsJob.CRON_PROPERTY,
331+
EncryptPlainPasswordsJob.DEFAULT_CRON_EXPRESSION));
332+
trigger.setMisfireInstruction(CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING);
333+
sched.addJob(job, true);
334+
335+
if (isNew) {
336+
sched.scheduleJob(trigger);
337+
} else {
338+
sched.rescheduleJob(EPPtriggerName, EPPtriggerGroup, trigger);
339+
}
340+
} catch (Exception e) {
341+
Logger.error(DotInitScheduler.class, e.getMessage(), e);
342+
}
343+
} else {
344+
if ((sched.getJobDetail(EPPjobName, EPPjobGroup)) != null) {
345+
sched.deleteJob(EPPjobName, EPPjobGroup);
346+
}
347+
}
348+
304349
addDropOldContentVersionsJob();
305350
if ( !Config.getBooleanProperty(DOTCMS_DISABLE_WEBSOCKET_PROTOCOL, false) ) {
306351
// Enabling the System Events Job
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.dotmarketing.quartz.job;
2+
3+
import com.dotcms.enterprise.PasswordFactoryProxy;
4+
import com.dotcms.enterprise.de.qaware.heimdall.PasswordException;
5+
import com.dotmarketing.common.db.DotConnect;
6+
import com.dotmarketing.db.DbConnectionFactory;
7+
import com.dotmarketing.exception.DotDataException;
8+
import com.dotmarketing.util.Config;
9+
import com.dotmarketing.util.Logger;
10+
import com.dotmarketing.util.UtilMethods;
11+
import java.util.List;
12+
import java.util.Map;
13+
import org.quartz.JobExecutionContext;
14+
import org.quartz.JobExecutionException;
15+
import org.quartz.StatefulJob;
16+
17+
/**
18+
* Sweeps the {@code user_} table for rows whose {@code passwordEncrypted} flag is {@code false}
19+
* (plaintext passwords) and rewrites them with a securely hashed value via
20+
* {@link PasswordFactoryProxy#generateHash(String)}. Once hashed, the row is flipped to
21+
* {@code passwordEncrypted = true}.
22+
*
23+
* Implementation notes:
24+
* <ul>
25+
* <li>Stateful job — concurrent firings cannot overlap.</li>
26+
* <li>The UPDATE is guarded by {@code passwordEncrypted = false AND password_ = ?} so that a
27+
* concurrent password change (via {@code UserAPI}) cannot be silently regressed: if the row
28+
* was already rewritten between the SELECT and the UPDATE, the UPDATE affects zero rows
29+
* and is logged at debug.</li>
30+
* <li>Each firing processes at most {@code ENCRYPT_PLAIN_PASSWORDS_BATCH_SIZE} rows so a bulk
31+
* import depositing tens of thousands of plaintext rows cannot pin a Quartz worker thread
32+
* for hours. Remaining rows are caught on subsequent ticks.</li>
33+
* </ul>
34+
*/
35+
public class EncryptPlainPasswordsJob implements StatefulJob {
36+
37+
public static final String ENABLE_PROPERTY = "ENABLE_ENCRYPT_PLAIN_PASSWORDS_JOB";
38+
public static final String CRON_PROPERTY = "ENCRYPT_PLAIN_PASSWORDS_CRON_EXPRESSION";
39+
public static final String BATCH_SIZE_PROPERTY = "ENCRYPT_PLAIN_PASSWORDS_BATCH_SIZE";
40+
public static final String DEFAULT_CRON_EXPRESSION = "0 0/5 * * * ?";
41+
public static final int DEFAULT_BATCH_SIZE = 500;
42+
43+
private static final String SELECT_PLAINTEXT_USERS_SQL =
44+
"select userId, password_ from user_ where passwordEncrypted = ? and password_ is not null limit ?";
45+
46+
private static final String UPDATE_HASHED_PASSWORD_SQL =
47+
"update user_ set password_ = ?, passwordEncrypted = ? "
48+
+ "where userId = ? and passwordEncrypted = ? and password_ = ?";
49+
50+
/**
51+
* Runtime kill switch. The scheduler also checks this property at startup; checking it here
52+
* lets an operator disable the job between firings without restarting the JVM.
53+
*/
54+
public static boolean isEnabled() {
55+
return Config.getBooleanProperty(ENABLE_PROPERTY, true);
56+
}
57+
58+
@Override
59+
public void execute(final JobExecutionContext context) throws JobExecutionException {
60+
if (com.dotcms.shutdown.ShutdownCoordinator.isShutdownStarted()) {
61+
Logger.info(EncryptPlainPasswordsJob.class,
62+
"Shutdown in progress - skipping EncryptPlainPasswordsJob execution");
63+
return;
64+
}
65+
66+
if (!isEnabled()) {
67+
Logger.debug(EncryptPlainPasswordsJob.class,
68+
() -> ENABLE_PROPERTY + "=false - skipping EncryptPlainPasswordsJob execution");
69+
return;
70+
}
71+
72+
final int batchSize = Config.getIntProperty(BATCH_SIZE_PROPERTY, DEFAULT_BATCH_SIZE);
73+
74+
try {
75+
final List<Map<String, Object>> rows = new DotConnect()
76+
.setSQL(SELECT_PLAINTEXT_USERS_SQL)
77+
.addParam(false)
78+
.addParam(batchSize)
79+
.loadObjectResults();
80+
81+
if (rows.isEmpty()) {
82+
return;
83+
}
84+
85+
Logger.info(EncryptPlainPasswordsJob.class,
86+
"Found " + rows.size() + " user(s) with unencrypted passwords. Hashing now.");
87+
88+
int hashed = 0;
89+
int skipped = 0;
90+
for (final Map<String, Object> row : rows) {
91+
final String userId = (String) row.get("userid");
92+
final String plaintext = (String) row.get("password_");
93+
94+
if (!UtilMethods.isSet(userId) || !UtilMethods.isSet(plaintext)) {
95+
continue;
96+
}
97+
98+
try {
99+
final String hash = PasswordFactoryProxy.generateHash(plaintext);
100+
final int updated = new DotConnect()
101+
.executeUpdate(UPDATE_HASHED_PASSWORD_SQL,
102+
hash, true, userId, false, plaintext);
103+
if (updated == 0) {
104+
// Row changed between SELECT and UPDATE — another writer got there first.
105+
skipped++;
106+
Logger.debug(EncryptPlainPasswordsJob.class,
107+
() -> "Skipped userId=" + userId
108+
+ " — row was modified between select and update");
109+
} else {
110+
hashed++;
111+
}
112+
} catch (PasswordException | DotDataException e) {
113+
Logger.error(EncryptPlainPasswordsJob.class,
114+
"Unable to hash password for userId=" + userId + ": " + e.getMessage(),
115+
e);
116+
}
117+
}
118+
119+
Logger.info(EncryptPlainPasswordsJob.class,
120+
"Encrypted " + hashed + " of " + rows.size()
121+
+ " plaintext password row(s) (skipped " + skipped + ").");
122+
} catch (DotDataException e) {
123+
Logger.error(EncryptPlainPasswordsJob.class,
124+
"Error scanning user_ table for unencrypted passwords", e);
125+
} finally {
126+
DbConnectionFactory.closeSilently();
127+
}
128+
}
129+
}

dotcms-integration/src/test/java/com/dotcms/MainSuite2b.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@
161161
import com.dotmarketing.quartz.DotStatefulJobTest;
162162
import com.dotmarketing.quartz.job.CleanUpFieldReferencesJobTest;
163163
import com.dotmarketing.quartz.job.DropOldContentVersionsJobTest;
164+
import com.dotmarketing.quartz.job.EncryptPlainPasswordsJobTest;
164165
import com.dotmarketing.quartz.job.IntegrityDataGenerationJobTest;
165166
import com.dotmarketing.quartz.job.PopulateContentletAsJSONJobTest;
166167
import com.dotmarketing.quartz.job.PruneTimeMachineBackupJobTest;
@@ -471,6 +472,7 @@
471472
com.dotmarketing.tag.business.TagAPITest.class,
472473
OSGIUtilTest.class,
473474
CleanUpFieldReferencesJobTest.class,
475+
EncryptPlainPasswordsJobTest.class,
474476
CachedParameterDecoratorTest.class,
475477
ContainerFactoryImplTest.class,
476478
TemplateFactoryImplTest.class,

0 commit comments

Comments
 (0)