Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aed3413
Service-side reauthentication infrastructure
labkey-adam Jun 5, 2026
c5e76d8
Validate reauthToken to complete flow
labkey-adam Jun 5, 2026
4b4cb01
Support local re-auth via login page for DB/LDAP. Push more validatio…
labkey-adam Jun 7, 2026
c7e31b6
Merge remote-tracking branch 'origin/develop' into fb_eln_signing
labkey-adam Jun 7, 2026
978e369
Add TestSSO re-auth support
labkey-adam Jun 8, 2026
5ab44c0
Set ForceAuthn on SAML reauthentication request
labkey-adam Jun 9, 2026
9d006ca
Remove distractions from login page when reauthenticating
labkey-adam Jun 9, 2026
431d5bf
Feedback
labkey-adam Jun 9, 2026
83fe120
Merge remote-tracking branch 'origin/develop' into fb_eln_signing
labkey-alan Jun 10, 2026
0e55f99
Merge remote-tracking branch 'origin/develop' into fb_eln_signing
labkey-alan Jun 11, 2026
343f3f7
Merge remote-tracking branch 'origin/develop' into fb_eln_signing
labkey-alan Jun 15, 2026
bc30051
adam@labkey.com
labkey-adam Jun 16, 2026
f165d6c
Support multiple tokens and expire them after five minutes
labkey-adam Jun 17, 2026
c832967
Unit test, use Instant, clear expired tokens
labkey-adam Jun 17, 2026
00fdab2
Comment
labkey-adam Jun 17, 2026
2360a69
Appease a very picky Claude
labkey-adam Jun 17, 2026
f3fe66d
Comment fix
labkey-adam Jun 17, 2026
106743b
Merge remote-tracking branch 'origin/develop' into fb_eln_signing
labkey-adam Jun 18, 2026
27f1f82
Fix CAS reauth with 2FA: preserve reauth state across Duo/TOTP callb…
labkey-bpatel Jun 19, 2026
055cea1
Merge remote-tracking branch 'origin/fb_eln_signing' into fb_eln_signing
labkey-bpatel Jun 19, 2026
74d990d
Fix failures related to merge.
labkey-bpatel Jun 19, 2026
a3315d1
Add AbstractReauthTest and implement for DB auth
labkey-tchad Jun 19, 2026
3707b50
Revert the fix that preserved reauth through Duo/TOTP callbacks, and …
labkey-bpatel Jun 20, 2026
53dc4ca
Merge remote-tracking branch 'origin/fb_eln_signing' into fb_eln_signing
labkey-bpatel Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/src/org/labkey/api/ApiModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions api/src/org/labkey/api/action/SimpleErrorView.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@

/**
* View that renders an error collection.
* User: adam
* Date: Sep 26, 2007
*/
public class SimpleErrorView extends JspView<Boolean>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ interface SSOAuthenticationConfiguration<AP extends SSOAuthenticationProvider<?>
{
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
Expand Down
244 changes: 233 additions & 11 deletions api/src/org/labkey/api/security/AuthenticationManager.java

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions api/src/org/labkey/api/security/AuthenticationProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ class AuthenticationResponse
private @NotNull Map<String, String> _userAttributeMap = Collections.emptyMap(); // A case-insensitive map of attribute names and values associated with the user
private @NotNull Map<String, Object> _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.

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion api/src/org/labkey/api/security/LoginUrls.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions core/resources/views/login.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<form class="auth-form" name="login" method="post">
<div class="auth-header">Sign In</div>
<div class="auth-header" id="header">Sign In</div>
<div class="labkey-error" id="errors"></div>
<div class="auth-form-body">
<label for="email">Email</label>
Expand All @@ -23,7 +23,7 @@
<div class="auth-item auth-credentials-submit">
<!-- Note: login.js attaches an authenticateUser() click event to elements with classes "loginSubmitButton" and "signin-btn" -->
<input type="submit" tabindex="-1" class="loginSubmitButton"/>
<button class="labkey-button primary signin-btn" type="button"><span>Sign In</span></button>
<button class="labkey-button primary signin-btn" type="button"><span id="sign-in-button">Sign In</span></button>
<span class="registrationSection" hidden>
<a class="labkey-button" id="registerButton" href="login-register.view">Register</a>
</span>
Expand Down
146 changes: 107 additions & 39 deletions core/src/org/labkey/core/login/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<? extends Controller> 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();
Expand Down Expand Up @@ -650,7 +669,7 @@ public class LoginApiAction extends MutatingApiAction<LoginForm>
@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
Expand All @@ -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)
{
Expand All @@ -689,14 +708,19 @@ 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();
URLHelper redirectUrl = authResult.getRedirectURL();
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);
Expand All @@ -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();

Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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<SsoRedirectForm>
private static abstract class BaseSsoRedirectAction extends SimpleViewAction<SsoRedirectForm>
{
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())
Expand All @@ -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.
Expand All @@ -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<ReturnUrlForm>
{
@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";

Expand Down
Loading