Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5993fe0
Adds opt-in X-Session-Id header.
jason-p-pickering Jan 4, 2026
64c12de
Linting
jason-p-pickering Jan 4, 2026
edba124
Remove unused import
jason-p-pickering Jan 4, 2026
a644453
Merge branch 'master' into session-id-header
jason-p-pickering Jan 4, 2026
62fc022
Cleanup
jason-p-pickering Jan 5, 2026
321c9bd
Merge branch 'session-id-header' of github.com:dhis2/dhis2-core into …
jason-p-pickering Jan 5, 2026
507c2bc
Change encoding method
jason-p-pickering Jan 5, 2026
49a2841
Add caching
jason-p-pickering Jan 5, 2026
6eee5ce
Refactor for X-User-ID based on feedback
jason-p-pickering Jan 6, 2026
87dd2cd
Add missing files
jason-p-pickering Jan 6, 2026
e93b44c
Fix test
jason-p-pickering Jan 6, 2026
efdd13d
Merge branch 'master' of github.com:dhis2/dhis2-core into session-id-…
jason-p-pickering Jan 12, 2026
042eca6
Fix test
jason-p-pickering Jan 12, 2026
10120d4
Merge branch 'master' into session-id-header
jason-p-pickering Jan 19, 2026
34b20f3
Merge branch 'master' into session-id-header
jason-p-pickering Jan 19, 2026
cbd44c1
Merge branch 'master' into session-id-header
jason-p-pickering Jan 27, 2026
a36c786
Merge branch 'master' into session-id-header
jason-p-pickering Jan 28, 2026
fa22dbd
Merge branch 'master' into session-id-header
jason-p-pickering Feb 3, 2026
8cb25d9
Merge branch 'master' into session-id-header
jason-p-pickering Feb 24, 2026
9845d97
Address reviewer comments on session-id-header PR
jason-p-pickering May 11, 2026
1b159e2
Merge origin/master into session-id-header
jason-p-pickering May 11, 2026
6dfc622
Linting
jason-p-pickering May 11, 2026
8e2d0c3
More noise suppression
jason-p-pickering May 11, 2026
3b4c45a
Revert "More noise suppression"
jason-p-pickering May 11, 2026
6bdc5f2
Merge branch 'master' into session-id-header
jason-p-pickering Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -526,12 +526,6 @@ public enum ConfigurationKey {
/** Number of log file archives to keep around. Does not affect audit logs. (default: 1). */
LOGGING_FILE_MAX_ARCHIVES("logging.file.max_archives", "1"),

/**
* Adds a hashed (SHA-256) session ID to the MDC ({@code sessionId} key). Include it in log output
* via {@code %X{sessionId}} in the log4j2 pattern layout.
*/
LOGGING_SESSION_ID("logging.session_id", Constants.ON, false),

/**
* Enables SQL query logging via a datasource proxy. All {@code logging.query.*} keys below
* require this to be enabled.
Expand All @@ -542,6 +536,15 @@ public enum ConfigurationKey {
LOGGING_QUERY_SLOW_THRESHOLD(
"logging.query.slow_threshold", String.valueOf(SECONDS.toMillis(1)), false),

/** Adds a hashed session identifier to the X-Session-ID response header. (default: off). */
LOGGING_SESSION_ID_HEADER_ENABLED("logging.session_id_header.enabled", Constants.OFF, false),

/** Adds an encrypted user identifier to the X-User-ID response header. (default: off). */
LOGGING_USER_ID_HEADER_ENABLED("logging.user_id_header.enabled", Constants.OFF, false),

/** Base64-encoded 32-byte encryption key for X-User-ID header value (sensitive). */
LOGGING_USER_ID_ENCRYPTION_KEY("logging.user_id_encryption_key", "", true),

/** Base URL to the DHIS 2 instance. */
SERVER_BASE_URL("server.base.url", "", false),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,23 @@
*/
package org.hisp.dhis.webapi.filter;

import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID;
import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED;
import static org.hisp.dhis.log.MdcKeys.MDC_SESSION_ID;
import static org.hisp.dhis.webapi.filter.SessionIdFilter.hashToBase64;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.util.List;
import java.util.function.Consumer;
import org.hisp.dhis.external.conf.DhisConfigurationProvider;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
Expand All @@ -68,50 +70,97 @@ class SessionIdFilterTest {
@BeforeEach
void setUp() {
MDC.clear();
SecurityContextHolder.clearContext();
}

@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}

@Test
void testIsDisabled() throws Exception {
void testMdcNotSetWhenUnauthenticated() throws Exception {
init(false);
doFilter(request -> {});
String[] capturedMdc = {null};
HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse res = mock(HttpServletResponse.class);
FilterChain chain = (r, p) -> capturedMdc[0] = MDC.get(MDC_SESSION_ID);

subject.doFilter(req, res, chain);

assertNull(capturedMdc[0]);
assertNull(MDC.get(MDC_SESSION_ID));
}

@Test
void testIsEnabled() throws Exception {
void testMdcSetWhenAuthenticated() throws Exception {
withAuthenticatedUser();
init(false);

Authentication authentication =
new UsernamePasswordAuthenticationToken(
"admin", "admin", List.of((GrantedAuthority) () -> "ALL"));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse res = mock(HttpServletResponse.class);
HttpSession session = mock(HttpSession.class);
when(req.getSession()).thenReturn(session);
when(session.getId()).thenReturn("ABCDEFGHILMNO");

SecurityContextHolder.setContext(context);
String[] capturedMdc = {null};
FilterChain chain = (r, p) -> capturedMdc[0] = MDC.get(MDC_SESSION_ID);

init(true);
doFilter(
request -> {
HttpSession session = mock(HttpSession.class);
when(request.getSession()).thenReturn(session);
when(session.getId()).thenReturn("ABCDEFGHILMNO");
});

assertEquals("ID" + hashToBase64("ABCDEFGHILMNO"), MDC.get(MDC_SESSION_ID));
subject.doFilter(req, res, chain);

assertEquals("ID" + hashToBase64("ABCDEFGHILMNO"), capturedMdc[0]);
assertNull(MDC.get(MDC_SESSION_ID));
}

private void doFilter(Consumer<HttpServletRequest> withRequest) throws Exception {
@Test
void testHeaderNotAddedWhenDisabled() throws Exception {
withAuthenticatedUser();
init(false);

HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse res = mock(HttpServletResponse.class);
FilterChain chain = mock(FilterChain.class);
HttpSession session = mock(HttpSession.class);
when(req.getSession()).thenReturn(session);
when(session.getId()).thenReturn("ABCDEFGHILMNO");

subject.doFilter(req, res, chain);

verify(res, never())
.addHeader(
org.mockito.ArgumentMatchers.eq("X-Session-ID"),
org.mockito.ArgumentMatchers.anyString());
}

@Test
void testHeaderAddedWhenEnabled() throws Exception {
withAuthenticatedUser();
init(true);

HttpServletRequest req = mock(HttpServletRequest.class);
HttpServletResponse res = mock(HttpServletResponse.class);
FilterChain filterChain = mock(FilterChain.class);
FilterChain chain = mock(FilterChain.class);
HttpSession session = mock(HttpSession.class);
when(req.getSession()).thenReturn(session);
when(session.getId()).thenReturn("ABCDEFGHILMNO");

subject.doFilter(req, res, chain);

withRequest.accept(req);
verify(res).addHeader("X-Session-ID", "ID" + hashToBase64("ABCDEFGHILMNO"));
}

subject.doFilter(req, res, filterChain);
private void withAuthenticatedUser() {
Authentication authentication =
new UsernamePasswordAuthenticationToken(
"admin", "admin", List.of((GrantedAuthority) () -> "ALL"));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
}

private void init(boolean enabled) {
when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID)).thenReturn(enabled);
private void init(boolean headerEnabled) {
when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED))
.thenReturn(headerEnabled);
subject = new SessionIdFilter(dhisConfigurationProvider);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2004-2025, University of Oslo
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
package org.hisp.dhis.webapi.filter;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Properties;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.hisp.dhis.external.conf.DhisConfigurationProvider;
import org.hisp.dhis.test.config.H2DhisConfigurationProvider;
import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Controller;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@ContextConfiguration(classes = UserIdHeaderFilterTest.TestConfig.class)
class UserIdHeaderFilterTest extends H2ControllerIntegrationTestBase {
private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
private static final String HEADER_NAME = "X-User-ID";

@Autowired private RequestIdFilter requestIdFilter;
@Autowired private ApiVersionFilter apiVersionFilter;
@Autowired private UserIdFilter userIdHeaderFilter;

@BeforeEach
void setupFilters() {
mvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilter(requestIdFilter)
.addFilter(apiVersionFilter)
.addFilter(userIdHeaderFilter)
.build();
}

@Test
void shouldEncryptUserUidInHeader() throws Exception {
HttpResponse response = GET("/test/userHeader");
String header = response.header(HEADER_NAME);
assertNotNull(header);

String payload = decryptHeader(header, ENCRYPTION_KEY);
assertEquals(getAdminUid(), payload);
}
Comment thread
jason-p-pickering marked this conversation as resolved.

private static String decryptHeader(String header, String key) throws GeneralSecurityException {
byte[] combined = Base64.getUrlDecoder().decode(header.substring(3));
byte[] iv = Arrays.copyOfRange(combined, 0, 12);
byte[] cipherText = Arrays.copyOfRange(combined, 12, combined.length);
byte[] keyBytes = Base64.getDecoder().decode(key);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, iv));
return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
}

@Configuration
static class TestConfig {
@Bean
@Primary
DhisConfigurationProvider dhisConfigurationProvider() {
H2DhisConfigurationProvider provider = new H2DhisConfigurationProvider();
Properties properties = new Properties();
properties.setProperty("logging.user_id_header.enabled", "on");
properties.setProperty("logging.user_id_encryption_key", ENCRYPTION_KEY);
provider.addProperties(properties);
return provider;
}
}

@Controller
static class TestUserHeaderController {
@GetMapping("/api/test/userHeader")
@ResponseBody
public String ok() {
return "ok";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
*/
package org.hisp.dhis.webapi.filter;

import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID;
import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED;
import static org.hisp.dhis.log.MdcKeys.MDC_SESSION_ID;

import jakarta.servlet.FilterChain;
Expand All @@ -51,10 +51,12 @@
/**
* Filter that adds a hashed version of the Session ID to the Mapped Diagnostic Context (MDC) for
* authenticated users. This allows correlating multiple requests from the same user session. Access
* via {@code %X{sessionId}} in log4j2 pattern layouts.
* via {@code %X{sessionId}} in log4j2 pattern layouts. Optionally emits the same value as the
* {@code X-Session-ID} response header for use by reverse proxies.
*
* <p>The session ID is hashed using SHA-256 and base64-encoded for security. Only enabled when
* {@code logging.session_id} is true.
* <p>The session ID is hashed using SHA-256 and base64-encoded for security. MDC is always
* populated for authenticated requests; configure the log4j2 pattern layout to include or exclude
* it. Header emission is controlled by {@code logging.session_id_header.enabled} in dhis.conf.
*
* @author Luciano Fiandesio
* @see <a href="https://logback.qos.ch/manual/mdc.html">MDC Documentation</a>
Expand All @@ -65,36 +67,43 @@
@Slf4j
@Component
public class SessionIdFilter extends OncePerRequestFilter {
/** The hash algorithm to use. */
private static final String HASH_ALGO = "SHA-256";

private static final String IDENTIFIER_PREFIX = "ID";
private static final String X_SESSION_ID_HEADER = "X-Session-ID";

private final boolean enabled;
private final boolean headerEnabled;

public SessionIdFilter(DhisConfigurationProvider dhisConfig) {
this.enabled = dhisConfig.isEnabled(LOGGING_SESSION_ID);
this.headerEnabled = dhisConfig.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED);
}

@Override
protected void doFilterInternal(
HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
if (enabled) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String sessionValue = null;
if (authentication != null
&& authentication.isAuthenticated()
&& !authentication.getPrincipal().equals("anonymousUser")) {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !authentication.getPrincipal().equals("anonymousUser")) {

MDC.put(MDC_SESSION_ID, IDENTIFIER_PREFIX + hashToBase64(req.getSession().getId()));
sessionValue = IDENTIFIER_PREFIX + hashToBase64(req.getSession().getId());
MDC.put(MDC_SESSION_ID, sessionValue);
if (headerEnabled) {
res.addHeader(X_SESSION_ID_HEADER, sessionValue);
}
} catch (NoSuchAlgorithmException e) {
log.error(String.format("Invalid Hash algorithm provided (%s)", HASH_ALGO), e);
}
}

chain.doFilter(req, res);
try {
chain.doFilter(req, res);
} finally {
if (sessionValue != null) {
MDC.remove(MDC_SESSION_ID);
}
}
}

static String hashToBase64(String sessionId) throws NoSuchAlgorithmException {
Expand Down
Loading
Loading