diff --git a/api/src/org/labkey/api/ApiModule.java b/api/src/org/labkey/api/ApiModule.java index def6dcec40c..ff196ee1ef1 100644 --- a/api/src/org/labkey/api/ApiModule.java +++ b/api/src/org/labkey/api/ApiModule.java @@ -389,6 +389,7 @@ public void registerServlets(ServletContext servletCtx) ApiXmlWriter.TestCase.class, ArrayListMap.TestCase.class, AssayResultsFileWriter.TestCase.class, + AuthenticationManager.ReauthTokenTest.class, BaseServerProperties.TestCase.class, BooleanFormat.TestCase.class, BuilderObjectFactory.TestCase.class, diff --git a/api/src/org/labkey/api/action/SimpleErrorView.java b/api/src/org/labkey/api/action/SimpleErrorView.java index b7f58fcd742..b50c5cea200 100644 --- a/api/src/org/labkey/api/action/SimpleErrorView.java +++ b/api/src/org/labkey/api/action/SimpleErrorView.java @@ -23,8 +23,6 @@ /** * View that renders an error collection. - * User: adam - * Date: Sep 26, 2007 */ public class SimpleErrorView extends JspView { diff --git a/api/src/org/labkey/api/security/AuthenticationConfiguration.java b/api/src/org/labkey/api/security/AuthenticationConfiguration.java index 9cc866d7432..d308e98c9da 100644 --- a/api/src/org/labkey/api/security/AuthenticationConfiguration.java +++ b/api/src/org/labkey/api/security/AuthenticationConfiguration.java @@ -114,6 +114,7 @@ interface SSOAuthenticationConfiguration { LinkFactory getLinkFactory(); URLHelper getUrl(ViewContext ctx); + URLHelper getReauthUrl(ViewContext ctx); /** * Allows an SSO auth configuration to specify that it should be used automatically instead of showing the standard diff --git a/api/src/org/labkey/api/security/AuthenticationManager.java b/api/src/org/labkey/api/security/AuthenticationManager.java index c6051d89850..7f2d96d14ee 100644 --- a/api/src/org/labkey/api/security/AuthenticationManager.java +++ b/api/src/org/labkey/api/security/AuthenticationManager.java @@ -22,8 +22,8 @@ import org.apache.commons.lang3.Strings; import org.apache.commons.lang3.mutable.MutableInt; import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.junit.Test; import org.labkey.api.action.ApiResponseWriter.Format; @@ -86,6 +86,7 @@ import org.labkey.api.usageMetrics.UsageMetricsService; import org.labkey.api.util.DateUtil; import org.labkey.api.util.ExceptionUtil; +import org.labkey.api.util.GUID; import org.labkey.api.util.HeartBeat; import org.labkey.api.util.HtmlString; import org.labkey.api.util.HtmlStringBuilder; @@ -94,6 +95,7 @@ import org.labkey.api.util.Rate; import org.labkey.api.util.RateLimiter; import org.labkey.api.util.SessionHelper; +import org.labkey.api.util.TestContext; import org.labkey.api.util.URLHelper; import org.labkey.api.util.logging.LogHelper; import org.labkey.api.view.ActionURL; @@ -108,6 +110,8 @@ import org.springframework.validation.BindException; import org.springframework.web.servlet.ModelAndView; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -549,7 +553,12 @@ public static abstract class BaseSsoValidateAction
validators = new LinkedList<>(); if (primaryAuthResult.getResponse().requireSecondary()) @@ -1712,6 +1776,30 @@ public URLHelper getRedirectURL() return new AuthenticationResult(primaryAuthUser, url); } + // Builds the URL that receives the reauth token from setReauthUser(). + // Unlike getAfterLoginURL(), this keeps the caller's return URL even if the user needs a profile update. + private static URLHelper getAfterReauthURL(Container current, @Nullable LoginReturnProperties properties, @NotNull User user) + { + URLHelper returnUrl; + + if (null != properties && null != properties.getReturnUrl()) + { + returnUrl = properties.getReturnUrl(); + } + else + { + Container c = (null == current || current.isRoot() ? ContainerManager.getHomeContainer() : current); + returnUrl = !c.hasPermission(user, ReadPermission.class) ? getWelcomeURL() : c.getStartURL(user); + } + + if (null != properties && null != properties.getUrlhash()) + { + returnUrl.setFragment(properties.getUrlhash().replace("#", "")); + } + + return returnUrl; + } + public static URLHelper getAfterLoginURL(Container current, @Nullable LoginReturnProperties properties, @NotNull User user) { @@ -1767,10 +1855,71 @@ public static URLHelper getWelcomeURL() return new URLHelper(true); } - public record Reauth(String token, User user){} - public static final String REAUTH_TOKEN_NAME = "reauthToken"; + public record ReauthContext(User user, Instant expiration) + { + public boolean isExpired() + { + return Instant.now().isAfter(expiration()); + } + } + + public static final String REAUTH_TOKEN_NAME = "reauthToken"; // URL parameter name for re-auth token + public static final String ERROR_MESSAGE = "errorMessage"; // URL parameter name for error message + public static final String REAUTH_TOKEN_MAP_NAME = "reauthTokenSet"; // Session attribute name for token map + + /** + * @param reauthUser Re-auth user to stash in session with the re-auth token + * @param sessionUser If not null, validate that this user and reauthUser are the same + * @param request Request from which to retrieve the session + * @param errorMessage Pre-existing error message to add to the URL + * @param redirectUrl URL to which the token (on success) or error message (on failure) gets added + */ + public static void setReauthUser(User reauthUser, @Nullable User sessionUser, HttpServletRequest request, @Nullable String errorMessage, URLHelper redirectUrl) + { + if (errorMessage == null && sessionUser != null && !sessionUser.equals(reauthUser)) + { + errorMessage = "Reauthentication failed: wrong user reauthenticated"; + } + + if (errorMessage != null) + { + redirectUrl.addParameter(ERROR_MESSAGE, errorMessage); + } + else + { + String reauthToken = GUID.makeHash(); + redirectUrl.addParameter(REAUTH_TOKEN_NAME, reauthToken); + // The setReauthUser() and getAndClearReauthUser() should be invoked within a second of each other + // (browser redirects with no user interaction). Five minute expiration should be more than ample. + addToken(request, reauthUser, reauthToken, Instant.now().plus(5, ChronoUnit.MINUTES)); + } + } - public static @Nullable User getAndClearReauthUser(HttpServletRequest request, @Nullable String token) + // Separate method to allow unit testing + private static void addToken(HttpServletRequest request, User reauthUser, String reauthToken, Instant expiration) + { + // Very unlikely to have contention or even multiple elements, and synchronized map is lightweight + Map tokenMap = getTokenMap(request); + if (!tokenMap.isEmpty()) + clearExpiredTokens(tokenMap); + tokenMap.put(reauthToken, new ReauthContext(reauthUser, expiration)); + } + + // Separate method to allow unit testing + private static Map getTokenMap(HttpServletRequest request) + { + return SessionHelper.getAttribute(request, REAUTH_TOKEN_MAP_NAME, () -> Collections.synchronizedMap(new HashMap<>(5))); + } + + /** + * 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. + * @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. + */ + public static @Nullable User getAndClearReauthUser(HttpServletRequest request, @Nullable String token, @Nullable User sessionUser) { if (token != null) { @@ -1778,16 +1927,20 @@ public record Reauth(String token, User user){} if (session != null) { - Reauth reauth = (Reauth) session.getAttribute(REAUTH_TOKEN_NAME); + @SuppressWarnings("unchecked") + Map tokenMap = (Map) session.getAttribute(REAUTH_TOKEN_MAP_NAME); - if (reauth != null) + if (tokenMap != null) { - boolean matches = token.equals(reauth.token()); + clearExpiredTokens(tokenMap); + ReauthContext context = tokenMap.remove(token); - if (matches) + if (context != null && !context.isExpired()) { - session.removeAttribute(REAUTH_TOKEN_NAME); - return reauth.user(); + User reauthUser = context.user(); + + if (sessionUser == null || sessionUser.equals(reauthUser)) + return reauthUser; } } } @@ -1796,12 +1949,81 @@ public record Reauth(String token, User user){} return null; } + // Clear any abandoned tokens + private static void clearExpiredTokens(@NotNull Map tokenMap) + { + //noinspection SynchronizationOnLocalVariableOrMethodParameter + synchronized (tokenMap) + { + tokenMap.entrySet().removeIf(e -> e.getValue().isExpired()); + } + } + + public static class ReauthTokenTest extends Assert + { + @Test + public void testReauthTokens() throws InterruptedException + { + HttpServletRequest request = TestContext.get().getRequest(); + 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. + int initialCount = map.size(); + ActionURL url = new ActionURL("core", "begin.view", ContainerManager.getRoot()); + + ActionURL clone = url.clone(); + setReauthUser(admin, admin, request, null, clone); + assertEquals(initialCount + 1, map.size()); + String token = clone.getParameter(REAUTH_TOKEN_NAME); + ReauthContext ctx = map.get(token); + assertFalse(ctx.isExpired()); + assertEquals(admin, ctx.user()); + assertEquals(admin, getAndClearReauthUser(request, token, admin)); + assertEquals(initialCount, map.size()); + + // Try same token again + assertNull(getAndClearReauthUser(request, token, admin)); + // Try a bogus token + assertNull(getAndClearReauthUser(request, "xyz", admin)); + + // Wrong user on set case + clone = url.clone(); + setReauthUser(admin, new User(), request, null, clone); + assertNull(clone.getParameter(REAUTH_TOKEN_NAME)); + assertEquals("Reauthentication failed: wrong user reauthenticated", clone.getParameter(ERROR_MESSAGE)); + assertEquals(initialCount, map.size()); + + // Wrong user on get case + clone = url.clone(); + setReauthUser(admin, admin, request, null, clone); + assertEquals(initialCount + 1, map.size()); + token = clone.getParameter(REAUTH_TOKEN_NAME); + ctx = map.get(token); + assertFalse(ctx.isExpired()); + assertEquals(admin, ctx.user()); + assertNull(getAndClearReauthUser(request, token, new User())); + assertEquals(initialCount, map.size()); + + addToken(request, admin, "abc", Instant.now().plus(1, ChronoUnit.SECONDS)); + addToken(request, admin, "xyz", Instant.now().plus(1, ChronoUnit.SECONDS)); + addToken(request, admin, "123", Instant.now().plus(1, ChronoUnit.SECONDS)); + assertEquals(initialCount + 3, map.size()); + + // Wait a second then add another one -- tokens above should all get removed + Thread.sleep(1000); + addToken(request, admin, "foo", Instant.now().plus(10, ChronoUnit.SECONDS)); + assertEquals(initialCount + 1, map.size()); + assertEquals(admin, getAndClearReauthUser(request, "foo", admin)); + assertEquals(initialCount, map.size()); + } + } + // test() method should return true if the authentication is still valid public interface AuthenticationValidator extends Predicate { } - public static class LinkFactory { private final SSOAuthenticationConfiguration _configuration; diff --git a/api/src/org/labkey/api/security/AuthenticationProvider.java b/api/src/org/labkey/api/security/AuthenticationProvider.java index c59c66f6b4c..b293a5ddf02 100644 --- a/api/src/org/labkey/api/security/AuthenticationProvider.java +++ b/api/src/org/labkey/api/security/AuthenticationProvider.java @@ -307,6 +307,7 @@ class AuthenticationResponse private @NotNull Map _userAttributeMap = Collections.emptyMap(); // A case-insensitive map of attribute names and values associated with the user private @NotNull Map _authenticationProperties = Collections.emptyMap(); private boolean _requireSecondary = true; // Require secondary authentication + private boolean _reauth = false; private @Nullable String _successDetails = null; // An optional string describing how successful authentication took place, which will // appear in the audit log. If null, the configuration's description will be used. @@ -447,6 +448,17 @@ public AuthenticationResponse setRequireSecondary(boolean requireSecondary) _requireSecondary = requireSecondary; return this; } + + public boolean isReauth() + { + return _reauth; + } + + public AuthenticationResponse setReauth(boolean reauth) + { + _reauth = reauth; + return this; + } } // FailureReasons are only reported to administrators (in the audit log and/or server log), NOT to users (and potential diff --git a/api/src/org/labkey/api/security/LoginUrls.java b/api/src/org/labkey/api/security/LoginUrls.java index 856ffb05afe..b1739d85432 100644 --- a/api/src/org/labkey/api/security/LoginUrls.java +++ b/api/src/org/labkey/api/security/LoginUrls.java @@ -35,10 +35,11 @@ public interface LoginUrls extends UrlProvider ActionURL getLoginURL(URLHelper returnUrl); ActionURL getRegisterURL(Container c, @Nullable URLHelper returnUrl); ActionURL getLoginURL(Container c, @Nullable URLHelper returnUrl); - ActionURL getForceReauthURL(Container c, @Nullable URLHelper returnUrl); + ActionURL getForceReauthURL(Container c, boolean local, @Nullable URLHelper returnUrl); ActionURL getLogoutURL(Container c); ActionURL getLogoutURL(Container c, URLHelper returnUrl); ActionURL getStopImpersonatingURL(Container c, @Nullable URLHelper returnUrl); ActionURL getAgreeToTermsURL(Container c, URLHelper returnUrl); ActionURL getSSORedirectURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl, boolean skipProfile); + ActionURL getSSOReauthURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl); } diff --git a/core/resources/views/login.html b/core/resources/views/login.html index fa20a14729e..340813fadb0 100644 --- a/core/resources/views/login.html +++ b/core/resources/views/login.html @@ -1,5 +1,5 @@ -
Sign In
+
@@ -23,7 +23,7 @@
- + diff --git a/core/src/org/labkey/core/login/LoginController.java b/core/src/org/labkey/core/login/LoginController.java index 5e1e40e98d5..1c077c7affb 100644 --- a/core/src/org/labkey/core/login/LoginController.java +++ b/core/src/org/labkey/core/login/LoginController.java @@ -25,6 +25,7 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; import org.json.JSONArray; +import org.json.JSONObject; import org.labkey.api.action.ApiResponse; import org.labkey.api.action.ApiSimpleResponse; import org.labkey.api.action.ApiUsageException; @@ -52,6 +53,7 @@ import org.labkey.api.security.ActionNames; import org.labkey.api.security.AdminConsoleAction; import org.labkey.api.security.AuthenticationConfiguration.LoginFormAuthenticationConfiguration; +import org.labkey.api.security.AuthenticationConfiguration.PrimaryAuthenticationConfiguration; import org.labkey.api.security.AuthenticationConfiguration.SSOAuthenticationConfiguration; import org.labkey.api.security.AuthenticationConfiguration.SecondaryAuthenticationConfiguration; import org.labkey.api.security.AuthenticationConfigurationCache; @@ -60,7 +62,6 @@ import org.labkey.api.security.AuthenticationManager.AuthenticationStatus; import org.labkey.api.security.AuthenticationManager.LoginReturnProperties; import org.labkey.api.security.AuthenticationManager.PrimaryAuthenticationResult; -import org.labkey.api.security.AuthenticationManager.Reauth; import org.labkey.api.security.AuthenticationProvider; import org.labkey.api.security.AuthenticationProvider.SSOAuthenticationProvider; import org.labkey.api.security.CSRF; @@ -72,6 +73,7 @@ import org.labkey.api.security.MutableSecurityPolicy; import org.labkey.api.security.PasswordExpiration; import org.labkey.api.security.PasswordRule; +import org.labkey.api.security.RequiresLogin; import org.labkey.api.security.RequiresNoPermission; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.SecurityManager; @@ -93,7 +95,6 @@ import org.labkey.api.settings.WriteableLookAndFeelProperties; import org.labkey.api.util.CSRFUtil; import org.labkey.api.util.ConfigurationException; -import org.labkey.api.util.GUID; import org.labkey.api.util.HelpTopic; import org.labkey.api.util.HtmlString; import org.labkey.api.util.MailHelper; @@ -115,6 +116,7 @@ import org.labkey.api.view.RedirectException; import org.labkey.api.view.UnsafeExternalRedirectException; import org.labkey.api.view.VBox; +import org.labkey.api.view.ViewContext; import org.labkey.api.view.WebPartView; import org.labkey.api.view.template.PageConfig; import org.labkey.api.wiki.WikiRendererType; @@ -141,7 +143,6 @@ import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_LIMIT_KEY; import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_PERIOD_KEY; import static org.labkey.api.security.AuthenticationManager.LOGIN_ATTEMPT_RESET_TIME_KEY; -import static org.labkey.api.security.AuthenticationManager.REAUTH_TOKEN_NAME; import static org.labkey.api.security.AuthenticationManager.SELF_REGISTRATION_KEY; import static org.labkey.api.security.AuthenticationManager.SELF_SERVICE_EMAIL_CHANGES_KEY; @@ -248,10 +249,16 @@ public ActionURL getLoginURL(Container c, @Nullable URLHelper returnUrl) } @Override - public ActionURL getForceReauthURL(Container c, @Nullable URLHelper returnUrl) + public ActionURL getForceReauthURL(Container c, boolean local, @Nullable URLHelper returnUrl) { - return getLoginURL(c, returnUrl) + ActionURL url = getLoginURL(c, returnUrl) .addParameter("forceReauth", true); + + // Customizes re-auth behavior for the local login page case (vs. CAS IdP case) + if (local) + url.addParameter("local", true); + + return url; } @Override @@ -301,12 +308,24 @@ public ActionURL getAgreeToTermsURL(Container c, URLHelper returnUrl) @Override public ActionURL getSSORedirectURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl, boolean skipProfile) { - ActionURL url = new ActionURL(SsoRedirectAction.class, ContainerManager.getRoot()); - url.addParameter("configuration", configuration.getRowId()); + ActionURL url = getRedirectURL(SsoRedirectAction.class, configuration, returnUrl); if (skipProfile) { url.addParameter("skipProfile", 1); } + return url; + } + + @Override + public ActionURL getSSOReauthURL(SSOAuthenticationConfiguration configuration, URLHelper returnUrl) + { + return getRedirectURL(SsoReauthAction.class, configuration, returnUrl); + } + + private ActionURL getRedirectURL(Class redirectActionClass, SSOAuthenticationConfiguration configuration, @Nullable URLHelper returnUrl) + { + ActionURL url = new ActionURL(redirectActionClass, ContainerManager.getRoot()); + url.addParameter("configuration", configuration.getRowId()); if (null != returnUrl) { String fragment = returnUrl.getFragment(); @@ -650,7 +669,7 @@ public class LoginApiAction extends MutatingApiAction @Override public Object execute(LoginForm form, BindException errors) { - HttpServletRequest request = getViewContext().getRequest(); + HttpServletRequest request = getViewContext().getRequestOrThrow(); // Store passed in returnUrl and skipProfile param at the start of the login so we can redirect to it after // any password resets, secondary logins, profile updates, etc. have finished @@ -665,7 +684,7 @@ public Object execute(LoginForm form, BindException errors) Project termsProject = getTermsOfUseProject(form); boolean isGuest = getUser().isGuest(); - if (!isTermsOfUseApproved(form) && !form.isApprovedTermsOfUse()) + if (!form.isForceReauth() && !isTermsOfUseApproved(form) && !form.isApprovedTermsOfUse()) { if (null != termsProject) { @@ -689,6 +708,11 @@ public Object execute(LoginForm form, BindException errors) // Don't touch the session in the re-auth case (e.g., CAS renew=true). The CAS spec is silent on // expected behavior when no "ticket-signing ticket" (session, in our case) exists and a "renew" is // requested, but this seems consistent with "ignore the current session" when renew is requested. + // Stash the reauth context in session so handleAuthentication can issue the reauth token after + // primary authentication succeeds without running the full login completion flow. + if (form.isForceReauth()) + AuthenticationManager.setReauthFlow(request, form.isLocal()); + AuthenticationResult authResult = AuthenticationManager.handleAuthentication(request, getContainer(), !form.isForceReauth()); // getUser will return null if authentication is incomplete as is the case when secondary authentication is required User user = authResult.getUser(); @@ -696,7 +720,7 @@ public Object execute(LoginForm form, BindException errors) response = new ApiSimpleResponse(); response.put("success", true); - if (form.isApprovedTermsOfUse()) + if (!form.isForceReauth() && form.isApprovedTermsOfUse()) { if (form.getTermsOfUseType() == TermsOfUseType.PROJECT_LEVEL) WikiTermsOfUseProvider.setTermsOfUseApproved(getViewContext(), termsProject, true); @@ -705,13 +729,6 @@ else if (form.getTermsOfUseType() == TermsOfUseType.SITE_WIDE) response.put("approvedTermsOfUse", true); } - if (form.isForceReauth()) - { - String reauthToken = GUID.makeHash(); - redirectUrl.addParameter(REAUTH_TOKEN_NAME, reauthToken); - request.getSession().setAttribute(REAUTH_TOKEN_NAME, new Reauth(reauthToken, user)); - } - // Use the full hostname in the URL if we have one, otherwise just go with a local URI String redirectString = redirectUrl.getHost() != null && redirectUrl.getScheme() != null ? redirectUrl.getURIString() : redirectUrl.toString(); @@ -1388,7 +1405,8 @@ public static class LoginForm extends AgreeToTermsForm private String email; private String password; private String provider; - private boolean forceReauth = false; + private boolean forceReauth = false; // If true, require valid credentials even if logged in + private boolean local = false; // If true, require on re-auth that current session user matches re-auth user public String getProvider() { @@ -1433,6 +1451,17 @@ public void setForceReauth(boolean forceReauth) { this.forceReauth = forceReauth; } + + public boolean isLocal() + { + return local; + } + + @SuppressWarnings("unused") + public void setLocal(boolean local) + { + this.local = local; + } } @RequiresNoPermission @@ -1539,20 +1568,8 @@ public Object execute(ReturnUrlForm form, BindException errors) public static class SsoRedirectForm extends AbstractLoginForm { - private String _provider; private int _configuration; - public String getProvider() - { - return _provider; - } - - @SuppressWarnings("unused") - public void setProvider(String provider) - { - _provider = provider; - } - public int getConfiguration() { return _configuration; @@ -1565,18 +1582,13 @@ public void setConfiguration(int configuration) } } - @RequiresNoPermission - @AllowedDuringUpgrade - // Always invoked in the root, so no need to ignore locked projects - public static class SsoRedirectAction extends SimpleViewAction + private static abstract class BaseSsoRedirectAction extends SimpleViewAction { + protected abstract URLHelper getUrl(SSOAuthenticationConfiguration configuration, ViewContext context); + @Override public ModelAndView getView(SsoRedirectForm form, BindException errors) { - // If logged in then redirect immediately - if (!getUser().isGuest()) - return HttpView.redirect(form.getReturnActionURL(AppProps.getInstance().getHomePageActionURL())); - // If we have a returnUrl or skipProfile param then create and stash LoginReturnProperties URLHelper returnUrl = form.getReturnUrlHelper(); if (null != returnUrl || form.getSkipProfile()) @@ -1592,7 +1604,7 @@ public ModelAndView getView(SsoRedirectForm form, BindException errors) if (null == configuration) throw new NotFoundException("Authentication configuration is not valid"); - url = configuration.getUrl(getViewContext()); + url = getUrl(configuration, getViewContext()); // It's safe to bypass checking the external redirect allow list in this case because we are redirecting to // the administrator-provided URL from the SSO authentication configuration. @@ -1605,6 +1617,62 @@ public final void addNavTrail(NavTree root) } } + @RequiresNoPermission + @AllowedDuringUpgrade + // Always invoked in the root, so no need to ignore locked projects + public static class SsoRedirectAction extends BaseSsoRedirectAction + { + @Override + public ModelAndView getView(SsoRedirectForm form, BindException errors) + { + // If logged in then redirect immediately + if (!getUser().isGuest()) + return HttpView.redirect(form.getReturnActionURL(AppProps.getInstance().getHomePageActionURL())); + + return super.getView(form, errors); + } + + @Override + protected URLHelper getUrl(SSOAuthenticationConfiguration configuration, ViewContext context) + { + return configuration.getUrl(context); + } + } + + // Very similar to SsoRedirectAction, but needs different annotations, so we have two separate classes + @RequiresLogin + public static class SsoReauthAction extends BaseSsoRedirectAction + { + @Override + protected URLHelper getUrl(SSOAuthenticationConfiguration configuration, ViewContext context) + { + return configuration.getReauthUrl(context); + } + } + + @SuppressWarnings("unused") // Called from client code + @RequiresLogin + public static class GetAuthenticationConfigurationAction extends ReadOnlyApiAction + { + @Override + public Object execute(ReturnUrlForm form, BindException errors) + { + PrimaryAuthenticationConfiguration configuration = AuthenticationManager.getConfiguration(getViewContext().getSession()); + if (configuration == null) + { + throw new NotFoundException("No configuration found"); + } + 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()) : + urls.getForceReauthURL(getContainer(), true, form.getReturnActionURL()); + resp.put("reauthUrl", reauthUrl.getLocalURIString()); + return success(resp); + } + } + public static final String PASSWORD1_TEXT_FIELD_NAME = "password"; public static final String PASSWORD2_TEXT_FIELD_NAME = "password2"; diff --git a/core/webapp/login.js b/core/webapp/login.js index e2534ede023..576ca9b95ba 100644 --- a/core/webapp/login.js +++ b/core/webapp/login.js @@ -13,10 +13,14 @@ // on document ready $('.signin-btn').click(authenticateUser); $('.loginSubmitButton').click(authenticateUser); - init(); - getTermsOfUse(); - getOtherLoginMechanisms(); - toggleRegistrationLink(); + const reauth = LABKEY.ActionURL.getParameter("forceReauth"); + init(reauth); + if (!reauth) + { + getTermsOfUse(); + getOtherLoginMechanisms(); + toggleRegistrationLink(); + } } function authenticateUser() { @@ -44,7 +48,8 @@ returnUrl: returnUrlElement && returnUrlElement.value ? returnUrlElement.value : LABKEY.ActionURL.getParameter("returnUrl"), skipProfile: LABKEY.ActionURL.getParameter("skipProfile") || 0, urlhash: document.getElementById('urlhash').value, - forceReauth: LABKEY.ActionURL.getParameter("forceReauth") || false + forceReauth: LABKEY.ActionURL.getParameter("forceReauth") || false, + local: LABKEY.ActionURL.getParameter("local") || false }, success: LABKEY.Utils.getCallbackWrapper(function(response) { setSubmitting(false, [{msg: ''}]); @@ -232,12 +237,12 @@ } } - function init() { + function init(reauth) { // Provide support for persisting the url hash through a login redirect if (window && window.location && window.location.hash) { - var h = document.getElementById('urlhash'); - if (h) { - h.value = window.location.hash; + const hash = document.getElementById('urlhash'); + if (hash) { + hash.value = window.location.hash; } } @@ -268,12 +273,24 @@ } // examine cookies to determine if user wants the email pre-populated on form - var h = document.getElementById('email'); - if (h && LABKEY.Utils.getCookie("email")) { - h.value = decodeURIComponent(LABKEY.Utils.getCookie("email")); + const email = document.getElementById('email'); + if (email && LABKEY.Utils.getCookie("email")) { + email.value = decodeURIComponent(LABKEY.Utils.getCookie("email")); + } + const remember = document.getElementById('remember'); + if (reauth) { + remember.hidden = true; + document.getElementById('remember-label').hidden = true; + const forgot = document.getElementsByClassName("forgot-password-link") + if (forgot && forgot.length > 0) { + forgot[0].style = "display: none"; + } + document.getElementById("header").textContent = "Reauthenticate" + document.getElementById("sign-in-button").textContent = "Reauthenticate"; + } + else { + remember.checked = remember && LABKEY.Utils.getCookie("email"); } - h = document.getElementById('remember'); - h.checked = h && LABKEY.Utils.getCookie("email"); // set autofocus to email field if email is blank otherwise set it to password field if (!document.getElementById('email').value) { diff --git a/devtools/src/org/labkey/devtools/TestController.java b/devtools/src/org/labkey/devtools/TestController.java index 1240c01fb0e..6c49f6572c5 100644 --- a/devtools/src/org/labkey/devtools/TestController.java +++ b/devtools/src/org/labkey/devtools/TestController.java @@ -19,6 +19,7 @@ import jakarta.servlet.http.HttpServletResponse; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.junit.Assert; import org.junit.Test; import org.labkey.api.action.ApiResponse; @@ -40,12 +41,14 @@ import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpService; import org.labkey.api.security.AdminConsoleAction; +import org.labkey.api.security.AuthenticationManager; import org.labkey.api.security.CSRF; import org.labkey.api.security.MethodsAllowed; import org.labkey.api.security.RequiresLogin; import org.labkey.api.security.RequiresNoPermission; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.RequiresSiteAdmin; +import org.labkey.api.security.User; import org.labkey.api.security.permissions.AdminOperationsPermission; import org.labkey.api.security.permissions.AdminPermission; import org.labkey.api.security.permissions.DeletePermission; @@ -1492,4 +1495,41 @@ public void testJacksonInputLimitExceeded() throws Exception } } + public record ReauthForm(@Nullable String reauthToken, @Nullable String errorMessage){} + + @RequiresLogin + public class TestReauthAction extends FormViewAction + { + @Override + public void validateCommand(ReauthForm form, Errors errors) + { + } + + @Override + public ModelAndView getView(ReauthForm form, boolean reshow, BindException errors) + { + getPageConfig().setTemplate(PageConfig.Template.Dialog); + return new JspView<>("/org/labkey/devtools/view/testReauth.jsp", form, errors); + } + + @Override + public boolean handlePost(ReauthForm form, BindException errors) + { + User reauthUser = AuthenticationManager.getAndClearReauthUser(getViewContext().getRequestOrThrow(), form.reauthToken(), getUser()); + if (reauthUser == null) + throw new NotFoundException("Reauthentication validation failed!"); + return true; + } + + @Override + public URLHelper getSuccessURL(ReauthForm form) + { + return actionURL(BeginAction.class); + } + + @Override + public void addNavTrail(NavTree root) + { + } + } } diff --git a/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java b/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java index 34da73011d4..c409e6dd7d8 100644 --- a/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java +++ b/devtools/src/org/labkey/devtools/authentication/TestSsoConfiguration.java @@ -20,7 +20,6 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.security.AuthenticationManager.LinkFactory; import org.labkey.api.security.BaseSSOAuthenticationConfiguration; -import org.labkey.api.util.URLHelper; import org.labkey.api.view.ActionURL; import org.labkey.api.view.ViewContext; @@ -45,7 +44,7 @@ protected TestSsoConfiguration(TestSsoProvider provider, Map sta } @Override - public URLHelper getUrl(ViewContext ctx) + public ActionURL getUrl(ViewContext ctx) { ActionURL url = new ActionURL(TestSsoController.TestSsoAction.class, ContainerManager.getRoot()); url.addParameter("configuration", getRowId()); @@ -53,6 +52,12 @@ public URLHelper getUrl(ViewContext ctx) return url; } + @Override + public ActionURL getReauthUrl(ViewContext ctx) + { + return getUrl(ctx).addParameter("reauth", true); + } + @Override public LinkFactory getLinkFactory() { diff --git a/devtools/src/org/labkey/devtools/authentication/TestSsoController.java b/devtools/src/org/labkey/devtools/authentication/TestSsoController.java index 06290a5abcd..1017fc879a5 100644 --- a/devtools/src/org/labkey/devtools/authentication/TestSsoController.java +++ b/devtools/src/org/labkey/devtools/authentication/TestSsoController.java @@ -50,10 +50,10 @@ public TestSsoController() @RequiresNoPermission @AllowedDuringUpgrade - public static class TestSsoAction extends SimpleViewAction + public static class TestSsoAction extends SimpleViewAction { @Override - public ModelAndView getView(AuthenticationConfigurationForm form, BindException errors) + public ModelAndView getView(TestSsoForm form, BindException errors) { getPageConfig().setTemplate(PageConfig.Template.Dialog); return new JspView<>("/org/labkey/devtools/authentication/testSso.jsp", form, errors); @@ -68,6 +68,7 @@ public void addNavTrail(NavTree root) public static class TestSsoForm extends AuthenticationConfigurationForm { private String _email; + private boolean _reauth; public String getEmail() { @@ -79,6 +80,17 @@ public void setEmail(String email) { _email = email; } + + public boolean isReauth() + { + return _reauth; + } + + @SuppressWarnings("unused") + public void setReauth(boolean reauth) + { + _reauth = reauth; + } } @AllowedDuringUpgrade @@ -94,7 +106,7 @@ public static class ValidateAction extends BaseSsoValidateAction if (null == configuration) throw new NotFoundException("Invalid TestSso configuration"); - return AuthenticationResponse.success(configuration, new ValidEmail(form.getEmail())); + return AuthenticationResponse.success(configuration, new ValidEmail(form.getEmail())).setReauth(form.isReauth()); } } diff --git a/devtools/src/org/labkey/devtools/authentication/testSso.jsp b/devtools/src/org/labkey/devtools/authentication/testSso.jsp index 246ced97d51..a34e22c60b8 100644 --- a/devtools/src/org/labkey/devtools/authentication/testSso.jsp +++ b/devtools/src/org/labkey/devtools/authentication/testSso.jsp @@ -15,21 +15,25 @@ * limitations under the License. */ %> -<%@ page import="org.labkey.api.security.AuthenticationManager.AuthenticationConfigurationForm" %> <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.devtools.authentication.TestSsoController.TestSsoForm" %> <%@ page import="org.labkey.devtools.authentication.TestSsoController.ValidateAction" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <% - JspView me = HttpView.currentView(); - AuthenticationConfigurationForm form = me.getModelBean(); + JspView me = HttpView.currentView(); + TestSsoForm form = me.getModelBean(); + boolean reauth = form.isReauth(); + String label = "SSO Test " + (reauth ? "Reauthentication" : "Authentication"); + String contextContent = "Type an email address below to \"" + (reauth ? "re" : "") + "authenticate\" as that user."; %> + - <%= button("Authenticate").submit(true) %> + <%= button(form.isReauth() ? "Reauthenticate" : "Authenticate").submit(true) %> \ No newline at end of file diff --git a/devtools/src/org/labkey/devtools/view/testReauth.jsp b/devtools/src/org/labkey/devtools/view/testReauth.jsp new file mode 100644 index 00000000000..ecc40c615e4 --- /dev/null +++ b/devtools/src/org/labkey/devtools/view/testReauth.jsp @@ -0,0 +1,81 @@ +<% +/* + * 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. + */ +%> +<%@ page import="org.labkey.api.view.HttpView" %> +<%@ page import="org.labkey.api.view.JspView" %> +<%@ page import="org.labkey.devtools.TestController.ReauthForm" %> +<%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> +<%@ page extends="org.labkey.api.jsp.JspBase" %> +<% + JspView me = HttpView.currentView(); + ReauthForm form = me.getModelBean(); +%> + + +You authenticated with:
+ +<% + if (form.reauthToken() != null) + { +%> +Looks like you successfully re-authenticated and received token: <%=h(form.reauthToken())%>
+ + + + + +<% + } + else + { + if (form.errorMessage() != null) + { +%> +Looks like your reauthentication failed: <%=h(form.errorMessage())%>. Try again? +<% + } + else + { +%> +You need to re-authenticate. +<% + } +%> + Click here +<% + } +%>