Skip to content

Commit 5a7f2e2

Browse files
authored
fix: redirect legacy /dhis-web-login/ to /login/ [2.43] (#23856)
1 parent 7506abd commit 5a7f2e2

4 files changed

Lines changed: 227 additions & 1 deletion

File tree

dhis-2/dhis-api/src/main/java/org/hisp/dhis/user/UserConstants.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public final class UserConstants {
4141

4242
public static final String PW_NO_INTERNAL_LOGIN = "--[##no_internal_login##]--";
4343

44-
public static final String RESTORE_PATH = "/dhis-web-login/index.html#/";
44+
public static final String RESTORE_PATH = "/login/index.html#/";
4545

4646
public static final String TBD_NAME = "(TBD)";
4747

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.webapi.filter;
31+
32+
import jakarta.servlet.FilterChain;
33+
import jakarta.servlet.ServletException;
34+
import jakarta.servlet.http.HttpServletRequest;
35+
import jakarta.servlet.http.HttpServletResponse;
36+
import java.io.IOException;
37+
import javax.annotation.Nonnull;
38+
import org.springframework.http.HttpHeaders;
39+
import org.springframework.stereotype.Component;
40+
import org.springframework.web.filter.OncePerRequestFilter;
41+
42+
/**
43+
* Permanently redirects legacy {@code /dhis-web-login/...} requests to {@code /login/...}.
44+
*
45+
* <p>The login app moved from {@code /dhis-web-login/} to {@code /login/} in the new app-bundling
46+
* setup. Bookmarked links and password-restore emails still point at the old path; this filter
47+
* keeps them working with a 301 so the URL is updated client-side.
48+
*/
49+
@Component
50+
public class LegacyDhisWebLoginRedirectFilter extends OncePerRequestFilter {
51+
52+
static final String LEGACY_PREFIX = "/dhis-web-login";
53+
static final String NEW_PREFIX = "/login";
54+
55+
@Override
56+
protected void doFilterInternal(
57+
@Nonnull HttpServletRequest request,
58+
@Nonnull HttpServletResponse response,
59+
@Nonnull FilterChain chain)
60+
throws IOException, ServletException {
61+
String contextPath = request.getContextPath();
62+
String requestUri = request.getRequestURI();
63+
String legacyBase = contextPath + LEGACY_PREFIX;
64+
65+
if (requestUri == null || !requestUri.startsWith(legacyBase)) {
66+
chain.doFilter(request, response);
67+
return;
68+
}
69+
70+
String tail = requestUri.substring(legacyBase.length());
71+
if (!tail.isEmpty() && tail.charAt(0) != '/') {
72+
chain.doFilter(request, response);
73+
return;
74+
}
75+
76+
StringBuilder target = new StringBuilder(contextPath).append(NEW_PREFIX).append(tail);
77+
String query = request.getQueryString();
78+
if (query != null && !query.isEmpty()) {
79+
target.append('?').append(query);
80+
}
81+
82+
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
83+
response.setHeader(HttpHeaders.LOCATION, target.toString());
84+
}
85+
}

dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ public static void setupServlets(
146146
characterEncodingFilter.addMappingForUrlPatterns(null, false, "/*");
147147
characterEncodingFilter.addMappingForServletNames(null, false, "dispatcher");
148148

149+
context
150+
.addFilter(
151+
"LegacyDhisWebLoginRedirectFilter",
152+
new DelegatingFilterProxy("legacyDhisWebLoginRedirectFilter"))
153+
.addMappingForUrlPatterns(null, false, "/dhis-web-login", "/dhis-web-login/*");
154+
149155
context
150156
.addFilter(
151157
"springSecurityFilterChain", new DelegatingFilterProxy("springSecurityFilterChain"))
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.webapi.filter;
31+
32+
import static org.junit.jupiter.api.Assertions.assertEquals;
33+
import static org.mockito.Mockito.never;
34+
import static org.mockito.Mockito.verify;
35+
36+
import jakarta.servlet.FilterChain;
37+
import jakarta.servlet.http.HttpServletResponse;
38+
import org.junit.jupiter.api.Test;
39+
import org.junit.jupiter.api.extension.ExtendWith;
40+
import org.mockito.Mock;
41+
import org.mockito.junit.jupiter.MockitoExtension;
42+
import org.springframework.mock.web.MockHttpServletRequest;
43+
import org.springframework.mock.web.MockHttpServletResponse;
44+
45+
/**
46+
* Unit tests for {@link LegacyDhisWebLoginRedirectFilter}.
47+
*
48+
* @author Morten Svanæs
49+
*/
50+
@ExtendWith(MockitoExtension.class)
51+
class LegacyDhisWebLoginRedirectFilterTest {
52+
53+
private final LegacyDhisWebLoginRedirectFilter filter = new LegacyDhisWebLoginRedirectFilter();
54+
55+
@Mock private FilterChain chain;
56+
57+
@Test
58+
void redirectsBareLegacyPath() throws Exception {
59+
MockHttpServletResponse response = doFilter("/dhis-web-login", null, "");
60+
assertRedirect(response, "/login");
61+
verify(chain, never())
62+
.doFilter(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
63+
}
64+
65+
@Test
66+
void redirectsLegacyRoot() throws Exception {
67+
MockHttpServletResponse response = doFilter("/dhis-web-login/", null, "");
68+
assertRedirect(response, "/login/");
69+
}
70+
71+
@Test
72+
void redirectsIndexHtmlPreservingHash() throws Exception {
73+
// The hash fragment is client-side and never sent to the server, so the
74+
// filter only needs to redirect /index.html — the browser keeps the hash.
75+
MockHttpServletResponse response = doFilter("/dhis-web-login/index.html", null, "");
76+
assertRedirect(response, "/login/index.html");
77+
}
78+
79+
@Test
80+
void redirectsNestedPath() throws Exception {
81+
MockHttpServletResponse response = doFilter("/dhis-web-login/static/main.js", null, "");
82+
assertRedirect(response, "/login/static/main.js");
83+
}
84+
85+
@Test
86+
void preservesQueryString() throws Exception {
87+
MockHttpServletResponse response = doFilter("/dhis-web-login/", "oidcFailure=true", "");
88+
assertRedirect(response, "/login/?oidcFailure=true");
89+
}
90+
91+
@Test
92+
void preservesContextPath() throws Exception {
93+
MockHttpServletResponse response = doFilter("/dhis/dhis-web-login/index.html", null, "/dhis");
94+
assertRedirect(response, "/dhis/login/index.html");
95+
}
96+
97+
@Test
98+
void doesNotRedirectFalsePositivePrefix() throws Exception {
99+
// /dhis-web-loginabc starts with /dhis-web-login but isn't the legacy login app.
100+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/dhis-web-loginabc");
101+
request.setRequestURI("/dhis-web-loginabc");
102+
MockHttpServletResponse response = new MockHttpServletResponse();
103+
filter.doFilter(request, response, chain);
104+
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
105+
verify(chain).doFilter(request, response);
106+
}
107+
108+
@Test
109+
void doesNotRedirectUnrelatedPath() throws Exception {
110+
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/me");
111+
request.setRequestURI("/api/me");
112+
MockHttpServletResponse response = new MockHttpServletResponse();
113+
filter.doFilter(request, response, chain);
114+
assertEquals(HttpServletResponse.SC_OK, response.getStatus());
115+
verify(chain).doFilter(request, response);
116+
}
117+
118+
private MockHttpServletResponse doFilter(
119+
String requestUri, String queryString, String contextPath) throws Exception {
120+
MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
121+
request.setRequestURI(requestUri);
122+
request.setContextPath(contextPath);
123+
if (queryString != null) {
124+
request.setQueryString(queryString);
125+
}
126+
MockHttpServletResponse response = new MockHttpServletResponse();
127+
filter.doFilter(request, response, chain);
128+
return response;
129+
}
130+
131+
private static void assertRedirect(MockHttpServletResponse response, String expectedLocation) {
132+
assertEquals(HttpServletResponse.SC_MOVED_PERMANENTLY, response.getStatus());
133+
assertEquals(expectedLocation, response.getHeader("Location"));
134+
}
135+
}

0 commit comments

Comments
 (0)