From 622a0f91aaee29151ce199465b4be1c958f28cc1 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:03:31 +0100 Subject: [PATCH] Fixed blank login page when v2 is disabled --- .../src/main/resources/application.properties | 2 +- .../CustomAuthenticationSuccessHandler.java | 3 + .../configuration/SecurityConfiguration.java | 8 +- ...tSaml2AuthenticationRequestRepository.java | 5 + .../filter/JwtAuthenticationFilterTest.java | 307 ++++++++++++++++++ 5 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index ec3a0a390..e0273eb5a 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -50,4 +50,4 @@ spring.main.allow-bean-definition-overriding=true java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} # V2 features -v2=true +v2=false diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index 418d2c366..63c653a3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -78,6 +78,9 @@ public class CustomAuthenticationSuccessHandler request.getContextPath(), savedRequest.getRedirectUrl())) { // Redirect to the original destination super.onAuthenticationSuccess(request, response, authentication); + } else { + // No saved request or it's a static resource, redirect to home page + getRedirectStrategy().sendRedirect(request, response, "/"); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 92c969056..094a7986b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -135,7 +135,7 @@ public class SecurityConfiguration { boolean v2Enabled = appConfig.v2Enabled(); if (v2Enabled) { - http.addFilterBefore( + http.addFilterAt( jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .exceptionHandling( @@ -143,7 +143,8 @@ public class SecurityConfiguration { exceptionHandling.authenticationEntryPoint( jwtAuthenticationEntryPoint)); } - http.addFilterAt(userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + http.addFilterBefore( + userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(rateLimitingFilter(), userAuthenticationFilter.getClass()) .addFilterAfter(firstLoginFilter, rateLimitingFilter().getClass()); @@ -209,8 +210,7 @@ public class SecurityConfiguration { securityProperties, appConfig, jwtService)) .clearAuthentication(true) .invalidateHttpSession(true) - .deleteCookies( - "JSESSIONID", "remember-me", "STIRLING_JWT_TOKEN")); + .deleteCookies("JSESSIONID", "remember-me", "stirling_jwt")); http.rememberMe( rememberMeConfigurer -> // Use the configurator directly rememberMeConfigurer diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java index 3e6d4491a..f1f2da6b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java @@ -38,6 +38,11 @@ public class JwtSaml2AuthenticationRequestRepository Saml2PostAuthenticationRequest authRequest, HttpServletRequest request, HttpServletResponse response) { + if (!jwtService.isJwtEnabled()) { + log.warn("SAML2 v2 is not enabled, skipping saveAuthenticationRequest"); + return; + } + if (authRequest == null) { removeAuthenticationRequest(request, response); return; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 000000000..ecb84122a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,307 @@ +package stirling.software.proprietary.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.AuthenticationEntryPoint; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.service.CustomUserDetailsService; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import stirling.software.proprietary.security.service.UserService; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterTest { + + @Mock + private JwtServiceInterface jwtService; + + @Mock + private CustomUserDetailsService userDetailsService; + + @Mock + private UserService userService; + + @Mock + private ApplicationProperties.Security securityProperties; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private FilterChain filterChain; + + @Mock + private UserDetails userDetails; + + @Mock + private SecurityContext securityContext; + + @Mock + private AuthenticationEntryPoint authenticationEntryPoint; + + @InjectMocks + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @Test + void shouldNotAuthenticateWhenJwtDisabled() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(false); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).extractTokenFromRequest(any()); + } + + @Test + void shouldNotFilterWhenPageIsLogin() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/login"); + when(request.getMethod()).thenReturn("POST"); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain).doFilter(request, response); + verify(jwtService, never()).extractTokenFromRequest(any()); + } + + @Test + void testDoFilterInternal() throws ServletException, IOException { + String token = "valid-jwt-token"; + String newToken = "new-jwt-token"; + String username = "testuser"; + Map claims = Map.of("sub", username, "authType", "WEB"); + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doNothing().when(jwtService).validateToken(token); + when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(userDetails.getAuthorities()).thenReturn(Collections.emptyList()); + when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails); + + try (MockedStatic mockedSecurityContextHolder = mockStatic(SecurityContextHolder.class)) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + when(securityContext.getAuthentication()).thenReturn(null).thenReturn(authToken); + mockedSecurityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + when(jwtService.generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims))).thenReturn(newToken); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(jwtService).validateToken(token); + verify(jwtService).extractAllClaims(token); + verify(userDetailsService).loadUserByUsername(username); + verify(securityContext).setAuthentication(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtService).generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); + verify(jwtService).addTokenToResponse(response, newToken); + verify(filterChain).doFilter(request, response); + } + } + + @Test + void testDoFilterInternalWithMissingTokenForRootPath() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractTokenFromRequest(request)).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(response).sendRedirect("/login"); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void validationFailsWithInvalidToken() throws ServletException, IOException { + String token = "invalid-jwt-token"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doThrow(new AuthenticationFailureException("Invalid token")).when(jwtService).validateToken(token); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(jwtService).validateToken(token); + verify(authenticationEntryPoint).commence(eq(request), eq(response), any(AuthenticationFailureException.class)); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void validationFailsWithExpiredToken() throws ServletException, IOException { + String token = "expired-jwt-token"; + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doThrow(new AuthenticationFailureException("The token has expired")).when(jwtService).validateToken(token); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(jwtService).validateToken(token); + verify(authenticationEntryPoint).commence(eq(request), eq(response), any()); + verify(filterChain, never()).doFilter(request, response); + } + + @Test + void exceptinonThrown_WhenUserNotFound() throws ServletException, IOException { + String token = "valid-jwt-token"; + String username = "nonexistentuser"; + Map claims = Map.of("sub", username, "authType", "WEB"); + + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doNothing().when(jwtService).validateToken(token); + when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(userDetailsService.loadUserByUsername(username)).thenReturn(null); + + try (MockedStatic mockedSecurityContextHolder = mockStatic(SecurityContextHolder.class)) { + when(securityContext.getAuthentication()).thenReturn(null); + mockedSecurityContextHolder.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + UsernameNotFoundException result = assertThrows(UsernameNotFoundException.class, () -> jwtAuthenticationFilter.doFilterInternal(request, response, filterChain)); + + assertEquals("User not found: " + username, result.getMessage()); + verify(userDetailsService).loadUserByUsername(username); + verify(filterChain, never()).doFilter(request, response); + } + } + + @Test + void shouldNotFilterLoginPost() { + when(request.getRequestURI()).thenReturn("/login"); + when(request.getMethod()).thenReturn("POST"); + + assertTrue(jwtAuthenticationFilter.shouldNotFilter(request)); + } + + @Test + void shouldNotFilterLoginGet() { + when(request.getRequestURI()).thenReturn("/login"); + when(request.getMethod()).thenReturn("GET"); + + assertTrue(jwtAuthenticationFilter.shouldNotFilter(request)); + } + + @Test + void shouldNotFilterPublicPaths() { + String[] publicPaths = { + "/register", + "/error", + "/images/logo.png", + "/public/file.txt", + "/css/style.css", + "/fonts/font.ttf", + "/js/script.js", + "/pdfjs/viewer.js", + "/pdfjs-legacy/viewer.js", + "/api/v1/info/status", + "/site.webmanifest", + "/favicon.ico" + }; + + for (String path : publicPaths) { + when(request.getRequestURI()).thenReturn(path); + when(request.getMethod()).thenReturn("GET"); + + assertTrue(jwtAuthenticationFilter.shouldNotFilter(request), + "Should not filter path: " + path); + } + } + + @Test + void shouldNotFilterStaticFiles() { + String[] staticFiles = { + "/some/path/file.svg", + "/another/path/image.png", + "/path/to/icon.ico" + }; + + for (String file : staticFiles) { + when(request.getRequestURI()).thenReturn(file); + when(request.getMethod()).thenReturn("GET"); + + assertTrue(jwtAuthenticationFilter.shouldNotFilter(request), + "Should not filter file: " + file); + } + } + + @Test + void shouldFilterProtectedPaths() { + String[] protectedPaths = { + "/protected", + "/api/v1/user/profile", + "/admin", + "/dashboard" + }; + + for (String path : protectedPaths) { + when(request.getRequestURI()).thenReturn(path); + when(request.getMethod()).thenReturn("GET"); + + assertFalse(jwtAuthenticationFilter.shouldNotFilter(request), + "Should filter path: " + path); + } + } + + @Test + void shouldFilterRootPath() { + when(request.getRequestURI()).thenReturn("/"); + when(request.getMethod()).thenReturn("GET"); + + assertFalse(jwtAuthenticationFilter.shouldNotFilter(request)); + } + + @Test + void testAuthenticationEntryPointCalledWithCorrectException() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getMethod()).thenReturn("GET"); + when(jwtService.extractTokenFromRequest(request)).thenReturn(null); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(authenticationEntryPoint).commence(eq(request), eq(response), argThat(exception -> + exception.getMessage().equals("JWT is missing from the request") + )); + verify(filterChain, never()).doFilter(request, response); + } +}