From 6d5adc8af08d4547d45a51969e41bbef24f6b9e3 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 20 Oct 2026 21:34:09 -0700 Subject: [PATCH 1/5] Database readers can take over accounts that have a pending setup or reset link --- api/src/org/labkey/api/ApiModule.java | 2 + .../org/labkey/api/data/SqlScriptRunner.java | 2 - .../org/labkey/api/security/LoginManager.java | 88 ++++++++++++++++--- .../labkey/api/security/SecurityManager.java | 22 ++++- .../org/labkey/api/security/UserManager.java | 14 +-- core/module.properties | 2 +- .../postgresql/core-26.008-26.009.sql | 8 ++ .../org/labkey/core/login/DbLoginManager.java | 2 +- .../labkey/core/login/LoginController.java | 7 +- .../core/security/SecurityController.java | 71 +++++++++++++-- .../org/labkey/core/user/UserController.java | 8 +- 11 files changed, 185 insertions(+), 41 deletions(-) create mode 100644 core/resources/schemas/dbscripts/postgresql/core-26.008-26.009.sql diff --git a/api/src/org/labkey/api/ApiModule.java b/api/src/org/labkey/api/ApiModule.java index 10e54ea41db..0d7c28d5432 100644 --- a/api/src/org/labkey/api/ApiModule.java +++ b/api/src/org/labkey/api/ApiModule.java @@ -152,6 +152,7 @@ import org.labkey.api.security.EntropyPasswordValidator; import org.labkey.api.security.GroupManager; import org.labkey.api.security.LimitedUser; +import org.labkey.api.security.LoginManager.VerificationMaintenanceTask; import org.labkey.api.security.NestedGroupsTest; import org.labkey.api.security.PasswordExpiration; import org.labkey.api.security.RoleSet; @@ -288,6 +289,7 @@ protected Collection createWebPartFactories() protected void doStartup(ModuleContext moduleContext) { SystemMaintenance.addTask(new ApiKeyMaintenanceTask()); + SystemMaintenance.addTask(new VerificationMaintenanceTask()); AuthenticationManager.registerMetricsProvider(); ContentSecurityPolicyFilter.registerMetricsProvider(); ApiKeyManager.get().handleStartupProperties(); diff --git a/api/src/org/labkey/api/data/SqlScriptRunner.java b/api/src/org/labkey/api/data/SqlScriptRunner.java index 56fc83c22e7..84c21f4f5a8 100644 --- a/api/src/org/labkey/api/data/SqlScriptRunner.java +++ b/api/src/org/labkey/api/data/SqlScriptRunner.java @@ -31,8 +31,6 @@ /** * Responsible for executing SQL scripts as part of module install or upgrade at server startup-time. - * User: arauch - * Date: Jun 14, 2005 */ public class SqlScriptRunner { diff --git a/api/src/org/labkey/api/security/LoginManager.java b/api/src/org/labkey/api/security/LoginManager.java index 84a52cc6b5d..e4ba910aa09 100644 --- a/api/src/org/labkey/api/security/LoginManager.java +++ b/api/src/org/labkey/api/security/LoginManager.java @@ -17,7 +17,9 @@ import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; +import org.labkey.api.data.CompareType; import org.labkey.api.data.Container; import org.labkey.api.data.CoreSchema; import org.labkey.api.data.DbScope; @@ -26,13 +28,17 @@ import org.labkey.api.data.SimpleFilter; import org.labkey.api.data.SqlExecutor; import org.labkey.api.data.SqlSelector; -import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; +import org.labkey.api.query.FieldKey; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; +import org.labkey.api.util.SystemMaintenance.MaintenanceTask; import org.labkey.api.view.ActionURL; import org.springframework.dao.DataIntegrityViolationException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -45,6 +51,8 @@ public class LoginManager { public static final int TEMP_PASSWORD_LENGTH = 32; + public static final Duration SETUP_LINK_TIMEOUT = Duration.ofDays(7); + public static final Duration RESET_LINK_TIMEOUT = Duration.ofDays(1); private static final int MAX_HISTORY = 10; private static final CoreSchema CORE = CoreSchema.getInstance(); @@ -60,8 +68,8 @@ public static String createLogin(int userId, String email /* Just for logging er try { int rowCount = new SqlExecutor(CORE.getSchema()).execute("INSERT INTO " + CORE.getTableInfoLogins() + - " (UserId, Crypt, LastChanged, Verification, PreviousCrypts) VALUES (?, ?, ?, ?, ?)", - userId, crypt, new Date(), verification, crypt); + " (UserId, Crypt, LastChanged, Verification, VerificationTimeout, PreviousCrypts) VALUES (?, ?, ?, ?, ?, ?)", + userId, crypt, new Date(), hashVerification(verification), Date.from(Instant.now().plus(SETUP_LINK_TIMEOUT)), crypt); if (1 != rowCount) throw new SecurityManager.UserManagementException(email, "Login creation statement affected " + rowCount + " rows."); } @@ -187,39 +195,58 @@ public static ActionURL createModuleVerificationURL(Container c, User user, Stri // Test if user has been verified for database authentication public static boolean isVerified(User user) { - return (null == getVerification(user)); + return null == new SqlSelector(CORE.getSchema(), "SELECT Verification FROM " + CORE.getTableInfoLogins() + " WHERE UserId = ?", user.getUserId()).getObject(String.class); } - // Look up a user based on the verification string + // Look up a user based on an unexpired verification token public static @Nullable User verify(String verification) { Integer userId = null; if (StringUtils.length(verification) == TEMP_PASSWORD_LENGTH) { - TableInfo logins = CORE.getTableInfoLogins(); - userId = new TableSelector(CORE.getTableInfoLogins(), Collections.singleton("UserId"), new SimpleFilter(logins.getColumn("Verification").getFieldKey(), verification), null) + SimpleFilter filter = new SimpleFilter(FieldKey.fromParts("Verification"), hashVerification(verification)); + filter.addCondition(FieldKey.fromParts("VerificationTimeout"), new Date(), CompareType.GT); + userId = new TableSelector(CORE.getTableInfoLogins(), Collections.singleton("UserId"), filter, null) .getObject(Integer.class); } return userId != null ? UserManager.getUser(userId) : null; } - public static void setVerification(User user, @Nullable String verification) throws SecurityManager.UserManagementException + // Store the hash of a new verification token and return the token, which can't be retrieved later + public static String createVerification(User user, Duration timeout) throws SecurityManager.UserManagementException { - int rows = new SqlExecutor(CORE.getSchema()).execute("UPDATE " + CORE.getTableInfoLogins() + " SET Verification = ? WHERE UserId = ?", verification, user.getUserId()); + String verification = createTempPassword(); + updateVerification(user, new SQLFragment("Verification = ?, VerificationTimeout = ?", hashVerification(verification), Date.from(Instant.now().plus(timeout)))); + return verification; + } + + public static void clearVerification(User user) throws SecurityManager.UserManagementException + { + // Literal NULLs: a null Date parameter binds as varchar, which PostgreSQL rejects for a timestamp column + updateVerification(user, new SQLFragment("Verification = NULL, VerificationTimeout = NULL")); + } + + private static void updateVerification(User user, SQLFragment assignments) throws SecurityManager.UserManagementException + { + SQLFragment sql = new SQLFragment("UPDATE " + CORE.getTableInfoLogins() + " SET ").append(assignments).append(" WHERE UserId = ?").add(user.getUserId()); + int rows = new SqlExecutor(CORE.getSchema()).execute(sql); if (1 != rows) throw new SecurityManager.UserManagementException(user.getEmail(), "Unexpected number of rows returned when setting verification: " + rows); } - public static String getVerification(User user) + // Tokens are long and random, so a fast unsalted hash suffices; core-26.008-26.009.sql hashes legacy rows identically + private static String hashVerification(String verification) { - return new SqlSelector(CORE.getSchema(), "SELECT Verification FROM " + CORE.getTableInfoLogins() + " WHERE UserId = ?", user.getUserId()).getObject(String.class); + return Crypt.SHA256.digestWithPrefix(verification); } + // verification holds the token's hash public record VerifyEmail(String requestedEmail, String verification, Date verificationTimeout) { public boolean isVerified(String userProvidedToken) { - return userProvidedToken != null && userProvidedToken.equals(verification); + return userProvidedToken != null && verification != null && + MessageDigest.isEqual(hashVerification(userProvidedToken).getBytes(StandardCharsets.US_ASCII), verification.getBytes(StandardCharsets.US_ASCII)); } } @@ -240,11 +267,11 @@ public static void requestEmailChange(User userToChange, ValidEmail requestedEma Instant timeoutDate = Instant.now().plus(UserManager.VERIFICATION_EMAIL_TIMEOUT, ChronoUnit.MINUTES); SqlExecutor executor = new SqlExecutor(CORE.getSchema()); int rows = executor.execute("UPDATE " + CORE.getTableInfoLogins() + " SET RequestedEmail = ?, Verification = ?, VerificationTimeout = ? WHERE UserId = ?", - requestedEmail.getEmailAddress(), verificationToken, Date.from(timeoutDate), userToChange.getUserId()); + requestedEmail.getEmailAddress(), hashVerification(verificationToken), Date.from(timeoutDate), userToChange.getUserId()); if (1 != rows) throw new SecurityManager.UserManagementException(requestedEmail, "Unexpected number of rows returned when setting verification: " + rows); UserManager.addToUserHistory(userToChange, currentUser + " requested email address change from " + userToChange.getEmail() + " to " + requestedEmail + - " with token '" + verificationToken + "' and timeout date '" + Date.from(timeoutDate) + "'."); + " with timeout date '" + Date.from(timeoutDate) + "'."); transaction.commit(); } } @@ -261,4 +288,37 @@ public static void deleteLoginsRow(@Nullable User userToChange, @Nullable User a UserManager.addToUserHistory(userToChange, adminUser.getEmail() + " deleted the password."); } } + + public static class VerificationMaintenanceTask implements MaintenanceTask + { + @Override + public String getDescription() + { + return "Purge expired verification tokens"; + } + + @Override + public String getName() + { + return "PurgeExpiredVerificationTokens"; + } + + @Override + public void run(Logger log) + { + new SqlExecutor(CORE.getSchema()).execute("UPDATE " + CORE.getTableInfoLogins() + " SET Verification = NULL, VerificationTimeout = NULL WHERE VerificationTimeout < ?", new Date()); + } + + @Override + public boolean canDisable() + { + return false; + } + + @Override + public boolean hideFromAdminPage() + { + return true; + } + } } diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index 3338af62448..685f75303a7 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -132,6 +132,7 @@ import java.io.Closeable; import java.io.UnsupportedEncodingException; import java.io.Writer; +import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -2669,9 +2670,8 @@ public static void adminRotatePassword(User affectedUser, BindException errors, { // Create a placeholder password that's impossible to guess and a separate email // verification key that gets emailed. - verification = LoginManager.createTempPassword(); LoginManager.setPassword(affectedUser, LoginManager.createTempPassword()); - LoginManager.setVerification(affectedUser, verification); + verification = LoginManager.createVerification(affectedUser, LoginManager.RESET_LINK_TIMEOUT); } else { @@ -3277,7 +3277,23 @@ public void testCreateUser() throws Exception assertNotNull(verifiedUser); assertEquals(user, verifiedUser); - LoginManager.setVerification(user, null); + String stored = new SqlSelector(CoreSchema.getInstance().getSchema(), "SELECT Verification FROM " + CoreSchema.getInstance().getTableInfoLogins() + " WHERE UserId = ?", user.getUserId()).getObject(String.class); + assertNotNull(stored); + assertNotEquals("Verification token stored in plain text", status.getVerification(), stored); + + String rotated = LoginManager.createVerification(user, LoginManager.RESET_LINK_TIMEOUT); + assertNull("Replaced verification token still valid", LoginManager.verify(status.getVerification())); + assertEquals(user, LoginManager.verify(rotated)); + + String expired = LoginManager.createVerification(user, Duration.ofSeconds(-1)); + assertNull("Expired verification token still valid", LoginManager.verify(expired)); + + new LoginManager.VerificationMaintenanceTask().run(LOG); + assertTrue("Maintenance task didn't purge expired verification token", LoginManager.isVerified(user)); + + LoginManager.createVerification(user, LoginManager.RESET_LINK_TIMEOUT); + LoginManager.clearVerification(user); + assertTrue(LoginManager.isVerified(user)); String password = generateStrongPassword(user); LoginManager.setPassword(user, password); diff --git a/api/src/org/labkey/api/security/UserManager.java b/api/src/org/labkey/api/security/UserManager.java index acfe7ce9004..463405d67f0 100644 --- a/api/src/org/labkey/api/security/UserManager.java +++ b/api/src/org/labkey/api/security/UserManager.java @@ -860,7 +860,7 @@ public static void changeEmail(User currentUser, User userToChange, boolean isAd { if (!LoginManager.getVerifyEmail(userToChange).isVerified(verificationToken)) // shouldn't happen! should be testing this earlier too { - throw new UserManagementException(oldEmail, "Verification token '" + verificationToken + "' is incorrect for email change for user " + oldEmail); + throw new UserManagementException(oldEmail, "Verification token is incorrect for email change for user " + oldEmail); } } @@ -875,7 +875,7 @@ public static void changeEmail(User currentUser, User userToChange, boolean isAd } else { - addToUserHistory(userToChange, currentUser + " changed their email address from " + oldEmail + " to " + newEmail + " with token '" + verificationToken + "'."); + addToUserHistory(userToChange, currentUser + " changed their email address from " + oldEmail + " to " + newEmail + "."); } if (userToChange.getDisplayName(userToChange).equals(oldEmail)) @@ -887,7 +887,7 @@ public static void changeEmail(User currentUser, User userToChange, boolean isAd if (LoginManager.loginExists(userToChange)) { - LoginManager.setVerification(userToChange, null); // so we don't let user use this link again + LoginManager.clearVerification(userToChange); // so we don't let user use this link again } transaction.commit(); @@ -896,16 +896,16 @@ public static void changeEmail(User currentUser, User userToChange, boolean isAd clearUserList(); } - public static void auditEmailTimeout(int userId, String oldEmail, String newEmail, String verificationToken, User currentUser) + public static void auditEmailTimeout(int userId, String oldEmail, String newEmail, User currentUser) { addToUserHistory(getUser(userId), currentUser + " tried to change an email address from " + oldEmail + " to " + newEmail + - " with token '" + verificationToken + "', but the verification link was timed out."); + ", but the verification link was timed out."); } - public static void auditBadVerificationToken(int userId, String oldEmail, String newEmail, String verificationToken, User currentUser) + public static void auditBadVerificationToken(int userId, String oldEmail, String newEmail, User currentUser) { addToUserHistory(getUser(userId), currentUser + " tried to change an email address from " + oldEmail + " to " + newEmail + - " with token '" + verificationToken + "', but the verification token for that email address was not correct."); + ", but the verification token for that email address was not correct."); } public static void deleteUser(int userId) throws UserManagementException diff --git a/core/module.properties b/core/module.properties index 8285cf71395..093340ff4cd 100644 --- a/core/module.properties +++ b/core/module.properties @@ -1,6 +1,6 @@ Name: Core ModuleClass: org.labkey.core.CoreModule -SchemaVersion: 26.008 +SchemaVersion: 26.009 Label: Administration and Essential Services Description: The Core module provides central services such as login, \ security, administration, folder management, user management, \ diff --git a/core/resources/schemas/dbscripts/postgresql/core-26.008-26.009.sql b/core/resources/schemas/dbscripts/postgresql/core-26.008-26.009.sql new file mode 100644 index 00000000000..345c1202f89 --- /dev/null +++ b/core/resources/schemas/dbscripts/postgresql/core-26.008-26.009.sql @@ -0,0 +1,8 @@ +-- Make room for sha256: prefix plus 64-character hash +ALTER TABLE core.Logins ALTER COLUMN Verification TYPE VARCHAR(100); + +-- Must match LoginManager.hashVerification(). Legacy setup and reset links (no timeout) expire seven days after upgrade. +UPDATE core.Logins +SET Verification = 'sha256:' || encode(sha256(convert_to(Verification, 'UTF8')), 'hex'), + VerificationTimeout = COALESCE(VerificationTimeout, LOCALTIMESTAMP + INTERVAL '7 days') +WHERE Verification IS NOT NULL; diff --git a/core/src/org/labkey/core/login/DbLoginManager.java b/core/src/org/labkey/core/login/DbLoginManager.java index c5cb453f599..77a0b669ae1 100644 --- a/core/src/org/labkey/core/login/DbLoginManager.java +++ b/core/src/org/labkey/core/login/DbLoginManager.java @@ -147,7 +147,7 @@ public void complete(int count) try { if (clearVerification) - LoginManager.setVerification(affectedUser, null); + LoginManager.clearVerification(affectedUser); UserManager.addToUserHistory(affectedUser, auditMessage); } catch (SecurityManager.UserManagementException e) diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index 44b834eaa44..96aeb442bc0 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -924,8 +924,7 @@ private Pair attemptReset(String rawEmail, String providerName) try { // Create a verification key to email the user - String verification = LoginManager.createTempPassword(); - LoginManager.setVerification(user, verification); + String verification = LoginManager.createVerification(user, LoginManager.RESET_LINK_TIMEOUT); try { Container c = getContainer(); @@ -1706,7 +1705,7 @@ protected void verifyBeforeView(SetPasswordForm form, boolean reshow, BindExcept if (errors.hasErrors()) { if (_unrecoverableError) - _log.warn("Verification failed: {} {}", form.getEmail(), form.getVerification()); + _log.warn("Verification failed: {}", form.getEmail()); else _log.warn("Password entry error: {}", form.getEmail()); } @@ -1910,7 +1909,7 @@ public static void checkVerificationErrors(boolean isVerified, User user, String else if (verification.length() < LoginManager.TEMP_PASSWORD_LENGTH) errors.reject("setPassword", "Verification failed. The verification parameter is shorter than expected. Make sure you've copied the entire link into your browser's address bar."); else - errors.reject("setPassword", "Verification failed. You may have already verified."); + errors.reject("setPassword", "Verification failed. The link may have expired or already been used. Request a new one if needed."); } } diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index f51431de05e..379b9cbf34b 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -128,6 +128,7 @@ import org.labkey.api.security.roles.RoleManager; import org.labkey.api.security.roles.SiteAdminRole; import org.labkey.api.settings.AppProps; +import org.labkey.api.util.DOM; import org.labkey.api.util.HtmlString; import org.labkey.api.util.HtmlStringBuilder; import org.labkey.api.util.PageFlowUtil; @@ -158,6 +159,7 @@ import java.io.IOException; import java.io.Writer; import java.sql.SQLException; +import java.time.Duration; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -173,6 +175,9 @@ import java.util.stream.Stream; import static org.apache.commons.lang3.StringUtils.trimToNull; +import static org.labkey.api.util.DOM.Attribute.method; +import static org.labkey.api.util.DOM.Attribute.type; +import static org.labkey.api.util.DOM.at; public class SecurityController extends SpringActionController { @@ -1790,12 +1795,50 @@ public void setMailPrefix(String mailPrefix) } } - private abstract static class AbstractEmailAction extends SimpleViewAction + // Only a token hash is stored, so GET shows the email with the link masked; site admins can POST to replace the token and see the new link + private abstract static class AbstractEmailAction extends FormViewAction { + private String _verification = null; + protected abstract SecurityMessage createMessage(UserForm form); + protected abstract Duration getLinkTimeout(); + + @Override + public void validateCommand(UserForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(UserForm form, boolean reshow, BindException errors) throws Exception + { + return renderEmail(form); + } + + @Override + public boolean handlePost(UserForm form, BindException errors) throws Exception + { + User affectedUser = form.getUser(true); + createMessage(form); // permission check + if (!getUser().hasSiteAdminPermission()) + throw new UnauthorizedException(); + if (!LoginManager.isVerified(affectedUser)) + _verification = LoginManager.createVerification(affectedUser, getLinkTimeout()); + return true; + } @Override - public ModelAndView getView(UserForm form, BindException errors) throws Exception + public URLHelper getSuccessURL(UserForm form) + { + return null; + } + + @Override + public ModelAndView getSuccessView(UserForm form) throws Exception + { + return renderEmail(form); + } + + private ModelAndView renderEmail(UserForm form) throws Exception { Writer out = getViewContext().getResponse().getWriter(); User affectedUser = form.getUser(); @@ -1807,7 +1850,7 @@ public ModelAndView getView(UserForm form, BindException errors) throws Exceptio { SecurityMessage message = createMessage(form); - // Issue 33254: only allow Site Admins to see the verification token + // Issue 33254: allow only Site Admins to see the verification token message.setMaskToken(true); if (LoginManager.isVerified(affectedUser)) @@ -1816,9 +1859,15 @@ public ModelAndView getView(UserForm form, BindException errors) throws Exceptio } else { - String verification = LoginManager.getVerification(affectedUser); - ActionURL verificationURL = LoginManager.createVerificationURL(getContainer(), affectedUser, verification, null); + ActionURL verificationURL = LoginManager.createVerificationURL(getContainer(), affectedUser, null != _verification ? _verification : "**********", null); SecurityManager.renderEmail(getContainer(), getUser(), message, affectedUser.getEmail(), verificationURL, out); + + if (null == _verification && getUser().hasSiteAdminPermission()) + { + out.write(DOM.createHtml(DOM.LK.FORM(at(method, "POST"), + DOM.P("To get a usable link, generate a new one. The link already sent will stop working."), + DOM.BUTTON(at(type, "submit"), "Generate New Link"))).toString()); // No page template, so no script for a LabKey button + } } } @@ -1848,6 +1897,12 @@ protected SecurityMessage createMessage(UserForm form) } return SecurityManager.getRegistrationMessage(form.getMailPrefix(), false); } + + @Override + protected Duration getLinkTimeout() + { + return LoginManager.SETUP_LINK_TIMEOUT; + } } @@ -1859,6 +1914,12 @@ protected SecurityMessage createMessage(UserForm form) { return SecurityManager.getResetMessage(false); } + + @Override + protected Duration getLinkTimeout() + { + return LoginManager.RESET_LINK_TIMEOUT; + } } diff --git a/core/src/org/labkey/core/user/UserController.java b/core/src/org/labkey/core/user/UserController.java index 75fcb5dac0d..b13b9c06f48 100644 --- a/core/src/org/labkey/core/user/UserController.java +++ b/core/src/org/labkey/core/user/UserController.java @@ -1904,7 +1904,7 @@ else if (!userToChange.equals(loggedInUser)) { if (!canUpdateUser) // don't bother auditing admin password link clicks { - UserManager.auditEmailTimeout(loggedInUser.getUserId(), oldEmail, requestedEmail, verificationToken, getUser()); + UserManager.auditEmailTimeout(loggedInUser.getUserId(), oldEmail, requestedEmail, getUser()); } errors.reject(ERROR_MSG, "This verification link has expired. Please try to change your email address again."); } @@ -1915,15 +1915,15 @@ else if (!userToChange.equals(loggedInUser)) { if (!canUpdateUser) // don't bother auditing admin verification link clicks { - UserManager.auditBadVerificationToken(loggedInUser.getUserId(), oldEmail, verifyEmail.requestedEmail(), verificationToken, loggedInUser); + UserManager.auditBadVerificationToken(loggedInUser.getUserId(), oldEmail, verifyEmail.requestedEmail(), loggedInUser); } errors.reject(ERROR_MSG, "Verification was incorrect. Make sure you've copied the entire link into your browser's address bar."); // double error, to better explain to user } - else if (!verificationToken.equals(verifyEmail.verification())) + else { if (!canUpdateUser) // don't bother auditing admin verification link clicks { - UserManager.auditBadVerificationToken(loggedInUser.getUserId(), oldEmail, verifyEmail.requestedEmail(), verificationToken, loggedInUser); + UserManager.auditBadVerificationToken(loggedInUser.getUserId(), oldEmail, verifyEmail.requestedEmail(), loggedInUser); } } } From ef339f5d71021a9bca70e8430046399bc4c21df9 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 21 Oct 2026 17:06:56 -0700 Subject: [PATCH 2/5] Stop the double rendering --- core/src/org/labkey/core/security/SecurityController.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index 379b9cbf34b..32b329ffd50 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -1832,12 +1832,6 @@ public URLHelper getSuccessURL(UserForm form) return null; } - @Override - public ModelAndView getSuccessView(UserForm form) throws Exception - { - return renderEmail(form); - } - private ModelAndView renderEmail(UserForm form) throws Exception { Writer out = getViewContext().getResponse().getWriter(); From e9e9d944c76f6d6a56a2ef56669ad226691565f7 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 22 Oct 2026 06:46:02 -0700 Subject: [PATCH 3/5] Remove the purge. Audit admins creating new links. --- api/src/org/labkey/api/ApiModule.java | 2 -- .../org/labkey/api/security/LoginManager.java | 35 ------------------- .../labkey/api/security/SecurityManager.java | 5 +-- .../core/security/SecurityController.java | 5 ++- 4 files changed, 5 insertions(+), 42 deletions(-) diff --git a/api/src/org/labkey/api/ApiModule.java b/api/src/org/labkey/api/ApiModule.java index 0b6fff23988..8f2afd91661 100644 --- a/api/src/org/labkey/api/ApiModule.java +++ b/api/src/org/labkey/api/ApiModule.java @@ -156,7 +156,6 @@ import org.labkey.api.security.EntropyPasswordValidator; import org.labkey.api.security.GroupManager; import org.labkey.api.security.LimitedUser; -import org.labkey.api.security.LoginManager.VerificationMaintenanceTask; import org.labkey.api.security.NestedGroupsTest; import org.labkey.api.security.PasswordExpiration; import org.labkey.api.security.RoleSet; @@ -293,7 +292,6 @@ protected Collection createWebPartFactories() protected void doStartup(ModuleContext moduleContext) { SystemMaintenance.addTask(new ApiKeyMaintenanceTask()); - SystemMaintenance.addTask(new VerificationMaintenanceTask()); AuthenticationManager.registerMetricsProvider(); ContentSecurityPolicyFilter.registerMetricsProvider(); ApiKeyManager.get().handleStartupProperties(); diff --git a/api/src/org/labkey/api/security/LoginManager.java b/api/src/org/labkey/api/security/LoginManager.java index e4ba910aa09..05c7797792b 100644 --- a/api/src/org/labkey/api/security/LoginManager.java +++ b/api/src/org/labkey/api/security/LoginManager.java @@ -17,7 +17,6 @@ import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; -import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; import org.labkey.api.data.CompareType; import org.labkey.api.data.Container; @@ -32,7 +31,6 @@ import org.labkey.api.query.FieldKey; import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; -import org.labkey.api.util.SystemMaintenance.MaintenanceTask; import org.labkey.api.view.ActionURL; import org.springframework.dao.DataIntegrityViolationException; @@ -288,37 +286,4 @@ public static void deleteLoginsRow(@Nullable User userToChange, @Nullable User a UserManager.addToUserHistory(userToChange, adminUser.getEmail() + " deleted the password."); } } - - public static class VerificationMaintenanceTask implements MaintenanceTask - { - @Override - public String getDescription() - { - return "Purge expired verification tokens"; - } - - @Override - public String getName() - { - return "PurgeExpiredVerificationTokens"; - } - - @Override - public void run(Logger log) - { - new SqlExecutor(CORE.getSchema()).execute("UPDATE " + CORE.getTableInfoLogins() + " SET Verification = NULL, VerificationTimeout = NULL WHERE VerificationTimeout < ?", new Date()); - } - - @Override - public boolean canDisable() - { - return false; - } - - @Override - public boolean hideFromAdminPage() - { - return true; - } - } } diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index 942880912e8..0ea6453c12e 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -3287,11 +3287,8 @@ public void testCreateUser() throws Exception String expired = LoginManager.createVerification(user, Duration.ofSeconds(-1)); assertNull("Expired verification token still valid", LoginManager.verify(expired)); + assertFalse("User with only an expired token reported as verified", LoginManager.isVerified(user)); - new LoginManager.VerificationMaintenanceTask().run(LOG); - assertTrue("Maintenance task didn't purge expired verification token", LoginManager.isVerified(user)); - - LoginManager.createVerification(user, LoginManager.RESET_LINK_TIMEOUT); LoginManager.clearVerification(user); assertTrue(LoginManager.isVerified(user)); diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index 32b329ffd50..510b5f3d479 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -1818,11 +1818,14 @@ public ModelAndView getView(UserForm form, boolean reshow, BindException errors) public boolean handlePost(UserForm form, BindException errors) throws Exception { User affectedUser = form.getUser(true); - createMessage(form); // permission check + SecurityMessage message = createMessage(form); // permission check if (!getUser().hasSiteAdminPermission()) throw new UnauthorizedException(); if (!LoginManager.isVerified(affectedUser)) + { _verification = LoginManager.createVerification(affectedUser, getLinkTimeout()); + UserManager.addToUserHistory(affectedUser, getUser().getEmail() + " generated a new link for the " + message.getType().toLowerCase() + "."); + } return true; } From 1a944f014860718fded04adef1b81986384d73c7 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Sat, 24 Oct 2026 09:56:41 -0700 Subject: [PATCH 4/5] Update email templates with link expiration --- .../org/labkey/api/security/LoginManager.java | 4 +- .../labkey/api/security/SecurityManager.java | 44 ++++++++++++++----- .../labkey/api/security/SecurityMessage.java | 14 ++++++ .../org/labkey/api/security/UserManager.java | 1 - .../labkey/core/login/LoginController.java | 10 ++--- .../core/security/SecurityController.java | 4 +- .../org/labkey/core/user/UserController.java | 4 +- 7 files changed, 56 insertions(+), 25 deletions(-) diff --git a/api/src/org/labkey/api/security/LoginManager.java b/api/src/org/labkey/api/security/LoginManager.java index 05c7797792b..235d2911a73 100644 --- a/api/src/org/labkey/api/security/LoginManager.java +++ b/api/src/org/labkey/api/security/LoginManager.java @@ -38,7 +38,6 @@ import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -51,6 +50,7 @@ public class LoginManager public static final int TEMP_PASSWORD_LENGTH = 32; public static final Duration SETUP_LINK_TIMEOUT = Duration.ofDays(7); public static final Duration RESET_LINK_TIMEOUT = Duration.ofDays(1); + public static final Duration EMAIL_CHANGE_LINK_TIMEOUT = Duration.ofDays(1); private static final int MAX_HISTORY = 10; private static final CoreSchema CORE = CoreSchema.getInstance(); @@ -262,7 +262,7 @@ public static void requestEmailChange(User userToChange, ValidEmail requestedEma DbScope scope = CORE.getSchema().getScope(); try (DbScope.Transaction transaction = scope.ensureTransaction()) { - Instant timeoutDate = Instant.now().plus(UserManager.VERIFICATION_EMAIL_TIMEOUT, ChronoUnit.MINUTES); + Instant timeoutDate = Instant.now().plus(EMAIL_CHANGE_LINK_TIMEOUT); SqlExecutor executor = new SqlExecutor(CORE.getSchema()); int rows = executor.execute("UPDATE " + CORE.getTableInfoLogins() + " SET RequestedEmail = ?, Verification = ?, VerificationTimeout = ? WHERE UserId = ?", requestedEmail.getEmailAddress(), hashVerification(verificationToken), Date.from(timeoutDate), userToChange.getUserId()); diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index 0ea6453c12e..f1e2d49369a 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -106,6 +106,7 @@ import org.labkey.api.util.PageFlowUtil; import org.labkey.api.util.Pair; import org.labkey.api.util.SessionHelper; +import org.labkey.api.util.StringUtilsLabKey; import org.labkey.api.util.TestContext; import org.labkey.api.util.URLHelper; import org.labkey.api.util.emailTemplate.EmailTemplateService; @@ -1203,19 +1204,25 @@ else if (email.getEmailAddress().indexOf("@") > 0) return displayName; } + // Passes no timeout duration. Should be called only by MacCoss SignUpManager, which doesn't implement expiration. public static void sendEmail(Container c, User fromUser, SecurityMessage message, String to, ActionURL verificationURL) throws ConfigurationException, MessagingException { - MimeMessage m = createMessage(c, fromUser, message, to, verificationURL); + sendEmail(c, fromUser, message, to, verificationURL, null); + } + + public static void sendEmail(Container c, User fromUser, SecurityMessage message, String to, ActionURL verificationURL, @Nullable Duration linkTimeout) throws ConfigurationException, MessagingException + { + MimeMessage m = createMessage(c, fromUser, message, to, verificationURL, linkTimeout); MailHelper.send(m, fromUser, c); } - public static void renderEmail(Container c, User fromUser, SecurityMessage message, String to, ActionURL verificationURL, Writer out) throws MessagingException + public static void renderEmail(Container c, User fromUser, SecurityMessage message, String to, ActionURL verificationURL, @Nullable Duration linkTimeout, Writer out) throws MessagingException { - MimeMessage m = createMessage(c, fromUser, message, to, verificationURL); + MimeMessage m = createMessage(c, fromUser, message, to, verificationURL, linkTimeout); MailHelper.renderHtml(m, message.getType(), out); } - private static MimeMessage createMessage(Container c, User fromUser, SecurityMessage message, String to, ActionURL verificationURL) throws MessagingException + private static MimeMessage createMessage(Container c, User fromUser, SecurityMessage message, String to, ActionURL verificationURL, @Nullable Duration linkTimeout) throws MessagingException { try { @@ -1224,6 +1231,7 @@ private static MimeMessage createMessage(Container c, User fromUser, SecurityMes verificationURL.replaceParameter("verification", "**********"); message.setVerificationURL(verificationURL.getURIString()); + message.setLinkTimeout(linkTimeout); message.setOriginatingUser(fromUser); if (message.getTo() == null) message.setTo(to); @@ -2335,12 +2343,12 @@ public static void sendRegistrationEmail(ViewContext context, ValidEmail email, ActionURL verificationURL = LoginManager.createModuleVerificationURL(context.getContainer(), newUser, newUserStatus.getVerification(), extraParameters, provider, isAddUser); - sendEmail(c, currentUser, getRegistrationMessage(mailPrefix, false), email.getEmailAddress(), verificationURL); + sendEmail(c, currentUser, getRegistrationMessage(mailPrefix, false), email.getEmailAddress(), verificationURL, LoginManager.SETUP_LINK_TIMEOUT); if (!currentUser.isGuest() && !currentUser.equals(newUser)) { SecurityMessage msg = getRegistrationMessage(mailPrefix, true); msg.setTo(email.getEmailAddress()); - sendEmail(c, currentUser, msg, currentUser.getEmail(), verificationURL); + sendEmail(c, currentUser, msg, currentUser.getEmail(), verificationURL, LoginManager.SETUP_LINK_TIMEOUT); } } @@ -2509,6 +2517,7 @@ public abstract static class SecurityEmailTemplate extends UserOriginatedEmailTe protected String _optionalPrefix; private String _verificationUrl = ""; private String _recipient = ""; + private @Nullable Duration _linkTimeout; protected boolean _verificationUrlRequired = true; protected SecurityEmailTemplate(@NotNull String name, String description, String subject, String body) @@ -2519,12 +2528,20 @@ protected SecurityEmailTemplate(@NotNull String name, String description, String public void setOptionPrefix(String optionalPrefix){_optionalPrefix = optionalPrefix;} public void setVerificationUrl(String verificationUrl){_verificationUrl = verificationUrl;} public void setRecipient(String recipient){_recipient = recipient;} + public void setLinkTimeout(@Nullable Duration linkTimeout){_linkTimeout = linkTimeout;} + + private static String formatLinkTimeout(Duration timeout) + { + long hours = timeout.toHours(); + return hours > 24 && hours % 24 == 0 ? StringUtilsLabKey.pluralize(hours / 24, "day") : StringUtilsLabKey.pluralize(hours, "hour"); + } @Override protected void addCustomReplacements(Replacements replacements) { super.addCustomReplacements(replacements); replacements.add("verificationURL", String.class, "Link for a user to set a password", ContentType.Plain, c -> _verificationUrl); + replacements.add("linkExpiration", String.class, "How long the verification link remains valid, e.g., \"7 days\"", ContentType.Plain, c -> null == _linkTimeout ? null : formatLinkTimeout(_linkTimeout)); replacements.add("emailAddress", String.class, "The email address of the user performing the operation", ContentType.Plain, c -> _originatingUser == null ? null : _originatingUser.getEmail()); replacements.add("recipient", String.class, "The email address on the 'to:' line", ContentType.Plain, c -> _recipient); } @@ -2534,7 +2551,7 @@ public boolean isValid(String[] error) { if (super.isValid(error)) { - // add an additional requirement for the verification url + // add a requirement for the verification url if (!_verificationUrlRequired || getBody().contains("^verificationURL^")) { return true; @@ -2556,7 +2573,7 @@ public static class RegistrationEmailTemplate extends SecurityEmailTemplate You now have an account on the ^organizationName^ ^siteShortName^ web site. We are sending \ you this message to verify your email address and to allow you to create a password that will provide secure \ access to your data on the web site. To complete the registration process, simply click the link below or \ - copy it to your browser's address bar. You will then be asked to choose a password. + copy it to your browser's address bar. You will then be asked to choose a password.^linkExpiration| This link expires in %s.^ ^verificationURL^ @@ -2601,7 +2618,7 @@ public static class PasswordResetEmailTemplate extends SecurityEmailTemplate We have reset your password on the ^organizationName^ ^siteShortName^ web site. \ To sign in to the system you will need \ to specify a new password. Click the link below or copy it to your browser's address bar. You will then be \ - asked to enter a new password. + asked to enter a new password.^linkExpiration| This link expires in %s.^ ^verificationURL^ @@ -2665,29 +2682,32 @@ public static void adminRotatePassword(User affectedUser, BindException errors, try { String verification; + Duration linkTimeout; if (loginExists) { // Create a placeholder password that's impossible to guess and a separate email // verification key that gets emailed. LoginManager.setPassword(affectedUser, LoginManager.createTempPassword()); - verification = LoginManager.createVerification(affectedUser, LoginManager.RESET_LINK_TIMEOUT); + linkTimeout = LoginManager.RESET_LINK_TIMEOUT; + verification = LoginManager.createVerification(affectedUser, linkTimeout); } else { + linkTimeout = LoginManager.SETUP_LINK_TIMEOUT; verification = LoginManager.createLogin(affectedUser.getUserId(), affectedUser.getEmail()); } try { ActionURL verificationURL = LoginManager.createVerificationURL(c, affectedUser, verification, null); - sendEmail(c, currentUser, getResetMessage(false), affectedUser.getEmail(), verificationURL); + sendEmail(c, currentUser, getResetMessage(false), affectedUser.getEmail(), verificationURL, linkTimeout); if (!currentUser.equals(affectedUser)) { SecurityMessage msg = getResetMessage(true); msg.setTo(affectedUser.getEmail()); - sendEmail(c, currentUser, msg, currentUser.getEmail(), verificationURL); + sendEmail(c, currentUser, msg, currentUser.getEmail(), verificationURL, linkTimeout); } UserManager.addToUserHistory(affectedUser, currentUser.getEmail() + " " + pastVerb + " the password."); } diff --git a/api/src/org/labkey/api/security/SecurityMessage.java b/api/src/org/labkey/api/security/SecurityMessage.java index 23cf4f53a1e..71f87846a74 100644 --- a/api/src/org/labkey/api/security/SecurityMessage.java +++ b/api/src/org/labkey/api/security/SecurityMessage.java @@ -16,6 +16,7 @@ package org.labkey.api.security; +import org.jetbrains.annotations.Nullable; import org.labkey.api.data.Container; import org.labkey.api.settings.LookAndFeelProperties; import org.labkey.api.util.MailHelper; @@ -24,6 +25,8 @@ import jakarta.mail.Address; import jakarta.mail.internet.MimeMessage; +import java.time.Duration; + public class SecurityMessage { private String _verificationURL; @@ -33,6 +36,7 @@ public class SecurityMessage private String _messagePrefix; private SecurityManager.SecurityEmailTemplate _template; private boolean _maskToken; + private @Nullable Duration _linkTimeout; public SecurityMessage(){} @@ -56,6 +60,7 @@ public MimeMessage createMailMessage(Container c) throws Exception _template.setOriginatingUser(getOriginatingUser()); _template.setRecipient(getTo()); _template.setOptionPrefix(getMessagePrefix()); + _template.setLinkTimeout(getLinkTimeout()); m.setTemplate(_template, c); LookAndFeelProperties properties = LookAndFeelProperties.getInstance(c); @@ -115,4 +120,13 @@ public boolean isMaskToken() { return _maskToken; } + + public @Nullable Duration getLinkTimeout() + { + return _linkTimeout; + } + public void setLinkTimeout(@Nullable Duration linkTimeout) + { + _linkTimeout = linkTimeout; + } } diff --git a/api/src/org/labkey/api/security/UserManager.java b/api/src/org/labkey/api/security/UserManager.java index f718464d7dc..6af4a5c099c 100644 --- a/api/src/org/labkey/api/security/UserManager.java +++ b/api/src/org/labkey/api/security/UserManager.java @@ -116,7 +116,6 @@ public class UserManager public static final String USER_AUDIT_EVENT = "UserAuditEvent"; public static final int VALID_GROUP_NAME_LENGTH = 64; - public static final int VERIFICATION_EMAIL_TIMEOUT = 60 * 24; // in minutes, one day currently public static final Comparator USER_DISPLAY_NAME_COMPARATOR = Comparator.comparing(User::getFriendlyName, String.CASE_INSENSITIVE_ORDER); diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index 96aeb442bc0..cae977fb4db 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -874,7 +874,7 @@ public Object execute(LoginForm form, BindException errors) { final ApiSimpleResponse response = new ApiSimpleResponse(); - Pair pair = attemptReset(form.getEmail(), form.getProvider()); + Pair pair = attemptPasswordReset(form.getEmail(), form.getProvider()); response.put("success", pair.first); response.put("message", pair.second); @@ -882,7 +882,7 @@ public Object execute(LoginForm form, BindException errors) } } - private Pair attemptReset(String rawEmail, String providerName) + private Pair attemptPasswordReset(String rawEmail, String providerName) { if (null == rawEmail) { @@ -934,14 +934,14 @@ private Pair attemptReset(String rawEmail, String providerName) final User system = new User(laf.getSystemEmailAddress(), 0); system.setFirstName(laf.getCompanyName()); - SecurityManager.sendEmail(c, system, message, email.getEmailAddress(), verificationURL); + SecurityManager.sendEmail(c, system, message, email.getEmailAddress(), verificationURL, LoginManager.RESET_LINK_TIMEOUT); // TODO: When is this ever true? Admin reset goes through a different code path! if (!user.getEmail().equals(email.getEmailAddress())) { final SecurityMessage adminMessage = SecurityManager.getResetMessage(true, user, providerName); message.setTo(email.getEmailAddress()); - SecurityManager.sendEmail(c, user, adminMessage, user.getEmail(), verificationURL); + SecurityManager.sendEmail(c, user, adminMessage, user.getEmail(), verificationURL, LoginManager.RESET_LINK_TIMEOUT); } } catch (ConfigurationException | MessagingException e) @@ -2295,7 +2295,7 @@ public ModelAndView getView(LoginForm form, boolean reshow, BindException errors @Override public boolean handlePost(LoginForm form, BindException errors) { - Pair pair = attemptReset(form.getEmail(), form.getProvider()); + Pair pair = attemptPasswordReset(form.getEmail(), form.getProvider()); if (pair.first) _finishView = new JspView<>("/org/labkey/core/login/finishResetPassword.jsp", pair.second); diff --git a/core/src/org/labkey/core/security/SecurityController.java b/core/src/org/labkey/core/security/SecurityController.java index d8490aac493..419f6d2d59c 100644 --- a/core/src/org/labkey/core/security/SecurityController.java +++ b/core/src/org/labkey/core/security/SecurityController.java @@ -1846,8 +1846,6 @@ private ModelAndView renderEmail(UserForm form) throws Exception else { SecurityMessage message = createMessage(form); - - // Issue 33254: allow only Site Admins to see the verification token message.setMaskToken(true); if (LoginManager.isVerified(affectedUser)) @@ -1857,7 +1855,7 @@ private ModelAndView renderEmail(UserForm form) throws Exception else { ActionURL verificationURL = LoginManager.createVerificationURL(getContainer(), affectedUser, null != _verification ? _verification : "**********", null); - SecurityManager.renderEmail(getContainer(), getUser(), message, affectedUser.getEmail(), verificationURL, out); + SecurityManager.renderEmail(getContainer(), getUser(), message, affectedUser.getEmail(), verificationURL, getLinkTimeout(), out); if (null == _verification && getUser().hasSiteAdminPermission()) { diff --git a/core/src/org/labkey/core/user/UserController.java b/core/src/org/labkey/core/user/UserController.java index b13b9c06f48..c8b1b7c30a2 100644 --- a/core/src/org/labkey/core/user/UserController.java +++ b/core/src/org/labkey/core/user/UserController.java @@ -2028,7 +2028,7 @@ else if (form.getIsPasswordPrompt() || _isPasswordPrompt) verifyLinkUrl.addParameter("verificationToken", verificationToken); verifyLinkUrl.addParameter("isFromVerifiedLink", true); - SecurityManager.sendEmail(c, userToChange, getRequestEmailMessage(userEmail, validRequestedEmail.getEmailAddress()), validRequestedEmail.getEmailAddress(), verifyLinkUrl); + SecurityManager.sendEmail(c, userToChange, getRequestEmailMessage(userEmail, validRequestedEmail.getEmailAddress()), validRequestedEmail.getEmailAddress(), verifyLinkUrl, LoginManager.EMAIL_CHANGE_LINK_TIMEOUT); } else { @@ -2377,7 +2377,7 @@ public static class RequestAddressEmailTemplate extends SecurityManager.Security "You recently requested a change to the email address associated with your account on the " + "^organizationName^ ^siteShortName^ Web Site. If you did not issue this request, you can ignore this email.\n\n" + "To complete the process of changing your account's email address from ^currentEmailAddress^ to ^newEmailAddress^, " + - "simply click the link below or copy it to your browser's address bar.\n\n" + + "simply click the link below or copy it to your browser's address bar.^linkExpiration| This link expires in %s.^\n\n" + "^verificationURL^\n\n" + "This step confirms that you are the owner of the new email account. After you click the link, you will need " + "to use the new email address when logging into the server."; From c65546c2c994a043463f326e55a97f2833852142 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Sat, 24 Oct 2026 10:39:28 -0700 Subject: [PATCH 5/5] Annotate a couple constructors flagged as unused --- api/src/org/labkey/api/security/SecurityManager.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/src/org/labkey/api/security/SecurityManager.java b/api/src/org/labkey/api/security/SecurityManager.java index f1e2d49369a..c7341173e6a 100644 --- a/api/src/org/labkey/api/security/SecurityManager.java +++ b/api/src/org/labkey/api/security/SecurityManager.java @@ -2624,6 +2624,7 @@ public static class PasswordResetEmailTemplate extends SecurityEmailTemplate The ^siteShortName^ home page is ^homePageURL^."""; + @SuppressWarnings("unused") // Called via reflection from EmailTemplateService public PasswordResetEmailTemplate() { this("Reset password", DEFAULT_SUBJECT, DEFAULT_BODY); @@ -2637,6 +2638,7 @@ private PasswordResetEmailTemplate(String name, String subject, String body) public static class PasswordResetAdminEmailTemplate extends PasswordResetEmailTemplate { + @SuppressWarnings("unused") // Called via reflection from EmailTemplateService public PasswordResetAdminEmailTemplate() { super("Reset password (bcc to admin)", "^recipient^ : " + DEFAULT_SUBJECT, "The following message was sent to ^recipient^ :\n\n" + DEFAULT_BODY);