Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions server/conf/mirth.properties
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ password.expiration = 0
password.graceperiod = 0
password.reuseperiod = 0
password.reuselimit = 0
# Check new passwords against the Have I Been Pwned range API, as recommended by
# NIST SP 800-63B (see https://haveibeenpwned.com/NIST). Secured by k-anonymity.
password.breachedurl = https://api.pwnedpasswords.com/range/

# Only used for migration purposes, do not modify
version = 4.6.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class PasswordRequirements implements Serializable {
private int gracePeriod;
private int reusePeriod;
private int reuseLimit;
private String breachedUrl;

public PasswordRequirements() {
this.minLength = 0;
Expand All @@ -42,6 +43,7 @@ public PasswordRequirements() {
this.gracePeriod = 0;
this.reusePeriod = 0;
this.reuseLimit = 0;
this.breachedUrl = "";
}

public PasswordRequirements(int minLength, int minUpper, int minLower, int minNumeric, int minSpecial, int retryLimit, int lockoutPeriod, int expiration, int gracePeriod, int reusePeriod, int reuseLimit) {
Expand Down Expand Up @@ -145,4 +147,12 @@ public int getReuseLimit() {
public void setReuseLimit(int reuseLimit) {
this.reuseLimit = reuseLimit;
}

public String getBreachedUrl() {
return breachedUrl;
}

public void setBreachedUrl(String breachedUrl) {
this.breachedUrl = breachedUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2025 Mitch Gaffigan

package com.mirth.connect.server.util;

import java.util.Locale;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.mirth.connect.util.HttpUtil;
import com.mirth.connect.util.MirthSSLUtil;

/**
* Checks a candidate password against a Have I Been Pwned style range API
*/
public class BreachedPasswordChecker {

private static final Logger logger = LogManager.getLogger(BreachedPasswordChecker.class);

private static final int REQUEST_TIMEOUT = 3000;

private BreachedPasswordChecker() {
// nop, static
}

/** Determines whether the password appears in a known breach. */
public static boolean checkBreached(String plainPassword, String rangeUrl) {
if (StringUtils.isBlank(rangeUrl)) {
throw new IllegalArgumentException("rangeUrl must not be blank");
}

// HIBP uses the first 20 bits of the SHA-1 hash to limit disclosure (k-anonymity)
String hash = DigestUtils.sha1Hex(plainPassword).toUpperCase(Locale.ROOT);
String prefix = hash.substring(0, 5);
String suffix = hash.substring(5);

// getOrEmpty returns an empty string on any failure, so the check fails open
String response = getOrEmpty(StringUtils.appendIfMissing(rangeUrl, "/") + prefix);
return response.contains(suffix);
}

/** Insulate password checks from network and service failures */
private static String getOrEmpty(String url) {
FutureTask<String> task = new FutureTask<String>(() -> HttpUtil.executeGetRequest(url,
REQUEST_TIMEOUT, true, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES));
Thread thread = new Thread(task, "Breached Password Checker");
thread.setDaemon(true);
thread.start();

try {
return task.get(REQUEST_TIMEOUT, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "";
} catch (Exception e) {
logger.warn("The breached password service at " + url + " did not respond within "
+ REQUEST_TIMEOUT + "ms. Skipping the breached password check.");
return "";
} finally {
task.cancel(true);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public class PasswordRequirementsChecker implements Serializable {
private static final String PASSWORD_MUST_CONTAIN_AN_UPPERCASE_LETTER = "Password must contain %d uppercase letter(s)";
private static final String PASSWORD_MUST_NOT_CONTAIN_AN_UPPERCASE_LETTER = "Password not must contain an uppercase letter";

private static final String PASSWORD_HAS_BEEN_BREACHED = "Password has appeared in a known data breach";

private static final String PASSWORD_MINLENGTH = "password.minlength";
private static final String PASSWORD_MIN_NUMERIC = "password.minnumeric";
private static final String PASSWORD_MIN_LOWER = "password.minlower";
Expand All @@ -57,6 +59,7 @@ public class PasswordRequirementsChecker implements Serializable {
private static final String PASSWORD_LOCKOUT_PERIOD = "password.lockoutperiod";
private static final String PASSWORD_REUSE_PERIOD = "password.reuseperiod";
private static final String PASSWORD_REUSE_LIMIT = "password.reuselimit";
private static final String PASSWORD_BREACHED_URL = "password.breachedurl";

private static PasswordRequirementsChecker instance = null;

Expand Down Expand Up @@ -88,6 +91,7 @@ public PasswordRequirements loadPasswordRequirements(PropertiesConfiguration sec
passwordRequirements.setLockoutPeriod(securityProperties.getInt(PASSWORD_LOCKOUT_PERIOD, 0));
passwordRequirements.setReusePeriod(securityProperties.getInt(PASSWORD_REUSE_PERIOD, 0));
passwordRequirements.setReuseLimit(securityProperties.getInt(PASSWORD_REUSE_LIMIT, 0));
passwordRequirements.setBreachedUrl(securityProperties.getString(PASSWORD_BREACHED_URL, ""));

return passwordRequirements;
}
Expand Down Expand Up @@ -123,10 +127,15 @@ public List<String> doesPasswordMeetRequirements(Integer userId, String plainPas
addResult(resultList, checkReusePeriod(previousCredentials, plainPassword, passwordRequirements.getReusePeriod()));
addResult(resultList, checkReuseLimit(previousCredentials, plainPassword, passwordRequirements.getReuseLimit()));
} catch (ControllerException e) {
addResult(resultList, "There was an error checking against previous user passwords.");
addResult(resultList, "There was an error checking against previous user passwords");
}
}

String breachedUrl = passwordRequirements.getBreachedUrl();
if (resultList.isEmpty() && StringUtils.isNotBlank(breachedUrl)) {
addResult(resultList, BreachedPasswordChecker.checkBreached(plainPassword, breachedUrl) ? PASSWORD_HAS_BEEN_BREACHED : null);
}

if (resultList.size() == 0) {
return null;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import com.mirth.connect.client.core.ControllerException;
import com.mirth.connect.model.PasswordRequirements;

public class PasswordRequirementsTests extends TestCase {
public class PasswordRequirementsTest extends TestCase {

protected void setUp() throws Exception {
super.setUp();
Expand Down
1 change: 1 addition & 0 deletions server/src/test/resources/mirth.properties
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ password.expiration = 0
password.graceperiod = 0
password.reuseperiod = 0
password.reuselimit = 0
password.breachedurl =

# Only used for migration purposes, do not modify
version = 4.6.0
Expand Down
Loading