diff --git a/api/src/org/labkey/api/security/AuthenticationConfiguration.java b/api/src/org/labkey/api/security/AuthenticationConfiguration.java index d308e98c9da..f35af7ff279 100644 --- a/api/src/org/labkey/api/security/AuthenticationConfiguration.java +++ b/api/src/org/labkey/api/security/AuthenticationConfiguration.java @@ -124,6 +124,11 @@ interface SSOAuthenticationConfiguration */ boolean isAutoRedirect(); + /** + * Currently SSO-only since SAML is the only production provider that needs the skip re-auth option + */ + boolean isReauthenticationSupported(); + @Override default void handleStartupProperties(Map map) { diff --git a/api/src/org/labkey/api/security/AuthenticationManager.java b/api/src/org/labkey/api/security/AuthenticationManager.java index 9f35e3b36a1..ba60877c838 100644 --- a/api/src/org/labkey/api/security/AuthenticationManager.java +++ b/api/src/org/labkey/api/security/AuthenticationManager.java @@ -563,7 +563,7 @@ private ModelAndView getAuthView(AuthenticationResponse response, BindException if (errors.hasErrors() || !response.isAuthenticated()) { if (!errors.hasErrors()) - errors.addError(new LabKeyError("Bad credentials")); + errors.addError(new LabKeyError(getFailureMessage(response))); } else { @@ -595,6 +595,16 @@ private ModelAndView getAuthView(AuthenticationResponse response, BindException return new SimpleErrorView(errors, false); } + // Most failures can't distinguish a bad password from an unknown user, so they share a deliberately vague + // message. reauthNotConfirmed is different: the user's credentials were never in question, so say what actually + // went wrong and who can fix it. + private String getFailureMessage(AuthenticationResponse response) + { + return FailureReason.reauthNotConfirmed == response.getFailureReason() ? + "Reauthentication failed: your identity provider did not re-verify your credentials. Please contact your administrator." : + "Bad credentials"; + } + // Reauthentication case for electronic signing and other sensitive operations. Check that reauthentication // was successful and re-auth user matches session user. Not currently verifying that the same authentication // configuration was used to reauthenticate. @@ -1914,18 +1924,23 @@ private static Map getTokenMap(HttpServletRequest request /** * Retrieves and validates the re-auth context associated with the provided token. If the token has an associated * context that's not expired and (if requested) the context user matches the provided user, then return the user. + * If there's no token, sessionUser is non-null, and the user's authentication configuration has disabled re-auth, + * return the session user. * @param request Request from which to retrieve the session - * @param token The reauth token to validate - * @param sessionUser If non-null, causes validation that this user matches the reauth user - * @return The re-auth user, if token is valid and session user check passes. Otherwise, null. + * @param token Re-auth token to validate + * @param sessionUser If non-null, causes validation that this user matches the reauth user. A null value also + * suppresses the skip-reauthentication exemption described above, so callers that require + * proof of an actual reauthentication (e.g. the CAS server's "renew" handling) must pass null. + * @return The re-auth user, if the token is valid and the session user check passes, or the session + * user if reauthentication is disabled for the user's configuration. Otherwise, null. */ public static @Nullable User getAndClearReauthUser(HttpServletRequest request, @Nullable String token, @Nullable User sessionUser) { - if (token != null) - { - HttpSession session = request.getSession(false); + HttpSession session = request.getSession(false); - if (session != null) + if (session != null) + { + if (!StringUtils.isEmpty(token)) { @SuppressWarnings("unchecked") Map tokenMap = (Map) session.getAttribute(REAUTH_TOKEN_MAP_NAME); @@ -1944,6 +1959,14 @@ private static Map getTokenMap(HttpServletRequest request } } } + else if (sessionUser != null) // Skip the CAS IdP case where sessionUser is null + { + // Potential skip re-auth scenario. If we have a session but no token and the configuration has disabled + // re-auth, just return the session user. + PrimaryAuthenticationConfiguration config = getConfiguration(session); + if (config instanceof AuthenticationConfiguration.SSOAuthenticationConfiguration sso && !sso.isReauthenticationSupported()) + return SecurityManager.getSessionUser(request); + } } return null; @@ -1968,7 +1991,7 @@ public void testReauthTokens() throws InterruptedException User admin = TestContext.get().getUser(); Map map = getTokenMap(request); clearExpiredTokens(map); - // Might have some unexpired tokens stashed away. Assume they won't expired during this test run. + // Might have some unexpired tokens stashed away. Assume they won't expire during this test run. int initialCount = map.size(); ActionURL url = new ActionURL("core", "begin.view", ContainerManager.getRoot()); @@ -1982,7 +2005,7 @@ public void testReauthTokens() throws InterruptedException assertEquals(admin, getAndClearReauthUser(request, token, admin)); assertEquals(initialCount, map.size()); - // Try same token again + // Try the same token again assertNull(getAndClearReauthUser(request, token, admin)); // Try a bogus token assertNull(getAndClearReauthUser(request, "xyz", admin)); diff --git a/api/src/org/labkey/api/security/AuthenticationProvider.java b/api/src/org/labkey/api/security/AuthenticationProvider.java index b293a5ddf02..636ffe1e45f 100644 --- a/api/src/org/labkey/api/security/AuthenticationProvider.java +++ b/api/src/org/labkey/api/security/AuthenticationProvider.java @@ -468,6 +468,7 @@ enum FailureReason userDoesNotExist(ReportType.onFailure, "user does not exist", null), badPassword(ReportType.onFailure, "incorrect password", null), badCredentials(ReportType.onFailure, "invalid credentials", null), // Use for cases where we can't distinguish between userDoesNotExist and badPassword + reauthNotConfirmed(ReportType.onFailure, "identity provider did not reauthenticate the user", null), // Credentials were fine; the IdP declined to reauthenticate (e.g. ignored SAML ForceAuthn) complexity(ReportType.onFailure, "password does not meet the complexity requirements", AuthenticationStatus.Complexity), expired(ReportType.onFailure, "password has expired", AuthenticationStatus.PasswordExpired), configurationError(ReportType.always, "configuration problem", null), diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index 2a0d3ac1adc..9e46a3afd46 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -1657,10 +1657,10 @@ public Object execute(ReturnUrlForm form, BindException errors) JSONObject resp = new JSONObject(); resp.put("description", configuration.getDescription()); LoginUrls urls = urlProvider(LoginUrls.class); - ActionURL reauthUrl = configuration instanceof SSOAuthenticationConfiguration sso ? - urls.getSSOReauthURL(sso, form.getReturnActionURL()) : + @Nullable ActionURL reauthUrl = configuration instanceof SSOAuthenticationConfiguration sso ? + (sso.isReauthenticationSupported() ? urls.getSSOReauthURL(sso, form.getReturnActionURL()) : null) : urls.getForceReauthURL(getContainer(), true, form.getReturnActionURL()); - resp.put("reauthUrl", reauthUrl.getLocalURIString()); + resp.put("reauthUrl", reauthUrl != null ? reauthUrl.getLocalURIString() : null); return success(resp); } } diff --git a/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java b/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java index c409e6dd7d8..1f32a210b3a 100644 --- a/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java +++ b/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java @@ -23,18 +23,22 @@ import org.labkey.api.view.ActionURL; import org.labkey.api.view.ViewContext; -import java.util.Collections; +import java.util.HashMap; import java.util.Map; public class TestSsoConfiguration extends BaseSSOAuthenticationConfiguration { + static final String SKIP_REAUTHENTICATION = "SkipReauthentication"; + private final String _domain; + private final boolean _skipReauthentication; private final LinkFactory _linkFactory = new LinkFactory(this); protected TestSsoConfiguration(TestSsoProvider provider, Map standardSettings, Map properties) { super(provider, standardSettings); _domain = (String)properties.get("domain"); + _skipReauthentication = Boolean.TRUE.equals(properties.get(SKIP_REAUTHENTICATION)); } @Override @@ -64,9 +68,21 @@ public LinkFactory getLinkFactory() return _linkFactory; } + @Override + public boolean isReauthenticationSupported() + { + return !_skipReauthentication; + } + @Override public @NotNull Map getCustomProperties() { - return null != _domain ? Map.of("domain", _domain) : Collections.emptyMap(); + Map map = new HashMap<>(); + map.put(SKIP_REAUTHENTICATION, _skipReauthentication); + + if (null != _domain) + map.put("domain", _domain); + + return map; } } diff --git a/devtools/src/org/labkey/devtools/authentication/TestSsoController.java b/devtools/src/org/labkey/devtools/authentication/TestSsoController.java index 4de5a08c8df..6a507474134 100644 --- a/devtools/src/org/labkey/devtools/authentication/TestSsoController.java +++ b/devtools/src/org/labkey/devtools/authentication/TestSsoController.java @@ -38,6 +38,7 @@ import org.springframework.validation.Errors; import org.springframework.web.servlet.ModelAndView; +import java.util.HashMap; import java.util.Map; public class TestSsoController extends SpringActionController @@ -124,16 +125,35 @@ public void validate(TestSsoSaveConfigurationForm form, Errors errors) public static class TestSsoSaveConfigurationForm extends SsoSaveConfigurationForm { + private boolean _skipReauthentication = false; + @Override public String getProvider() { return TestSsoProvider.NAME; } + public boolean isSkipReauthentication() + { + return _skipReauthentication; + } + + @SuppressWarnings("unused") + public void setSkipReauthentication(boolean skipReauthentication) + { + _skipReauthentication = skipReauthentication; + } + @Override public @NotNull Map getPropertyMap() { - return null != _domain ? Map.of("domain", _domain) : Map.of(); + Map map = new HashMap<>(); + map.put(TestSsoConfiguration.SKIP_REAUTHENTICATION, _skipReauthentication); + + if (null != _domain) + map.put("domain", _domain); + + return map; } } } diff --git a/devtools/src/org/labkey/devtools/authentication/TestSsoProvider.java b/devtools/src/org/labkey/devtools/authentication/TestSsoProvider.java index b23753d4e1a..253cb442fb5 100644 --- a/devtools/src/org/labkey/devtools/authentication/TestSsoProvider.java +++ b/devtools/src/org/labkey/devtools/authentication/TestSsoProvider.java @@ -66,6 +66,7 @@ public String getDescription() { return List.of( SettingsField.of("autoRedirect", SettingsField.FieldType.checkbox, "Default to this TestSSO configuration", "Redirects the login page directly to the TestSSO page instead of requiring the user to click on a logo.", false, false), + SettingsField.of(TestSsoConfiguration.SKIP_REAUTHENTICATION, SettingsField.FieldType.checkbox, "Skip Reauthentication", "Users who authenticate with this TestSSO configuration will not need to reauthenticate when signing electronically. This is not recommended since reauthentication is a requirement of 21 CFR Part 11.", false, false), SettingsField.getStandardDomainField() ); } diff --git a/devtools/src/org/labkey/devtools/view/testReauth.jsp b/devtools/src/org/labkey/devtools/view/testReauth.jsp index ecc40c615e4..b48e61fbe40 100644 --- a/devtools/src/org/labkey/devtools/view/testReauth.jsp +++ b/devtools/src/org/labkey/devtools/view/testReauth.jsp @@ -34,19 +34,29 @@ success: function(response) { const needReauth = <%=form.reauthToken() == null%>; const data = JSON.parse(response.responseText).data; - document.getElementById("description").textContent = data.description; - if (needReauth) { + const skipReauth = (data.reauthUrl == null); + // Setting textContent HTML encodes the value + document.getElementById("description").textContent = data.description + (skipReauth ? ', but that configuration has disabled reauthentication' : ''); + if (skipReauth || !needReauth) { + document.getElementById("sign").style.display = "block"; + if (skipReauth && needReauth) { + document.getElementById("reauth").style.display = "none"; + } + } + else { document.getElementById("link").href = data.reauthUrl; } }, - failure: function() { - alert('Failed to retrieve configuration!'); - } + failure: LABKEY.Utils.getCallbackWrapper(function(errorInfo) { + document.getElementById("content").innerHTML = '' + LABKEY.Utils.encodeHtml(errorInfo.exception ?? 'Failed to retrieve configuration') + ''; + }, this, true) }); }); -You authenticated with:
+
+ + You authenticated with:
<% if (form.reauthToken() != null) @@ -54,14 +64,13 @@ You authenticated with:
%> Looks like you successfully re-authenticated and received token: <%=h(form.reauthToken())%>
- - - - <% } else { +%> +
+<% if (form.errorMessage() != null) { %> @@ -76,6 +85,15 @@ You need to re-authenticate. } %> Click here + +
<% } %> + +
diff --git a/devtools/test/src/org/labkey/test/pages/devtools/TestSsoPage.java b/devtools/test/src/org/labkey/test/pages/devtools/TestSsoPage.java new file mode 100644 index 00000000000..0e45d0fdc04 --- /dev/null +++ b/devtools/test/src/org/labkey/test/pages/devtools/TestSsoPage.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.test.pages.devtools; + +import org.labkey.test.Locator; +import org.labkey.test.WebDriverWrapper; +import org.labkey.test.WebTestHelper; +import org.labkey.test.components.html.Input; +import org.labkey.test.pages.LabKeyPage; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +/** + * The devtools TestSSO provider's stand-in for an identity provider: type an email address to "authenticate" (or, when + * the browser was sent here to reauthenticate, "reauthenticate") as that user. No password required. + */ +public class TestSsoPage extends LabKeyPage +{ + public TestSsoPage(WebDriver driver) + { + super(driver); + } + + /** + * Navigates to the sign-in page, which redirects to the TestSSO page when a TestSSO configuration has + * "Default to this TestSSO configuration" enabled. + */ + public static TestSsoPage beginAtLogin(WebDriverWrapper webDriverWrapper) + { + webDriverWrapper.beginAt(WebTestHelper.buildURL("login", "login")); + return new TestSsoPage(webDriverWrapper.getDriver()); + } + + @Override + protected void waitForPage() + { + waitFor(() -> Locators.emailInput.existsIn(getDriver()), "TestSSO page did not load in time.", WAIT_FOR_PAGE); + } + + /** + * The form's label, which identifies whether the browser was sent here to authenticate or to reauthenticate. + */ + public String getLabel() + { + return elementCache().label.getText(); + } + + public TestSsoPage setEmail(String email) + { + elementCache().emailInput.set(email); + return this; + } + + /** + * Authenticates as the given user, which leaves the browser wherever LabKey sends the user after signing in. + */ + public void authenticate(String email) + { + setEmail(email); + clickAndWait(elementCache().authenticateButton); + clearCache(); + } + + /** + * Reauthenticates as the given user, which returns the browser to the page that requested reauthentication. Only + * available when the browser arrived here through a reauthentication redirect. + */ + public void reauthenticate(String email) + { + setEmail(email); + clickAndWait(elementCache().reauthenticateButton); + clearCache(); + } + + @Override + protected ElementCache newElementCache() + { + return new ElementCache(); + } + + protected class ElementCache extends LabKeyPage.ElementCache + { + final WebElement label = Locator.tagWithClass("label", "control-label").findWhenNeeded(this); + final Input emailInput = new Input(Locators.emailInput.findWhenNeeded(this), getDriver()); + final WebElement authenticateButton = Locator.lkButton("Authenticate").findWhenNeeded(this); + final WebElement reauthenticateButton = Locator.lkButton("Reauthenticate").findWhenNeeded(this); + } + + private static class Locators + { + static final Locator emailInput = Locator.name("email"); + } +} diff --git a/devtools/test/src/org/labkey/test/params/devtools/TestSsoProvider.java b/devtools/test/src/org/labkey/test/params/devtools/TestSsoProvider.java index 82189134588..cf574307b7e 100644 --- a/devtools/test/src/org/labkey/test/params/devtools/TestSsoProvider.java +++ b/devtools/test/src/org/labkey/test/params/devtools/TestSsoProvider.java @@ -15,6 +15,8 @@ */ package org.labkey.test.params.devtools; +import org.labkey.test.Locator; +import org.labkey.test.components.html.Checkbox; import org.labkey.test.pages.core.login.LoginConfigRow; import org.labkey.test.pages.core.login.SsoAuthDialogBase; import org.labkey.test.params.login.AuthenticationProvider; @@ -58,11 +60,48 @@ public TestSsoConfigureDialog(WebDriver driver) super(TestSsoProvider.this, driver); } + /** + * Sets "Default to this TestSSO configuration", which redirects the login page straight to the TestSSO page + * instead of requiring the user to click on a logo. + */ + public TestSsoConfigureDialog setDefaultConfiguration(boolean isDefault) + { + elementCache().autoRedirectCheckbox.set(isDefault); + return this; + } + + /** + * Sets "Skip Reauthentication", which exempts users who authenticated with this configuration from + * reauthenticating when signing electronically. + */ + public TestSsoConfigureDialog setSkipReauthentication(boolean skipReauthentication) + { + elementCache().skipReauthenticationCheckbox.set(skipReauthentication); + return this; + } + @Override protected TestSsoConfigureDialog getThis() { return this; } + @Override + protected ElementCache newElementCache() + { + return new ElementCache(); + } + + @Override + protected ElementCache elementCache() + { + return (ElementCache) super.elementCache(); + } + + protected class ElementCache extends SsoAuthDialogBase.ElementCache + { + Checkbox autoRedirectCheckbox = new Checkbox(Locator.tagWithId("input", "autoRedirect").findWhenNeeded(this)); + Checkbox skipReauthenticationCheckbox = new Checkbox(Locator.tagWithId("input", "SkipReauthentication").findWhenNeeded(this)); + } } }