From ae8980f65688b18d5bc9496af5f9e3386100519d Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 18 Jul 2025 10:25:14 +0100 Subject: [PATCH] Completed SAML2 JWT auth, fixed InResponseTo error --- .../main/resources/messages_en_GB.properties | 3 +- .../CustomAuthenticationSuccessHandler.java | 6 +- .../security/CustomLogoutSuccessHandler.java | 10 +- .../security/JwtAuthenticationEntryPoint.java | 22 ++ .../security/config/AccountWebController.java | 2 +- .../configuration/SecurityConfiguration.java | 15 +- ...tomOAuth2AuthenticationSuccessHandler.java | 4 +- ...stomSaml2AuthenticationSuccessHandler.java | 28 +- .../security/saml2/SAML2Configuration.java | 179 ------------ .../CustomLogoutSuccessHandlerTest.java | 4 +- .../JwtAuthenticationEntryPointTest.java | 40 +++ .../security/service/JwtServiceTest.java | 269 ++++++++++++++++++ 12 files changed, 369 insertions(+), 213 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index f78e80b65..32f1bb6ea 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -861,7 +861,7 @@ login.rememberme=Remember me login.invalid=Invalid username or password. login.locked=Your account has been locked. login.signinTitle=Please sign in -login.ssoSignIn=Login via Single Sign-on +login.ssoSignIn=Login via Single Sign-On login.oAuth2AutoCreateDisabled=OAUTH2 Auto-Create User Disabled login.oAuth2AdminBlockedUser=Registration or logging in of non-registered users is currently blocked. Please contact the administrator. login.oauth2RequestNotFound=Authorization request not found @@ -876,6 +876,7 @@ login.alreadyLoggedIn=You are already logged in to login.alreadyLoggedIn2=devices. Please log out of the devices and try again. login.toManySessions=You have too many active sessions login.logoutMessage=You have been logged out. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Auto Redact 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 8bcddf7d3..418d2c366 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 @@ -19,7 +19,7 @@ import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.model.AuthenticationType; -import stirling.software.proprietary.security.service.JWTServiceInterface; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -29,12 +29,12 @@ public class CustomAuthenticationSuccessHandler private final LoginAttemptService loginAttemptService; private final UserService userService; - private final JWTServiceInterface jwtService; + private final JwtServiceInterface jwtService; public CustomAuthenticationSuccessHandler( LoginAttemptService loginAttemptService, UserService userService, - JWTServiceInterface jwtService) { + JwtServiceInterface jwtService) { this.loginAttemptService = loginAttemptService; this.userService = userService; this.jwtService = jwtService; 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 ceb474a7c..45869b05c 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 @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.List; import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -33,7 +34,7 @@ import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; import stirling.software.proprietary.security.saml2.CertificateUtils; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; -import stirling.software.proprietary.security.service.JWTServiceInterface; +import stirling.software.proprietary.security.service.JwtServiceInterface; @Slf4j @RequiredArgsConstructor @@ -45,7 +46,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { private final AppConfig appConfig; - private final JWTServiceInterface jwtService; + private final JwtServiceInterface jwtService; @Override @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) @@ -116,7 +117,10 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { samlClient.setSPKeys(certificate, privateKey); // Redirect to identity provider for logout. todo: add relay state - samlClient.redirectToIdentityProvider(response, null, nameIdValue); + // samlClient.redirectToIdentityProvider(response, null, nameIdValue); + samlClient.processLogoutRequestPostFromIdentityProvider(request, nameIdValue); + samlClient.redirectToIdentityProviderLogout( + response, HttpStatus.OK.name(), 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/JwtAuthenticationEntryPoint.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..6805bcb54 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.security; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index 0d846fc3d..830f8f195 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -184,7 +184,7 @@ public class AccountWebController { errorOAuth = "login.relyingPartyRegistrationNotFound"; // Valid InResponseTo was not available from the validation context, unable to // evaluate - case "invalid_in_response_to" -> errorOAuth = "login.invalid_in_response_to"; + case "invalid_in_response_to" -> errorOAuth = "login.invalidInResponseTo"; case "not_authentication_provider_found" -> errorOAuth = "login.not_authentication_provider_found"; } 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 2abeb5682..dbf2313db 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 @@ -38,7 +38,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.CustomAuthenticationFailureHandler; import stirling.software.proprietary.security.CustomAuthenticationSuccessHandler; import stirling.software.proprietary.security.CustomLogoutSuccessHandler; -import stirling.software.proprietary.security.JWTAuthenticationEntryPoint; +import stirling.software.proprietary.security.JwtAuthenticationEntryPoint; import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; import stirling.software.proprietary.security.filter.FirstLoginFilter; @@ -53,7 +53,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuc import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter; import stirling.software.proprietary.security.service.CustomOAuth2UserService; import stirling.software.proprietary.security.service.CustomUserDetailsService; -import stirling.software.proprietary.security.service.JWTServiceInterface; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -73,8 +73,8 @@ public class SecurityConfiguration { private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; - private final JWTServiceInterface jwtService; - private final JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtServiceInterface jwtService; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; @@ -92,8 +92,8 @@ public class SecurityConfiguration { AppConfig appConfig, ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, - JWTServiceInterface jwtService, - JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint, + JwtServiceInterface jwtService, + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @@ -185,7 +185,7 @@ public class SecurityConfiguration { // Configure session management based on JWT setting http.sessionManagement( sessionManagement -> { - if (v2Enabled) { + if (v2Enabled && !securityProperties.isSaml2Active()) { sessionManagement.sessionCreationPolicy( SessionCreationPolicy.STATELESS); } else { @@ -306,7 +306,6 @@ public class SecurityConfiguration { } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { - // Configure the authentication provider OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 0a3dd937e..71227b618 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -25,7 +25,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.AuthenticationType; -import stirling.software.proprietary.security.service.JWTServiceInterface; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -36,7 +36,7 @@ public class CustomOAuth2AuthenticationSuccessHandler private final LoginAttemptService loginAttemptService; private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; - private final JWTServiceInterface jwtService; + private final JwtServiceInterface jwtService; @Override public void onAuthenticationSuccess( 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 bc5ab5ecd..35ce832e9 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 @@ -24,7 +24,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.AuthenticationType; -import stirling.software.proprietary.security.service.JWTServiceInterface; +import stirling.software.proprietary.security.service.JwtServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -36,7 +36,7 @@ public class CustomSaml2AuthenticationSuccessHandler private LoginAttemptService loginAttemptService; private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; - private final JWTServiceInterface jwtService; + private final JwtServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -70,17 +70,6 @@ public class CustomSaml2AuthenticationSuccessHandler savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { - if (jwtService.isJwtEnabled()) { - String jwt = - jwtService.generateToken( - authentication, Map.of("authType", AuthenticationType.SAML2)); - jwtService.addTokenToResponse(response, jwt); - - super.onAuthenticationSuccess(request, response, authentication); - // getRedirectStrategy().sendRedirect(request, response, - // "/"); - // return; - } log.debug( "Processing SAML2 authentication with autoCreateUser: {}", saml2Properties.getAutoCreateUser()); @@ -121,7 +110,7 @@ public class CustomSaml2AuthenticationSuccessHandler } try { - if (saml2Properties.getBlockRegistration() && !userExists) { + if (!userExists || saml2Properties.getBlockRegistration()) { log.debug("Registration blocked for new user: {}", username); response.sendRedirect( contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); @@ -131,6 +120,8 @@ public class CustomSaml2AuthenticationSuccessHandler userService.processSSOPostLogin( username, saml2Properties.getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); + + generateJWT(response, authentication); response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( @@ -144,4 +135,13 @@ public class CustomSaml2AuthenticationSuccessHandler super.onAuthenticationSuccess(request, response, authentication); } } + + private void generateJWT(HttpServletResponse response, Authentication authentication) { + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.SAML2)); + jwtService.addTokenToResponse(response, jwt); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java deleted file mode 100644 index 70fc9a154..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java +++ /dev/null @@ -1,179 +0,0 @@ -package stirling.software.proprietary.security.saml2; - -import java.security.cert.X509Certificate; -import java.util.Collections; -import java.util.UUID; - -import org.opensaml.saml.saml2.core.AuthnRequest; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.Resource; -import org.springframework.security.saml2.core.Saml2X509Credential; -import org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType; -import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; -import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; -import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; -import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; -import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding; -import org.springframework.security.saml2.provider.service.web.HttpSessionSaml2AuthenticationRequestRepository; -import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; - -import jakarta.servlet.http.HttpServletRequest; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.SAML2; - -@Configuration -@Slf4j -@ConditionalOnProperty(value = "security.saml2.enabled", havingValue = "true") -@RequiredArgsConstructor -public class SAML2Configuration { - - private final ApplicationProperties applicationProperties; - - @Bean - @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") - public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { - SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert()); - Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); - Resource privateKeyResource = samlConf.getPrivateKey(); - Resource certificateResource = samlConf.getSpCert(); - Saml2X509Credential signingCredential = - new Saml2X509Credential( - CertificateUtils.readPrivateKey(privateKeyResource), - CertificateUtils.readCertificate(certificateResource), - Saml2X509CredentialType.SIGNING); - RelyingPartyRegistration rp = - RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) - .signingX509Credentials(c -> c.add(signingCredential)) - .entityId(samlConf.getIdpIssuer()) - .singleLogoutServiceBinding(Saml2MessageBinding.POST) - .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("{baseUrl}:{basePort}/login") - .assertionConsumerServiceBinding(Saml2MessageBinding.POST) - .assertionConsumerServiceLocation( - "{baseUrl}/login/saml2/sso/{registrationId}") - .assertingPartyMetadata( - metadata -> - metadata.entityId(samlConf.getIdpIssuer()) - .verificationX509Credentials( - c -> c.add(verificationCredential)) - .singleSignOnServiceBinding( - Saml2MessageBinding.POST) - .singleSignOnServiceLocation( - samlConf.getIdpSingleLoginUrl()) - .singleLogoutServiceBinding( - Saml2MessageBinding.POST) - .singleLogoutServiceLocation( - samlConf.getIdpSingleLogoutUrl()) - .wantAuthnRequestsSigned(true)) - .build(); - return new InMemoryRelyingPartyRegistrationRepository(rp); - } - - @Bean - @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") - public HttpSessionSaml2AuthenticationRequestRepository saml2AuthenticationRequestRepository() { - return new HttpSessionSaml2AuthenticationRequestRepository(); - } - - @Bean - @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") - public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( - RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, - HttpSessionSaml2AuthenticationRequestRepository saml2AuthenticationRequestRepository) { - OpenSaml4AuthenticationRequestResolver resolver = - new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); - - resolver.setAuthnRequestCustomizer( - customizer -> { - HttpServletRequest request = customizer.getRequest(); - AuthnRequest authnRequest = customizer.getAuthnRequest(); - AbstractSaml2AuthenticationRequest saml2AuthenticationRequest = - saml2AuthenticationRequestRepository.loadAuthenticationRequest(request); - - if (saml2AuthenticationRequest != null) { - String sessionId = request.getSession(false).getId(); - - log.debug( - "Retrieving SAML 2 authentication request ID from the current HTTP session {}", - sessionId); - - String authenticationRequestId = saml2AuthenticationRequest.getId(); - - if (!authenticationRequestId.isBlank()) { - authnRequest.setID(authenticationRequestId); - } else { - log.warn( - "No authentication request found for HTTP session {}. Generating new ID", - sessionId); - authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); - } - } else { - log.debug("Generating new authentication request ID"); - authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); - } - - logAuthnRequestDetails(authnRequest); - logHttpRequestDetails(request); - }); - return resolver; - } - - private static void logAuthnRequestDetails(AuthnRequest authnRequest) { - String message = - """ - AuthnRequest: - - ID: {} - Issuer: {} - IssueInstant: {} - AssertionConsumerService (ACS) URL: {} - """; - log.debug( - message, - authnRequest.getID(), - authnRequest.getIssuer() != null ? authnRequest.getIssuer().getValue() : null, - authnRequest.getIssueInstant(), - authnRequest.getAssertionConsumerServiceURL()); - - if (authnRequest.getNameIDPolicy() != null) { - log.debug("NameIDPolicy Format: {}", authnRequest.getNameIDPolicy().getFormat()); - } - } - - private static void logHttpRequestDetails(HttpServletRequest request) { - log.debug("HTTP Headers: "); - Collections.list(request.getHeaderNames()) - .forEach( - headerName -> - log.debug("{}: {}", headerName, request.getHeader(headerName))); - String message = - """ - HTTP Request Method: {} - Session ID: {} - Request Path: {} - Query String: {} - Remote Address: {} - - SAML Request Parameters: - - SAMLRequest: {} - RelayState: {} - """; - log.debug( - message, - request.getMethod(), - request.getSession().getId(), - request.getRequestURI(), - request.getQueryString(), - request.getRemoteAddr(), - request.getParameter("SAMLRequest"), - request.getParameter("RelayState")); - } -} 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 89851ce91..2ed4245f3 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 @@ -11,7 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; -import stirling.software.proprietary.security.service.JWTServiceInterface; +import stirling.software.proprietary.security.service.JwtServiceInterface; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -21,7 +21,7 @@ class CustomLogoutSuccessHandlerTest { @Mock private AppConfig appConfig; - @Mock private JWTServiceInterface jwtService; + @Mock private JwtServiceInterface jwtService; @InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java new file mode 100644 index 000000000..08abd1965 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java @@ -0,0 +1,40 @@ +package stirling.software.proprietary.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationEntryPointTest { + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private AuthenticationFailureException authException; + + @InjectMocks + private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Test + void testCommence() throws IOException { + String errorMessage = "Authentication failed"; + when(authException.getMessage()).thenReturn(errorMessage); + + jwtAuthenticationEntryPoint.commence(request, response, authException); + + verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage); + } +} 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 new file mode 100644 index 000000000..6f419e280 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -0,0 +1,269 @@ +package stirling.software.proprietary.security.service; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.contains; +import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + @Mock + private ApplicationProperties.Security securityProperties; + + @Mock + private Authentication authentication; + + @Mock + private User userDetails; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + private JwtService jwtService; + + @BeforeEach + void setUp() { + jwtService = new JwtService(true); + } + + @Test + void testGenerateTokenWithAuthentication() { + String username = "testuser"; + + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, Collections.emptyMap()); + + assertNotNull(token); + assertTrue(!token.isEmpty()); + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testGenerateTokenWithUsernameAndClaims() { + String username = "testuser"; + Map claims = new HashMap<>(); + claims.put("role", "admin"); + claims.put("department", "IT"); + + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertNotNull(token); + assertFalse(token.isEmpty()); + assertEquals(username, jwtService.extractUsername(token)); + + Map extractedClaims = jwtService.extractAllClaims(token); + assertEquals("admin", extractedClaims.get("role")); + assertEquals("IT", extractedClaims.get("department")); + } + + @Test + void testValidateTokenSuccess() { + String token = jwtService.generateToken(authentication, new HashMap<>()); + + assertDoesNotThrow(() -> jwtService.validateToken(token)); + } + + @Test + void testValidateTokenWithInvalidToken() { + assertThrows(AuthenticationFailureException.class, () -> { + jwtService.validateToken("invalid-token"); + }); + } + + // fixme +// @Test +// void testValidateTokenWithExpiredToken() { +// // Create a token that expires immediately +// JWTService shortLivedJwtService = new JWTService(true); +// String token = shortLivedJwtService.generateToken("testuser", new HashMap<>()); +// +// // Wait a bit to ensure expiration +// try { +// Thread.sleep(10); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// } +// +// assertThrows(AuthenticationFailureException.class, () -> { +// shortLivedJwtService.validateToken(token); +// }); +// } + + @Test + void testValidateTokenWithMalformedToken() { + AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { + jwtService.validateToken("malformed.token"); + }); + + assertTrue(exception.getMessage().contains("Invalid")); + } + + @Test + void testValidateTokenWithEmptyToken() { + AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { + jwtService.validateToken(""); + }); + + assertTrue(exception.getMessage().contains("Claims are empty") || exception.getMessage().contains("Invalid")); + } + + @Test + void testExtractUsername() { + String username = "testuser"; + User user = mock(User.class); + Map claims = Map.of("sub", "testuser", "authType", "WEB"); + + when(authentication.getPrincipal()).thenReturn(user); + when(user.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testExtractUsernameWithInvalidToken() { + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractUsername("invalid-token")); + } + + @Test + void testExtractAllClaims() { + String username = "testuser"; + Map claims = Map.of("role", "admin", "department", "IT"); + + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + Map extractedClaims = jwtService.extractAllClaims(token); + + assertEquals("admin", extractedClaims.get("role")); + assertEquals("IT", extractedClaims.get("department")); + assertEquals(username, extractedClaims.get("sub")); + assertEquals("Stirling PDF", extractedClaims.get("iss")); + } + + @Test + void testExtractAllClaimsWithInvalidToken() { + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractAllClaims("invalid-token")); + } + + // fixme +// @Test +// void testIsTokenExpired() { +// String token = jwtService.generateToken("testuser", new HashMap<>()); +// assertFalse(jwtService.isTokenExpired(token)); +// +// JWTService shortLivedJwtService = new JWTService(); +// String expiredToken = shortLivedJwtService.generateToken("testuser", new HashMap<>()); +// +// try { +// Thread.sleep(10); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// } +// +// assertThrows(AuthenticationFailureException.class, () -> shortLivedJwtService.isTokenExpired(expiredToken)); +// } + + @Test + void testExtractTokenFromRequestWithAuthorizationHeader() { + String token = "test-token"; + when(request.getHeader("Authorization")).thenReturn("Bearer " + token); + + assertEquals(token, jwtService.extractTokenFromRequest(request)); + } + + @Test + void testExtractTokenFromRequestWithCookie() { + String token = "test-token"; + Cookie[] cookies = { new Cookie("STIRLING_JWT", token) }; + when(request.getHeader("Authorization")).thenReturn(null); + when(request.getCookies()).thenReturn(cookies); + + assertEquals(token, jwtService.extractTokenFromRequest(request)); + } + + @Test + void testExtractTokenFromRequestWithNoCookies() { + when(request.getHeader("Authorization")).thenReturn(null); + when(request.getCookies()).thenReturn(null); + + assertNull(jwtService.extractTokenFromRequest(request)); + } + + @Test + void testExtractTokenFromRequestWithWrongCookie() { + Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; + when(request.getHeader("Authorization")).thenReturn(null); + when(request.getCookies()).thenReturn(cookies); + + assertNull(jwtService.extractTokenFromRequest(request)); + } + + @Test + void testExtractTokenFromRequestWithInvalidAuthorizationHeader() { + when(request.getHeader("Authorization")).thenReturn("Basic token"); + when(request.getCookies()).thenReturn(null); + + assertNull(jwtService.extractTokenFromRequest(request)); + } + + @Test + void testAddTokenToResponse() { + String token = "test-token"; + + jwtService.addTokenToResponse(response, token); + + verify(response).setHeader("Authorization", "Bearer " + token); + verify(response).addHeader(eq("Set-Cookie"), contains("STIRLING_JWT=" + token)); + verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly")); + verify(response).addHeader(eq("Set-Cookie"), contains("Secure")); +// verify(response).addHeader(eq("Set-Cookie"), contains("SameSite=Strict")); + } + + @Test + void testClearTokenFromResponse() { + jwtService.clearTokenFromResponse(response); + + verify(response).setHeader("Authorization", ""); + verify(response).addHeader(eq("Set-Cookie"), contains("STIRLING_JWT=")); + verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); + } +}