diff --git a/doc/release-notes/12178-session-cookie-api-hardening.md b/doc/release-notes/12178-session-cookie-api-hardening.md new file mode 100644 index 00000000000..7cfbb747935 --- /dev/null +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -0,0 +1,21 @@ +Session-cookie API authentication now has an opt-in hardening track controlled by a new feature flag: `dataverse.feature.api-session-auth-hardening` (requires `dataverse.feature.api-session-auth`). + +When hardening is enabled, every API request authenticated via session cookie must include: + +- A valid same-origin `Origin` or `Referer` header. +- The `X-Dataverse-CSRF-Token` header matching the token from `GET /api/users/:csrf-token`. + +This applies uniformly to all HTTP methods and all API paths, except for the CSRF bootstrap endpoint (`GET /api/users/:csrf-token`), which is intentionally callable without an existing `X-Dataverse-CSRF-Token` header so clients can obtain the initial token. All subsequent session-cookie-authenticated requests must include the header. Clients not on the same origin should use bearer-token authentication instead. + +Additional changes: + +- Auth-mechanism-aware request tagging in the API auth flow. + +A new endpoint is available for session-cookie clients to fetch the CSRF token when hardening is enabled: + +- `GET /api/users/:csrf-token` + +Documentation updates: + +- Installation guide: feature flag behavior and deployment guidance. +- Native API guide: `GET /api/users/:csrf-token` usage and `X-Dataverse-CSRF-Token` header expectations. diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 808dbeec815..60bada840e9 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -6159,8 +6159,38 @@ Delete a Token In order to delete a token use:: curl -H "X-Dataverse-key:$API_TOKEN" -X DELETE "$SERVER_URL/api/users/token" - - + +Get CSRF Token for Session-Cookie API Auth +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When both ``dataverse.feature.api-session-auth`` and +``dataverse.feature.api-session-auth-hardening`` are enabled, clients using +session-cookie API authentication can fetch a CSRF token from this endpoint:: + + GET /api/users/:csrf-token + +This endpoint requires an authenticated session-cookie request and returns a +token that must be sent in the ``X-Dataverse-CSRF-Token`` header for all +subsequent session-cookie API requests protected by the hardening rules. +These hardened requests must also include an ``Origin`` or ``Referer`` header. + +Example:: + + curl -b cookies.txt -H "Origin:$SERVER_URL" "$SERVER_URL/api/users/:csrf-token" + +Example response:: + + { + "status": "OK", + "data": { + "csrfToken": "9f2a8f8c-7e1f-4bf1-8fd6-4c3e3b522f3f" + } + } + +To use this token in a subsequent API request:: + + curl -b cookies.txt -H "Origin:$SERVER_URL" -H "X-Dataverse-CSRF-Token:$CSRF_TOKEN" -X POST "$SERVER_URL/api/datasets/$ID/actions/:publish?type=minor" + Builtin Users ------------- @@ -8913,4 +8943,3 @@ A curl example listing collections: curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/mydata/retrieve/collectionList" curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/mydata/retrieve/collectionList?userIdentifier=anotherUser" - diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index e5ed52acb83..728df2ee32e 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -216,6 +216,7 @@ By default, Payara doesn't send the SameSite cookie attribute, which browsers sh Dataverse installations are explicity set to "Lax" out of the box by the installer (in the case of a "classic" installation) or through the base image (in the case of a Docker installation). For classic, see :ref:`http.cookie-same-site-value` and :ref:`http.cookie-same-site-enabled` for how to change the values. For Docker, you must rebuild the :doc:`base image `. See also Payara's `documentation `_ for the settings above. To inspect cookie attributes like SameSite, you can use ``curl -s -I http://localhost:8080 | grep JSESSIONID``, for example, looking for the "Set-Cookie" header. +For session-cookie API hardening guidance (including how to verify and set ``Secure``/``HttpOnly`` for ``JSESSIONID``), see :ref:`session-cookie-hardening-guidance`. .. _ongoing-security: @@ -3933,7 +3934,88 @@ To check the status of feature flags via API, see :ref:`list-all-feature-flags` dataverse.feature.api-session-auth ++++++++++++++++++++++++++++++++++ -Enables API authentication via session cookie (JSESSIONID). **Caution: Enabling this feature flag exposes the installation to CSRF risks!** We expect this feature flag to be temporary (only used by frontend developers, see `#9063 `_) and for the feature to be removed in the future. +Enables API authentication via session cookie (JSESSIONID). This is needed for some JSF/SAML-oriented integrations where bearer tokens are not used. + +.. warning:: + + Enabling this flag without also enabling :ref:`dataverse.feature.api-session-auth-hardening` exposes the installation to CSRF risks. + Always enable both flags together in production. + +By itself, this feature flag does not enable CSRF protections. For stricter protections, also enable :ref:`dataverse.feature.api-session-auth-hardening`. + +.. _dataverse.feature.api-session-auth-hardening: + +dataverse.feature.api-session-auth-hardening +++++++++++++++++++++++++++++++++++++++++++++ + +Enables additional hardening for session-cookie API usage. This flag only has an effect when ``dataverse.feature.api-session-auth`` is also enabled. +The rules are based on request authentication mechanism (session cookie), not on the identity provider used to create the session +(``builtin``, Shibboleth, OAuth, OIDC, etc.). + +When enabled, Dataverse requires all API requests authenticated via session cookie (except the CSRF bootstrap endpoint) to include: + +- A valid same-origin ``Origin`` or ``Referer`` header. +- The ``X-Dataverse-CSRF-Token`` header matching the token obtained from ``GET /api/users/:csrf-token``. + +The only per-endpoint exception is the CSRF bootstrap call itself (``GET /api/users/:csrf-token``), which by design cannot send the token it is obtaining. All other API paths are subject to these requirements, and this applies uniformly to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.). +The simplicity is intentional: session-cookie API auth +is only used by same-origin front-end clients that always have the CSRF token available. +Some ``GET`` endpoints in the codebase have side effects, so exempting reads would leave gaps. + +Clients not on the same origin should use bearer-token authentication instead. + +.. _session-cookie-hardening-guidance: + +Session-cookie hardening deployment guidance +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Use HTTPS end-to-end (or trusted TLS termination before Dataverse). +- Ensure JSESSIONID cookies are set with ``Secure`` and ``HttpOnly``. +- Use ``SameSite=Lax`` (recommended default) or ``SameSite=Strict`` if your login/redirect flow supports it. + ``SameSite=Strict`` can break some cross-site IdP/login return flows. + +How to verify and set ``JSESSIONID`` cookie flags (Payara) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Verify cookie flags from a response header: + + ``curl -s -I https:/// | grep -i "set-cookie: JSESSIONID"`` + + The ``Set-Cookie`` header should include ``HttpOnly``, ``Secure``, and your expected ``SameSite`` value. + +- Verify current Payara virtual-server settings: + + ``./asadmin get "configs.config.server-config.http-service.virtual-server.*.session-cookie-http-only"`` + + ``./asadmin get "configs.config.server-config.http-service.virtual-server.*.session-cookie-secure"`` + +- Set ``JSESSIONID`` flags on the default virtual server (``server``): + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.session-cookie-http-only=true`` + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.session-cookie-secure=true`` + +- If you use SSO cookie flows (``JSESSIONIDSSO``), set those too: + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.sso-cookie-http-only=true`` + + ``./asadmin set configs.config.server-config.http-service.virtual-server.server.sso-cookie-secure=true`` + +After changing these settings, restart Payara and re-check the response headers. + +Session-Cookie Hardening vs Bearer Token Auth +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Session-cookie auth and bearer-token auth use different trust models. Session cookie + (``JSESSIONID``) is automatically sent by browsers, while bearer token is sent only when the + client explicitly includes it. +- Because of browser auto-send behavior, session-cookie auth requires anti-CSRF controls for + state-changing API calls. + With this hardening track enabled, Dataverse enforces Origin/Referer and CSRF token checks, which brings session-cookie browser usage into a security posture comparable to bearer for first-party, same-origin UI calls. +- Bearer remains preferable for non-browser and cross-origin API clients. +- Neither model protects against stolen credentials by itself (session hijack via stolen + ``JSESSIONID`` or bearer-token theft). For both, use HTTPS, secure cookie/token handling, short + lifetimes where possible, and strong XSS prevention. .. _dataverse.feature.api-bearer-auth: diff --git a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java index 607bc9d5a47..6d36c63a56d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -17,6 +17,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.UUID; import java.util.logging.Logger; import jakarta.ejb.EJB; import jakarta.enterprise.context.SessionScoped; @@ -88,6 +89,7 @@ public void setDismissedMessages(List dismissedMessages) { * leave the state alone (see setDebug()). */ private Boolean debug; + private String apiCsrfToken; public User getUser() { return getUser(false); @@ -122,6 +124,25 @@ public User getUser(boolean lookupAuthenticatedUserAgain) { return user; } + /** + * Returns a CSRF token scoped to the current Dataverse session. + * The token is lazily created and reused until it is explicitly cleared. + */ + public synchronized String getOrCreateApiCsrfToken() { + if (apiCsrfToken == null || apiCsrfToken.isBlank()) { + apiCsrfToken = UUID.randomUUID().toString(); + } + return apiCsrfToken; + } + + public synchronized boolean matchesApiCsrfToken(String token) { + return token != null && apiCsrfToken != null && apiCsrfToken.equals(token); + } + + public synchronized void clearApiCsrfToken() { + apiCsrfToken = null; + } + /** * Sets the user and configures the session timeout. */ @@ -136,6 +157,7 @@ public void setUser(User aUser) { JsfHelper.addErrorMessage(BundleUtil.getStringFromBundle("deactivated.error")); return; } + clearApiCsrfToken(); FacesContext context = FacesContext.getCurrentInstance(); // Log the login/logout and Change the session id if we're using the UI and have // a session, versus an API call with no session - (i.e. /admin/submitToArchive() diff --git a/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java b/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java index 15114085c21..d249d3344eb 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/ApiConstants.java @@ -12,6 +12,14 @@ private ApiConstants() { // Authentication public static final String CONTAINER_REQUEST_CONTEXT_USER = "user"; + public static final String CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM = "authMechanism"; + public static final String AUTH_MECHANISM_NONE = "none"; + public static final String AUTH_MECHANISM_API_KEY = "apiKey"; + public static final String AUTH_MECHANISM_WORKFLOW_KEY = "workflowKey"; + public static final String AUTH_MECHANISM_SIGNED_URL = "signedUrl"; + public static final String AUTH_MECHANISM_BEARER_TOKEN = "bearerToken"; + public static final String AUTH_MECHANISM_SESSION_COOKIE = "sessionCookie"; + public static final String CSRF_TOKEN_HEADER = "X-Dataverse-CSRF-Token"; // Dataset public static final String DS_VERSION_LATEST = ":latest"; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Users.java b/src/main/java/edu/harvard/iq/dataverse/api/Users.java index af6b533d46d..79d77fcc085 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -6,6 +6,7 @@ package edu.harvard.iq.dataverse.api; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; @@ -30,6 +31,8 @@ import edu.harvard.iq.dataverse.util.json.JsonPrinter; import edu.harvard.iq.dataverse.util.json.JsonUtil; import jakarta.ejb.Stateless; +import jakarta.inject.Inject; +import jakarta.json.Json; import jakarta.json.JsonArray; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; @@ -47,6 +50,9 @@ public class Users extends AbstractApiBean { private static final Logger logger = Logger.getLogger(Users.class.getName()); + + @Inject + private DataverseSession session; @POST @AuthRequired @@ -208,6 +214,30 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc return ok(json(authenticatedUser)); } + @GET + @AuthRequired + @Path(":csrf-token") + public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return error(Response.Status.BAD_REQUEST, "Session-auth hardening is disabled."); + } + Object authMechanism = crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM); + if (!ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism)) { + return error( + Response.Status.FORBIDDEN, + "CSRF token endpoint is only available for session-cookie authentication."); + } + // AuthFilter handles authentication and Origin/Referer validation. + // The CSRF header check is skipped for this endpoint (bootstrap exception in AuthFilter) + // so the client can obtain the token before making subsequent hardened calls. + try { + getRequestAuthenticatedUserOrDie(crc); + return ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken())); + } catch (WrappedResponse wr) { + return wr.getResponse(); + } + } + @POST @AuthRequired @Path("{identifier}/removeRoles") diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java index 34a72d718f0..1bd363d1f28 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/AuthFilter.java @@ -1,7 +1,10 @@ package edu.harvard.iq.dataverse.api.auth; +import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.SystemConfig; import jakarta.annotation.Priority; import jakarta.inject.Inject; @@ -10,6 +13,9 @@ import jakarta.ws.rs.container.ContainerRequestFilter; import jakarta.ws.rs.ext.Provider; import java.io.IOException; +import java.net.URI; +import java.util.Locale; +import java.util.logging.Logger; /** * @author Guillermo Portas @@ -20,16 +26,135 @@ @Priority(Priorities.AUTHENTICATION) public class AuthFilter implements ContainerRequestFilter { + private static final Logger logger = Logger.getLogger(AuthFilter.class.getCanonicalName()); + @Inject private CompoundAuthMechanism compoundAuthMechanism; + @Inject + private DataverseSession session; + + @Inject + private SystemConfig systemConfig; + @Override public void filter(ContainerRequestContext containerRequestContext) throws IOException { try { User user = compoundAuthMechanism.findUserFromRequest(containerRequestContext); containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + applySessionAuthHardening(containerRequestContext); } catch (WrappedAuthErrorResponse e) { containerRequestContext.abortWith(e.getResponse()); } } + + private void applySessionAuthHardening(ContainerRequestContext containerRequestContext) + throws WrappedAuthErrorResponse { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return; + } + if (!isSessionCookieRequest(containerRequestContext)) { + return; + } + + if (!isOriginOrRefererAllowed(containerRequestContext)) { + throw new WrappedForbiddenAuthErrorResponse( + "Request origin validation failed for session-cookie authentication."); + } + + // Allow the CSRF-token-issuing endpoint to be called without an existing CSRF header. + // This endpoint is used to bootstrap the CSRF token for the current authenticated session. + if (isCsrfTokenBootstrapEndpoint(containerRequestContext)) { + return; + } + + if (!isCsrfTokenValid(containerRequestContext)) { + throw new WrappedForbiddenAuthErrorResponse( + "Missing or invalid CSRF token for session-cookie authentication."); + } + } + + private boolean isSessionCookieRequest(ContainerRequestContext containerRequestContext) { + Object authMechanism = containerRequestContext + .getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM); + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals(authMechanism); + } + + /** + * Returns {@code true} if the current request targets the CSRF-token-issuing endpoint + * (e.g. GET /api/users/:csrf-token). For this endpoint we rely on same-origin checks + * plus the authenticated session cookie, and do not require an existing CSRF header. + */ + private boolean isCsrfTokenBootstrapEndpoint(ContainerRequestContext containerRequestContext) { + String path = containerRequestContext.getUriInfo().getPath(); + if (path == null) { + return false; + } + String normalizedPath = path.toLowerCase(Locale.ROOT); + + // Support common variants such as "api/users/:csrf-token" or "users/:csrf-token". + if ("api/users/:csrf-token".equals(normalizedPath) || "users/:csrf-token".equals(normalizedPath)) { + return true; + } + return normalizedPath.endsWith("/api/users/:csrf-token") || normalizedPath.endsWith("/users/:csrf-token"); + } + + private boolean isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { + String allowedOrigin = toOrigin(systemConfig.getDataverseSiteUrl()); + if (allowedOrigin == null) { + logger.warning("Unable to validate Origin/Referer for session hardening: dataverse site URL is invalid."); + return false; + } + String originHeader = containerRequestContext.getHeaderString("Origin"); + String refererHeader = containerRequestContext.getHeaderString("Referer"); + boolean hasOrigin = originHeader != null && !originHeader.isBlank(); + boolean hasReferer = refererHeader != null && !refererHeader.isBlank(); + + if (!hasOrigin && !hasReferer) { + return false; + } + if (hasOrigin && !allowedOrigin.equals(toOrigin(originHeader))) { + return false; + } + if (hasReferer && !allowedOrigin.equals(toOrigin(refererHeader))) { + return false; + } + return true; + } + + private boolean isCsrfTokenValid(ContainerRequestContext containerRequestContext) { + String requestToken = containerRequestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER); + return requestToken != null && !requestToken.isBlank() && session.matchesApiCsrfToken(requestToken); + } + + private String toOrigin(String url) { + if (url == null || url.isBlank()) { + return null; + } + try { + URI uri = URI.create(url.trim()); + if (uri.getScheme() == null || uri.getHost() == null) { + return null; + } + String scheme = uri.getScheme().toLowerCase(Locale.ROOT); + String host = uri.getHost().toLowerCase(Locale.ROOT); + int port = uri.getPort(); + if (port == -1 || port == defaultPort(scheme)) { + return scheme + "://" + host; + } + return scheme + "://" + host + ":" + port; + } catch (IllegalArgumentException e) { + return null; + } + } + + private int defaultPort(String scheme) { + if ("http".equals(scheme)) { + return 80; + } + if ("https".equals(scheme)) { + return 443; + } + return -1; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java index e5be5144897..4c9a90d4327 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanism.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.api.auth; +import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; @@ -35,11 +36,13 @@ public void add(AuthMechanism... authMechanisms) { @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { + containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, ApiConstants.AUTH_MECHANISM_NONE); User user = null; for (AuthMechanism authMechanism : authMechanisms) { User userFromRequest = authMechanism.findUserFromRequest(containerRequestContext); if (userFromRequest != null) { user = userFromRequest; + containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, getAuthMechanismTag(authMechanism)); break; } } @@ -48,4 +51,23 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } return user; } + + private String getAuthMechanismTag(AuthMechanism authMechanism) { + if (authMechanism instanceof ApiKeyAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_API_KEY; + } + if (authMechanism instanceof WorkflowKeyAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_WORKFLOW_KEY; + } + if (authMechanism instanceof SignedUrlAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_SIGNED_URL; + } + if (authMechanism instanceof BearerTokenAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_BEARER_TOKEN; + } + if (authMechanism instanceof SessionCookieAuthMechanism) { + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE; + } + return ApiConstants.AUTH_MECHANISM_NONE; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java b/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java index c1471c3f5b3..3a1d93041bd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanism.java @@ -14,7 +14,8 @@ public class SessionCookieAuthMechanism implements AuthMechanism { @Override public User findUserFromRequest(ContainerRequestContext containerRequestContext) throws WrappedAuthErrorResponse { if (FeatureFlags.API_SESSION_AUTH.enabled()) { - return session.getUser(); + User user = session.getUser(); + return user != null && user.isAuthenticated() ? user : null; } return null; } diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index e1c7e69f7db..0e6f157ea69 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -25,11 +25,27 @@ public enum FeatureFlags { /** - * Enables API authentication via session cookie (JSESSIONID). Caution: Enabling this feature flag may expose the installation to CSRF risks + * Enables API authentication via session cookie (JSESSIONID). + * Needed for JSF/SAML-oriented integrations where bearer tokens are not used. + *

Caution: Enabling this flag without also enabling + * {@link #API_SESSION_AUTH_HARDENING} exposes the installation to CSRF risks.

+ * By itself this flag does not enable CSRF protections; for stricter protections, + * also enable {@link #API_SESSION_AUTH_HARDENING}. + * * @apiNote Raise flag by setting "dataverse.feature.api-session-auth" * @since Dataverse 5.14 */ API_SESSION_AUTH("api-session-auth"), + /** + * Enables additional hardening for session-cookie API authentication. + * When enabled, every session-cookie API request must include a valid same-origin + * Origin/Referer header and the X-Dataverse-CSRF-Token header. + * This feature only works when the feature flag {@link #API_SESSION_AUTH} is also enabled. + * + * @apiNote Raise flag by setting "dataverse.feature.api-session-auth-hardening" + * @since Dataverse 6.10 + */ + API_SESSION_AUTH_HARDENING("api-session-auth-hardening"), /** * Enables API authentication via Bearer Token. * @apiNote Raise flag by setting "dataverse.feature.api-bearer-auth" diff --git a/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java new file mode 100644 index 00000000000..b8c85dae079 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java @@ -0,0 +1,31 @@ +package edu.harvard.iq.dataverse; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DataverseSessionTest { + + @Test + void testCsrfTokenIsGeneratedAndReused() { + DataverseSession session = new DataverseSession(); + + String token1 = session.getOrCreateApiCsrfToken(); + String token2 = session.getOrCreateApiCsrfToken(); + + assertEquals(token1, token2); + assertTrue(session.matchesApiCsrfToken(token1)); + } + + @Test + void testCsrfTokenCanBeCleared() { + DataverseSession session = new DataverseSession(); + String token = session.getOrCreateApiCsrfToken(); + + session.clearApiCsrfToken(); + + assertFalse(session.matchesApiCsrfToken(token)); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java new file mode 100644 index 00000000000..0775182876a --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java @@ -0,0 +1,101 @@ +package edu.harvard.iq.dataverse.api; + +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the GET /api/users/:csrf-token endpoint in {@link Users}. + *

+ * These tests exercise the endpoint handler directly with mocked dependencies + * to verify the success path (session-cookie auth) and rejection paths + * (hardening disabled, non-session auth). + */ +@LocalJvmSettings +class UsersSessionCsrfTokenTest { + + @Test + void testGetCsrfToken_HardeningDisabled_Returns400() throws Exception { + Users sut = new Users(); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), response.getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_NonSessionCookieAuth_Returns403() throws Exception { + Users sut = new Users(); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_API_KEY); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), response.getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_SessionCookieAuth_ReturnsToken() throws Exception { + Users sut = new Users(); + DataverseSession session = Mockito.mock(DataverseSession.class); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + AuthenticatedUser authenticatedUser = new AuthenticatedUser(); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)) + .thenReturn(authenticatedUser); + when(session.getOrCreateApiCsrfToken()).thenReturn("test-csrf-token-value"); + + inject(sut, Users.class, "session", session); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.OK.getStatusCode(), response.getStatus()); + String body = response.getEntity().toString(); + assertTrue(body.contains("test-csrf-token-value")); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_SessionCookieAuthUnauthenticatedUser_ReturnsUnauthorized() throws Exception { + Users sut = new Users(); + DataverseSession session = Mockito.mock(DataverseSession.class); + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // Simulate a guest/unauthenticated user set by AuthFilter + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)) + .thenReturn(edu.harvard.iq.dataverse.authorization.users.GuestUser.get()); + + inject(sut, Users.class, "session", session); + + Response response = sut.getSessionCsrfToken(crc); + + // getRequestAuthenticatedUserOrDie throws WrappedResponse for non-authenticated users + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + } + + private void inject(Object target, Class clazz, String fieldName, Object value) throws Exception { + Field field = clazz.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java new file mode 100644 index 00000000000..c92e67d75e4 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java @@ -0,0 +1,539 @@ +package edu.harvard.iq.dataverse.api.auth; + +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.api.ApiConstants; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@LocalJvmSettings +class AuthFilterTest { + + @Test + void testFilter_HardeningDisabled_DoesNotAbort() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + User user = new AuthenticatedUser(); + when(compound.findUserFromRequest(requestContext)).thenReturn(user); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + verify(requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieGetAllowedWithOriginAndCsrf() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "access/datafile/123"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieGetBlockedWithoutOriginAndCsrf() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "access/datafile/123"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // No Origin, Referer, or CSRF token + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostRequiresOriginAndCsrf() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "access/datafiles"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Referer")).thenReturn("https://demo.dataverse.org/dataset.xhtml"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostWithApiPrefixAndTrailingSlashAllowed() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "api/access/datafiles/"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Referer")).thenReturn("https://demo.dataverse.org/dataset.xhtml"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieAccessBatchDownloadPostCrossOriginBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "access/datafiles"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Referer")).thenReturn("https://evil.example/malicious"); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointAllowedWithOriginAndCsrf() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("PUT", "access/datafile/1/requestAccess"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_SessionCookieMutatingAccessEndpointBlockedWithoutCsrf() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("PUT", "access/datafile/1/requestAccess"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + // No CSRF token header + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallNeedsCsrfAndOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1/add"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString("Referer")).thenReturn("https://demo.dataverse.org/dataset.xhtml"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("valid-token"); + when(session.matchesApiCsrfToken("valid-token")).thenReturn(true); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_NonSessionAuthNotBlockedForAnyPath() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "access/datafile/123"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, ApiConstants.AUTH_MECHANISM_API_KEY); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_API_KEY); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallWithNoOriginOrRefererBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("POST", "datasets/1/add"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // No Origin or Referer headers + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallWithMissingCsrfTokenBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("DELETE", "datasets/1"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + // CSRF token header absent (returns null by default from mock) + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_StateChangingCallWithWrongCsrfTokenBlocked() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("PUT", "datasets/1/editMetadata"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + when(requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("wrong-token"); + when(session.matchesApiCsrfToken("wrong-token")).thenReturn(false); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_CsrfBootstrapEndpointAllowedWithOriginOnly() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "users/:csrf-token"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + // No CSRF token header — bootstrap endpoint should not require it + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_CsrfBootstrapEndpointWithApiPrefixAllowed() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "api/users/:csrf-token"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + when(requestContext.getHeaderString("Origin")).thenReturn("https://demo.dataverse.org"); + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + verify(requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_HardeningEnabled_CsrfBootstrapEndpointBlockedWithoutOrigin() throws Exception { + AuthFilter sut = new AuthFilter(); + ContainerRequestContext requestContext = mockRequestContext("GET", "users/:csrf-token"); + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + DataverseSession session = Mockito.mock(DataverseSession.class); + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + + when(systemConfig.getDataverseSiteUrl()).thenReturn("https://demo.dataverse.org"); + when(compound.findUserFromRequest(requestContext)).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return new AuthenticatedUser(); + }); + when(requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + // No Origin or Referer — should still be blocked even for bootstrap endpoint + + inject(sut, "compoundAuthMechanism", compound); + inject(sut, "session", session); + inject(sut, "systemConfig", systemConfig); + + sut.filter(requestContext); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + private ContainerRequestContext mockRequestContext(String method, String path) { + ContainerRequestContext requestContext = Mockito.mock(ContainerRequestContext.class); + UriInfo uriInfo = Mockito.mock(UriInfo.class); + when(requestContext.getMethod()).thenReturn(method); + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPath()).thenReturn(path); + return requestContext; + } + + private void inject(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java index b3435d53ca2..799bed15187 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/CompoundAuthMechanismTest.java @@ -1,9 +1,13 @@ package edu.harvard.iq.dataverse.api.auth; -import edu.harvard.iq.dataverse.api.auth.doubles.ContainerRequestTestFake; +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.api.ApiConstants; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.JvmSettings; +import edu.harvard.iq.dataverse.util.testing.JvmSetting; +import edu.harvard.iq.dataverse.util.testing.LocalJvmSettings; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -13,7 +17,10 @@ import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.verify; +@LocalJvmSettings public class CompoundAuthMechanismTest { @Test @@ -25,25 +32,51 @@ public void testFindUserFromRequest_CanNotAuthenticateUserWithAnyMechanism() thr Mockito.when(authMechanismStub2.findUserFromRequest(any(ContainerRequestContext.class))).thenReturn(null); CompoundAuthMechanism sut = new CompoundAuthMechanism(authMechanismStub1, authMechanismStub2); + ContainerRequestContext containerRequestContext = Mockito.mock(ContainerRequestContext.class); - User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + User actual = sut.findUserFromRequest(containerRequestContext); assertThat(actual, equalTo(GuestUser.get())); + verify(containerRequestContext, atLeastOnce()).setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_NONE); } @Test public void testFindUserFromRequest_UserAuthenticated() throws WrappedAuthErrorResponse { AuthMechanism authMechanismStub1 = Mockito.mock(AuthMechanism.class); AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); - Mockito.when(authMechanismStub1.findUserFromRequest(any(ContainerRequestContext.class))).thenReturn(testAuthenticatedUser); + Mockito.when(authMechanismStub1.findUserFromRequest(any(ContainerRequestContext.class))) + .thenReturn(testAuthenticatedUser); AuthMechanism authMechanismStub2 = Mockito.mock(AuthMechanism.class); Mockito.when(authMechanismStub2.findUserFromRequest(any(ContainerRequestContext.class))).thenReturn(null); CompoundAuthMechanism sut = new CompoundAuthMechanism(authMechanismStub1, authMechanismStub2); + ContainerRequestContext containerRequestContext = Mockito.mock(ContainerRequestContext.class); - User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + User actual = sut.findUserFromRequest(containerRequestContext); assertEquals(actual, testAuthenticatedUser); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth") + void testFindUserFromRequest_TagsSessionCookieMechanism() throws WrappedAuthErrorResponse { + SessionCookieAuthMechanism sessionCookieAuthMechanism = new SessionCookieAuthMechanism(); + DataverseSession dataverseSessionStub = Mockito.mock(DataverseSession.class); + AuthenticatedUser testAuthenticatedUser = new AuthenticatedUser(); + Mockito.when(dataverseSessionStub.getUser()).thenReturn(testAuthenticatedUser); + sessionCookieAuthMechanism.session = dataverseSessionStub; + + CompoundAuthMechanism sut = new CompoundAuthMechanism(sessionCookieAuthMechanism); + ContainerRequestContext containerRequestContext = Mockito.mock(ContainerRequestContext.class); + + User actual = sut.findUserFromRequest(containerRequestContext); + + assertEquals(actual, testAuthenticatedUser); + verify(containerRequestContext).setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java index 74a7d239c05..4be29c32387 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCookieAuthMechanismTest.java @@ -3,6 +3,7 @@ import edu.harvard.iq.dataverse.DataverseSession; import edu.harvard.iq.dataverse.api.auth.doubles.ContainerRequestTestFake; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.User; import edu.harvard.iq.dataverse.settings.JvmSettings; import edu.harvard.iq.dataverse.util.testing.JvmSetting; @@ -46,4 +47,16 @@ void testFindUserFromRequest_FeatureFlagEnabled_UserAuthenticated() throws Wrapp assertEquals(testAuthenticatedUser, actual); } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth") + void testFindUserFromRequest_FeatureFlagEnabled_GuestSessionUserReturnsNull() throws WrappedAuthErrorResponse { + DataverseSession dataverseSessionStub = Mockito.mock(DataverseSession.class); + Mockito.when(dataverseSessionStub.getUser()).thenReturn(GuestUser.get()); + sut.session = dataverseSessionStub; + + User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + + assertNull(actual); + } }