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..c1fa1b34c15 --- /dev/null +++ b/doc/release-notes/12178-session-cookie-api-hardening.md @@ -0,0 +1,23 @@ +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 (from a fully-authenticated session) must satisfy: + +- Any `Origin` or `Referer` header it carries must match the site origin. Requests carrying neither header — such as same-origin `GET`s under `Referrer-Policy: no-referrer` — are not rejected on that basis; the CSRF token below is then the deciding credential. (Cross-site `fetch`/XHR/form submissions always carry `Origin`, and a cross-site page can neither read nor set the CSRF header.) +- The `X-Dataverse-CSRF-Token` header matching the token from `GET /api/users/:csrf-token`. + +This applies to all HTTP methods on all `@AuthRequired` API endpoints — the set the authentication filter runs on. That includes endpoints that also read the JSF session directly (e.g. the multipart direct-upload helpers `GET {id}/uploadurls` and `PUT`/`DELETE /api/datasets/mpupload`) and `POST /api/logout` (annotated as part of this change). Session-cookie clients must send the `X-Dataverse-CSRF-Token` header on those calls too — including logout and the direct-upload flow. The one per-endpoint exception is the CSRF bootstrap call itself (`GET /api/users/:csrf-token`), which is intentionally callable without an existing `X-Dataverse-CSRF-Token` header so clients can obtain the initial token; its response is marked `Cache-Control: no-store`. Guest and `PrivateUrlUser` (anonymized preview link) sessions are exempt — a guest has nothing to forge and a preview session is read-only with no cross-origin-readable response, so anonymous preview-link downloads keep working under hardening without a token. Clients not on the same origin should use bearer-token authentication instead. + +Startup diagnostics: enabling the hardening flag without `dataverse.feature.api-session-auth` logs a warning (the hardening would otherwise be silently inert), and a missing/unparseable site URL logs a SEVERE message since origin validation depends on it. + +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 eaff1c77057..3649c3d72fb 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -6458,8 +6458,43 @@ 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 a fully-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. +(Guest and private-URL preview sessions are exempt from the hardening and are +not issued a token.) Any ``Origin`` +or ``Referer`` header those requests carry must match the site origin; +requests carrying neither header are accepted on the strength of the token. +The response is marked ``Cache-Control: no-store`` — treat the token like a +session secret. + +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 ------------- @@ -9262,4 +9297,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 0670855791a..3d00da3c2da 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`. .. _dataverse.cors: @@ -3978,7 +3979,92 @@ 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 API requests authenticated via session cookie (from a fully-authenticated session) to satisfy: + +- Any ``Origin`` or ``Referer`` header the request carries must match the site origin. Requests carrying neither header — for example same-origin ``GET`` requests from a page served with ``Referrer-Policy: no-referrer`` — are not rejected on that basis; the CSRF token below then decides. This keeps the hardening usable on installations that suppress referrer information, without weakening the protection: cross-site ``fetch``/XHR/form submissions always carry ``Origin``, and a cross-site page can neither read nor set the CSRF 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 (its response carries ``Cache-Control: no-store``). These requirements apply to all HTTP methods (``GET``, ``POST``, ``PUT``, ``DELETE``, etc.) on all ``@AuthRequired`` API endpoints — the set the authentication filter runs on. That is every endpoint whose request user is resolved by the filter, including ones that also read the JSF session directly (such as the multipart direct-upload helpers ``GET {id}/uploadurls``, ``PUT``/``DELETE /api/datasets/mpupload``) and ``POST /api/logout``. A session-cookie client must therefore send ``X-Dataverse-CSRF-Token`` on those calls too — including logout and the direct-upload flow — once hardening is enabled. +The per-method 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. + +Guest sessions and private-URL preview sessions (``PrivateUrlUser``) are exempt: a guest holds no privileges worth forging, and a private-URL preview is read-only (no state to forge, and its responses are not readable cross-origin), so the checks would add no protection. This also keeps anonymous preview-link file downloads — which are served through the JSF preview flow that has no way to obtain a token — working with hardening enabled. + +The CSRF token is **session-lifetime**: it is minted on first request to the bootstrap endpoint, reused for the duration of the session, and invalidated whenever the session identity changes (login, logout, or the account being deleted/deactivated). There is no per-request rotation. Treat the token like other session-scoped secrets — do not log it, and refresh it (by logging in again) if you suspect it has leaked. + +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..c3e6bfdce25 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java +++ b/src/main/java/edu/harvard/iq/dataverse/DataverseSession.java @@ -14,9 +14,12 @@ import edu.harvard.iq.dataverse.util.SystemConfig; import java.io.IOException; import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; 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 +91,7 @@ public void setDismissedMessages(List dismissedMessages) { * leave the state alone (see setDebug()). */ private Boolean debug; + private String apiCsrfToken; public User getUser() { return getUser(false); @@ -111,17 +115,52 @@ public User getUser(boolean lookupAuthenticatedUserAgain) { AuthenticatedUser auFreshLookup = authenticationService.findByID(auFromSession.getId()); if (auFreshLookup == null) { logger.fine("getUser found user no longer exists (was deleted). Returning GuestUser."); - user = GuestUser.get(); + demoteToGuest(); } else { if (auFreshLookup.isDeactivated()) { logger.fine("getUser found user is deactivated. Returning GuestUser."); - user = GuestUser.get(); + demoteToGuest(); } } } return user; } + /** + * Demotes the session to guest after discovering the authenticated user was + * deleted or deactivated. The CSRF token is dropped along with the identity + * it was minted for — {@code setUser} does the same on login/logout, and a + * token must never outlive its user. + */ + private void demoteToGuest() { + user = GuestUser.get(); + clearApiCsrfToken(); + } + + /** + * 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) { + if (token == null || apiCsrfToken == null) { + return false; + } + return MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), + apiCsrfToken.getBytes(StandardCharsets.UTF_8)); + } + + public synchronized void clearApiCsrfToken() { + apiCsrfToken = null; + } + /** * Sets the user and configures the session timeout. */ @@ -136,6 +175,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..a08386afeea 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,16 @@ private ApiConstants() { // Authentication public static final String CONTAINER_REQUEST_CONTEXT_USER = "user"; + // Set on the request by CompoundAuthMechanism only for session-cookie + // authentication — the one mechanism downstream code (AuthFilter's CSRF + // hardening, the token bootstrap endpoint) needs to distinguish. Absent + // for every other mechanism. + public static final String CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM = "authMechanism"; + public static final String AUTH_MECHANISM_SESSION_COOKIE = "sessionCookie"; + public static final String CSRF_TOKEN_HEADER = "X-Dataverse-CSRF-Token"; + public static final String CSRF_TOKEN_ENDPOINT_PATH = ":csrf-token"; + public static final String ORIGIN_HEADER = "Origin"; + public static final String REFERER_HEADER = "Referer"; // Dataset public static final String DS_VERSION_LATEST = ":latest"; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Logout.java b/src/main/java/edu/harvard/iq/dataverse/api/Logout.java index e8d8be04459..a6518e293dd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Logout.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Logout.java @@ -2,6 +2,7 @@ import edu.harvard.iq.dataverse.DataverseHeaderFragment; import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.api.auth.AuthRequired; import edu.harvard.iq.dataverse.settings.FeatureFlags; import jakarta.inject.Inject; @@ -23,9 +24,21 @@ public class Logout extends AbstractApiBean { * This endpoint replicates the logic from the JSF Log Out feature: * @see DataverseHeaderFragment#logout() *

+ * {@code @AuthRequired} routes this endpoint through {@code AuthFilter} so the + * session-cookie CSRF hardening (when enabled) covers it: logout is state-changing + * and session-cookie-authenticated, and without the annotation it would be the one + * such endpoint the hardening never sees. Two behavior changes follow from running + * the filter, both intended: with hardening enabled a session-cookie logout must + * now carry the {@code X-Dataverse-CSRF-Token} header like any other state-changing + * call, and an invalid non-session credential (e.g. a bad API key) is now + * rejected by the filter with 401 rather than reaching the handler. A valid + * non-session credential still falls through to the handler's "no session cookie" + * response as before, since the handler reads the JSF session directly. + *

* TODO: This endpoint must change when a final API authentication mechanism is established for use cases / applications subject to Log Out */ @POST + @AuthRequired @Path("/") public Response logout() { if (!FeatureFlags.API_SESSION_AUTH.enabled()) { 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..7d88779c800 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Users.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Users.java @@ -6,7 +6,9 @@ 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.api.auth.CompoundAuthMechanism; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.authorization.users.GuestUser; @@ -30,6 +32,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 +51,9 @@ public class Users extends AbstractApiBean { private static final Logger logger = Logger.getLogger(Users.class.getName()); + + @Inject + private DataverseSession session; @POST @AuthRequired @@ -208,6 +215,39 @@ public Response getAuthenticatedUserByToken(@Context ContainerRequestContext crc return ok(json(authenticatedUser)); } + @GET + @AuthRequired + @Path(ApiConstants.CSRF_TOKEN_ENDPOINT_PATH) + public Response getSessionCsrfToken(@Context ContainerRequestContext crc) { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return error(Response.Status.BAD_REQUEST, "Session-auth hardening is disabled."); + } + if (!CompoundAuthMechanism.isSessionCookieRequest(crc)) { + 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. + // + // Only fully-authenticated session users get a token — mirroring the hardening + // scope in AuthFilter, which exempts guests and PrivateUrlUser preview sessions + // (both isAuthenticated() == false). Those exempt sessions are never asked for a + // token, so issuing one would be pointless. + User user = getRequestUser(crc); + if (user == null || !user.isAuthenticated()) { + return error(Response.Status.UNAUTHORIZED, "An authenticated session is required to obtain a CSRF token."); + } + // no-store: the body carries a session secret; neither browser caches nor + // intermediaries should retain it (bfcache/disk-cache copies would outlive + // the page that fetched it). + return Response.fromResponse( + ok(Json.createObjectBuilder().add("csrfToken", session.getOrCreateApiCsrfToken()))) + .header("Cache-Control", "no-store") + .build(); + } + @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..6014970bda3 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,35 +1,156 @@ 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.api.Users; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.settings.FeatureFlags; +import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.util.UrlOriginUtil; import jakarta.annotation.Priority; import jakarta.inject.Inject; import jakarta.ws.rs.Priorities; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.ResourceInfo; +import jakarta.ws.rs.core.Context; import jakarta.ws.rs.ext.Provider; import java.io.IOException; +import java.lang.reflect.Method; +import java.util.logging.Logger; /** * @author Guillermo Portas * Dedicated filter to authenticate the user requesting an API endpoint that requires user authentication. + * + *

Note on scope: this filter is name-bound to {@link AuthRequired}, so the + * session-cookie CSRF hardening below protects exactly the {@code @AuthRequired} + * endpoints. Endpoints that read the session directly without the annotation are + * NOT covered — keep that set empty for state-changing endpoints, or annotate + * them (as {@code /api/logout} is). */ @AuthRequired @Provider @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; + + @Context + private ResourceInfo resourceInfo; + @Override public void filter(ContainerRequestContext containerRequestContext) throws IOException { try { User user = compoundAuthMechanism.findUserFromRequest(containerRequestContext); containerRequestContext.setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + applySessionAuthHardening(containerRequestContext, user); } catch (WrappedAuthErrorResponse e) { containerRequestContext.abortWith(e.getResponse()); } } + + private void applySessionAuthHardening(ContainerRequestContext containerRequestContext, User user) + throws WrappedAuthErrorResponse { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return; + } + if (!CompoundAuthMechanism.isSessionCookieRequest(containerRequestContext)) { + return; + } + // Only fully-authenticated session users are hardened. isAuthenticated() is false + // for both GuestUser and PrivateUrlUser, and both are deliberately exempt: + // - a guest carries no privileges worth forging; + // - a private-URL preview session is read-only (no state to forge) and its + // responses are not cross-origin readable, so CSRF hardening adds no + // protection — while the JSF preview flow that mints these sessions has no + // way to obtain or send a token, so hardening it would only break anonymous + // preview downloads. + if (user == null || !user.isAuthenticated()) { + return; + } + + // Browser-sent Origin/Referer headers must match the site origin when present. + // When BOTH are absent we fall through to the CSRF token instead of failing + // closed: browsers omit Origin on same-origin GETs and suppress Referer under + // Referrer-Policy: no-referrer, so a legitimate same-origin request can carry + // neither — while the cross-site deliveries that matter cannot reach the token + // check anyway (cross-site fetch/XHR/form POSTs always carry Origin, and a + // cross-site attacker cannot read or set the X-Dataverse-CSRF-Token header). + 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()) { + return; + } + + if (!isCsrfTokenValid(containerRequestContext)) { + throw new WrappedForbiddenAuthErrorResponse( + "Missing or invalid CSRF token for session-cookie authentication."); + } + } + + /** + * Returns {@code true} if JAX-RS resolved the request to {@link Users#getSessionCsrfToken}. + * For this single endpoint we rely on the authenticated session cookie plus same-origin + * checks and do not require an existing CSRF header; that's how clients bootstrap the token. + * Matching on the resolved resource method (rather than a path string) keeps the exemption + * scoped to exactly the intended endpoint. + */ + private boolean isCsrfTokenBootstrapEndpoint() { + if (resourceInfo == null) { + return false; + } + Method method = resourceInfo.getResourceMethod(); + return method != null + && Users.class.equals(method.getDeclaringClass()) + && "getSessionCsrfToken".equals(method.getName()); + } + + /** + * Validates any Origin/Referer headers that are present against the site origin. + * Absence of both headers is allowed — the CSRF token check that follows is then + * the deciding credential (see the caller for the reasoning). + */ + private boolean isOriginOrRefererAllowed(ContainerRequestContext containerRequestContext) { + String originHeader = containerRequestContext.getHeaderString(ApiConstants.ORIGIN_HEADER); + String refererHeader = containerRequestContext.getHeaderString(ApiConstants.REFERER_HEADER); + boolean hasOrigin = originHeader != null && !originHeader.isBlank(); + boolean hasReferer = refererHeader != null && !refererHeader.isBlank(); + + if (!hasOrigin && !hasReferer) { + return true; + } + String allowedOrigin = UrlOriginUtil.toOrigin(systemConfig.getDataverseSiteUrl()); + if (allowedOrigin == null) { + logger.warning("Unable to validate Origin/Referer for session hardening: dataverse site URL is invalid."); + return false; + } + if (hasOrigin && !allowedOrigin.equals(UrlOriginUtil.toOrigin(originHeader))) { + return false; + } + if (hasReferer && !allowedOrigin.equals(UrlOriginUtil.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); + } } 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..10a767c8779 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; @@ -40,6 +41,15 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) User userFromRequest = authMechanism.findUserFromRequest(containerRequestContext); if (userFromRequest != null) { user = userFromRequest; + // Session-cookie is the only mechanism downstream code needs to + // recognize (AuthFilter's CSRF hardening, the token bootstrap + // endpoint), so it is the only one tagged — the property is + // simply absent for every other mechanism. + if (authMechanism instanceof SessionCookieAuthMechanism) { + containerRequestContext.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + } break; } } @@ -48,4 +58,15 @@ public User findUserFromRequest(ContainerRequestContext containerRequestContext) } return user; } + + /** + * Whether this request was authenticated via the session cookie, per the + * tag {@link #findUserFromRequest} set. Lives next to the code that writes + * the tag so readers ({@code AuthFilter}, the CSRF token endpoint) share + * one definition. + */ + public static boolean isSessionCookieRequest(ContainerRequestContext containerRequestContext) { + return ApiConstants.AUTH_MECHANISM_SESSION_COOKIE.equals( + containerRequestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java b/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java index 96222f40daf..097848e332d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/ConfigCheckService.java @@ -6,6 +6,8 @@ import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; import edu.harvard.iq.dataverse.util.FileUtil; import edu.harvard.iq.dataverse.util.MailSessionProducer; +import edu.harvard.iq.dataverse.util.SystemConfig; +import edu.harvard.iq.dataverse.util.UrlOriginUtil; import jakarta.annotation.PostConstruct; import jakarta.ejb.DependsOn; import jakarta.ejb.Singleton; @@ -47,9 +49,44 @@ public void startup() { if (!checkSystemDirectories() || !checkPidProviders()) { throw new ConfigurationError("Not all configuration checks passed successfully. See logs above."); } - + // Only checks resulting in warnings, nothing critical that needs to stop deployment checkSystemMailSetup(); + checkSessionAuthHardening(); + } + + /** + * Startup diagnostics for session-cookie API auth hardening. Two silent + * misconfigurations are called out: + *

    + *
  • Hardening enabled without {@code api-session-auth}: requests are never + * tagged as session-cookie-authenticated, so the hardening checks never + * run — the operator believes CSRF protections are active while getting + * none. (The reverse combination — session auth without hardening — is + * warned about in the feature-flag documentation.)
  • + *
  • Hardening enabled with a missing/unparseable site URL: {@code AuthFilter} + * cannot establish the allowed origin, so every session-cookie request that + * carries an {@code Origin} or {@code Referer} header is rejected with 403 + * (header-less requests are still decided by the CSRF token).
  • + *
+ */ + void checkSessionAuthHardening() { + if (!FeatureFlags.API_SESSION_AUTH_HARDENING.enabled()) { + return; + } + if (!FeatureFlags.API_SESSION_AUTH.enabled()) { + logger.log(Level.WARNING, () -> "Feature flag " + FeatureFlags.API_SESSION_AUTH_HARDENING.name() + + " is enabled, but " + FeatureFlags.API_SESSION_AUTH.name() + + " is not. Session-cookie API authentication is off, so the hardening checks never run" + + " and the flag has no effect. Enable dataverse.feature.api-session-auth as well."); + } + String siteUrl = SystemConfig.getDataverseSiteUrlStatic(); + if (UrlOriginUtil.toOrigin(siteUrl) == null) { + logger.log(Level.SEVERE, () -> "Feature flag " + FeatureFlags.API_SESSION_AUTH_HARDENING.name() + + " is enabled, but the configured dataverse site URL (" + siteUrl + + ") is missing or unparseable. Session-cookie API requests carrying an Origin or Referer" + + " header will be rejected with 403 until this is fixed."); + } } /** 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 6ae5114015c..c94c44ca78c 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,33 @@ 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 on + * {@code @AuthRequired} endpoints. When enabled, a session-cookie request + * from a fully-authenticated session must carry the {@code X-Dataverse-CSRF-Token} + * header (obtained from {@code GET /api/users/:csrf-token}), and any + * {@code Origin}/{@code Referer} headers it carries must match the site + * origin. Requests carrying neither header — e.g. same-origin GETs under + * {@code Referrer-Policy: no-referrer} — are decided by the CSRF token + * alone. + * This feature only works when the feature flag {@link #API_SESSION_AUTH} + * is also enabled; a startup warning is logged otherwise. + * + * @apiNote Raise flag by setting "dataverse.feature.api-session-auth-hardening" + * @since Dataverse 6.11 + */ + 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/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java b/src/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java new file mode 100644 index 00000000000..a4b529e1fd2 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/util/UrlOriginUtil.java @@ -0,0 +1,59 @@ +package edu.harvard.iq.dataverse.util; + +import java.net.MalformedURLException; +import java.net.URI; +import java.util.Locale; + +/** + * Normalizes URLs to their web-origin form for same-origin comparisons. + * Lives in the util layer because it is shared by request-time checks + * ({@code AuthFilter}'s session-cookie CSRF hardening) and startup + * configuration validation ({@code ConfigCheckService}) — neither of which + * should depend on the other. + */ +public final class UrlOriginUtil { + + private UrlOriginUtil() { + } + + /** + * Normalizes {@code url} to its origin form ({@code scheme://host[:port]}), + * lowercasing scheme and host and omitting the scheme's default port. + * Returns {@code null} if the input is missing scheme or host, or is + * unparseable. + */ + public static 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(uri)) { + return scheme + "://" + host; + } + return scheme + "://" + host + ":" + port; + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * The scheme's default port per the JDK's protocol handlers (80 for http, + * 443 for https, ...), or -1 when the JDK has no handler for the scheme — + * in which case no port is ever considered "default" and an explicit port + * always stays part of the origin. + */ + private static int defaultPort(URI uri) { + try { + return uri.toURL().getDefaultPort(); + } catch (MalformedURLException | IllegalArgumentException e) { + return -1; + } + } +} 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..20756fef431 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/DataverseSessionTest.java @@ -0,0 +1,53 @@ +package edu.harvard.iq.dataverse; + +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +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)); + } + + @Test + void testCsrfTokenClearedWhenUserDemotedToGuest() { + // getUser(true) demotes deleted/deactivated users to guest; the CSRF + // token minted for the old identity must not survive the demotion. + DataverseSession session = new DataverseSession(); + AuthenticatedUser au = new AuthenticatedUser(); + au.setId(7L); + session.setUser(au); + String token = session.getOrCreateApiCsrfToken(); + + session.authenticationService = Mockito.mock(AuthenticationServiceBean.class); + when(session.authenticationService.findByID(7L)).thenReturn(null); // deleted + + assertEquals(GuestUser.get(), session.getUser(true)); + 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..8366bb06598 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/UsersSessionCsrfTokenTest.java @@ -0,0 +1,125 @@ +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); + // The auth-mechanism tag is absent for every non-session mechanism + // (api key, bearer, signed URL, ...) — the mock's default null models that. + + 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")); + // The body carries a session secret; browsers and intermediaries must not cache it. + assertEquals("no-store", response.getHeaderString("Cache-Control")); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testGetCsrfToken_PrivateUrlUserSession_ReturnsUnauthorized() throws Exception { + // PrivateUrlUser.isAuthenticated() is false and its preview sessions are exempt + // from hardening (read-only), so it is never asked for a token and is not issued + // one — symmetric with the AuthFilter exemption. + 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); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)) + .thenReturn(new edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser(42L)); + + inject(sut, Users.class, "session", session); + + Response response = sut.getSessionCsrfToken(crc); + + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + } + + @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); + + // Guests hold no session-borne privileges and get no token. + 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..237cf353c15 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/AuthFilterTest.java @@ -0,0 +1,317 @@ +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.api.Users; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.users.GuestUser; +import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; +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.container.ResourceInfo; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +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; + +/** + * Unit tests for the session-cookie CSRF hardening in {@link AuthFilter}. + * + *

The filter's decisions depend only on the auth-mechanism tag, the user, + * the Origin/Referer/CSRF headers, and the resolved resource method (for the + * bootstrap exemption) — deliberately NOT on the HTTP method or request path, + * so no test stubs those. + */ +@LocalJvmSettings +class AuthFilterTest { + + private static final String SITE_URL = "https://demo.dataverse.org"; + private static final String VALID_TOKEN = "valid-token"; + + @Test + void testFilter_HardeningDisabled_DoesNotAbort() throws Exception { + User user = new AuthenticatedUser(); + Fixture f = fixture(user, false); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + verify(f.requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, user); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_MatchingOriginAndValidToken_Allowed() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_MatchingRefererAndValidToken_Allowed() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.REFERER_HEADER)) + .thenReturn(SITE_URL + "/dataset.xhtml"); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + /** + * Browsers omit Origin on same-origin GETs, and Referrer-Policy: no-referrer + * suppresses Referer — a legitimate same-origin request can carry neither + * header. The CSRF token is then the deciding credential; failing closed here + * would make the hardening unusable on no-referrer installations. + */ + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_NoOriginNoReferer_ValidTokenSufficient() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_NoOriginNoRefererNoToken_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_CrossOriginReferer_BlockedEvenWithValidToken() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.REFERER_HEADER)) + .thenReturn("https://evil.example/malicious"); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_CrossOriginOrigin_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn("https://evil.example"); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_MatchingOriginButMissingToken_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + // CSRF token header absent (mock returns null by default) + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_MatchingOriginButWrongToken_Blocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + when(f.requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn("wrong-token"); + when(f.session.matchesApiCsrfToken("wrong-token")).thenReturn(false); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + /** + * A guest session carries no privileges worth forging, so hardening + * short-circuits before the origin/token checks. + */ + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_GuestSessionCookieRequest_SkipsHardening() throws Exception { + Fixture f = sessionCookieFixture(GuestUser.get()); + // No Origin/Referer and no CSRF token — would block an authenticated session. + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + verify(f.requestContext).setProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER, GuestUser.get()); + } + + /** + * PrivateUrlUser.isAuthenticated() is false, so a read-only private-URL + * preview session is exempt like a guest: hardening it adds no protection + * (no state to forge, responses not cross-origin readable) and the JSF + * preview flow that mints these sessions cannot obtain a token. + */ + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_PrivateUrlUserSessionCookieRequest_IsExemptLikeGuest() throws Exception { + Fixture f = sessionCookieFixture(new PrivateUrlUser(42L)); + // No Origin/Referer and no token — would block an authenticated session. + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_NonSessionCookieMechanism_SkipsHardening() throws Exception { + // No auth-mechanism property set — how every non-session mechanism looks. + Fixture f = fixture(new AuthenticatedUser(), false); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_CsrfBootstrapEndpoint_AllowedWithMatchingOriginAndNoToken() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + inject(f.sut, "resourceInfo", mockBootstrapResourceInfo()); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + /** + * The bootstrap endpoint must also work when neither Origin nor Referer is + * sent (no-referrer installations): the request holds the session cookie, + * and the token it fetches is useless to a party that lacks that cookie. + */ + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_CsrfBootstrapEndpoint_AllowedWithoutOriginHeaders() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + inject(f.sut, "resourceInfo", mockBootstrapResourceInfo()); + + f.sut.filter(f.requestContext); + + verify(f.requestContext, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_CsrfBootstrapEndpoint_CrossOriginBlocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn("https://evil.example"); + inject(f.sut, "resourceInfo", mockBootstrapResourceInfo()); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + /** + * With an unparseable site URL the allowed origin cannot be established: + * requests carrying origin headers fail closed, while header-less requests + * are still decided by the token (ConfigCheckService flags this state at + * startup). + */ + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testFilter_SessionCookie_UnparseableSiteUrl_OriginCarryingRequestBlocked() throws Exception { + Fixture f = sessionCookieFixture(new AuthenticatedUser()); + when(f.systemConfig.getDataverseSiteUrl()).thenReturn("not a url"); + when(f.requestContext.getHeaderString(ApiConstants.ORIGIN_HEADER)).thenReturn(SITE_URL); + givenValidToken(f); + + f.sut.filter(f.requestContext); + + assertForbidden(f); + } + + // ---- fixtures --------------------------------------------------------- + + private static final class Fixture { + AuthFilter sut; + ContainerRequestContext requestContext; + DataverseSession session; + SystemConfig systemConfig; + } + + private Fixture fixture(User user, boolean sessionCookieAuthenticated) throws Exception { + Fixture f = new Fixture(); + f.sut = new AuthFilter(); + f.requestContext = Mockito.mock(ContainerRequestContext.class); + f.session = Mockito.mock(DataverseSession.class); + f.systemConfig = Mockito.mock(SystemConfig.class); + when(f.systemConfig.getDataverseSiteUrl()).thenReturn(SITE_URL); + + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + when(compound.findUserFromRequest(f.requestContext)).thenReturn(user); + if (sessionCookieAuthenticated) { + when(f.requestContext.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + } + + inject(f.sut, "compoundAuthMechanism", compound); + inject(f.sut, "session", f.session); + inject(f.sut, "systemConfig", f.systemConfig); + return f; + } + + private Fixture sessionCookieFixture(User user) throws Exception { + return fixture(user, true); + } + + private void givenValidToken(Fixture f) { + when(f.requestContext.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn(VALID_TOKEN); + when(f.session.matchesApiCsrfToken(VALID_TOKEN)).thenReturn(true); + } + + private void assertForbidden(Fixture f) { + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(f.requestContext).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), abortResponseCaptor.getValue().getStatus()); + } + + private ResourceInfo mockBootstrapResourceInfo() throws NoSuchMethodException { + ResourceInfo resourceInfo = Mockito.mock(ResourceInfo.class); + Method method = Users.class.getMethod("getSessionCsrfToken", ContainerRequestContext.class); + Mockito.>when(resourceInfo.getResourceClass()).thenReturn(Users.class); + when(resourceInfo.getResourceMethod()).thenReturn(method); + return resourceInfo; + } + + 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..9af2698d3c8 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.never; +import static org.mockito.Mockito.verify; +@LocalJvmSettings public class CompoundAuthMechanismTest { @Test @@ -25,25 +32,52 @@ 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())); + // The auth-mechanism tag is only ever set for session-cookie auth; + // for everything else (including the guest fallback) it stays absent. + verify(containerRequestContext, never()).setProperty( + Mockito.eq(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM), Mockito.any()); } @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..a87e8edcb4b 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_GuestSessionUserReturnedAsIs() throws WrappedAuthErrorResponse { + DataverseSession dataverseSessionStub = Mockito.mock(DataverseSession.class); + Mockito.when(dataverseSessionStub.getUser()).thenReturn(GuestUser.get()); + sut.session = dataverseSessionStub; + + User actual = sut.findUserFromRequest(new ContainerRequestTestFake()); + + assertEquals(GuestUser.get(), actual); + } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java new file mode 100644 index 00000000000..5bce822dc41 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/auth/SessionCsrfRoundTripTest.java @@ -0,0 +1,201 @@ +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.api.Users; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +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.container.ResourceInfo; +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 java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Wider unit tests that exercise the {@link AuthFilter} + {@link Users#getSessionCsrfToken} + * composition with a real {@link DataverseSession} (not mocked). These tests verify the + * actual CSRF token round-trip: the endpoint mints a token, the filter accepts the same + * token on follow-up requests, and rejects mismatches. + * + *

Existing tests mock the session, so they don't catch issues where the endpoint and + * filter diverge on token storage or comparison. + */ +@LocalJvmSettings +class SessionCsrfRoundTripTest { + + private static final String SITE_URL = "https://demo.dataverse.org"; + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testRoundTrip_BootstrapThenMutatingCall_Allowed() throws Exception { + DataverseSession session = newSessionWithAuthenticatedUser(); + SystemConfig systemConfig = mockSystemConfig(); + AuthenticatedUser user = (AuthenticatedUser) session.getUser(); + + // Step 1: bootstrap GET /api/users/:csrf-token through the filter. + ContainerRequestContext bootstrapCtx = mockSessionCookieRequest("GET", "users/:csrf-token"); + when(bootstrapCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + AuthFilter filter = new AuthFilter(); + injectAuthFilterDeps(filter, mockCompoundAuth(user), session, systemConfig, mockBootstrapResourceInfo()); + + filter.filter(bootstrapCtx); + verify(bootstrapCtx, never()).abortWith(any(Response.class)); + + // Step 2: call the endpoint method directly and verify it issued an OK response. + Users users = newUsersWithSession(session); + when(bootstrapCtx.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_USER)).thenReturn(user); + Response bootstrapResponse = users.getSessionCsrfToken(bootstrapCtx); + assertEquals(Response.Status.OK.getStatusCode(), bootstrapResponse.getStatus()); + + // The endpoint stored the token on the real session — pull it back out to drive the next request. + String token = session.getOrCreateApiCsrfToken(); + assertNotNull(token); + + // Step 3: subsequent mutating call with that token should pass through the filter. + ContainerRequestContext mutatingCtx = mockSessionCookieRequest("POST", "datasets/1/add"); + when(mutatingCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + when(mutatingCtx.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)).thenReturn(token); + AuthFilter mutatingFilter = new AuthFilter(); + injectAuthFilterDeps(mutatingFilter, mockCompoundAuth(user), session, systemConfig, null); + + mutatingFilter.filter(mutatingCtx); + verify(mutatingCtx, never()).abortWith(any(Response.class)); + } + + @Test + @JvmSetting(key = JvmSettings.FEATURE_FLAG, value = "true", varArgs = "api-session-auth-hardening") + void testRoundTrip_MutatingCallWithWrongToken_Blocked() throws Exception { + DataverseSession session = newSessionWithAuthenticatedUser(); + SystemConfig systemConfig = mockSystemConfig(); + AuthenticatedUser user = (AuthenticatedUser) session.getUser(); + // Mint a token on the real session. + String validToken = session.getOrCreateApiCsrfToken(); + assertNotNull(validToken); + + ContainerRequestContext mutatingCtx = mockSessionCookieRequest("POST", "datasets/1/add"); + when(mutatingCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + when(mutatingCtx.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)) + .thenReturn(validToken + "-not-quite"); + + AuthFilter filter = new AuthFilter(); + injectAuthFilterDeps(filter, mockCompoundAuth(user), session, systemConfig, null); + + filter.filter(mutatingCtx); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mutatingCtx).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 testRoundTrip_MutatingCallWithoutBootstrap_Blocked() throws Exception { + DataverseSession session = newSessionWithAuthenticatedUser(); + SystemConfig systemConfig = mockSystemConfig(); + AuthenticatedUser user = (AuthenticatedUser) session.getUser(); + + // No bootstrap: don't call getOrCreateApiCsrfToken. Client supplies a fabricated value. + ContainerRequestContext mutatingCtx = mockSessionCookieRequest("POST", "datasets/1/add"); + when(mutatingCtx.getHeaderString("Origin")).thenReturn(SITE_URL); + when(mutatingCtx.getHeaderString(ApiConstants.CSRF_TOKEN_HEADER)) + .thenReturn("00000000-0000-0000-0000-000000000000"); + + AuthFilter filter = new AuthFilter(); + injectAuthFilterDeps(filter, mockCompoundAuth(user), session, systemConfig, null); + + filter.filter(mutatingCtx); + + ArgumentCaptor abortResponseCaptor = ArgumentCaptor.forClass(Response.class); + verify(mutatingCtx).abortWith(abortResponseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), + abortResponseCaptor.getValue().getStatus()); + } + + private DataverseSession newSessionWithAuthenticatedUser() { + DataverseSession session = new DataverseSession(); + AuthenticatedUser user = new AuthenticatedUser(); + // setUser does FacesContext-dependent work; we bypass it by writing the user field directly + // (the session-cookie auth chain only reads getUser()). + try { + Field userField = DataverseSession.class.getDeclaredField("user"); + userField.setAccessible(true); + userField.set(session, user); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + return session; + } + + private SystemConfig mockSystemConfig() { + SystemConfig systemConfig = Mockito.mock(SystemConfig.class); + when(systemConfig.getDataverseSiteUrl()).thenReturn(SITE_URL); + return systemConfig; + } + + private CompoundAuthMechanism mockCompoundAuth(AuthenticatedUser user) throws WrappedAuthErrorResponse { + CompoundAuthMechanism compound = Mockito.mock(CompoundAuthMechanism.class); + when(compound.findUserFromRequest(any(ContainerRequestContext.class))).thenAnswer(invocation -> { + ContainerRequestContext crc = invocation.getArgument(0); + crc.setProperty( + ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM, + ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return user; + }); + return compound; + } + + private ContainerRequestContext mockSessionCookieRequest(String method, String path) { + ContainerRequestContext crc = Mockito.mock(ContainerRequestContext.class); + UriInfo uriInfo = Mockito.mock(UriInfo.class); + when(crc.getMethod()).thenReturn(method); + when(crc.getUriInfo()).thenReturn(uriInfo); + when(uriInfo.getPath()).thenReturn(path); + when(crc.getProperty(ApiConstants.CONTAINER_REQUEST_CONTEXT_AUTH_MECHANISM)) + .thenReturn(ApiConstants.AUTH_MECHANISM_SESSION_COOKIE); + return crc; + } + + private ResourceInfo mockBootstrapResourceInfo() throws NoSuchMethodException { + ResourceInfo resourceInfo = Mockito.mock(ResourceInfo.class); + Method method = Users.class.getMethod("getSessionCsrfToken", ContainerRequestContext.class); + when(resourceInfo.getResourceMethod()).thenReturn(method); + return resourceInfo; + } + + private void injectAuthFilterDeps(AuthFilter filter, CompoundAuthMechanism compound, + DataverseSession session, SystemConfig systemConfig, + ResourceInfo resourceInfo) throws Exception { + injectField(filter, "compoundAuthMechanism", compound); + injectField(filter, "session", session); + injectField(filter, "systemConfig", systemConfig); + injectField(filter, "resourceInfo", resourceInfo); + } + + private Users newUsersWithSession(DataverseSession session) throws Exception { + Users users = new Users(); + injectField(users, "session", session); + return users; + } + + private void injectField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +}