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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public final class SystemInfo {
@JsonProperty private final String dateFormat;
@JsonProperty private final Date serverDate;
@JsonProperty private final String serverTimeZoneId;
@JsonProperty private final int sessionTimeout;
@JsonProperty private final String serverTimeZoneDisplayName;
@JsonProperty private final Date lastAnalyticsTableSuccess;
@JsonProperty private final String intervalSinceLastAnalyticsTableSuccess;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ public SystemInfo getSystemInfo() {
.capability(capabilityProvider.getSystemCapability())
.calendar(calendarService.getSystemCalendar().name())
.dateFormat(calendarService.getSystemDateFormat().getJs())
.sessionTimeout(dhisConfig.getIntProperty(ConfigurationKey.SYSTEM_SESSION_TIMEOUT))
.serverDate(now)
.serverTimeZoneId(tz.getID())
.serverTimeZoneDisplayName(tz.getDisplayName())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2004-2026, 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 jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.SessionCookieConfig;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.time.Instant;
import org.hisp.dhis.user.CurrentUserUtil;
import org.springframework.http.ResponseCookie;
import org.springframework.web.filter.OncePerRequestFilter;

/**
* Adds a {@code SESSION_EXPIRE} cookie to every authenticated API response that has a server-side
* session.
*
* <p>The header value is the session's {@code maxInactiveInterval} in seconds. The cookie value is
* the epoch-second timestamp when the session will expire.
*/
public class SessionTimeoutHeaderFilter extends OncePerRequestFilter {

public static final String COOKIE_NAME = "SESSION_EXPIRE";

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
HttpSession session = request.getSession(false);
if (session != null
&& session.getMaxInactiveInterval() > 0
&& CurrentUserUtil.hasCurrentUser()) {
int maxInactiveInterval = session.getMaxInactiveInterval();
long expiresEpochSecond = Instant.now().plusSeconds(maxInactiveInterval).getEpochSecond();
SessionCookieConfig sessionCookieConfig =
request.getServletContext().getSessionCookieConfig();
String cookieValue =
String.format(
"server_time=%s&expiry_time=%s", Instant.now().getEpochSecond(), expiresEpochSecond);
ResponseCookie cookie =
ResponseCookie.from(COOKIE_NAME, cookieValue)
.maxAge(maxInactiveInterval)
.path("/")
.httpOnly(false)
.secure(sessionCookieConfig.isSecure())
.sameSite(sessionCookieConfig.getAttribute("SameSite"))
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
filterChain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2004-2026, 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.security;

import jakarta.servlet.http.HttpServletRequest;

/**
* Detects whether an HTTP request originates from an API client (SPA, mobile app, script) as
* opposed to a browser navigation. Used by authentication entry points to decide between returning
* a 401 JSON response or redirecting to the login page.
*/
public class ApiRequestDetector {

private ApiRequestDetector() {}

/**
* Returns {@code true} if the request appears to come from an API client rather than a browser
* navigating to a page.
*
* <p>Currently checks for the {@code X-Requested-With: XMLHttpRequest} header.
*/
public static boolean isApiRequest(HttpServletRequest request) {
return "XMLHttpRequest".equals(request.getHeader("X-Requested-With"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,10 @@ public void commence(
AuthenticationException authException)
throws IOException, ServletException {
String acceptHeader = MoreObjects.firstNonNull(request.getHeader(HttpHeaders.ACCEPT), "");
String requestWithHeader =
MoreObjects.firstNonNull(request.getHeader(HttpHeaders.X_REQUESTED_WITH), "");
String authorizationHeader =
MoreObjects.firstNonNull(request.getHeader(HttpHeaders.AUTHORIZATION), "");

if ("XMLHttpRequest".equals(requestWithHeader) || authorizationHeader.contains("Basic")) {
if (ApiRequestDetector.isApiRequest(request) || authorizationHeader.contains("Basic")) {
String message = "Unauthorized";

if (ExceptionUtils.indexOfThrowable(authException, LockedException.class) != -1) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public void commence(
HttpServletResponse response,
AuthenticationException authException)
throws IOException, ServletException {
if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) {
if (ApiRequestDetector.isApiRequest(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
renderService.toJson(response.getOutputStream(), unauthorized("Unauthorized"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
*/
package org.hisp.dhis.webapi.security.config;

import static org.hisp.dhis.dxf2.webmessage.WebMessageUtils.unauthorized;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
Expand All @@ -45,6 +47,7 @@
import org.hisp.dhis.configuration.ConfigurationService;
import org.hisp.dhis.external.conf.ConfigurationKey;
import org.hisp.dhis.external.conf.DhisConfigurationProvider;
import org.hisp.dhis.render.RenderService;
import org.hisp.dhis.security.Authorities;
import org.hisp.dhis.security.SystemAuthoritiesProvider;
import org.hisp.dhis.security.apikey.DhisApiTokenAuthenticationEntryPoint;
Expand All @@ -60,6 +63,8 @@
import org.hisp.dhis.security.spring2fa.TwoFactorWebAuthenticationDetailsSource;
import org.hisp.dhis.webapi.filter.CspFilter;
import org.hisp.dhis.webapi.filter.DhisCorsProcessor;
import org.hisp.dhis.webapi.filter.SessionTimeoutHeaderFilter;
import org.hisp.dhis.webapi.security.ApiRequestDetector;
import org.hisp.dhis.webapi.security.FormLoginBasicAuthenticationEntryPoint;
import org.hisp.dhis.webapi.security.Http401LoginUrlAuthenticationEntryPoint;
import org.hisp.dhis.webapi.security.apikey.ApiTokenAuthManager;
Expand All @@ -70,6 +75,7 @@
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.DefaultAuthenticationEventPublisher;
Expand All @@ -92,6 +98,7 @@
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -151,6 +158,8 @@ public static void setApiContextPath(String apiContextPath) {

@Autowired private DhisAuthorizationCodeTokenResponseClient jwtPrivateCodeTokenResponseClient;

@Autowired private RenderService renderService;

@Autowired private RequestCache requestCache;

private static class CustomRequestMatcher implements RequestMatcher {
Expand Down Expand Up @@ -265,6 +274,8 @@ protected SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
configureApiTokenAuthorizationFilter(http);
configureOAuthTokenFilters(http);

http.addFilterAfter(new SessionTimeoutHeaderFilter(), SessionManagementFilter.class);

setHttpHeaders(http);

return http.build();
Expand Down Expand Up @@ -429,7 +440,7 @@ public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
.logout()
.logoutUrl("/dhis-web-commons-security/logout.action")
.logoutSuccessHandler(dhisOidcLogoutSuccessHandler)
.deleteCookies("JSESSIONID")
.deleteCookies("JSESSIONID", "SESSION_EXPIRE")
.and()
////////////////////
.sessionManagement()
Expand All @@ -439,7 +450,19 @@ public <O extends BasicAuthenticationFilter> O postProcess(O filter) {
.enableSessionUrlRewriting(false)
.maximumSessions(
Integer.parseInt(dhisConfig.getProperty(ConfigurationKey.MAX_SESSIONS_PER_USER)))
.expiredUrl("/dhis-web-commons-security/logout.action");
.expiredSessionStrategy(
event -> {
HttpServletRequest request = event.getRequest();
HttpServletResponse response = event.getResponse();
if (ApiRequestDetector.isApiRequest(request)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
renderService.toJson(response.getOutputStream(), unauthorized("Session expired"));
} else {
response.sendRedirect(
request.getContextPath() + "/dhis-web-commons-security/logout.action");
}
});
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2004-2026, 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.security;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;

import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class ApiRequestDetectorTest {

@Mock private HttpServletRequest request;

@Test
void shouldDetectXmlHttpRequestHeader() {
when(request.getHeader("X-Requested-With")).thenReturn("XMLHttpRequest");

assertTrue(ApiRequestDetector.isApiRequest(request));
}

@Test
void shouldNotDetectWhenHeaderAbsent() {
when(request.getHeader("X-Requested-With")).thenReturn(null);

assertFalse(ApiRequestDetector.isApiRequest(request));
}
}
Loading