diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index ba11aeae5707..9811e304afe4 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -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. @@ -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), diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java index 08a7bcd257df..beedf70292a2 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java @@ -29,12 +29,14 @@ */ 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; @@ -42,8 +44,8 @@ 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; @@ -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 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); } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java new file mode 100644 index 000000000000..7930202077b9 --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java @@ -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); + } + + 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"; + } + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java index 00c33e43217c..517b346d11ff 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java @@ -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; @@ -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. * - *

The session ID is hashed using SHA-256 and base64-encoded for security. Only enabled when - * {@code logging.session_id} is true. + *

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 MDC Documentation @@ -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 { diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/UserIdFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/UserIdFilter.java new file mode 100644 index 000000000000..6ac0644fc956 --- /dev/null +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/UserIdFilter.java @@ -0,0 +1,178 @@ +/* + * 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.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_ENCRYPTION_KEY; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_HEADER_ENABLED; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.user.CurrentUserUtil; +import org.hisp.dhis.user.UserDetails; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filter that adds an encrypted user identifier to the response headers for authenticated users. + * The header value is reversible using the configured key, allowing downstream systems to correlate + * requests to a user. + * + *

Enabled when {@code logging.user_id_header.enabled} is true and {@code + * logging.user_id_encryption_key} is set. + */ +@Slf4j +@Component("userIdHeaderFilter") +public class UserIdFilter extends OncePerRequestFilter { + private static final String HEADER_NAME = "X-User-ID"; + private static final String MDC_KEY = "userUid"; + private static final String HEADER_VERSION_PREFIX = "v1."; + private static final int GCM_IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + private static final String CIPHER_ALGO = "AES/GCM/NoPadding"; + private static final int KEY_LENGTH_BYTES = 32; + private static final long CACHE_TTL_MILLIS = TimeUnit.MINUTES.toMillis(5); + private static final int MAX_CACHE_SIZE = 10_000; + private static final ConcurrentHashMap HEADER_CACHE = + new ConcurrentHashMap<>(); + + private final boolean enabled; + private final byte[] keyBytes; + private final SecureRandom secureRandom = new SecureRandom(); + + public UserIdFilter(DhisConfigurationProvider dhisConfig) { + boolean configEnabled = dhisConfig.isEnabled(LOGGING_USER_ID_HEADER_ENABLED); + String token = dhisConfig.getProperty(LOGGING_USER_ID_ENCRYPTION_KEY); + if (configEnabled && (token == null || token.isBlank())) { + log.warn( + "Session ID header logging enabled, but logging.session_id_encryption_key is not set."); + this.enabled = false; + this.keyBytes = new byte[0]; + } else { + byte[] decodedKey = token == null ? new byte[0] : decodeKey(token); + if (decodedKey.length != KEY_LENGTH_BYTES) { + log.warn( + "User ID header logging enabled, but logging.user_id_encryption_key must be a base64-encoded 32-byte key."); + this.enabled = false; + this.keyBytes = new byte[0]; + } else { + this.enabled = configEnabled; + this.keyBytes = decodedKey; + } + } + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + String headerValue = null; + if (enabled && CurrentUserUtil.hasCurrentUser()) { + try { + UserDetails userDetails = CurrentUserUtil.getCurrentUserDetails(); + headerValue = getOrCreateHeaderValue(userDetails.getUid()); + response.addHeader(HEADER_NAME, headerValue); + MDC.put(MDC_KEY, headerValue); + } catch (GeneralSecurityException ex) { + log.error("Failed to encrypt user header payload", ex); + } + } + + try { + chain.doFilter(request, response); + } finally { + if (headerValue != null) { + MDC.remove(MDC_KEY); + } + } + } + + private String encrypt(String payload) throws GeneralSecurityException { + byte[] iv = new byte[GCM_IV_LENGTH_BYTES]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(CIPHER_ALGO); + SecretKeySpec key = new SecretKeySpec(keyBytes, "AES"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + return HEADER_VERSION_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + } + + private String getOrCreateHeaderValue(String cacheKey) throws GeneralSecurityException { + long now = System.currentTimeMillis(); + CacheEntry cached = HEADER_CACHE.get(cacheKey); + if (cached != null && cached.expiresAtMillis > now) { + return cached.headerValue; + } + + if (cached != null) { + HEADER_CACHE.remove(cacheKey, cached); + } + + String headerValue = encrypt(cacheKey); + CacheEntry entry = new CacheEntry(headerValue, now + CACHE_TTL_MILLIS); + HEADER_CACHE.put(cacheKey, entry); + + if (HEADER_CACHE.size() > MAX_CACHE_SIZE) { + HEADER_CACHE.entrySet().removeIf(e -> e.getValue().expiresAtMillis <= now); + if (HEADER_CACHE.size() > MAX_CACHE_SIZE) { + HEADER_CACHE.clear(); + } + } + + return headerValue; + } + + private static byte[] decodeKey(String token) { + try { + return Base64.getDecoder().decode(token); + } catch (IllegalArgumentException ex) { + return new byte[0]; + } + } + + private record CacheEntry(String headerValue, long expiresAtMillis) {} +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java index da4dd630ad65..03aa37ee5d79 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java @@ -172,6 +172,10 @@ public static void setupServlets( .addFilter("sessionIdFilter", new DelegatingFilterProxy("sessionIdFilter")) .addMappingForUrlPatterns(null, true, "/*"); + context + .addFilter("userIdHeaderFilter", new DelegatingFilterProxy("userIdHeaderFilter")) + .addMappingForUrlPatterns(null, true, "/*"); + context .addFilter("GlobalShellFilter", new DelegatingFilterProxy("globalShellFilter")) .addMappingForUrlPatterns(null, true, "/*"); diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java new file mode 100644 index 000000000000..33a01cc14710 --- /dev/null +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java @@ -0,0 +1,159 @@ +/* + * 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.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_ENCRYPTION_KEY; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_HEADER_ENABLED; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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 java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; +import java.util.List; +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.user.UserDetails; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class UserIdHeaderFilterUnitTest { + private static final String HEADER_NAME = "X-User-ID"; + private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + private static final String HEADER_VERSION_PREFIX = "v1."; + private static final int GCM_IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + + @Mock private DhisConfigurationProvider dhisConfigurationProvider; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldNotAddHeaderWhenDisabled() throws Exception { + UserIdFilter filter = init(false); + withAuthenticatedUser(); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + @Test + void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { + UserIdFilter filter = init(true); + SecurityContextHolder.clearContext(); + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + @Test + void shouldAddHeaderWhenAuthenticatedUser() throws Exception { + + String userUID = "uid123"; + UserIdFilter filter = init(true); + UserDetails userDetails = withAuthenticatedUser(); + when(userDetails.getUid()).thenReturn(userUID); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + ArgumentCaptor headerValueCaptor = ArgumentCaptor.forClass(String.class); + verify(res).addHeader(eq(HEADER_NAME), headerValueCaptor.capture()); + String headerValue = headerValueCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertTrue(headerValue.startsWith(HEADER_VERSION_PREFIX)); + org.junit.jupiter.api.Assertions.assertEquals(userUID, decryptUserIDHeaderValue(headerValue)); + } + + private UserIdFilter init(boolean enabled) { + when(dhisConfigurationProvider.isEnabled(LOGGING_USER_ID_HEADER_ENABLED)).thenReturn(enabled); + when(dhisConfigurationProvider.getProperty(LOGGING_USER_ID_ENCRYPTION_KEY)) + .thenReturn(ENCRYPTION_KEY); + return new UserIdFilter(dhisConfigurationProvider); + } + + private UserDetails withAuthenticatedUser() { + UserDetails userDetails = mock(UserDetails.class); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, "pw", List.of((GrantedAuthority) () -> "ALL")); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + return userDetails; + } + + private String decryptUserIDHeaderValue(String headerValue) throws GeneralSecurityException { + String token = headerValue.substring(HEADER_VERSION_PREFIX.length()); + byte[] combined = Base64.getUrlDecoder().decode(token); + byte[] iv = new byte[GCM_IV_LENGTH_BYTES]; + byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH_BYTES]; + System.arraycopy(combined, 0, iv, 0, iv.length); + System.arraycopy(combined, iv.length, ciphertext, 0, ciphertext.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec key = new SecretKeySpec(Base64.getDecoder().decode(ENCRYPTION_KEY), "AES"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] plaintext = cipher.doFinal(ciphertext); + return new String(plaintext, StandardCharsets.UTF_8); + } +}