-
Notifications
You must be signed in to change notification settings - Fork 4.3k
TRUNK-6664: Implement deterministic bootstrap password mechanism #6355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 2.8.x
Are you sure you want to change the base?
Changes from all commits
3da6bea
87348ac
422376f
173fe7a
8e6fa19
09c176c
c2f398b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| import java.security.spec.KeySpec; | ||
| import javax.crypto.SecretKeyFactory; | ||
| import javax.crypto.spec.PBEKeySpec; | ||
|
|
||
| /** | ||
| * OpenMRS's security class deals with the hashing of passwords. | ||
| */ | ||
|
|
@@ -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() { | ||
| } | ||
|
|
||
|
|
@@ -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 failureCode 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
|
||
|
Comment on lines
+104
to
+109
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
There was a problem hiding this comment.
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, orisBootstrapPasswordExpiredanywhere outside this class,Security, and the new test - includingDaoAuthenticationScheme/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?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.