Skip to content
49 changes: 49 additions & 0 deletions api/src/main/java/org/openmrs/api/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -605,4 +605,53 @@ List<User> getUsers(String name, List<Role> roles, boolean includeRetired, Integ
*/
@Authorized
String getLastLoginTime(User user);

/**
* Generates a deterministic bootstrap password for a user.
*
* The password is derived from the user's UUID and a system-wide salt using PBKDF2.
* It is not stored in the database and can be regenerated at any time.
*
* @param user the user for whom to generate the bootstrap password
* @return the generated bootstrap password
* @throws APIException if the user is null, has no UUID, or system salt is not configured
* @since 2.8.8
*/
@Authorized( { PrivilegeConstants.GET_USERS })
String generateBootstrapPassword(User user);

/**
* Validates a password against a user's bootstrap password.
*
* @param user the user
* @param password the password to validate
* @return true if the password matches the user's bootstrap password
* @since 2.8.8
*/
@Authorized( { PrivilegeConstants.GET_USERS })
boolean validateBootstrapPassword(User user, String password);

/**
* Forces a user to change their password on next login.
*
* This is typically called after a user successfully logs in with a bootstrap password.
*
* @param user the user whose password change should be forced
* @since 2.8.8
*/
@Authorized( { PrivilegeConstants.EDIT_USER_PASSWORDS })
void forcePasswordChange(User user);

/**
* Checks if a user's bootstrap password has expired.
*
* Bootstrap passwords are considered expired when the user has been
* successfully authenticated with them and was forced to change their password.
*
* @param user the user
* @return true if the bootstrap password is expired
* @since 2.8.8
*/
@Authorized( { PrivilegeConstants.GET_USERS })
boolean isBootstrapPasswordExpired(User user);
}
36 changes: 36 additions & 0 deletions api/src/main/java/org/openmrs/api/impl/UserServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -822,4 +822,40 @@ public void changePasswordUsingActivationKey(String activationKey, String newPas
public String getLastLoginTime(User user) {
return dao.getLastLoginTime(user);
}

/**
* @see org.openmrs.api.UserService#generateBootstrapPassword(User)
*/
@Override
public String generateBootstrapPassword(User user) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't find any caller of generateBootstrapPassword, validateBootstrapPassword, forcePasswordChange, or isBootstrapPasswordExpired anywhere outside this class, Security, and the new test - including DaoAuthenticationScheme / UsernamePasswordAuthenticationScheme, which is where I'd expect a bootstrap password to actually get checked during login. As merged, nothing in openmrs-core itself can authenticate a user with a bootstrap password. Is wiring this into the login flow planned as a follow-up PR, or are these methods meant to be called directly by downstream modules?

@praisekavuma4 praisekavuma4 Jul 20, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wiring the bootstrap password check into the authentication flow is planned as a follow-up ticket. This PR focuses on the backend service layer. We'll coordinate with the auth framework team and see how to integrate it.

return Security.generateBootstrapPassword(user);
}

/**
* @see org.openmrs.api.UserService#validateBootstrapPassword(User, String)
*/
@Override
public boolean validateBootstrapPassword(User user, String password) {
return Security.validateBootstrapPassword(user, password);
}

/**
* @see org.openmrs.api.UserService#forcePasswordChange(User)
*/
@Override
@Authorized( { PrivilegeConstants.EDIT_USER_PASSWORDS })
public void forcePasswordChange(User user) {
if (user != null) {
Security.forcePasswordChange(user); // Sets the property
dao.saveUser(user, null);
}
}

/**
* @see org.openmrs.api.UserService#isBootstrapPasswordExpired(User)
*/
@Override
public boolean isBootstrapPasswordExpired(User user) {
return Security.isBootstrapPasswordExpired(user);
}
}
10 changes: 10 additions & 0 deletions api/src/main/java/org/openmrs/util/OpenmrsConstants.java
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,13 @@ public static final Collection<String> AUTO_ROLES() {
*/
public static final String GP_PASSWORD_RESET_URL = "security.passwordResetUrl";

/**
* Global property that stores the number of PBKDF2 iterations for bootstrap password generation.
* Higher values increase security but slow down generation.
* Default: 600000 (OWASP 2025 recommendation)
*/
public static final String GP_BOOTSTRAP_ITERATIONS = "security.bootstrap.iterations";

/**
* Global property that stores the number of days for users to be deactivated.
*/
Expand Down Expand Up @@ -735,6 +742,9 @@ public static final List<GlobalProperty> CORE_GLOBAL_PROPERTIES() {

props.add(new GlobalProperty(GP_PASSWORD_RESET_URL, "",
"The URL to redirect to after requesting for a password reset. Always provide a place holder in this url with name {activationKey}, it will get substituted by the actual activation key."));
props.add(new GlobalProperty(GP_BOOTSTRAP_ITERATIONS, "600000",
"Number of PBKDF2 iterations for bootstrap password derivation. "
+ "Higher values increase security. Default: 600000 (OWASP 2025)."));

props.add(new GlobalProperty("mail.transport_protocol", "smtp",
"Transport protocol for the messaging engine. Valid values: smtp"));
Expand Down
202 changes: 202 additions & 0 deletions api/src/main/java/org/openmrs/util/Security.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
import org.slf4j.LoggerFactory;
import org.springframework.util.StringUtils;

import java.security.MessageDigest;

Check warning on line 32 in api/src/main/java/org/openmrs/util/Security.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this duplicated import.

See more on https://sonarcloud.io/project/issues?id=openmrs_openmrs-core&issues=AZ-FNdpp1YFO5dkuhfuN&open=AZ-FNdpp1YFO5dkuhfuN&pullRequest=6355
import java.security.spec.KeySpec;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;

/**
* OpenMRS's security class deals with the hashing of passwords.
*/
Expand All @@ -41,6 +46,12 @@

private static final Random RANDOM = new SecureRandom();

private static final String PBKDF2_ALGORITHM = "PBKDF2WithHmacSHA256";
private static final int PBKDF2_DEFAULT_ITERATIONS = 600000;
private static final int PBKDF2_KEY_LENGTH = 256;
private static final int BOOTSTRAP_MAX_PASSWORD_LENGTH = 20;
private static final byte[] PBKDF2_SALT = "OpenMRS-Bootstrap-PBKDF2".getBytes(StandardCharsets.UTF_8);

private Security() {
}

Expand Down Expand Up @@ -69,6 +80,197 @@
|| hashedPassword.equals(incorrectlyEncodeString(passwordToHash));
}

/**
* Generates a deterministic hash using PBKDF2.
*
* This is used for bootstrap password generation where deterministic output is required.
* Unlike encodeString() which uses SHA-512, this method uses PBKDF2 which is designed
* for password derivation and can be configured with iteration counts.
*
* @param input the input string to derive from
* @param iterations the number of PBKDF2 iterations (use 0 for default)
* @return the derived hash as a Base64 encoded string
* @since 2.8.8
*/
public static String generateDeterministicHash(String input, int iterations) {
if (input == null) {
throw new APIException("bootstrap.input.null", (Object[]) null);
}

int actualIterations = iterations > 0 ? iterations : PBKDF2_DEFAULT_ITERATIONS;

try {
SecretKeyFactory factory = SecretKeyFactory.getInstance(PBKDF2_ALGORITHM);
KeySpec spec = new PBEKeySpec(
input.toCharArray(),
PBKDF2_SALT, // Non-empty fixed salt
actualIterations,
PBKDF2_KEY_LENGTH

Check failure

Code scanning / SonarCloud

Password hashing functions should use an unpredictable salt High

Make this salt unpredictable. See more on SonarQube Cloud
);

Check failure on line 109 in api/src/main/java/org/openmrs/util/Security.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make this salt unpredictable.

See more on https://sonarcloud.io/project/issues?id=openmrs_openmrs-core&issues=AZ-AOqQF-TchFAS-RJ1G&open=AZ-AOqQF-TchFAS-RJ1G&pullRequest=6355
Comment on lines +104 to +109

@praisekavuma4 praisekavuma4 Jul 20, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

intentionally a fixed salt was used because bootstrap passwords must be deterministic (same input → same output). The system pepper, stored in runtime.properties, provides the unpredictability per installation. This is a deliberate design trade-off.

byte[] derived = factory.generateSecret(spec).getEncoded();

// Base64 encode without padding and truncate for user-friendliness
String base64 = Base64.getEncoder().withoutPadding().encodeToString(derived);

// Remove ambiguous characters
String cleaned = base64.replaceAll("[0OIl]", "");

// Truncate to reasonable length
return cleaned.substring(0, Math.min(cleaned.length(), BOOTSTRAP_MAX_PASSWORD_LENGTH));

} catch (GeneralSecurityException e) {
log.error("Failed to generate deterministic hash", e);
throw new APIException("bootstrap.hash.generation.failed", null, e);
}
}

/**
* Generates a deterministic hash using PBKDF2 with default iterations.
*
* @param input the input string to derive from
* @return the derived hash as a Base64 encoded string
* @since 2.8.8
*/
public static String generateDeterministicHash(String input) {
return generateDeterministicHash(input, PBKDF2_DEFAULT_ITERATIONS);
}

/**
* Gets the pepper used for bootstrap password generation.
*
*The pepper is read from the runtime property "openmrs.bootstrap.pepper".
*
* @return the pepper, or null if not configured
* @since 2.8.8
*/
public static String getBootstrapPepper() {
return Context.getRuntimeProperties()
.getProperty("openmrs.bootstrap.pepper");
}

/**
* Gets the configured number of PBKDF2 iterations for bootstrap password generation.
*
* Reads from global property "security.bootstrap.iterations".
* Falls back to default (600,000) if not configured or invalid.
*
* @return the number of iterations (always > 0)
* @since 2.8.8
*/
public static int getBootstrapIterations() {
try {
String value = Context.getAdministrationService()
.getGlobalProperty(OpenmrsConstants.GP_BOOTSTRAP_ITERATIONS);

if (StringUtils.hasText(value)) {
int iterations = Integer.parseInt(value.trim());
if (iterations > 0) {
return iterations;
}
log.warn("Invalid bootstrap iterations value (must be > 0): {}, using default: {}",
value, PBKDF2_DEFAULT_ITERATIONS);
}
} catch (Exception e) {
log.debug("Could not read bootstrap iterations from global properties", e);
}

return PBKDF2_DEFAULT_ITERATIONS;
}

/**
* Generates a deterministic bootstrap password for a user.
*
* The password is derived from the user's UUID and pepper using PBKDF2.
* Same input always produces the same output (deterministic).
*
* @param user the user
* @return the generated bootstrap password
* @throws APIException if user is null, has no UUID, or pepper is not configured
* @since 2.8.8
*/
public static String generateBootstrapPassword(org.openmrs.User user) {
if (user == null) {
throw new APIException("bootstrap.user.null", (Object[]) null);
}

String uuid = user.getUuid();
if (!StringUtils.hasText(uuid)) {
throw new APIException("bootstrap.user.uuid.missing", (Object[]) null);
}

String pepper = getBootstrapPepper();
if (!StringUtils.hasText(pepper)) {
throw new APIException("bootstrap.pepper.missing", (Object[]) null);
}

String input = uuid + pepper;
int iterations = getBootstrapIterations();

return generateDeterministicHash(input, iterations);
}

/**
* Validates a password against a user's bootstrap password.
*
* @param user the user
* @param password the password to validate
* @return true if the password matches the user's bootstrap password and the user has not been forced to change
* @since 2.8.8
*/
public static boolean validateBootstrapPassword(org.openmrs.User user, String password) {
if (user == null || password == null) {
return false;
}

// If the user has already been forced to change, the bootstrap password is no longer valid
if (isBootstrapPasswordExpired(user)) {
return false;
}

try {
String expected = generateBootstrapPassword(user);
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
password.getBytes(StandardCharsets.UTF_8)
);
} catch (APIException e) {
log.debug("Failed to validate bootstrap password: {}", e.getMessage());
return false;
}
}

/**
* Checks if a user's bootstrap password has expired.
*
* Bootstrap passwords are expired if the user has the "forcePassword" user property set to "true".
*
* @param user the user
* @return true if the bootstrap password is expired
* @since 2.8.8
*/
public static boolean isBootstrapPasswordExpired(org.openmrs.User user) {
if (user == null) {
return false;
}
String forcePassword = user.getUserProperty(OpenmrsConstants.USER_PROPERTY_CHANGE_PASSWORD);
return "true".equals(forcePassword);
}

/**
* Forces a user to change their password on next login.
*
* Sets the "forcePassword" user property to "true".
* The user will be redirected to change password on next login.
*
* @param user the user whose password change should be forced
* @since 2.8.8
*/
public static void forcePasswordChange(org.openmrs.User user) {
if (user != null) {
user.setUserProperty(OpenmrsConstants.USER_PROPERTY_CHANGE_PASSWORD, "true");
}
}

/**
/**
* This method will hash <code>strToEncode</code> using the preferred algorithm. Currently,
Expand Down
Loading