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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/src/org/labkey/api/security/AuthenticationManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ public static Map<String, Object> getLoginPageConfiguration(Project project)
{
Map<String, Object> config = new HashMap<>();
config.put("registrationEnabled", isRegistrationEnabled());
config.put("requiresTermsOfUse", WikiTermsOfUseProvider.isTermsOfUseRequired(project));
config.put("requiresTermsOfUse", WikiTermsOfUseProvider.isTermsOfUseConfigured(project) && AppProps.getInstance().getTermsOfUseFrequencySeconds() == 0);
config.put("hasOtherLoginMechanisms", hasSSOAuthenticationConfiguration());
return config;
}
Expand Down
200 changes: 128 additions & 72 deletions api/src/org/labkey/api/security/WikiTermsOfUseProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,40 @@
*/
package org.labkey.api.security;

import jakarta.servlet.http.HttpSession;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.labkey.api.action.SpringActionController;
import org.labkey.api.data.Container;
import org.labkey.api.data.ContainerManager;
import org.labkey.api.data.Project;
import org.labkey.api.data.PropertyManager;
import org.labkey.api.data.PropertyManager.WritablePropertyMap;
import org.labkey.api.module.ModuleLoader;
import org.labkey.api.security.SecurityManager.TermsOfUseProvider;
import org.labkey.api.settings.AppProps;
import org.labkey.api.util.HtmlString;
import org.labkey.api.util.PageFlowUtil;
import org.labkey.api.util.SafeToRenderEnum;
import org.labkey.api.util.SessionHelper;
import org.labkey.api.util.logging.LogHelper;
import org.labkey.api.view.ActionURL;
import org.labkey.api.view.RedirectException;
import org.labkey.api.view.ViewContext;
import org.labkey.api.wiki.WikiService;

import jakarta.servlet.http.HttpSession;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.Callable;

/**
* Created by adam on 1/13/2016.
*/
public class WikiTermsOfUseProvider implements TermsOfUseProvider
{
public static final String TERMS_OF_USE_WIKI_NAME = "_termsOfUse";
public static final String TERMS_APPROVED_KEY = "TERMS_APPROVED_KEY";

private static final Logger LOG = LogHelper.getLogger(WikiTermsOfUseProvider.class, "Terms of use workflow");
private static final TermsOfUse NO_TERMS = new TermsOfUse(TermsOfUseType.NONE, null);

@Override
Expand All @@ -56,7 +62,8 @@ public void verifyTermsOfUse(ViewContext context, boolean isBasicAuth) throws Re
}
}

private static boolean isTermsOfUseRequired(ViewContext ctx)
// Are terms required in this container and the user hasn't approved them yet
public static boolean isTermsOfUseRequired(ViewContext ctx)
{
Container proj = ctx.getContainer().getProject();

Expand All @@ -66,107 +73,156 @@ private static boolean isTermsOfUseRequired(ViewContext ctx)
project = new Project(proj);
}

// not required if user has already approved at the project level
if (isTermsOfUseApproved(ctx, project))
return false;
return isTermsOfUseRequired(ctx, project);
}

TermsOfUse termsOfUse = getTermsOfUse(project);
boolean required;
switch (termsOfUse.getType())
{
case SITE_WIDE:
// if we don't require project-level and have approved site-wide level, not required to ask again,
// but we don't cache for the project in case we set project-level terms later
required = !isTermsOfUseApproved(ctx, null);
break;
case PROJECT_LEVEL: // we already checked if the project-level terms were approved, so we know that they are required here
required = true;
break;
default:
required = false;
}
// Are terms required in this container and the user hasn't approved them yet
public static boolean isTermsOfUseRequired(ViewContext ctx, @Nullable Project project)
{
// First, quick check for whether terms are ever needed for this project
TermsOfUseConfiguration config = getTermsOfUseConfiguration(project);
if (config.type() == TermsOfUseType.NONE)
return false;

return required;
// Terms are needed, so check if user has already approved
//noinspection DataFlowIssue - termsContainer() is null only in the NONE case (see above)
return !isTermsOfUseApproved(ctx, config.termsContainer());
}

public static boolean isTermsOfUseApproved(ViewContext ctx, @Nullable Project project)
private static final String LAST_TERMS_ACCEPTANCE = "lastTermsAcceptance";
private static final String DATE = "date";

// termsContainer is guaranteed to have a terms-of-use wiki
public static boolean isTermsOfUseApproved(ViewContext ctx, @NotNull Container termsContainer)
{
HttpSession session = ctx.getRequest().getSession(false);
if (null == session)
return false;
@Nullable Set<Project> termsApproved = getApprovedTerms(session);
return null != termsApproved && termsApproved.contains(project);
}

public static @Nullable Set<Project> getApprovedTerms(@NotNull HttpSession session)
{
boolean approved;
synchronized (SessionHelper.getSessionLock(session))
{
return (Set<Project>) session.getAttribute(TERMS_APPROVED_KEY);
@NotNull Set<Container> termsApproved = getApprovedTerms(session);
approved = termsApproved.contains(termsContainer);
}
if (!approved)
LOG.debug("Approved terms did not include {} for {}", termsContainer, ctx.getUser());
if (!approved)
{
User user = ctx.getUser();
if (!user.isGuest())
{
int frequencySeconds = AppProps.getInstance().getTermsOfUseFrequencySeconds();
if (frequencySeconds > 0)
{
String isoDateString = PropertyManager.getProperties(user, termsContainer, LAST_TERMS_ACCEPTANCE).get(DATE);
if (isoDateString != null)
{
Instant lastAccepted = Instant.parse(isoDateString);
approved = Instant.now().isBefore(lastAccepted.plusSeconds(frequencySeconds));
// On first terms check at the site or each project, if last acceptance hasn't expired, stash
// an "approval" into session. This short-circuits future requests (so we don't run through
// this code block on every request). It also ensures the terms dialogs don't randomly pop up
// later in the session (when acceptance expires).
if (approved)
{
try (var ignored = SpringActionController.ignoreSqlUpdates())
{
setTermsOfUseApprovedInSession(ctx, termsContainer);
}
}
}
}
}
}
return approved;
}

public static boolean isTermsOfUseRequired(@Nullable Project project)
public static @NotNull Container getTermsContainer(@Nullable Project project)
{
//TODO: Should do this more efficiently, but no efficient public wiki api for this yet
TermsOfUse terms = getTermsOfUse(project);
return terms.getType() != TermsOfUseType.NONE;
return project != null ? project.getContainer() : ContainerManager.getRoot();
}

@NotNull
public static TermsOfUse getTermsOfUse(@Nullable Project project)
public static @NotNull Set<Container> getApprovedTerms(@NotNull HttpSession session)
{
if (!ModuleLoader.getInstance().isStartupComplete())
return NO_TERMS;
return SessionHelper.getAttribute(session, TERMS_APPROVED_KEY, (Callable<Set<Container>>) HashSet::new);
}

public static boolean isTermsOfUseConfigured(@Nullable Project project)
{
// This should be fairly efficient. We could consider caching a project -> terms configuration map.
return getTermsOfUseConfiguration(project).type() != TermsOfUseType.NONE;
}

WikiService service = WikiService.get();
//No wiki service. Wiki module most not be present. Don't do terms here...
if (null == service)
return NO_TERMS;
public record TermsOfUseConfiguration(TermsOfUseType type, /* Null only if type is NONE */ @Nullable Container termsContainer){}
public static final TermsOfUseConfiguration NO_TERMS_CONFIGURATION = new TermsOfUseConfiguration(TermsOfUseType.NONE, null);

HtmlString termsString;
if (null != project) // find project-level terms of use, if any
public static TermsOfUseConfiguration getTermsOfUseConfiguration(@Nullable Project project)
{
if (ModuleLoader.getInstance().isStartupComplete())
{
termsString = service.getHtml(project.getContainer(), TERMS_OF_USE_WIKI_NAME);
if (null != termsString)
WikiService service = WikiService.get();

// Need the wiki service to do terms
if (null != service)
{
return new TermsOfUse(TermsOfUseType.PROJECT_LEVEL, termsString);
// Project terms override root terms
if (project != null)
{
Container c = project.getContainer();
if (service.hasTermsOfUseWiki(c))
return new TermsOfUseConfiguration(TermsOfUseType.PROJECT_LEVEL, c);
}

// Now check the root
Container c = ContainerManager.getRoot();
if (service.hasTermsOfUseWiki(c))
return new TermsOfUseConfiguration(TermsOfUseType.SITE_WIDE, c);
}
}

// now check if we have site-wide terms of use
termsString = service.getHtml(ContainerManager.getRoot(), TERMS_OF_USE_WIKI_NAME);
if (null != termsString)
return NO_TERMS_CONFIGURATION;
}

@NotNull
public static TermsOfUse getTermsOfUse(@Nullable Project project)
{
TermsOfUseConfiguration config = getTermsOfUseConfiguration(project);

if (config.type() != TermsOfUseType.NONE)
{
return new TermsOfUse(TermsOfUseType.SITE_WIDE, termsString);
// Check above guarantees that wiki service is present and getContainer is non-null
@SuppressWarnings("DataFlowIssue")
HtmlString termsString = WikiService.get().getHtml(config.termsContainer(), TERMS_OF_USE_WIKI_NAME);
if (null != termsString)
{
return new TermsOfUse(config.type(), termsString);
}
}
return NO_TERMS;
}

public static void setTermsOfUseApproved(ViewContext ctx, @Nullable Project project, boolean approved)
public static void setTermsOfUseApproved(ViewContext ctx, @NotNull Container termsContainer)
{
HttpSession session = ctx.getRequest().getSession(false);
if (null == session && !approved)
return;
session = ctx.getRequest().getSession(true);
setTermsOfUseApprovedInSession(ctx, termsContainer);
User user = ctx.getUser();
if (!user.isGuest() && AppProps.getInstance().getTermsOfUseFrequencySeconds() > 0)
{
WritablePropertyMap map = PropertyManager.getWritableProperties(ctx.getUser(), termsContainer, LAST_TERMS_ACCEPTANCE, true);
map.put(DATE, Instant.now().toString());
map.save();
LOG.debug("Saving terms acceptance timestamp for {} in {}", ctx.getUser(), termsContainer);
}
}

private static void setTermsOfUseApprovedInSession(ViewContext ctx, @NotNull Container termsContainer)
{
HttpSession session = ctx.getRequest().getSession(true);
synchronized (SessionHelper.getSessionLock(session))
{
Set<Project> termsApproved = (Set<Project>) session.getAttribute(TERMS_APPROVED_KEY);
if (null == termsApproved)
{
termsApproved = new HashSet<>();
session.setAttribute(TERMS_APPROVED_KEY, termsApproved);
}
if (approved)
{
termsApproved.add(project);
}
else
{
termsApproved.remove(project);
}
Set<Container> termsApproved = getApprovedTerms(session);
termsApproved.add(termsContainer);
}
LOG.debug("Stashing terms acceptance in session for {} in {}", ctx.getUser(), termsContainer);
}

public enum TermsOfUseType implements SafeToRenderEnum
Expand Down
7 changes: 7 additions & 0 deletions api/src/org/labkey/api/settings/AppProps.java
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ static WriteableAppProps getWriteableInstance()
/** @return whether the server should include its name and version as a header in HTTP responses */
boolean isIncludeServerHttpHeader();

/**
* Returns the terms-of-use re-acceptance frequency in seconds. 0 means require acceptance on every sign-in, which
* is the default. Positive value means acceptance is valid for that many seconds before the user is prompted again
* after next login.
*/
int getTermsOfUseFrequencySeconds();

/**
* @return List of configured external redirect hosts
*/
Expand Down
6 changes: 6 additions & 0 deletions api/src/org/labkey/api/settings/AppPropsImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,12 @@ public boolean isIncludeServerHttpHeader()
return lookupBooleanValue(includeServerHttpHeader, true);
}

@Override
public int getTermsOfUseFrequencySeconds()
{
return lookupIntValue(termsOfUseFrequencySeconds, 0);
}

@Override
@NotNull
public List<String> getExternalRedirectHosts()
Expand Down
8 changes: 8 additions & 0 deletions api/src/org/labkey/api/settings/SiteSettingsProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ public void setValue(WriteableAppProps writeable, String value)
{
writeable.setIncludeServerHttpHeader(Boolean.parseBoolean(value));
}
},
termsOfUseFrequencySeconds("Require terms-of-use acceptance frequency in seconds. 0 = every sign-in, or positive number of seconds between required re-acceptance.")
{
@Override
public void setValue(WriteableAppProps writeable, String value)
{
writeable.setTermsOfUseFrequencySeconds(Integer.parseInt(value));
}
};

private final static Logger LOG = LogHelper.getLogger(SiteSettingsProperties.class, "Warnings about setting properties");
Expand Down
7 changes: 7 additions & 0 deletions api/src/org/labkey/api/settings/WriteableAppProps.java
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ public void setIncludeServerHttpHeader(boolean b)
storeBooleanValue(includeServerHttpHeader, b);
}

public void setTermsOfUseFrequencySeconds(int seconds)
{
if (seconds < 0)
throw new IllegalArgumentException("termsOfUseFrequencySeconds must be >= 0");
storeIntValue(termsOfUseFrequencySeconds, seconds);
}

public void setAdministratorContactEmail(String email)
{
storeStringValue(administratorContactEmail, email);
Expand Down
6 changes: 0 additions & 6 deletions api/src/org/labkey/api/util/SessionHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,6 @@
import java.util.Set;
import java.util.concurrent.Callable;


/**
* User: matthewb
* Date: 2012-01-24
* Time: 3:03 PM
*/
public class SessionHelper
{
private static final LockManager<HttpSession> LOCK_MANAGER = new LockManager<>();
Expand Down
3 changes: 3 additions & 0 deletions api/src/org/labkey/api/wiki/WikiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ record RenderedWiki (String name, String title, HtmlString html, String entityId

RenderedWiki getRenderedWiki(Container c, String name);

// Quick check for a terms-of-use wiki in the provided container. Helps optimize the every-request terms check.
boolean hasTermsOfUseWiki(Container c);

default HtmlString getHtml(Container c, String name)
{
var wiki = getRenderedWiki(c, name);
Expand Down
Loading