diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index d341a9d0c..7bab01135 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -121,6 +121,7 @@ public class ApplicationProperties { private String customGlobalAPIKey; private Jwt jwt = new Jwt(); private Validation validation = new Validation(); + private RateLimit rateLimit = new RateLimit(); public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); @@ -344,6 +345,12 @@ public class ApplicationProperties { private boolean hardFail = false; } } + + @Data + public static class RateLimit { + private int maxRequests = 1000; + private String resetSchedule = "0 0 0 * * MON"; // Cron expression: At 00:00 every Monday + } } @Data diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index d8b02b38f..522ca8830 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -61,6 +61,3 @@ java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} # V2 features v2=true - -# OAuth2 configuration -security.oauth2.enabled=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 28e07c177..ebfdc8afe 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -60,6 +60,7 @@ security: privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair jwt: # This feature is currently under development and not yet fully supported. Do not use in production. + issuer: https://stirling.com # The issuer field to include in the JWTs persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 272b7e407..9a8c74eac 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -114,8 +114,11 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { // Set service provider keys for the SamlClient samlClient.setSPKeys(certificate, privateKey); - // Redirect to identity provider for logout. todo: add relay state - samlClient.redirectToIdentityProvider(response, null, nameIdValue); + // Build relay state to return user to login page after IdP logout + String relayState = UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH; + + // Redirect to identity provider for logout with relay state + samlClient.redirectToIdentityProvider(response, relayState, nameIdValue); } catch (Exception e) { log.error( "Error retrieving logout URL from Provider {} for user {}", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java index 25b3c5096..a1e8112d1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/RateLimitResetScheduler.java @@ -13,7 +13,7 @@ public class RateLimitResetScheduler { private final IPRateLimitingFilter rateLimitingFilter; - @Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable + @Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}") public void resetRateLimit() { rateLimitingFilter.resetRequestCounts(); } 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 d45549a44..387bfc6bb 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 @@ -132,23 +132,16 @@ public class SecurityConfiguration { if (loginEnabledValue) { boolean v2Enabled = appConfig.v2Enabled(); + http.addFilterBefore( + userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore( + rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class); + // http.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class); + if (v2Enabled) { - http.addFilterBefore( - jwtAuthenticationFilter(), - UsernamePasswordAuthenticationFilter.class) - .exceptionHandling( - exceptionHandling -> - exceptionHandling.authenticationEntryPoint( - jwtAuthenticationEntryPoint)) - .addFilterAfter( - userAuthenticationFilter, - JwtAuthenticationFilter.class); // Run AFTER JWT filter - } else { - http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class); } - http.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) - .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -324,7 +317,8 @@ public class SecurityConfiguration { userInfoEndpoint .oidcUserService( new CustomOAuth2UserService( - securityProperties.getOauth2(), + securityProperties + .getOauth2(), userService, loginAttemptService)) .userAuthoritiesMapper( @@ -378,8 +372,7 @@ public class SecurityConfiguration { @Bean public IPRateLimitingFilter rateLimitingFilter() { - // Example limit TODO add config level - int maxRequestsPerIp = 1000000; + int maxRequestsPerIp = securityProperties.getRateLimit().getMaxRequests(); return new IPRateLimitingFilter(maxRequestsPerIp, maxRequestsPerIp); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index c66b8e7bf..2f8fa2722 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -121,8 +121,11 @@ public class CustomSaml2AuthenticationSuccessHandler String ssoProviderId = saml2Principal.nameId(); String ssoProvider = "saml2"; // fixme - log.debug("Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})", - username, ssoProvider, ssoProviderId); + log.debug( + "Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})", + username, + ssoProvider, + ssoProviderId); userService.processSSOPostLogin( username, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index 256cb8ade..6592bc95c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.enumeration.UsernameAttribute; import stirling.software.proprietary.security.model.User; @@ -42,17 +41,20 @@ public class CustomOAuth2UserService implements OAuth2UserService internalUser = userService.findByUsernameIgnoreCase(username); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java index 9e0855fc4..2107f2ffd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -5,7 +5,6 @@ import java.util.Map; import org.springframework.security.core.Authentication; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; public interface JwtServiceInterface { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 34b9eac93..80e48dbf6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -74,7 +74,8 @@ public class UserService implements UserServiceInterface { // Find user by SSO provider ID first Optional existingUser; if (ssoProviderId != null && ssoProvider != null) { - existingUser = userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId); + existingUser = + userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId); if (existingUser.isPresent()) { log.debug("User found by SSO provider ID: {}", ssoProviderId); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 7a4076260..c6b6d17e7 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java @@ -1,6 +1,8 @@ package stirling.software.proprietary.security; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.io.IOException; @@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest { when(response.isCommitted()).thenReturn(false); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest { when(response.isCommitted()).thenReturn(false); when(jwtService.extractToken(request)).thenReturn(token); - doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); customLogoutSuccessHandler.onLogoutSuccess(request, response, null); verify(response).sendRedirect(logoutPath); - verify(jwtService).clearToken(response); } @Test 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 index d3f484486..244ce387f 100644 --- 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 @@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest { .setAuthentication(any(UsernamePasswordAuthenticationToken.class)); verify(jwtService) .generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); - verify(jwtService).addToken(response, newToken); verify(filterChain).doFilter(request, response); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java index 6f9af4c54..b80d69a6c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.contains; -import static org.mockito.Mockito.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -17,7 +15,6 @@ import static org.mockito.Mockito.when; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Collections; import java.util.HashMap; @@ -27,13 +24,10 @@ import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; -import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -59,7 +53,7 @@ class JwtServiceTest { private JwtVerificationKey testVerificationKey; @BeforeEach - void setUp() throws NoSuchAlgorithmException { + void setUp() throws Exception { // Generate a test keypair KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); @@ -71,6 +65,11 @@ class JwtServiceTest { testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); jwtService = new JwtService(true, keystoreService); + + // Set the issuer field using reflection since @Value annotation isn't populated in tests + java.lang.reflect.Field issuerField = JwtService.class.getDeclaredField("issuer"); + issuerField.setAccessible(true); + issuerField.set(jwtService, "https://stirling.com"); } @Test @@ -224,7 +223,8 @@ class JwtServiceTest { assertEquals("admin", extractedClaims.get("role")); assertEquals("IT", extractedClaims.get("department")); assertEquals(username, extractedClaims.get("sub")); - assertEquals("Stirling PDF", extractedClaims.get("iss")); + // Issuer is now configurable, so check it exists rather than exact value + assertNotNull(extractedClaims.get("iss")); } @Test @@ -239,62 +239,27 @@ class JwtServiceTest { } @Test - void testExtractTokenWithCookie() { + void testExtractTokenWithAuthorizationHeader() { String token = "test-token"; - Cookie[] cookies = {new Cookie("stirling_jwt", token)}; - when(request.getCookies()).thenReturn(cookies); + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); assertEquals(token, jwtService.extractToken(request)); } @Test - void testExtractTokenWithNoCookies() { - when(request.getCookies()).thenReturn(null); + void testExtractTokenWithNoAuthorizationHeader() { + when(request.getHeader("Authorization")).thenReturn(null); assertNull(jwtService.extractToken(request)); } @Test - void testExtractTokenWithWrongCookie() { - Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; - when(request.getCookies()).thenReturn(cookies); + void testExtractTokenWithInvalidAuthorizationHeaderFormat() { + when(request.getHeader("Authorization")).thenReturn("InvalidFormat token"); assertNull(jwtService.extractToken(request)); } - @Test - void testExtractTokenWithInvalidAuthorizationHeader() { - when(request.getCookies()).thenReturn(null); - - assertNull(jwtService.extractToken(request)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void testAddToken(boolean secureCookie) throws Exception { - String token = "test-token"; - - // Create new JwtService instance with the secureCookie parameter - JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie); - - testJwtService.addToken(response, token); - - verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token)); - verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly")); - - if (secureCookie) { - verify(response).addHeader(eq("Set-Cookie"), contains("Secure")); - } - } - - @Test - void testClearToken() { - jwtService.clearToken(response); - - verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); - verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); - } - @Test void testGenerateTokenWithKeyId() throws Exception { String username = "testuser"; @@ -373,17 +338,4 @@ class JwtServiceTest { // Verify fallback logic was used verify(keystoreService, atLeast(1)).getActiveKey(); } - - private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception { - // Use reflection to create JwtService with custom secureCookie value - JwtService testService = new JwtService(true, keystoreService); - - // Set the secureCookie field using reflection - java.lang.reflect.Field secureCookieField = - JwtService.class.getDeclaredField("secureCookie"); - secureCookieField.setAccessible(true); - secureCookieField.set(testService, secureCookie); - - return testService; - } }