From 98fb8012476e36465bbaf381066efd20e97ec8f5 Mon Sep 17 00:00:00 2001 From: DarioGii Date: Fri, 4 Jul 2025 10:38:35 +0100 Subject: [PATCH 01/23] Adding JWTService and filter --- .claude/settings.local.json | 6 +- CLAUDE.md | 120 +++++++++++++++++ .../common/model/ApplicationProperties.java | 27 +++- .../src/main/resources/application.properties | 4 +- .../src/main/resources/settings.yml.template | 8 ++ app/proprietary/build.gradle | 11 ++ .../CustomAuthenticationSuccessHandler.java | 55 +++++--- .../security/CustomLogoutSuccessHandler.java | 21 ++- .../configuration/SecurityConfiguration.java | 122 ++++++++++++------ ...tomOAuth2AuthenticationSuccessHandler.java | 4 +- ...stomSaml2AuthenticationSuccessHandler.java | 4 +- .../service/CustomOAuth2UserService.java | 8 +- .../CustomLogoutSuccessHandlerTest.java | 35 ++--- 13 files changed, 326 insertions(+), 99 deletions(-) create mode 100644 CLAUDE.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6e006423a..13d8d8350 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,11 @@ "Bash(mkdir:*)", "Bash(./gradlew:*)", "Bash(grep:*)", - "Bash(cat:*)" + "Bash(cat:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(strings:*)" ], "deny": [] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..880e2bf40 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,120 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Essential Development Commands + +### Build and Run +```bash +# Build the project +./gradlew clean build + +# Run locally (includes JWT authentication work-in-progress) +./gradlew bootRun + +# Run specific module +./gradlew :stirling-pdf:bootRun + +# Build with security features enabled/disabled +DISABLE_ADDITIONAL_FEATURES=false ./gradlew clean build # enable security +DISABLE_ADDITIONAL_FEATURES=true ./gradlew clean build # disable security +``` + +### Testing +```bash +# Run unit tests +./gradlew test + +# Run comprehensive integration tests (builds all Docker versions and runs Cucumber tests) +./testing/test.sh + +# Run Cucumber/BDD tests specifically +cd testing/cucumber && python -m behave + +# Test web pages +cd testing && ./test_webpages.sh -f webpage_urls.txt -b http://localhost:8080 +``` + +### Code Quality and Formatting +```bash +# Apply Java code formatting (required before commits) +./gradlew spotlessApply + +# Check formatting compliance +./gradlew spotlessCheck + +# Generate license report +./gradlew generateLicenseReport +``` + +### Docker Development +```bash +# Build different Docker variants +docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile . +docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite . +DISABLE_ADDITIONAL_FEATURES=false docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . + +# Use example Docker Compose configs +docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up -d +``` + +## Architecture Overview + +Stirling-PDF is a Spring Boot web application for PDF manipulation with the following key architectural components: + +### Multi-Module Structure +- **stirling-pdf/**: Main application module with web UI and REST APIs +- **common/**: Shared utilities and common functionality +- **proprietary/**: Enterprise/security features (JWT authentication, audit, teams) + +### Technology Stack +- **Backend**: Spring Boot 3.5, Spring Security, Spring Data JPA +- **Frontend**: Thymeleaf templates, Bootstrap, vanilla JavaScript +- **PDF Processing**: Apache PDFBox 3.0, qpdf, LibreOffice +- **Authentication**: JWT-based stateless sessions (in development) +- **Database**: H2 (default), supports PostgreSQL/MySQL +- **Build**: Gradle with multi-project setup + +### Current Development Context +The repository is on the `jwt-authentication` branch with work-in-progress changes to: +- JWT-based authentication system (`JWTService`, `JWTServiceInterface`) +- Stateless session management +- User model updates for JWT support + +### Key Directories +- `stirling-pdf/src/main/java/stirling/software/SPDF/`: Main application code + - `controller/`: REST API endpoints and UI controllers + - `service/`: Business logic layer + - `config/`: Spring configuration classes + - `security/`: Authentication and authorization +- `stirling-pdf/src/main/resources/templates/`: Thymeleaf HTML templates +- `stirling-pdf/src/main/resources/static/`: CSS, JavaScript, and assets +- `proprietary/src/main/java/stirling/software/proprietary/`: Enterprise features +- `testing/`: Integration tests and Cucumber features + +### Configuration Management +- Environment variables or `settings.yml` for runtime configuration +- Conditional feature compilation based on `DISABLE_ADDITIONAL_FEATURES` +- Multi-environment Docker configurations in `exampleYmlFiles/` + +### API Design Patterns +- RESTful endpoints under `/api/v1/` +- OpenAPI/Swagger documentation available at `/swagger-ui/index.html` +- File upload/download handling with multipart form data +- Consistent error handling and response formats + +## Development Workflow + +1. **Environment Setup**: Set `DISABLE_ADDITIONAL_FEATURES=false` for full feature development +2. **Code Formatting**: Always run `./gradlew spotlessApply` before committing +3. **Testing Strategy**: Use `./testing/test.sh` for comprehensive testing before PRs +4. **Feature Development**: Follow the controller -> service -> template pattern +5. **Security**: JWT authentication is currently in development on this branch + +## Important Notes + +- The application supports conditional compilation of security features +- Translation files are in `messages_*.properties` format +- PDF processing operations are primarily stateless +- Docker is the recommended deployment method +- All text should be internationalized using translation keys \ No newline at end of file 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 802a55831..9e2705b3e 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 @@ -115,13 +115,14 @@ public class ApplicationProperties { private InitialLogin initialLogin = new InitialLogin(); private OAUTH2 oauth2 = new OAUTH2(); private SAML2 saml2 = new SAML2(); + private JWT jwt = new JWT(); private int loginAttemptCount; private long loginResetTimeMinutes; private String loginMethod = "all"; private String customGlobalAPIKey; public Boolean isAltLogin() { - return saml2.getEnabled() || oauth2.getEnabled(); + return saml2.getEnabled() || oauth2.getEnabled() || jwt.getEnabled(); } public enum LoginMethods { @@ -159,6 +160,10 @@ public class ApplicationProperties { && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); } + public boolean isJwtActive() { + return (jwt != null && jwt.getEnabled()); + } + @Data public static class InitialLogin { private String username; @@ -297,6 +302,26 @@ public class ApplicationProperties { } } } + + @Data + public static class JWT { + private Boolean enabled = false; + @ToString.Exclude private String secretKey; + private Long expiration = 3600000L; // Default 1 hour in milliseconds + private String algorithm = "HS256"; // Default HMAC algorithm + private String issuer = "Stirling-PDF"; // Default issuer + private Boolean enableRefreshToken = false; + private Long refreshTokenExpiration = 86400000L; // Default 24 hours + + public boolean isSettingsValid() { + return enabled != null + && enabled + && secretKey != null + && !secretKey.trim().isEmpty() + && expiration != null + && expiration > 0; + } + } } @Data diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index ea30bf78e..fecfd1c21 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -5,7 +5,7 @@ logging.level.org.eclipse.jetty=WARN #logging.level.org.springframework.security.saml2=TRACE #logging.level.org.springframework.security=DEBUG #logging.level.org.opensaml=DEBUG -#logging.level.stirling.software.SPDF.config.security: DEBUG +#logging.level.stirling.software.proprietary.security: DEBUG logging.level.com.zaxxer.hikari=WARN spring.jpa.open-in-view=false server.forward-headers-strategy=NATIVE @@ -47,4 +47,4 @@ posthog.host=https://eu.i.posthog.com spring.main.allow-bean-definition-overriding=true # Set up a consistent temporary directory location -java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} \ No newline at end of file +java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index cf22262e4..91a6e4f4f 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -59,6 +59,14 @@ security: idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider 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: + enabled: true # set to 'true' to enable JWT authentication + secretKey: 'Uz4BgfMySCz2Uplhp1x9ij19vVV2bXYktROtrlw3CC0=' # secret + expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms) + algorithm: HS256 # JWT signing algorithm. Default is HS256 + issuer: Stirling-PDF # Issuer of the JWT token. Default is 'Stirling-PDF' + refreshTokenEnabled: false # Set to 'true' to enable refresh tokens + refreshTokenExpiration: 86400000 # Expiration time for refresh tokens in milliseconds. Default is 1 day (86400000 ms) premium: key: 00000000-0000-0000-0000-000000000000 diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 2a72f8a65..197e5439e 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -1,9 +1,15 @@ repositories { maven { url = "https://build.shibboleth.net/maven/releases" } } + +ext { + jwtVersion = '0.12.6' +} + bootRun { enabled = false } + spotless { java { target sourceSets.main.allJava @@ -38,6 +44,11 @@ dependencies { implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' api 'io.micrometer:micrometer-registry-prometheus' implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0' + + // JWT dependencies + api "io.jsonwebtoken:jjwt-api:$jwtVersion" + runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion" + runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion" runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database runtimeOnly 'org.postgresql:postgresql:42.7.7' constraints { 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 d5180c321..ea115c4ef 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 @@ -17,6 +17,7 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; +import stirling.software.proprietary.security.service.JWTServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -24,13 +25,17 @@ import stirling.software.proprietary.security.service.UserService; public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { - private LoginAttemptService loginAttemptService; - private UserService userService; + private final LoginAttemptService loginAttemptService; + private final UserService userService; + private final JWTServiceInterface jwtService; public CustomAuthenticationSuccessHandler( - LoginAttemptService loginAttemptService, UserService userService) { + LoginAttemptService loginAttemptService, + UserService userService, + JWTServiceInterface jwtService) { this.loginAttemptService = loginAttemptService; this.userService = userService; + this.jwtService = jwtService; } @Override @@ -46,23 +51,35 @@ public class CustomAuthenticationSuccessHandler } loginAttemptService.loginSucceeded(userName); - // Get the saved request - HttpSession session = request.getSession(false); - SavedRequest savedRequest = - (session != null) - ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") - : null; - - if (savedRequest != null - && !RequestUriUtils.isStaticResource( - request.getContextPath(), savedRequest.getRedirectUrl())) { - // Redirect to the original destination - super.onAuthenticationSuccess(request, response, authentication); - } else { - // Redirect to the root URL (considering context path) - getRedirectStrategy().sendRedirect(request, response, "/"); + // Generate JWT token if JWT authentication is enabled + boolean jwtEnabled = jwtService.isJwtEnabled(); + if (jwtService != null && jwtEnabled) { + try { + String jwt = jwtService.generateToken(authentication); + jwtService.addTokenToResponse(response, jwt); + log.debug("JWT token generated and added to response for user: {}", userName); + } catch (Exception e) { + log.error("Failed to generate JWT token for user: {}", userName, e); + } } - // super.onAuthenticationSuccess(request, response, authentication); + if (jwtEnabled) { + // JWT mode: stateless authentication, redirect after setting token + getRedirectStrategy().sendRedirect(request, response, "/"); + } else { + // Get the saved request + HttpSession session = request.getSession(false); + SavedRequest savedRequest = + (session != null) + ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") + : null; + + if (savedRequest != null + && !RequestUriUtils.isStaticResource( + request.getContextPath(), savedRequest.getRedirectUrl())) { + // Redirect to the original destination + super.onAuthenticationSuccess(request, response, authentication); + } + } } } 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 033ea913c..2f19fedca 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 @@ -33,6 +33,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; @Slf4j @RequiredArgsConstructor @@ -40,15 +41,29 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { public static final String LOGOUT_PATH = "/login?logout=true"; - private final ApplicationProperties applicationProperties; + private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; + private final JWTServiceInterface jwtService; + @Override @Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC) public void onLogoutSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + + // Clear JWT token if JWT authentication is enabled + if (jwtService != null && jwtService.isJwtEnabled()) { + try { + jwtService.clearTokenFromResponse(response); + log.debug("JWT token cleared from response during logout"); + } catch (Exception e) { + log.error("Failed to clear JWT token during logout", e); + // Continue with normal logout flow even if JWT clearing fails + } + } + if (!response.isCommitted()) { if (authentication != null) { if (authentication instanceof Saml2Authentication samlAuthentication) { @@ -82,7 +97,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { Saml2Authentication samlAuthentication) throws IOException { - SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); + SAML2 samlConf = securityProperties.getSaml2(); String registrationId = samlConf.getRegistrationId(); CustomSaml2AuthenticatedPrincipal principal = @@ -127,7 +142,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { OAuth2AuthenticationToken oAuthToken) throws IOException { String registrationId; - OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); + OAUTH2 oauth = securityProperties.getOauth2(); String path = checkForErrors(request); String redirectUrl = UrlUtils.getOrigin(request) + "/login?" + path; 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 ab809a037..5185ac1ab 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 @@ -8,11 +8,14 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Lazy; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -39,6 +42,7 @@ import stirling.software.proprietary.security.database.repository.JPATokenReposi import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; import stirling.software.proprietary.security.filter.FirstLoginFilter; import stirling.software.proprietary.security.filter.IPRateLimitingFilter; +import stirling.software.proprietary.security.filter.JWTAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler; @@ -48,6 +52,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.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -64,9 +69,11 @@ public class SecurityConfiguration { private final boolean loginEnabledValue; private final boolean runningProOrHigher; - private final ApplicationProperties applicationProperties; + private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; + private final JWTAuthenticationFilter jwtAuthenticationFilter; + private final JWTServiceInterface jwtService; private final LoginAttemptService loginAttemptService; private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; @@ -82,8 +89,10 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, - ApplicationProperties applicationProperties, + ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, + JWTAuthenticationFilter jwtAuthenticationFilter, + JWTServiceInterface jwtService, LoginAttemptService loginAttemptService, FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @@ -97,8 +106,10 @@ public class SecurityConfiguration { this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; this.appConfig = appConfig; - this.applicationProperties = applicationProperties; + this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.jwtService = jwtService; this.loginAttemptService = loginAttemptService; this.firstLoginFilter = firstLoginFilter; this.sessionRegistry = sessionRegistry; @@ -115,14 +126,27 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) { - http.csrf(csrf -> csrf.disable()); + boolean jwtEnabled = securityProperties.isJwtActive(); + + // Disable CSRF if explicitly disabled, login is disabled, or JWT is enabled (stateless) + if (securityProperties.getCsrfDisabled() || !loginEnabledValue || jwtEnabled) { + http.csrf(CsrfConfigurer::disable); } if (loginEnabledValue) { - http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - if (!applicationProperties.getSecurity().getCsrfDisabled()) { + if (jwtEnabled && jwtAuthenticationFilter != null) { + http.addFilterBefore( + jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + // .addFilterAfter( + // jwtAuthenticationFilter, + // userAuthenticationFilter.getClass()); + } else { + http.addFilterBefore( + userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + } + http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitingFilter(), firstLoginFilter.getClass()); + if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = @@ -156,18 +180,25 @@ public class SecurityConfiguration { .csrfTokenRepository(cookieRepo) .csrfTokenRequestHandler(requestHandler)); } - http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + + // Configure session management based on JWT setting http.sessionManagement( - sessionManagement -> + sessionManagement -> { + if (jwtEnabled) { + sessionManagement.sessionCreationPolicy( + SessionCreationPolicy.STATELESS); + } else { sessionManagement .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(10) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry) - .expiredUrl("/login?logout=true")); + .expiredUrl("/login?logout=true"); + } + }); http.authenticationProvider(daoAuthenticationProvider()); http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); + // Configure logout behavior based on JWT setting http.logout( logout -> logout.logoutRequestMatcher( @@ -175,31 +206,36 @@ public class SecurityConfiguration { .matcher("/logout")) .logoutSuccessHandler( new CustomLogoutSuccessHandler( - applicationProperties, appConfig)) + securityProperties, appConfig, jwtService)) .clearAuthentication(true) .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "remember-me")); - http.rememberMe( - rememberMeConfigurer -> // Use the configurator directly - rememberMeConfigurer - .tokenRepository(persistentTokenRepository()) - .tokenValiditySeconds( // 14 days - 14 * 24 * 60 * 60) - .userDetailsService( // Your existing UserDetailsService - userDetailsService) - .useSecureCookie( // Enable secure cookie - true) - .rememberMeParameter( // Form parameter name - "remember-me") - .rememberMeCookieName( // Cookie name - "remember-me") - .alwaysRemember(false)); + .deleteCookies( + "JSESSIONID", "remember-me", "STIRLING_JWT_TOKEN")); + // Only configure remember-me if JWT is not enabled (stateless) todo: check if remember-me can be used with JWT + if (!jwtEnabled) { + http.rememberMe( + rememberMeConfigurer -> // Use the configurator directly + rememberMeConfigurer + .tokenRepository(persistentTokenRepository()) + .tokenValiditySeconds( // 14 days + 14 * 24 * 60 * 60) + .userDetailsService( // Your existing UserDetailsService + userDetailsService) + .useSecureCookie( // Enable secure cookie + true) + .rememberMeParameter( // Form parameter name + "remember-me") + .rememberMeCookieName( // Cookie name + "remember-me") + .alwaysRemember(false)); + } http.authorizeHttpRequests( authz -> authz.requestMatchers( req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); + // Remove the context path from the URI String trimmedUri = uri.startsWith(contextPath) @@ -224,22 +260,23 @@ public class SecurityConfiguration { .anyRequest() .authenticated()); // Handle User/Password Logins - if (applicationProperties.getSecurity().isUserPass()) { + if (securityProperties.isUserPass()) { http.formLogin( formLogin -> formLogin .loginPage("/login") .successHandler( new CustomAuthenticationSuccessHandler( - loginAttemptService, userService)) + loginAttemptService, + userService, + jwtService)) .failureHandler( new CustomAuthenticationFailureHandler( loginAttemptService, userService)) - .defaultSuccessUrl("/") .permitAll()); } // Handle OAUTH2 Logins - if (applicationProperties.getSecurity().isOauth2Active()) { + if (securityProperties.isOauth2Active()) { http.oauth2Login( oauth2 -> oauth2.loginPage("/oauth2") @@ -251,17 +288,17 @@ public class SecurityConfiguration { .successHandler( new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, - applicationProperties, + securityProperties, userService)) .failureHandler( new CustomOAuth2AuthenticationFailureHandler()) - . // Add existing Authorities from the database - userInfoEndpoint( + // Add existing Authorities from the database + .userInfoEndpoint( userInfoEndpoint -> userInfoEndpoint .oidcUserService( new CustomOAuth2UserService( - applicationProperties, + securityProperties, userService, loginAttemptService)) .userAuthoritiesMapper( @@ -269,7 +306,7 @@ public class SecurityConfiguration { .permitAll()); } // Handle SAML - if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) { + if (securityProperties.isSaml2Active() && runningProOrHigher) { // Configure the authentication provider OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); @@ -287,7 +324,7 @@ public class SecurityConfiguration { .successHandler( new CustomSaml2AuthenticationSuccessHandler( loginAttemptService, - applicationProperties, + securityProperties, userService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) @@ -306,6 +343,13 @@ public class SecurityConfiguration { return http.build(); } + // todo: check if this is needed + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) + throws Exception { + return configuration.getAuthenticationManager(); + } + public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userDetailsService); provider.setPasswordEncoder(passwordEncoder()); 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 71bd42a85..9b64bd68b 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 @@ -30,7 +30,7 @@ public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final LoginAttemptService loginAttemptService; - private final ApplicationProperties applicationProperties; + private final ApplicationProperties.Security securityProperties; private final UserService userService; @Override @@ -60,7 +60,7 @@ public class CustomOAuth2AuthenticationSuccessHandler // Redirect to the original destination super.onAuthenticationSuccess(request, response, authentication); } else { - OAUTH2 oAuth = applicationProperties.getSecurity().getOauth2(); + OAUTH2 oAuth = securityProperties.getOauth2(); if (loginAttemptService.isBlocked(username)) { if (session != null) { 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 2170a9632..eeb73ef7e 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 @@ -30,7 +30,7 @@ public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private LoginAttemptService loginAttemptService; - private ApplicationProperties applicationProperties; + private ApplicationProperties.Security securityProperties; private UserService userService; @Override @@ -65,7 +65,7 @@ public class CustomSaml2AuthenticationSuccessHandler savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { - SAML2 saml2 = applicationProperties.getSecurity().getSaml2(); + SAML2 saml2 = securityProperties.getSaml2(); log.debug( "Processing SAML2 authentication with autoCreateUser: {}", saml2.getAutoCreateUser()); 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 0b286e894..8f9afbe3d 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 @@ -27,13 +27,13 @@ public class CustomOAuth2UserService implements OAuth2UserService Date: Fri, 4 Jul 2025 18:46:32 +0100 Subject: [PATCH 02/23] Fixed JWT auth flow Adding tests --- .gitignore | 3 + .../common/model/ApplicationProperties.java | 8 +-- .../src/main/resources/settings.yml.template | 11 ++-- .../CustomAuthenticationSuccessHandler.java | 4 +- .../security/CustomLogoutSuccessHandler.java | 16 ++---- .../configuration/SecurityConfiguration.java | 57 ++++++++++--------- .../filter/UserAuthenticationFilter.java | 14 ++--- .../CustomLogoutSuccessHandlerTest.java | 24 ++++++++ 8 files changed, 76 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 6ebd87c35..55603719c 100644 --- a/.gitignore +++ b/.gitignore @@ -200,3 +200,6 @@ id_ed25519.pub # node_modules node_modules/ + +# Claude +CLAUDE.md 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 9e2705b3e..c3abe0c26 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 @@ -306,7 +306,6 @@ public class ApplicationProperties { @Data public static class JWT { private Boolean enabled = false; - @ToString.Exclude private String secretKey; private Long expiration = 3600000L; // Default 1 hour in milliseconds private String algorithm = "HS256"; // Default HMAC algorithm private String issuer = "Stirling-PDF"; // Default issuer @@ -314,12 +313,7 @@ public class ApplicationProperties { private Long refreshTokenExpiration = 86400000L; // Default 24 hours public boolean isSettingsValid() { - return enabled != null - && enabled - && secretKey != null - && !secretKey.trim().isEmpty() - && expiration != null - && expiration > 0; + return enabled != null && enabled && expiration != null && expiration > 0; } } } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 91a6e4f4f..1aae02f2d 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -11,14 +11,14 @@ ############################################################################################################# security: - enableLogin: false # set to 'true' to enable login - csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production) + enableLogin: true # set to 'true' to enable login + csrfDisabled: true # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) initialLogin: - username: '' # initial username for the first login - password: '' # initial password for the first login + username: 'admin' # initial username for the first login + password: 'stirling' # initial password for the first login oauth2: enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) client: @@ -61,12 +61,9 @@ security: spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair jwt: enabled: true # set to 'true' to enable JWT authentication - secretKey: 'Uz4BgfMySCz2Uplhp1x9ij19vVV2bXYktROtrlw3CC0=' # secret expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms) algorithm: HS256 # JWT signing algorithm. Default is HS256 issuer: Stirling-PDF # Issuer of the JWT token. Default is 'Stirling-PDF' - refreshTokenEnabled: false # Set to 'true' to enable refresh tokens - refreshTokenExpiration: 86400000 # Expiration time for refresh tokens in milliseconds. Default is 1 day (86400000 ms) premium: key: 00000000-0000-0000-0000-000000000000 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 ea115c4ef..63bb87613 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 @@ -53,11 +53,11 @@ public class CustomAuthenticationSuccessHandler // Generate JWT token if JWT authentication is enabled boolean jwtEnabled = jwtService.isJwtEnabled(); - if (jwtService != null && jwtEnabled) { + if (jwtEnabled) { try { String jwt = jwtService.generateToken(authentication); jwtService.addTokenToResponse(response, jwt); - log.debug("JWT token generated and added to response for user: {}", userName); + log.debug("JWT generated for user: {}", userName); } catch (Exception e) { log.error("Failed to generate JWT token for user: {}", userName, e); } 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 2f19fedca..8249d31f5 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 @@ -53,17 +53,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - // Clear JWT token if JWT authentication is enabled - if (jwtService != null && jwtService.isJwtEnabled()) { - try { - jwtService.clearTokenFromResponse(response); - log.debug("JWT token cleared from response during logout"); - } catch (Exception e) { - log.error("Failed to clear JWT token during logout", e); - // Continue with normal logout flow even if JWT clearing fails - } - } - if (!response.isCommitted()) { if (authentication != null) { if (authentication instanceof Saml2Authentication samlAuthentication) { @@ -82,6 +71,11 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } + } else if (jwtService.isJwtEnabled()) { + // Clear JWT token if JWT authentication is enabled + jwtService.clearTokenFromResponse(response); + log.debug("Cleared JWT from response"); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout String path = checkForErrors(request); 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 5185ac1ab..8090ced3b 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,6 +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.database.repository.JPATokenRepositoryImpl; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; import stirling.software.proprietary.security.filter.FirstLoginFilter; @@ -74,6 +75,7 @@ public class SecurityConfiguration { private final UserAuthenticationFilter userAuthenticationFilter; private final JWTAuthenticationFilter jwtAuthenticationFilter; private final JWTServiceInterface jwtService; + private final JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; @@ -93,6 +95,7 @@ public class SecurityConfiguration { UserAuthenticationFilter userAuthenticationFilter, JWTAuthenticationFilter jwtAuthenticationFilter, JWTServiceInterface jwtService, + JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @@ -110,6 +113,7 @@ public class SecurityConfiguration { this.userAuthenticationFilter = userAuthenticationFilter; this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.jwtService = jwtService; + this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; this.firstLoginFilter = firstLoginFilter; this.sessionRegistry = sessionRegistry; @@ -136,16 +140,19 @@ public class SecurityConfiguration { if (loginEnabledValue) { if (jwtEnabled && jwtAuthenticationFilter != null) { http.addFilterBefore( - jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - // .addFilterAfter( - // jwtAuthenticationFilter, - // userAuthenticationFilter.getClass()); + jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling( + exceptionHandling -> + exceptionHandling.authenticationEntryPoint( + jwtAuthenticationEntryPoint)); } else { http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + userAuthenticationFilter, + UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(userAuthenticationFilter, firstLoginFilter.getClass()); } - http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(rateLimitingFilter(), firstLoginFilter.getClass()); + http.addFilterAfter(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); + if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); @@ -198,7 +205,6 @@ public class SecurityConfiguration { }); http.authenticationProvider(daoAuthenticationProvider()); http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache())); - // Configure logout behavior based on JWT setting http.logout( logout -> logout.logoutRequestMatcher( @@ -211,24 +217,21 @@ public class SecurityConfiguration { .invalidateHttpSession(true) .deleteCookies( "JSESSIONID", "remember-me", "STIRLING_JWT_TOKEN")); - // Only configure remember-me if JWT is not enabled (stateless) todo: check if remember-me can be used with JWT - if (!jwtEnabled) { - http.rememberMe( - rememberMeConfigurer -> // Use the configurator directly - rememberMeConfigurer - .tokenRepository(persistentTokenRepository()) - .tokenValiditySeconds( // 14 days - 14 * 24 * 60 * 60) - .userDetailsService( // Your existing UserDetailsService - userDetailsService) - .useSecureCookie( // Enable secure cookie - true) - .rememberMeParameter( // Form parameter name - "remember-me") - .rememberMeCookieName( // Cookie name - "remember-me") - .alwaysRemember(false)); - } + http.rememberMe( + rememberMeConfigurer -> // Use the configurator directly + rememberMeConfigurer + .tokenRepository(persistentTokenRepository()) + .tokenValiditySeconds( // 14 days + 14 * 24 * 60 * 60) + .userDetailsService( // Your existing UserDetailsService + userDetailsService) + .useSecureCookie( // Enable secure cookie + true) + .rememberMeParameter( // Form parameter name + "remember-me") + .rememberMeCookieName( // Cookie name + "remember-me") + .alwaysRemember(false)); http.authorizeHttpRequests( authz -> authz.requestMatchers( @@ -253,6 +256,7 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/css/") || trimmedUri.startsWith("/fonts/") || trimmedUri.startsWith("/js/") + || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( "/api/v1/info/status"); }) @@ -343,7 +347,6 @@ public class SecurityConfiguration { return http.build(); } - // todo: check if this is needed @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index e9addd239..971cd4859 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -117,18 +117,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) { response.sendRedirect(contextPath + "/login"); // redirect to the login page - return; } else { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter() .write( - "Authentication required. Please provide a X-API-KEY in request" - + " header.\n" - + "This is found in Settings -> Account Settings -> API Key\n" - + "Alternatively you can disable authentication if this is" - + " unexpected"); - return; + """ + Authentication required. Please provide a X-API-KEY in request\ + header. + This is found in Settings -> Account Settings -> API Key + Alternatively you can disable authentication if this is\ + unexpected"""); } + return; } // Check if the authenticated user is disabled and invalidate their session if so 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 5a392f369..4e4bf8552 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 @@ -9,7 +9,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; 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 static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -17,6 +19,10 @@ class CustomLogoutSuccessHandlerTest { @Mock private ApplicationProperties.Security securityProperties; + @Mock private AppConfig appConfig; + + @Mock private JWTServiceInterface jwtService; + @InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler; @Test @@ -26,6 +32,7 @@ class CustomLogoutSuccessHandlerTest { String logoutPath = "logout=true"; when(response.isCommitted()).thenReturn(false); + when(jwtService.isJwtEnabled()).thenReturn(false); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -34,6 +41,23 @@ class CustomLogoutSuccessHandlerTest { verify(response).sendRedirect(logoutPath); } + @Test + void testSuccessfulLogoutViaJWT() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + String logoutPath = "/login?logout=true"; + + when(response.isCommitted()).thenReturn(false); + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn(""); + when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); + + customLogoutSuccessHandler.onLogoutSuccess(request, response, null); + + verify(response).sendRedirect(logoutPath); + verify(jwtService).clearTokenFromResponse(response); + } + @Test void testSuccessfulLogoutViaOAuth2() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); From 006c1866949d72ce6dc2ba296717b934aaeed7af Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 10 Jul 2025 13:16:29 +0100 Subject: [PATCH 03/23] More clean up --- .../configuration/SecurityConfiguration.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) 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 8090ced3b..6d3caa690 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 @@ -73,7 +73,6 @@ public class SecurityConfiguration { private final ApplicationProperties.Security securityProperties; private final AppConfig appConfig; private final UserAuthenticationFilter userAuthenticationFilter; - private final JWTAuthenticationFilter jwtAuthenticationFilter; private final JWTServiceInterface jwtService; private final JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; @@ -93,7 +92,6 @@ public class SecurityConfiguration { AppConfig appConfig, ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, - JWTAuthenticationFilter jwtAuthenticationFilter, JWTServiceInterface jwtService, JWTAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, @@ -111,7 +109,6 @@ public class SecurityConfiguration { this.appConfig = appConfig; this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; - this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.jwtService = jwtService; this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; @@ -138,9 +135,10 @@ public class SecurityConfiguration { } if (loginEnabledValue) { - if (jwtEnabled && jwtAuthenticationFilter != null) { + if (jwtEnabled) { http.addFilterBefore( - jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + jwtAuthenticationFilter(), + UsernamePasswordAuthenticationFilter.class) .exceptionHandling( exceptionHandling -> exceptionHandling.authenticationEntryPoint( @@ -370,4 +368,10 @@ public class SecurityConfiguration { public PersistentTokenRepository persistentTokenRepository() { return new JPATokenRepositoryImpl(persistentLoginRepository); } + + @Bean + public JWTAuthenticationFilter jwtAuthenticationFilter() { + return new JWTAuthenticationFilter( + jwtService, userDetailsService, jwtAuthenticationEntryPoint); + } } From b53ac8954116d028c491496801f5f1a041996dda Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 10 Jul 2025 13:41:12 +0100 Subject: [PATCH 04/23] Removed file --- CLAUDE.md | 120 ------------------ .../common/configuration/AppConfig.java | 15 ++- .../common/model/ApplicationProperties.java | 21 +-- .../src/main/resources/application.properties | 3 + .../src/main/resources/settings.yml.template | 29 ++--- .../CustomAuthenticationSuccessHandler.java | 13 +- .../security/CustomLogoutSuccessHandler.java | 4 +- .../configuration/SecurityConfiguration.java | 36 +++--- .../filter/UserAuthenticationFilter.java | 12 +- .../security/model/AuthenticationType.java | 5 +- .../proprietary/security/model/Authority.java | 4 +- .../proprietary/security/model/User.java | 4 +- ...tomOAuth2AuthenticationSuccessHandler.java | 27 ++-- .../security/oauth2/OAuth2Configuration.java | 15 ++- ...stomSaml2AuthenticationSuccessHandler.java | 44 +++++-- .../security/saml2/SAML2Configuration.java | 15 ++- .../service/CustomUserDetailsService.java | 27 +--- .../security/service/UserService.java | 11 +- .../CustomLogoutSuccessHandlerTest.java | 10 +- 19 files changed, 154 insertions(+), 261 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 880e2bf40..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,120 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Essential Development Commands - -### Build and Run -```bash -# Build the project -./gradlew clean build - -# Run locally (includes JWT authentication work-in-progress) -./gradlew bootRun - -# Run specific module -./gradlew :stirling-pdf:bootRun - -# Build with security features enabled/disabled -DISABLE_ADDITIONAL_FEATURES=false ./gradlew clean build # enable security -DISABLE_ADDITIONAL_FEATURES=true ./gradlew clean build # disable security -``` - -### Testing -```bash -# Run unit tests -./gradlew test - -# Run comprehensive integration tests (builds all Docker versions and runs Cucumber tests) -./testing/test.sh - -# Run Cucumber/BDD tests specifically -cd testing/cucumber && python -m behave - -# Test web pages -cd testing && ./test_webpages.sh -f webpage_urls.txt -b http://localhost:8080 -``` - -### Code Quality and Formatting -```bash -# Apply Java code formatting (required before commits) -./gradlew spotlessApply - -# Check formatting compliance -./gradlew spotlessCheck - -# Generate license report -./gradlew generateLicenseReport -``` - -### Docker Development -```bash -# Build different Docker variants -docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile . -docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite . -DISABLE_ADDITIONAL_FEATURES=false docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . - -# Use example Docker Compose configs -docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up -d -``` - -## Architecture Overview - -Stirling-PDF is a Spring Boot web application for PDF manipulation with the following key architectural components: - -### Multi-Module Structure -- **stirling-pdf/**: Main application module with web UI and REST APIs -- **common/**: Shared utilities and common functionality -- **proprietary/**: Enterprise/security features (JWT authentication, audit, teams) - -### Technology Stack -- **Backend**: Spring Boot 3.5, Spring Security, Spring Data JPA -- **Frontend**: Thymeleaf templates, Bootstrap, vanilla JavaScript -- **PDF Processing**: Apache PDFBox 3.0, qpdf, LibreOffice -- **Authentication**: JWT-based stateless sessions (in development) -- **Database**: H2 (default), supports PostgreSQL/MySQL -- **Build**: Gradle with multi-project setup - -### Current Development Context -The repository is on the `jwt-authentication` branch with work-in-progress changes to: -- JWT-based authentication system (`JWTService`, `JWTServiceInterface`) -- Stateless session management -- User model updates for JWT support - -### Key Directories -- `stirling-pdf/src/main/java/stirling/software/SPDF/`: Main application code - - `controller/`: REST API endpoints and UI controllers - - `service/`: Business logic layer - - `config/`: Spring configuration classes - - `security/`: Authentication and authorization -- `stirling-pdf/src/main/resources/templates/`: Thymeleaf HTML templates -- `stirling-pdf/src/main/resources/static/`: CSS, JavaScript, and assets -- `proprietary/src/main/java/stirling/software/proprietary/`: Enterprise features -- `testing/`: Integration tests and Cucumber features - -### Configuration Management -- Environment variables or `settings.yml` for runtime configuration -- Conditional feature compilation based on `DISABLE_ADDITIONAL_FEATURES` -- Multi-environment Docker configurations in `exampleYmlFiles/` - -### API Design Patterns -- RESTful endpoints under `/api/v1/` -- OpenAPI/Swagger documentation available at `/swagger-ui/index.html` -- File upload/download handling with multipart form data -- Consistent error handling and response formats - -## Development Workflow - -1. **Environment Setup**: Set `DISABLE_ADDITIONAL_FEATURES=false` for full feature development -2. **Code Formatting**: Always run `./gradlew spotlessApply` before committing -3. **Testing Strategy**: Use `./testing/test.sh` for comprehensive testing before PRs -4. **Feature Development**: Follow the controller -> service -> template pattern -5. **Security**: JWT authentication is currently in development on this branch - -## Important Notes - -- The application supports conditional compilation of security features -- Translation files are in `messages_*.properties` format -- PDF processing operations are primarily stateless -- Docker is the recommended deployment method -- All text should be internationalized using translation keys \ No newline at end of file diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index f611f42ca..e24a92d6a 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Locale; import java.util.Properties; import java.util.function.Predicate; +import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -51,6 +52,14 @@ public class AppConfig { @Value("${server.port:8080}") private String serverPort; + @Value("${v2}") + public boolean v2Enabled; + + @Bean + public boolean v2Enabled() { + return v2Enabled; + } + @Bean @ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true") public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) { @@ -120,7 +129,7 @@ public class AppConfig { public boolean rateLimit() { String rateLimit = System.getProperty("rateLimit"); if (rateLimit == null) rateLimit = System.getenv("rateLimit"); - return (rateLimit != null) ? Boolean.valueOf(rateLimit) : false; + return Boolean.parseBoolean(rateLimit); } @Bean(name = "RunningInDocker") @@ -140,8 +149,8 @@ public class AppConfig { if (!Files.exists(mountInfo)) { return true; } - try { - return Files.lines(mountInfo).anyMatch(line -> line.contains(" /configs ")); + try (Stream lines = Files.lines(mountInfo)) { + return lines.anyMatch(line -> line.contains(" /configs ")); } catch (IOException e) { return false; } 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 c3abe0c26..802a55831 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 @@ -115,14 +115,13 @@ public class ApplicationProperties { private InitialLogin initialLogin = new InitialLogin(); private OAUTH2 oauth2 = new OAUTH2(); private SAML2 saml2 = new SAML2(); - private JWT jwt = new JWT(); private int loginAttemptCount; private long loginResetTimeMinutes; private String loginMethod = "all"; private String customGlobalAPIKey; public Boolean isAltLogin() { - return saml2.getEnabled() || oauth2.getEnabled() || jwt.getEnabled(); + return saml2.getEnabled() || oauth2.getEnabled(); } public enum LoginMethods { @@ -160,10 +159,6 @@ public class ApplicationProperties { && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); } - public boolean isJwtActive() { - return (jwt != null && jwt.getEnabled()); - } - @Data public static class InitialLogin { private String username; @@ -302,20 +297,6 @@ public class ApplicationProperties { } } } - - @Data - public static class JWT { - private Boolean enabled = false; - private Long expiration = 3600000L; // Default 1 hour in milliseconds - private String algorithm = "HS256"; // Default HMAC algorithm - private String issuer = "Stirling-PDF"; // Default issuer - private Boolean enableRefreshToken = false; - private Long refreshTokenExpiration = 86400000L; // Default 24 hours - - public boolean isSettingsValid() { - return enabled != null && enabled && expiration != null && expiration > 0; - } - } } @Data diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index fecfd1c21..ec3a0a390 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -48,3 +48,6 @@ spring.main.allow-bean-definition-overriding=true # Set up a consistent temporary directory location java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} + +# V2 features +v2=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1aae02f2d..07e0fb7db 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -12,13 +12,13 @@ security: enableLogin: true # set to 'true' to enable login - csrfDisabled: true # set to 'true' to disable CSRF protection (not recommended for production) + csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) initialLogin: - username: 'admin' # initial username for the first login - password: 'stirling' # initial password for the first login + username: '' # initial username for the first login + password: '' # initial password for the first login oauth2: enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) client: @@ -47,29 +47,24 @@ security: scopes: openid, profile, email # specify the scopes for which the application will request permissions provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak' saml2: - enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) - provider: '' # The name of your Provider + enabled: true # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) + provider: authentik # The name of your Provider autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider - idpIssuer: '' # The ID of your Provider - idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider - 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: - enabled: true # set to 'true' to enable JWT authentication - expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms) - algorithm: HS256 # JWT signing algorithm. Default is HS256 - issuer: Stirling-PDF # Issuer of the JWT token. Default is 'Stirling-PDF' + idpIssuer: authentik # The ID of your Provider + idpCert: classpath:authentik-Self_Signed_Certificate.pem # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider + privateKey: classpath:private-key.key # Your private key. Generated from your keypair + spCert: classpath:cert.crt # Your signing certificate. Generated from your keypair premium: key: 00000000-0000-0000-0000-000000000000 - enabled: false # Enable license key checks for pro/enterprise features + enabled: true # Enable license key checks for pro/enterprise features proFeatures: - database: true # Enable database features + database: false # Enable database features SSOAutoLogin: false CustomMetadata: autoUpdateMetadata: false @@ -105,7 +100,7 @@ legal: system: defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc) googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow - enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) + enableAlphaFunctionality: true # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) showUpdate: false # see when a new update is available showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true' customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files 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 63bb87613..8bcddf7d3 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 @@ -1,6 +1,7 @@ package stirling.software.proprietary.security; import java.io.IOException; +import java.util.Map; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; @@ -17,6 +18,7 @@ import stirling.software.common.util.RequestUriUtils; 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.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -51,20 +53,17 @@ public class CustomAuthenticationSuccessHandler } loginAttemptService.loginSucceeded(userName); - // Generate JWT token if JWT authentication is enabled - boolean jwtEnabled = jwtService.isJwtEnabled(); - if (jwtEnabled) { + if (jwtService.isJwtEnabled()) { try { - String jwt = jwtService.generateToken(authentication); + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.WEB)); jwtService.addTokenToResponse(response, jwt); log.debug("JWT generated for user: {}", userName); } catch (Exception e) { log.error("Failed to generate JWT token for user: {}", userName, e); } - } - if (jwtEnabled) { - // JWT mode: stateless authentication, redirect after setting token getRedirectStrategy().sendRedirect(request, response, "/"); } else { // Get the saved request 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 8249d31f5..ceb474a7c 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 @@ -71,10 +71,8 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } - } else if (jwtService.isJwtEnabled()) { - // Clear JWT token if JWT authentication is enabled + } else if (!jwtService.extractTokenFromRequest(request).isBlank()) { jwtService.clearTokenFromResponse(response); - log.debug("Cleared JWT from response"); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout 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 6d3caa690..2abeb5682 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 @@ -127,15 +127,14 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - boolean jwtEnabled = securityProperties.isJwtActive(); - - // Disable CSRF if explicitly disabled, login is disabled, or JWT is enabled (stateless) - if (securityProperties.getCsrfDisabled() || !loginEnabledValue || jwtEnabled) { + if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { http.csrf(CsrfConfigurer::disable); } if (loginEnabledValue) { - if (jwtEnabled) { + boolean v2Enabled = appConfig.v2Enabled(); + + if (v2Enabled) { http.addFilterBefore( jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) @@ -143,13 +142,10 @@ public class SecurityConfiguration { exceptionHandling -> exceptionHandling.authenticationEntryPoint( jwtAuthenticationEntryPoint)); - } else { - http.addFilterBefore( - userAuthenticationFilter, - UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(userAuthenticationFilter, firstLoginFilter.getClass()); } - http.addFilterAfter(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); + http.addFilterAt(userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitingFilter(), userAuthenticationFilter.getClass()) + .addFilterAfter(firstLoginFilter, rateLimitingFilter().getClass()); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -189,7 +185,7 @@ public class SecurityConfiguration { // Configure session management based on JWT setting http.sessionManagement( sessionManagement -> { - if (jwtEnabled) { + if (v2Enabled) { sessionManagement.sessionCreationPolicy( SessionCreationPolicy.STATELESS); } else { @@ -290,8 +286,9 @@ public class SecurityConfiguration { .successHandler( new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, - securityProperties, - userService)) + securityProperties.getOauth2(), + userService, + jwtService)) .failureHandler( new CustomOAuth2AuthenticationFailureHandler()) // Add existing Authorities from the database @@ -326,8 +323,9 @@ public class SecurityConfiguration { .successHandler( new CustomSaml2AuthenticationSuccessHandler( loginAttemptService, - securityProperties, - userService)) + securityProperties.getSaml2(), + userService, + jwtService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( @@ -372,6 +370,10 @@ public class SecurityConfiguration { @Bean public JWTAuthenticationFilter jwtAuthenticationFilter() { return new JWTAuthenticationFilter( - jwtService, userDetailsService, jwtAuthenticationEntryPoint); + jwtService, + userService, + userDetailsService, + jwtAuthenticationEntryPoint, + securityProperties); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 971cd4859..8a148e931 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -9,7 +9,6 @@ import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; @@ -92,14 +91,9 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { response.getWriter().write("Invalid API Key."); return; } - List authorities = - user.get().getAuthorities().stream() - .map( - authority -> - new SimpleGrantedAuthority( - authority.getAuthority())) - .toList(); - authentication = new ApiKeyAuthenticationToken(user.get(), apiKey, authorities); + authentication = + new ApiKeyAuthenticationToken( + user.get(), apiKey, user.get().getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (AuthenticationException e) { // If API key authentication fails, deny the request diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index ca8140bca..b3042dd25 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,5 +2,8 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - SSO + SSO, + // TODO: Worth making a distinction between OAuth2 and SAML2? + OAUTH2, + SAML2 } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 382d3a71e..a32e7d7ca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -2,6 +2,8 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; +import org.springframework.security.core.GrantedAuthority; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +20,7 @@ import lombok.Setter; @Table(name = "authorities") @Getter @Setter -public class Authority implements Serializable { +public class Authority implements GrantedAuthority, Serializable { private static final long serialVersionUID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index d3e232f61..7d1b235cd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -7,6 +7,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.security.core.userdetails.UserDetails; + import jakarta.persistence.*; import lombok.EqualsAndHashCode; @@ -25,7 +27,7 @@ import stirling.software.proprietary.model.Team; @Setter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) -public class User implements Serializable { +public class User implements UserDetails, Serializable { private static final long serialVersionUID = 1L; 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 9b64bd68b..0a3dd937e 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 @@ -1,7 +1,11 @@ package stirling.software.proprietary.security.oauth2; +import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.io.IOException; import java.sql.SQLException; +import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -18,10 +22,10 @@ import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; 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.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -30,8 +34,9 @@ public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final LoginAttemptService loginAttemptService; - private final ApplicationProperties.Security securityProperties; + private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; + private final JWTServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -60,8 +65,6 @@ public class CustomOAuth2AuthenticationSuccessHandler // Redirect to the original destination super.onAuthenticationSuccess(request, response, authentication); } else { - OAUTH2 oAuth = securityProperties.getOauth2(); - if (loginAttemptService.isBlocked(username)) { if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); @@ -69,7 +72,12 @@ public class CustomOAuth2AuthenticationSuccessHandler throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.OAUTH2)); + jwtService.addTokenToResponse(response, jwt); + } if (userService.isUserDisabled(username)) { getRedirectStrategy() .sendRedirect(request, response, "/logout?userIsDisabled=true"); @@ -77,20 +85,21 @@ public class CustomOAuth2AuthenticationSuccessHandler } if (userService.usernameExistsIgnoreCase(username) && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername(username, AuthenticationType.SSO) - && oAuth.getAutoCreateUser()) { + && !userService.isAuthenticationTypeByUsername(username, SSO) + && oauth2Properties.getAutoCreateUser()) { response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (oAuth.getBlockRegistration() + if (oauth2Properties.getBlockRegistration() && !userService.usernameExistsIgnoreCase(username)) { response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true"); return; } if (principal instanceof OAuth2User) { - userService.processSSOPostLogin(username, oAuth.getAutoCreateUser()); + userService.processSSOPostLogin( + username, oauth2Properties.getAutoCreateUser(), OAUTH2); } response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index 6516cc7d7..913dc458a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -34,6 +34,7 @@ import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; import stirling.software.common.model.oauth2.Provider; +import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.NoProviderFoundException; import stirling.software.proprietary.security.service.UserService; @@ -239,12 +240,14 @@ public class OAuth2Configuration { Optional userOpt = userService.findByUsernameIgnoreCase( (String) oAuth2Auth.getAttributes().get(useAsUsername)); - if (userOpt.isPresent()) { - User user = userOpt.get(); - mappedAuthorities.add( - new SimpleGrantedAuthority( - userService.findRole(user).getAuthority())); - } + userOpt.ifPresent( + user -> + mappedAuthorities.add( + new Authority( + userService + .findRole(user) + .getAuthority(), + user))); } }); return mappedAuthorities; 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 eeb73ef7e..bc5ab5ecd 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 @@ -1,7 +1,11 @@ package stirling.software.proprietary.security.saml2; +import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.io.IOException; import java.sql.SQLException; +import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -17,10 +21,10 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.SAML2; 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.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -30,8 +34,9 @@ public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private LoginAttemptService loginAttemptService; - private ApplicationProperties.Security securityProperties; + private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; + private final JWTServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -65,10 +70,20 @@ public class CustomSaml2AuthenticationSuccessHandler savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { - SAML2 saml2 = securityProperties.getSaml2(); + 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: {}", - saml2.getAutoCreateUser()); + saml2Properties.getAutoCreateUser()); if (loginAttemptService.isBlocked(username)) { log.debug("User {} is blocked due to too many login attempts", username); @@ -82,17 +97,21 @@ public class CustomSaml2AuthenticationSuccessHandler boolean userExists = userService.usernameExistsIgnoreCase(username); boolean hasPassword = userExists && userService.hasPassword(username); boolean isSSOUser = - userExists - && userService.isAuthenticationTypeByUsername( - username, AuthenticationType.SSO); + userExists && userService.isAuthenticationTypeByUsername(username, SSO); + boolean isSAML2User = + userExists && userService.isAuthenticationTypeByUsername(username, SAML2); log.debug( - "User status - Exists: {}, Has password: {}, Is SSO user: {}", + "User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}", userExists, hasPassword, - isSSOUser); + isSSOUser, + isSAML2User); - if (userExists && hasPassword && !isSSOUser && saml2.getAutoCreateUser()) { + if (userExists + && hasPassword + && (!isSSOUser || !isSAML2User) + && saml2Properties.getAutoCreateUser()) { log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); @@ -102,14 +121,15 @@ public class CustomSaml2AuthenticationSuccessHandler } try { - if (saml2.getBlockRegistration() && !userExists) { + if (saml2Properties.getBlockRegistration() && !userExists) { log.debug("Registration blocked for new user: {}", username); response.sendRedirect( contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } log.debug("Processing SSO post-login for user: {}", username); - userService.processSSOPostLogin(username, saml2.getAutoCreateUser()); + userService.processSSOPostLogin( + username, saml2Properties.getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { 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 index 7fd4768b3..70fc9a154 100644 --- 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 @@ -54,7 +54,7 @@ public class SAML2Configuration { .entityId(samlConf.getIdpIssuer()) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .singleLogoutServiceResponseLocation("{baseUrl}:{basePort}/login") .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") @@ -76,10 +76,17 @@ public class SAML2Configuration { 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) { + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, + HttpSessionSaml2AuthenticationRequestRepository saml2AuthenticationRequestRepository) { OpenSaml4AuthenticationRequestResolver resolver = new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); @@ -87,10 +94,8 @@ public class SAML2Configuration { customizer -> { HttpServletRequest request = customizer.getRequest(); AuthnRequest authnRequest = customizer.getAuthnRequest(); - HttpSessionSaml2AuthenticationRequestRepository requestRepository = - new HttpSessionSaml2AuthenticationRequestRepository(); AbstractSaml2AuthenticationRequest saml2AuthenticationRequest = - requestRepository.loadAuthenticationRequest(request); + saml2AuthenticationRequestRepository.loadAuthenticationRequest(request); if (saml2AuthenticationRequest != null) { String sessionId = request.getSession(false).getId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java index 6ece48a4e..1704e5972 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java @@ -1,11 +1,6 @@ package stirling.software.proprietary.security.service; -import java.util.Collection; -import java.util.Set; - import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -14,7 +9,7 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; @Service @@ -34,26 +29,18 @@ public class CustomUserDetailsService implements UserDetailsService { () -> new UsernameNotFoundException( "No user found with username: " + username)); + if (loginAttemptService.isBlocked(username)) { throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - if (!user.hasPassword()) { + + AuthenticationType userAuthenticationType = + AuthenticationType.valueOf(user.getAuthenticationType().toUpperCase()); + if (!user.hasPassword() && userAuthenticationType == AuthenticationType.WEB) { throw new IllegalArgumentException("Password must not be null"); } - return new org.springframework.security.core.userdetails.User( - user.getUsername(), - user.getPassword(), - user.isEnabled(), - true, - true, - true, - getAuthorities(user.getAuthorities())); - } - private Collection getAuthorities(Set authorities) { - return authorities.stream() - .map(authority -> new SimpleGrantedAuthority(authority.getAuthority())) - .toList(); + return user; } } 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 50c8027f6..8cddebcbf 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 @@ -15,7 +15,6 @@ import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; @@ -73,7 +72,8 @@ public class UserService implements UserServiceInterface { } // Handle OAUTH2 login and user auto creation. - public void processSSOPostLogin(String username, boolean autoCreateUser) + public void processSSOPostLogin( + String username, boolean autoCreateUser, AuthenticationType type) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return; @@ -83,7 +83,7 @@ public class UserService implements UserServiceInterface { return; } if (autoCreateUser) { - saveUser(username, AuthenticationType.SSO); + saveUser(username, type); } } @@ -100,10 +100,7 @@ public class UserService implements UserServiceInterface { } private Collection getAuthorities(User user) { - // Convert each Authority object into a SimpleGrantedAuthority object. - return user.getAuthorities().stream() - .map((Authority authority) -> new SimpleGrantedAuthority(authority.getAuthority())) - .toList(); + return user.getAuthorities(); } private String generateApiKey() { 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 4e4bf8552..89851ce91 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 @@ -29,10 +29,12 @@ class CustomLogoutSuccessHandlerTest { void testSuccessfulLogout() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); - String logoutPath = "logout=true"; + String token = "token"; + String logoutPath = "/login?logout=true"; when(response.isCommitted()).thenReturn(false); - when(jwtService.isJwtEnabled()).thenReturn(false); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doNothing().when(jwtService).clearTokenFromResponse(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -46,9 +48,11 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); String logoutPath = "/login?logout=true"; + String token = "token"; when(response.isCommitted()).thenReturn(false); - when(jwtService.isJwtEnabled()).thenReturn(true); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doNothing().when(jwtService).clearTokenFromResponse(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); From ae8980f65688b18d5bc9496af5f9e3386100519d Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 18 Jul 2025 10:25:14 +0100 Subject: [PATCH 05/23] 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")); + } +} From f5756944ed937eaf5338b60f5fee4261d4ebdd47 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 18 Jul 2025 15:08:15 +0100 Subject: [PATCH 06/23] Added test --- .../main/resources/static/js/DecryptFiles.js | 3 +- .../main/resources/static/js/downloader.js | 2 +- .../main/resources/static/js/fetch-utils.js | 101 +++++++- .../src/main/resources/static/js/jwt-init.js | 121 +++++++++ .../src/main/resources/static/js/navbar.js | 92 ++----- .../src/main/resources/static/js/usage.js | 2 +- .../security/CustomLogoutSuccessHandler.java | 6 +- .../resources/static/js/audit/dashboard.js | 6 +- ...tSaml2AuthenticationRequestRepository.java | 130 ++++++++++ .../security/saml2/Saml2Configuration.java | 188 ++++++++++++++ .../security/service/JwtService.java | 196 +++++++++++++++ ...l2AuthenticationRequestRepositoryTest.java | 229 ++++++++++++++++++ 12 files changed, 992 insertions(+), 84 deletions(-) create mode 100644 app/core/src/main/resources/static/js/jwt-init.js create mode 100644 proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java create mode 100644 proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java create mode 100644 proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java diff --git a/app/core/src/main/resources/static/js/DecryptFiles.js b/app/core/src/main/resources/static/js/DecryptFiles.js index 67349a012..0e5b58a92 100644 --- a/app/core/src/main/resources/static/js/DecryptFiles.js +++ b/app/core/src/main/resources/static/js/DecryptFiles.js @@ -46,10 +46,9 @@ export class DecryptFile { formData.append('password', password); } // Send decryption request - const response = await fetch('/api/v1/security/remove-password', { + const response = await fetchWithCsrf('/api/v1/security/remove-password', { method: 'POST', body: formData, - headers: csrfToken ? {'X-XSRF-TOKEN': csrfToken} : undefined, }); if (response.ok) { diff --git a/app/core/src/main/resources/static/js/downloader.js b/app/core/src/main/resources/static/js/downloader.js index 42ba0c357..b5324dd82 100644 --- a/app/core/src/main/resources/static/js/downloader.js +++ b/app/core/src/main/resources/static/js/downloader.js @@ -218,7 +218,7 @@ formData.append('password', password); // Use handleSingleDownload to send the request - const decryptionResult = await fetch(removePasswordUrl, {method: 'POST', body: formData}); + const decryptionResult = await fetchWithCsrf(removePasswordUrl, {method: 'POST', body: formData}); if (decryptionResult && decryptionResult.blob) { const decryptedBlob = await decryptionResult.blob(); diff --git a/app/core/src/main/resources/static/js/fetch-utils.js b/app/core/src/main/resources/static/js/fetch-utils.js index dfe2604a8..3d202e47b 100644 --- a/app/core/src/main/resources/static/js/fetch-utils.js +++ b/app/core/src/main/resources/static/js/fetch-utils.js @@ -1,3 +1,76 @@ +// JWT Management Utility +window.JWTManager = { + JWT_STORAGE_KEY: 'stirling_jwt', + + // Store JWT token in localStorage + storeToken: function(token) { + if (token) { + localStorage.setItem(this.JWT_STORAGE_KEY, token); + } + }, + + // Get JWT token from localStorage + getToken: function() { + return localStorage.getItem(this.JWT_STORAGE_KEY); + }, + + // Remove JWT token from localStorage + removeToken: function() { + localStorage.removeItem(this.JWT_STORAGE_KEY); + }, + + // Extract JWT from Authorization header in response + extractTokenFromResponse: function(response) { + const authHeader = response.headers.get('Authorization'); + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + this.storeToken(token); + return token; + } + return null; + }, + + // Check if user is authenticated (has valid JWT) + isAuthenticated: function() { + const token = this.getToken(); + if (!token) return false; + + try { + // Basic JWT expiration check (decode payload) + const payload = JSON.parse(atob(token.split('.')[1])); + const now = Date.now() / 1000; + return payload.exp > now; + } catch (error) { + console.warn('Invalid JWT token:', error); + this.removeToken(); + return false; + } + }, + + // Logout - remove token and redirect to login + logout: function() { + this.removeToken(); + + // Clear all possible token storage locations + localStorage.removeItem(this.JWT_STORAGE_KEY); + sessionStorage.removeItem(this.JWT_STORAGE_KEY); + + // Clear JWT cookie manually (fallback) + document.cookie = 'STIRLING_JWT=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; + + // Perform logout request to clear server-side session + fetch('/logout', { + method: 'POST', + credentials: 'include' + }).then(() => { + window.location.href = '/login'; + }).catch(() => { + // Even if logout fails, redirect to login + window.location.href = '/login'; + }); + } +}; + window.fetchWithCsrf = async function(url, options = {}) { function getCsrfToken() { const cookieValue = document.cookie @@ -24,5 +97,31 @@ window.fetchWithCsrf = async function(url, options = {}) { fetchOptions.headers['X-XSRF-TOKEN'] = csrfToken; } - return fetch(url, fetchOptions); + // Add JWT token to Authorization header if available + const jwtToken = window.JWTManager.getToken(); + if (jwtToken) { + fetchOptions.headers['Authorization'] = `Bearer ${jwtToken}`; + // Include credentials when JWT is enabled + fetchOptions.credentials = 'include'; + } + + // Make the request + const response = await fetch(url, fetchOptions); + + // Extract JWT from response if present + window.JWTManager.extractTokenFromResponse(response); + + // Handle 401 responses (unauthorized) + if (response.status === 401) { + console.warn('Authentication failed, redirecting to login'); + window.JWTManager.logout(); + return response; + } + + return response; +} + +// Enhanced fetch function that always includes JWT +window.fetchWithJWT = async function(url, options = {}) { + return window.fetchWithCsrf(url, options); } diff --git a/app/core/src/main/resources/static/js/jwt-init.js b/app/core/src/main/resources/static/js/jwt-init.js new file mode 100644 index 000000000..72741013b --- /dev/null +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -0,0 +1,121 @@ +// JWT Initialization Script +// This script handles JWT token extraction during OAuth/Login flows and initializes the JWT manager + +(function() { + // Extract JWT token from URL parameters (for OAuth redirects) + function extractTokenFromUrl() { + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get('jwt') || urlParams.get('token'); + if (token) { + window.JWTManager.storeToken(token); + // Clean up URL by removing token parameter + urlParams.delete('jwt'); + urlParams.delete('token'); + const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); + window.history.replaceState({}, '', newUrl); + } + } + + // Extract JWT token from cookie on page load (fallback) + function extractTokenFromCookie() { + const cookieValue = document.cookie + .split('; ') + .find(row => row.startsWith('STIRLING_JWT=')) + ?.split('=')[1]; + + if (cookieValue) { + window.JWTManager.storeToken(cookieValue); + // Clear the cookie since we're using localStorage with consistent SameSite policy + document.cookie = 'STIRLING_JWT=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; + } + } + + // Initialize JWT handling when page loads + function initializeJWT() { + // Try to extract token from URL first (OAuth flow) + extractTokenFromUrl(); + + // If no token in URL, try cookie (login flow) + if (!window.JWTManager.getToken()) { + extractTokenFromCookie(); + } + + // Check if user is authenticated + if (window.JWTManager.isAuthenticated()) { + console.log('User is authenticated with JWT'); + } else { + console.log('User is not authenticated or token expired'); + // Only redirect to login if we're not already on login/register pages + const currentPath = window.location.pathname; + if (!currentPath.includes('/login') && + !currentPath.includes('/register') && + !currentPath.includes('/oauth') && + !currentPath.includes('/saml') && + !currentPath.includes('/error')) { + // Redirect to login after a short delay to allow other scripts to load + setTimeout(() => { + window.location.href = '/login'; + }, 100); + } + } + } + + // Override form submissions to include JWT + function enhanceFormSubmissions() { + // Override form submit for login forms + document.addEventListener('submit', function(event) { + const form = event.target; + + // Add JWT to form data if available + const jwtToken = window.JWTManager.getToken(); + if (jwtToken && form.method && form.method.toLowerCase() !== 'get') { + // Create a hidden input for JWT + const jwtInput = document.createElement('input'); + jwtInput.type = 'hidden'; + jwtInput.name = 'jwt'; + jwtInput.value = jwtToken; + form.appendChild(jwtInput); + } + }); + } + + // Add logout functionality to logout buttons + function enhanceLogoutButtons() { + document.addEventListener('click', function(event) { + const element = event.target; + + // Check if clicked element is a logout button/link + if (element.matches('a[href="/logout"], button[data-action="logout"], .logout-btn')) { + event.preventDefault(); + window.JWTManager.logout(); + } + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + initializeJWT(); + enhanceFormSubmissions(); + enhanceLogoutButtons(); + }); + } else { + initializeJWT(); + enhanceFormSubmissions(); + enhanceLogoutButtons(); + } + + // Handle page visibility changes to check token expiration + document.addEventListener('visibilitychange', function() { + if (!document.hidden && !window.JWTManager.isAuthenticated()) { + // Token expired while page was hidden, redirect to login + const currentPath = window.location.pathname; + if (!currentPath.includes('/login') && + !currentPath.includes('/register') && + !currentPath.includes('/oauth') && + !currentPath.includes('/saml')) { + window.location.href = '/login'; + } + } + }); +})(); \ No newline at end of file diff --git a/app/core/src/main/resources/static/js/navbar.js b/app/core/src/main/resources/static/js/navbar.js index a95ff1639..57f916f8a 100644 --- a/app/core/src/main/resources/static/js/navbar.js +++ b/app/core/src/main/resources/static/js/navbar.js @@ -42,39 +42,6 @@ function toolsManager() { }); } -function setupDropdowns() { - const dropdowns = document.querySelectorAll('.navbar-nav > .nav-item.dropdown'); - - dropdowns.forEach((dropdown) => { - const toggle = dropdown.querySelector('[data-bs-toggle="dropdown"]'); - if (!toggle) return; - - // Skip search dropdown, it has its own logic - if (toggle.id === 'searchDropdown') { - return; - } - - dropdown.addEventListener('show.bs.dropdown', () => { - // Find all other open dropdowns and hide them - const openDropdowns = document.querySelectorAll('.navbar-nav .dropdown-menu.show'); - openDropdowns.forEach((menu) => { - const parentDropdown = menu.closest('.dropdown'); - if (parentDropdown && parentDropdown !== dropdown) { - const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]'); - if (parentToggle) { - // Get or create Bootstrap dropdown instance - let instance = bootstrap.Dropdown.getInstance(parentToggle); - if (!instance) { - instance = new bootstrap.Dropdown(parentToggle); - } - instance.hide(); - } - } - }); - }); - }); -} - window.tooltipSetup = () => { const tooltipElements = document.querySelectorAll('[title]'); @@ -89,54 +56,37 @@ window.tooltipSetup = () => { document.body.appendChild(customTooltip); element.addEventListener('mouseenter', (event) => { - if (window.innerWidth >= 1200) { - customTooltip.style.display = 'block'; - customTooltip.style.left = `${event.pageX + 10}px`; - customTooltip.style.top = `${event.pageY + 10}px`; - } + customTooltip.style.display = 'block'; + customTooltip.style.left = `${event.pageX + 10}px`; // Position tooltip slightly away from the cursor + customTooltip.style.top = `${event.pageY + 10}px`; }); + // Update the position of the tooltip as the user moves the mouse element.addEventListener('mousemove', (event) => { - if (window.innerWidth >= 1200) { - customTooltip.style.left = `${event.pageX + 10}px`; - customTooltip.style.top = `${event.pageY + 10}px`; - } + customTooltip.style.left = `${event.pageX + 10}px`; + customTooltip.style.top = `${event.pageY + 10}px`; }); + // Hide the tooltip when the mouse leaves element.addEventListener('mouseleave', () => { customTooltip.style.display = 'none'; }); }); }; - -// Override the bootstrap dropdown styles for mobile -function fixNavbarDropdownStyles() { - if (window.innerWidth < 1200) { - document.querySelectorAll('.navbar .dropdown-menu').forEach(function(menu) { - menu.style.transform = 'none'; - menu.style.transformOrigin = 'none'; - menu.style.left = '0'; - menu.style.right = '0'; - menu.style.maxWidth = '95vw'; - menu.style.width = '100vw'; - menu.style.marginBottom = '0'; - }); - } else { - document.querySelectorAll('.navbar .dropdown-menu').forEach(function(menu) { - menu.style.transform = ''; - menu.style.transformOrigin = ''; - menu.style.left = ''; - menu.style.right = ''; - menu.style.maxWidth = ''; - menu.style.width = ''; - menu.style.marginBottom = ''; - }); - } -} - document.addEventListener('DOMContentLoaded', () => { tooltipSetup(); - setupDropdowns(); - fixNavbarDropdownStyles(); + + // Setup logout button functionality + const logoutButton = document.querySelector('a[href="/logout"]'); + if (logoutButton) { + logoutButton.addEventListener('click', function(event) { + event.preventDefault(); + if (window.JWTManager) { + window.JWTManager.logout(); + } else { + // Fallback if JWTManager is not available + window.location.href = '/logout'; + } + }); + } }); -window.addEventListener('resize', fixNavbarDropdownStyles); diff --git a/app/core/src/main/resources/static/js/usage.js b/app/core/src/main/resources/static/js/usage.js index 624e4ec78..443a27ce1 100644 --- a/app/core/src/main/resources/static/js/usage.js +++ b/app/core/src/main/resources/static/js/usage.js @@ -102,7 +102,7 @@ async function fetchEndpointData() { refreshBtn.classList.add('refreshing'); refreshBtn.disabled = true; - const response = await fetch('/api/v1/info/load/all'); + const response = await fetchWithCsrf('/api/v1/info/load/all'); if (!response.ok) { throw new Error('Network response was not ok'); } 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 45869b05c..aaf6ea2c3 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,7 +7,6 @@ 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; @@ -117,10 +116,7 @@ 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.processLogoutRequestPostFromIdentityProvider(request, nameIdValue); - samlClient.redirectToIdentityProviderLogout( - response, HttpStatus.OK.name(), nameIdValue); + samlClient.redirectToIdentityProvider(response, null, nameIdValue); } catch (Exception e) { log.error( "Error retrieving logout URL from Provider {} for user {}", diff --git a/app/proprietary/src/main/resources/static/js/audit/dashboard.js b/app/proprietary/src/main/resources/static/js/audit/dashboard.js index 5cc670908..c0b93bd8e 100644 --- a/app/proprietary/src/main/resources/static/js/audit/dashboard.js +++ b/app/proprietary/src/main/resources/static/js/audit/dashboard.js @@ -230,7 +230,7 @@ function loadAuditData(targetPage, realPageSize) { document.getElementById('page-indicator').textContent = `Page ${requestedPage + 1} of ?`; } - fetch(url) + fetchWithCsrf(url) .then(response => { return response.json(); }) @@ -302,7 +302,7 @@ function loadStats(days) { showLoading('user-chart-loading'); showLoading('time-chart-loading'); - fetch(`/audit/stats?days=${days}`) + fetchWithCsrf(`/audit/stats?days=${days}`) .then(response => response.json()) .then(data => { document.getElementById('total-events').textContent = data.totalEvents; @@ -835,7 +835,7 @@ function hideLoading(id) { // Load event types from the server for filter dropdowns function loadEventTypes() { - fetch('/audit/types') + fetchWithCsrf('/audit/types') .then(response => response.json()) .then(types => { if (!types || types.length === 0) { diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java b/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java new file mode 100644 index 000000000..3e6d4491a --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java @@ -0,0 +1,130 @@ +package stirling.software.proprietary.security.saml2; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; +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.web.Saml2AuthenticationRequestRepository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.service.JwtServiceInterface; + +@Slf4j +public class JwtSaml2AuthenticationRequestRepository + implements Saml2AuthenticationRequestRepository { + private final Map tokenStore; + private final JwtServiceInterface jwtService; + private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token"; + + public JwtSaml2AuthenticationRequestRepository( + Map tokenStore, + JwtServiceInterface jwtService, + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + this.tokenStore = tokenStore; + this.jwtService = jwtService; + this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository; + } + + @Override + public void saveAuthenticationRequest( + Saml2PostAuthenticationRequest authRequest, + HttpServletRequest request, + HttpServletResponse response) { + if (authRequest == null) { + removeAuthenticationRequest(request, response); + return; + } + + Map claims = serializeSamlRequest(authRequest); + String token = jwtService.generateToken("", claims); + String relayState = authRequest.getRelayState(); + + tokenStore.put(relayState, token); + request.setAttribute(SAML_REQUEST_TOKEN, relayState); + response.addHeader(SAML_REQUEST_TOKEN, relayState); + + log.debug("Saved SAMLRequest token with RelayState: {}", relayState); + } + + @Override + public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) { + String token = extractTokenFromStore(request); + + if (token == null) { + log.debug("No SAMLResponse token found in RelayState"); + return null; + } + + Map claims = jwtService.extractAllClaims(token); + return deserializeSamlRequest(claims); + } + + @Override + public Saml2PostAuthenticationRequest removeAuthenticationRequest( + HttpServletRequest request, HttpServletResponse response) { + Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request); + + String relayStateId = request.getParameter("RelayState"); + if (relayStateId != null) { + tokenStore.remove(relayStateId); + log.debug("Removed SAMLRequest token for RelayState ID: {}", relayStateId); + } + + return authRequest; + } + + private String extractTokenFromStore(HttpServletRequest request) { + String authnRequestId = request.getParameter("RelayState"); + + if (authnRequestId != null && !authnRequestId.isEmpty()) { + String token = tokenStore.get(authnRequestId); + + if (token != null) { + tokenStore.remove(authnRequestId); + log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId); + return token; + } else { + log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId); + } + } + + return null; + } + + private Map serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) { + Map claims = new HashMap<>(); + + claims.put("id", authRequest.getId()); + claims.put("relyingPartyRegistrationId", authRequest.getRelyingPartyRegistrationId()); + claims.put("authenticationRequestUri", authRequest.getAuthenticationRequestUri()); + claims.put("samlRequest", authRequest.getSamlRequest()); + claims.put("relayState", authRequest.getRelayState()); + + return claims; + } + + private Saml2PostAuthenticationRequest deserializeSamlRequest(Map claims) { + String relyingPartyRegistrationId = (String) claims.get("relyingPartyRegistrationId"); + RelyingPartyRegistration relyingPartyRegistration = + relyingPartyRegistrationRepository.findByRegistrationId(relyingPartyRegistrationId); + + if (relyingPartyRegistration == null) { + return null; + } + + return Saml2PostAuthenticationRequest.withRelyingPartyRegistration(relyingPartyRegistration) + .id((String) claims.get("id")) + .authenticationRequestUri((String) claims.get("authenticationRequestUri")) + .samlRequest((String) claims.get("samlRequest")) + .relayState((String) claims.get("relayState")) + .build(); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java new file mode 100644 index 000000000..9d21f88a3 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -0,0 +1,188 @@ +package stirling.software.proprietary.security.saml2; + +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +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.Saml2PostAuthenticationRequest; +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.Saml2AuthenticationRequestRepository; +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; +import stirling.software.proprietary.security.service.JwtServiceInterface; + +@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("http://localhost:8080/login") + .assertionConsumerServiceBinding(Saml2MessageBinding.POST) + .assertionConsumerServiceLocation( + "{baseUrl}/login/saml2/sso/{registrationId}") + .authnRequestsSigned(true) + .assertingPartyMetadata( + metadata -> + metadata.entityId(samlConf.getIdpIssuer()) + .verificationX509Credentials( + c -> c.add(verificationCredential)) + .singleSignOnServiceBinding( + Saml2MessageBinding.POST) + .singleSignOnServiceLocation( + samlConf.getIdpSingleLoginUrl()) + .singleLogoutServiceBinding( + Saml2MessageBinding.POST) + .singleLogoutServiceLocation( + samlConf.getIdpSingleLogoutUrl()) + .singleLogoutServiceResponseLocation( + "http://localhost:8080/login") + .wantAuthnRequestsSigned(true)) + .build(); + return new InMemoryRelyingPartyRegistrationRepository(rp); + } + + @Bean + @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") + public Saml2AuthenticationRequestRepository + saml2AuthenticationRequestRepository( + JwtServiceInterface jwtService, + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + return new JwtSaml2AuthenticationRequestRepository( + new ConcurrentHashMap<>(), jwtService, relyingPartyRegistrationRepository); + } + + @Bean + @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") + public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, + Saml2AuthenticationRequestRepository + saml2AuthenticationRequestRepository) { + OpenSaml4AuthenticationRequestResolver resolver = + new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); + + resolver.setAuthnRequestCustomizer( + customizer -> { + HttpServletRequest request = customizer.getRequest(); + AuthnRequest authnRequest = customizer.getAuthnRequest(); + Saml2PostAuthenticationRequest 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/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java new file mode 100644 index 000000000..5b1bb8ec2 --- /dev/null +++ b/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -0,0 +1,196 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import io.github.pixee.security.Newlines; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; +import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; + +@Slf4j +@Service +public class JwtService implements JwtServiceInterface { + + private static final String JWT_COOKIE_NAME = "STIRLING_JWT"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + private static final String ISSUER = "Stirling PDF"; + private static final long EXPIRATION = 3600000; + + private final KeyPair keyPair; + private final boolean v2Enabled; + + public JwtService(@Qualifier("v2Enabled") boolean v2Enabled) { + this.v2Enabled = v2Enabled; + keyPair = Jwts.SIG.RS256.keyPair().build(); + } + + @Override + public String generateToken(Authentication authentication, Map claims) { + Object principal = authentication.getPrincipal(); + String username = ""; + + if (principal instanceof UserDetails) { + username = ((UserDetails) principal).getUsername(); + } else if (principal instanceof OAuth2User) { + username = ((OAuth2User) principal).getName(); + } else if (principal instanceof CustomSaml2AuthenticatedPrincipal) { + username = ((CustomSaml2AuthenticatedPrincipal) principal).getName(); + } + + return generateToken(username, claims); + } + + @Override + public String generateToken(String username, Map claims) { + return Jwts.builder() + .claims(claims) + .subject(username) + .issuer(ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(keyPair.getPrivate(), Jwts.SIG.RS256) + .compact(); + } + + @Override + public void validateToken(String token) throws AuthenticationFailureException { + extractAllClaimsFromToken(token); + + // todo: test + if (isTokenExpired(token)) { + throw new AuthenticationFailureException("The token has expired"); + } + } + + @Override + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + @Override + public Map extractAllClaims(String token) { + Claims claims = extractAllClaimsFromToken(token); + return new HashMap<>(claims); + } + + @Override + public boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaimsFromToken(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaimsFromToken(String token) { + try { + return Jwts.parser() + .verifyWith(keyPair.getPublic()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException e) { + log.warn("Invalid signature: {}", e.getMessage()); + throw new AuthenticationFailureException("Invalid signature", e); + } catch (MalformedJwtException e) { + log.warn("Invalid token: {}", e.getMessage()); + throw new AuthenticationFailureException("Invalid token", e); + } catch (ExpiredJwtException e) { + log.warn("The token has expired: {}", e.getMessage()); + throw new AuthenticationFailureException("The token has expired", e); + } catch (UnsupportedJwtException e) { + log.warn("The token is unsupported: {}", e.getMessage()); + throw new AuthenticationFailureException("The token is unsupported", e); + } catch (IllegalArgumentException e) { + log.warn("Claims are empty: {}", e.getMessage()); + throw new AuthenticationFailureException("Claims are empty", e); + } + } + + @Override + public String extractTokenFromRequest(HttpServletRequest request) { + String authHeader = request.getHeader(AUTHORIZATION_HEADER); + + if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { + return authHeader.substring(BEARER_PREFIX.length()); + } + + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (JWT_COOKIE_NAME.equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + + return null; + } + + @Override + public void addTokenToResponse(HttpServletResponse response, String token) { + response.setHeader(AUTHORIZATION_HEADER, Newlines.stripAll(BEARER_PREFIX + token)); + + ResponseCookie cookie = + ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(EXPIRATION / 1000) + .path("/") + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + @Override + public void clearTokenFromResponse(HttpServletResponse response) { + // Remove Authorization header instead of setting empty string + response.setHeader(AUTHORIZATION_HEADER, null); + + ResponseCookie cookie = + ResponseCookie.from(JWT_COOKIE_NAME, "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(0) + .path("/") + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + @Override + public boolean isJwtEnabled() { + return v2Enabled; + } +} diff --git a/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java b/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java new file mode 100644 index 000000000..11f3c00d9 --- /dev/null +++ b/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java @@ -0,0 +1,229 @@ +package stirling.software.proprietary.security.saml2; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +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.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest; +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.AssertingPartyMetadata; +import stirling.software.proprietary.security.service.JwtServiceInterface; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.assertTrue; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtSaml2AuthenticationRequestRepositoryTest { + + private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token"; + + private Map tokenStore; + + @Mock + private JwtServiceInterface jwtService; + + @Mock + private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; + + private JwtSaml2AuthenticationRequestRepository jwtSaml2AuthenticationRequestRepository; + + @BeforeEach + void setUp() { + tokenStore = new ConcurrentHashMap<>(); + jwtSaml2AuthenticationRequestRepository = new JwtSaml2AuthenticationRequestRepository( + tokenStore, jwtService, relyingPartyRegistrationRepository); + } + + @Test + void saveAuthenticationRequest() { + var authRequest = mock(Saml2PostAuthenticationRequest.class); + var request = mock(MockHttpServletRequest.class); + var response = mock(MockHttpServletResponse.class); + String token = "testToken"; + String id = "testId"; + String relayState = "testRelayState"; + String authnRequestUri = "example.com/authnRequest"; + Map claims = Map.of(); + String samlRequest = "testSamlRequest"; + String relyingPartyRegistrationId = "stirling-pdf"; + + when(authRequest.getRelayState()).thenReturn(relayState); + when(authRequest.getId()).thenReturn(id); + when(authRequest.getAuthenticationRequestUri()).thenReturn(authnRequestUri); + when(authRequest.getSamlRequest()).thenReturn(samlRequest); + when(authRequest.getRelyingPartyRegistrationId()).thenReturn(relyingPartyRegistrationId); + when(jwtService.generateToken(eq(""), anyMap())).thenReturn(token); + + jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(authRequest, request, response); + + verify(request).setAttribute(SAML_REQUEST_TOKEN, relayState); + verify(response).addHeader(SAML_REQUEST_TOKEN, relayState); + } + + @Test + void saveAuthenticationRequestWithNullRequest() { + var request = mock(MockHttpServletRequest.class); + var response = mock(MockHttpServletResponse.class); + + jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(null, request, response); + + assertTrue(tokenStore.isEmpty()); + } + + @Test + void loadAuthenticationRequest() { + var request = mock(MockHttpServletRequest.class); + var relyingPartyRegistration = mock(RelyingPartyRegistration.class); + var assertingPartyMetadata = mock(AssertingPartyMetadata.class); + String relayState = "testRelayState"; + String token = "testToken"; + Map claims = Map.of( + "id", "testId", + "relyingPartyRegistrationId", "stirling-pdf", + "authenticationRequestUri", "example.com/authnRequest", + "samlRequest", "testSamlRequest", + "relayState", relayState + ); + + when(request.getParameter("RelayState")).thenReturn(relayState); + when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")).thenReturn(relyingPartyRegistration); + when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf"); + when(relyingPartyRegistration.getAssertingPartyMetadata()).thenReturn(assertingPartyMetadata); + when(assertingPartyMetadata.getSingleSignOnServiceLocation()).thenReturn("https://example.com/sso"); + tokenStore.put(relayState, token); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNotNull(result); + assertFalse(tokenStore.containsKey(relayState)); + } + + @ParameterizedTest + @NullAndEmptySource + void loadAuthenticationRequestWithInvalidRelayState(String relayState) { + var request = mock(MockHttpServletRequest.class); + when(request.getParameter("RelayState")).thenReturn(relayState); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNull(result); + } + + @Test + void loadAuthenticationRequestWithNonExistentToken() { + var request = mock(MockHttpServletRequest.class); + when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState"); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNull(result); + } + + @Test + void loadAuthenticationRequestWithNullRelyingPartyRegistration() { + var request = mock(MockHttpServletRequest.class); + String relayState = "testRelayState"; + String token = "testToken"; + Map claims = Map.of( + "id", "testId", + "relyingPartyRegistrationId", "stirling-pdf", + "authenticationRequestUri", "example.com/authnRequest", + "samlRequest", "testSamlRequest", + "relayState", relayState + ); + + when(request.getParameter("RelayState")).thenReturn(relayState); + when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")).thenReturn(null); + tokenStore.put(relayState, token); + + var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request); + + assertNull(result); + } + + @Test + void removeAuthenticationRequest() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + var relyingPartyRegistration = mock(RelyingPartyRegistration.class); + var assertingPartyMetadata = mock(AssertingPartyMetadata.class); + String relayState = "testRelayState"; + String token = "testToken"; + Map claims = Map.of( + "id", "testId", + "relyingPartyRegistrationId", "stirling-pdf", + "authenticationRequestUri", "example.com/authnRequest", + "samlRequest", "testSamlRequest", + "relayState", relayState + ); + + when(request.getParameter("RelayState")).thenReturn(relayState); + when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")).thenReturn(relyingPartyRegistration); + when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf"); + when(relyingPartyRegistration.getAssertingPartyMetadata()).thenReturn(assertingPartyMetadata); + when(assertingPartyMetadata.getSingleSignOnServiceLocation()).thenReturn("https://example.com/sso"); + tokenStore.put(relayState, token); + + var result = jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(request, response); + + assertNotNull(result); + assertFalse(tokenStore.containsKey(relayState)); + } + + @Test + void removeAuthenticationRequestWithNullRelayState() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + when(request.getParameter("RelayState")).thenReturn(null); + + var result = jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(request, response); + + assertNull(result); + } + + @Test + void removeAuthenticationRequestWithNonExistentToken() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState"); + + var result = jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(request, response); + + assertNull(result); + } + + @Test + void removeAuthenticationRequestWithOnlyRelayState() { + var request = mock(HttpServletRequest.class); + var response = mock(HttpServletResponse.class); + String relayState = "testRelayState"; + + when(request.getParameter("RelayState")).thenReturn(relayState); + + var result = jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(request, response); + + assertNull(result); + assertFalse(tokenStore.containsKey(relayState)); + } +} From 57b72c2d4ff429b063d76043cced1be1c287b814 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Mon, 21 Jul 2025 12:58:52 +0100 Subject: [PATCH 07/23] Sending token with requests to server --- .../main/resources/static/js/fetch-utils.js | 2 +- .../src/main/resources/static/js/jwt-init.js | 4 +- .../security/service/JwtServiceTest.java | 48 ++----------------- .../security/service/JwtService.java | 4 +- 4 files changed, 9 insertions(+), 49 deletions(-) diff --git a/app/core/src/main/resources/static/js/fetch-utils.js b/app/core/src/main/resources/static/js/fetch-utils.js index 3d202e47b..5946dc100 100644 --- a/app/core/src/main/resources/static/js/fetch-utils.js +++ b/app/core/src/main/resources/static/js/fetch-utils.js @@ -56,7 +56,7 @@ window.JWTManager = { sessionStorage.removeItem(this.JWT_STORAGE_KEY); // Clear JWT cookie manually (fallback) - document.cookie = 'STIRLING_JWT=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; + document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; // Perform logout request to clear server-side session fetch('/logout', { diff --git a/app/core/src/main/resources/static/js/jwt-init.js b/app/core/src/main/resources/static/js/jwt-init.js index 72741013b..4a8218c47 100644 --- a/app/core/src/main/resources/static/js/jwt-init.js +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -20,13 +20,13 @@ function extractTokenFromCookie() { const cookieValue = document.cookie .split('; ') - .find(row => row.startsWith('STIRLING_JWT=')) + .find(row => row.startsWith('stirling_jwt=')) ?.split('=')[1]; if (cookieValue) { window.JWTManager.storeToken(cookieValue); // Clear the cookie since we're using localStorage with consistent SameSite policy - document.cookie = 'STIRLING_JWT=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; + document.cookie = 'stirling_jwt=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; SameSite=None; Secure'; } } 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 6f419e280..d108d7db9 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 @@ -67,7 +67,7 @@ class JwtServiceTest { String token = jwtService.generateToken(authentication, Collections.emptyMap()); assertNotNull(token); - assertTrue(!token.isEmpty()); + assertFalse(token.isEmpty()); assertEquals(username, jwtService.extractUsername(token)); } @@ -106,25 +106,6 @@ class JwtServiceTest { }); } - // 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, () -> { @@ -184,24 +165,6 @@ class JwtServiceTest { 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"; @@ -213,7 +176,7 @@ class JwtServiceTest { @Test void testExtractTokenFromRequestWithCookie() { String token = "test-token"; - Cookie[] cookies = { new Cookie("STIRLING_JWT", token) }; + Cookie[] cookies = { new Cookie("stirling_jwt", token) }; when(request.getHeader("Authorization")).thenReturn(null); when(request.getCookies()).thenReturn(cookies); @@ -252,18 +215,17 @@ class JwtServiceTest { 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("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).setHeader("Authorization", null); + verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); } } diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 5b1bb8ec2..2ae2197b8 100644 --- a/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -34,7 +34,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin @Service public class JwtService implements JwtServiceInterface { - private static final String JWT_COOKIE_NAME = "STIRLING_JWT"; + private static final String JWT_COOKIE_NAME = "stirling_jwt"; private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; private static final String ISSUER = "Stirling PDF"; @@ -80,7 +80,6 @@ public class JwtService implements JwtServiceInterface { public void validateToken(String token) throws AuthenticationFailureException { extractAllClaimsFromToken(token); - // todo: test if (isTokenExpired(token)) { throw new AuthenticationFailureException("The token has expired"); } @@ -174,7 +173,6 @@ public class JwtService implements JwtServiceInterface { @Override public void clearTokenFromResponse(HttpServletResponse response) { - // Remove Authorization header instead of setting empty string response.setHeader(AUTHORIZATION_HEADER, null); ResponseCookie cookie = From 60b66926af072522d7a67e30482a16a0a8a49287 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Jul 2025 12:36:21 +0100 Subject: [PATCH 08/23] Bump com.unboundid.product.scim2:scim2-sdk-client from 2.3.5 to 4.0.0 (#3736) Bumps [com.unboundid.product.scim2:scim2-sdk-client](https://github.com/pingidentity/scim2) from 2.3.5 to 4.0.0.
Changelog

Sourced from com.unboundid.product.scim2:scim2-sdk-client's changelog.

v4.0.0 - 2025-Jun-10

Removed support for Java 11. The UnboundID SCIM 2 SDK now requires Java 17 or a later release.

Updated the following dependencies:

  • Jackson: 2.18.3
  • Jakarta RS: 4.0.0
  • Jersey: 3.1.10

Updated the default behavior for ADD patch requests with value filters (e.g., emails[type eq "work"].display). The SCIM SDK will now target existing values within the multi-valued attribute. For more background on this type of patch request, see the release notes for the 3.2.0 release where this was introduced (but not made the default). To restore the old behavior, set the following property in your application:

PatchOperation.APPEND_NEW_PATCH_VALUES_PROPERTY = true;

Updated SearchRequestBuilder to be more permissive of ListResponses with non-standard attribute casing (e.g., if a response includes a "resources" array instead of "Resources").

Updated the class-level documentation of SearchRequest to provide more background about how searches are performed in the SCIM standard.

Added a new property that allows ignoring unknown fields when converting JSON text to Java objects that inherit from BaseScimResource. This behaves similarly to the FAIL_ON_UNKNOWN_PROPERTIES setting from the Jackson library, and allows for easier integration with SCIM service providers that include additional non-standard data in their responses. To enable this setting, set the following property in your application code:

BaseScimResource.IGNORE_UNKNOWN_FIELDS = true;

Fixed an issue with methods that interface with schema extensions such as BaseScimResource.getExtensionValues(String). These accepted paths as a string, but previously performed updates to the extension data incorrectly.

Simplified the implementation of the StaticUtils#toLowerCase method. This had an optimization for Java versions before JDK 9 that was especially beneficial for the most common case of handling ASCII characters. Since JDK 9, however, the String class has been updated so that the class is backed by a byte array as opposed to a character array, so it is more optimal to use the JDK's implementation directly while handling null values.

Previous releases of the SCIM SDK set many classes as final to encourage applications to follow strict compliance to the SCIM standard. However, this also makes it difficult to integrate with services that violate the standard. An example of this is a SCIM error response that contains extra fields in the JSON body. To help accommodate these integrations, the SCIM SDK has been updated so that several model classes are no longer final, allowing applications to extend them if needed. The following classes were updated:

  • scim2-sdk-client builder classes such as CreateRequestBuilder.java
  • ErrorResponse.java

... (truncated)

Commits
  • 039c7e6 Setting release version 4.0.0
  • ea04864 Update CHANGELOG date for the 4.0.0 release.
  • bfd276e Make GenericScimResource extendable.
  • 9008757 Clean up POM and remove Guava test dependency.
  • a954381 Remove the deprecated ScimDateFormat class.
  • 76f2314 Enhance the Filter classes and their documentation
  • cfd9d7e Add a new filter method for SearchRequestBuilder.
  • 3c3c0ca Fix CodeQL by adding Java 17 installation step
  • 114ad51 Import the default codeql.yaml
  • 26fe8f1 Allow extending model classes
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=com.unboundid.product.scim2:scim2-sdk-client&package-manager=gradle&previous-version=2.3.5&new-version=4.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- app/proprietary/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 197e5439e..4154ec14c 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -45,7 +45,6 @@ dependencies { api 'io.micrometer:micrometer-registry-prometheus' implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0' - // JWT dependencies api "io.jsonwebtoken:jjwt-api:$jwtVersion" runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion" runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion" From 613bb08ed90abeada5f25094e8419d898c9fbf28 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 14 Jul 2025 21:53:11 +0200 Subject: [PATCH 09/23] refactor: move modules under app/ directory and update file paths (#3938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes - **What was changed:** - Renamed top-level directories: `stirling-pdf` → `app/core`, `common` → `app/common`, `proprietary` → `app/proprietary`. - Updated all path references in `.gitattributes`, GitHub workflows (`.github/workflows/*`), scripts (`.github/scripts/*`), `.gitignore`, Dockerfiles, license files, and template settings to reflect the new structure. - Added a new CI job `check-generateOpenApiDocs` to generate and upload OpenAPI documentation. - Removed redundant `@Autowired` annotations from `TempFileShutdownHook` and `UnlockPDFFormsController`. - Minor formatting and comment adjustments in YAML templates and resource files. - **Why the change was made:** - To introduce a clear `app/` directory hierarchy for core, common, and proprietary modules, improving organization and maintainability. - To ensure continuous integration and Docker builds continue to work seamlessly with the reorganized structure. - To automate OpenAPI documentation generation as part of the CI pipeline. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/build.yml | 121 ++---------------- .../AuthenticationFailureException.java | 13 ++ ...tSaml2AuthenticationRequestRepository.java | 0 .../security/saml2/SAML2Configuration.java | 0 .../security/service/JwtService.java | 0 .../security/service/JwtServiceInterface.java | 90 +++++++++++++ ...l2AuthenticationRequestRepositoryTest.java | 0 7 files changed, 114 insertions(+), 110 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java rename {proprietary => app/proprietary}/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java (100%) rename proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java => app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java (100%) rename {proprietary => app/proprietary}/src/main/java/stirling/software/proprietary/security/service/JwtService.java (100%) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java rename {proprietary => app/proprietary}/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db847f570..f00b927c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,8 @@ -name: Build and Test Workflow +name: Build repo on: - workflow_dispatch: - # push: - # branches: ["main"] + push: + branches: ["main"] pull_request: branches: ["main"] @@ -23,24 +22,6 @@ permissions: contents: read jobs: - files-changed: - name: detect what files changed - runs-on: ubuntu-latest - timeout-minutes: 3 - # Map a step output to a job output - outputs: - build: ${{ steps.changes.outputs.build }} - app: ${{ steps.changes.outputs.app }} - project: ${{ steps.changes.outputs.project }} - openapi: ${{ steps.changes.outputs.openapi }} - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Check for file changes - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: changes - with: - filters: ".github/config/.files.yaml" build: runs-on: ubuntu-latest @@ -56,7 +37,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -69,11 +50,6 @@ jobs: java-version: ${{ matrix.jdk-version }} distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 - with: - gradle-version: 8.14 - - name: Build with Gradle and spring security ${{ matrix.spring-security }} run: ./gradlew clean build env: @@ -124,17 +100,14 @@ jobs: if-no-files-found: warn check-generateOpenApiDocs: - if: needs.files-changed.outputs.openapi == 'true' - needs: [files-changed, build] runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit - - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -142,8 +115,7 @@ jobs: java-version: "17" distribution: "temurin" - - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs @@ -155,12 +127,10 @@ jobs: path: ./SwaggerDoc.json check-licence: - if: needs.files-changed.outputs.build == 'true' - needs: [files-changed, build] runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -171,7 +141,7 @@ jobs: uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "17" - distribution: "temurin" + distribution: "adopt" - name: check the licenses for compatibility run: ./gradlew clean checkLicense @@ -186,8 +156,6 @@ jobs: retention-days: 3 docker-compose-tests: - if: needs.files-changed.outputs.project == 'true' - needs: files-changed # if: github.event_name == 'push' && github.ref == 'refs/heads/main' || # (github.event_name == 'pull_request' && # contains(github.event.pull_request.labels.*.name, 'licenses') == false && @@ -206,7 +174,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -217,7 +185,7 @@ jobs: uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "17" - distribution: "temurin" + distribution: "adopt" - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 @@ -232,7 +200,6 @@ jobs: with: python-version: "3.12" cache: 'pip' # caching pip dependencies - cache-dependency-path: ./testing/cucumber/requirements.txt - name: Pip requirements run: | @@ -244,69 +211,3 @@ jobs: chmod +x ./testing/test.sh chmod +x ./testing/test_disabledEndpoints.sh ./testing/test.sh - - test-build-docker-images: - if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' - needs: [files-changed, build, check-generateOpenApiDocs, check-licence] - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"] - steps: - - name: Harden Runner - uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 - with: - egress-policy: audit - - - name: Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Set up JDK 17 - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 - with: - java-version: "17" - distribution: "temurin" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 - with: - gradle-version: 8.14 - - - name: Build application - run: ./gradlew clean build - env: - DISABLE_ADDITIONAL_FEATURES: true - STIRLING_PDF_DESKTOP_UI: false - - - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - - - name: Set up Docker Buildx - id: buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - - name: Build ${{ matrix.docker-rev }} - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - builder: ${{ steps.buildx.outputs.name }} - context: . - file: ./${{ matrix.docker-rev }} - push: false - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64/v8 - provenance: true - sbom: true - - - name: Upload Reports - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: reports-docker-${{ matrix.docker-rev }} - path: | - build/reports/tests/ - build/test-results/ - build/reports/problems/ - retention-days: 3 - if-no-files-found: warn diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java new file mode 100644 index 000000000..f2cd5e242 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.security.model.exception; + +import org.springframework.security.core.AuthenticationException; + +public class AuthenticationFailureException extends AuthenticationException { + public AuthenticationFailureException(String message) { + super(message); + } + + public AuthenticationFailureException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java similarity index 100% rename from proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java similarity index 100% rename from proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java diff --git a/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java similarity index 100% rename from proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java 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 new file mode 100644 index 000000000..664d812d8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java @@ -0,0 +1,90 @@ +package stirling.software.proprietary.security.service; + +import java.util.Map; + +import org.springframework.security.core.Authentication; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public interface JwtServiceInterface { + + /** + * Generate a JWT token for the authenticated user + * + * @param authentication Spring Security authentication object + * @return JWT token as a string + */ + String generateToken(Authentication authentication, Map claims); + + /** + * Generate a JWT token for a specific username + * + * @param username the username for which to generate the token + * @param claims additional claims to include in the token + * @return JWT token as a string + */ + String generateToken(String username, Map claims); + + /** + * Validate a JWT token + * + * @param token the JWT token to validate + * @return true if token is valid, false otherwise + */ + void validateToken(String token); + + /** + * Extract username from JWT token + * + * @param token the JWT token + * @return username extracted from token + */ + String extractUsername(String token); + + /** + * Extract all claims from JWT token + * + * @param token the JWT token + * @return map of claims + */ + Map extractAllClaims(String token); + + /** + * Check if token is expired + * + * @param token the JWT token + * @return true if token is expired, false otherwise + */ + boolean isTokenExpired(String token); + + /** + * Extract JWT token from HTTP request (header or cookie) + * + * @param request HTTP servlet request + * @return JWT token if found, null otherwise + */ + String extractTokenFromRequest(HttpServletRequest request); + + /** + * Add JWT token to HTTP response (header and cookie) + * + * @param response HTTP servlet response + * @param token JWT token to add + */ + void addTokenToResponse(HttpServletResponse response, String token); + + /** + * Clear JWT token from HTTP response (remove cookie) + * + * @param response HTTP servlet response + */ + void clearTokenFromResponse(HttpServletResponse response); + + /** + * Check if JWT authentication is enabled + * + * @return true if JWT is enabled, false otherwise + */ + boolean isJwtEnabled(); +} diff --git a/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java similarity index 100% rename from proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java rename to app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java From 61ca1a4def9f004d54926fd7dbe1722cdd4ec952 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:57:49 +0100 Subject: [PATCH 10/23] Nav Bar Fixes for Mobile Devices (#3927) # Description of Changes --- ## Checklist ### General - [x ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ x] I have performed a self-review of my own code - [ x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Ethan --- .../src/main/resources/static/js/navbar.js | 80 +++++++++++++++++-- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/app/core/src/main/resources/static/js/navbar.js b/app/core/src/main/resources/static/js/navbar.js index 57f916f8a..1fd46ed70 100644 --- a/app/core/src/main/resources/static/js/navbar.js +++ b/app/core/src/main/resources/static/js/navbar.js @@ -42,6 +42,39 @@ function toolsManager() { }); } +function setupDropdowns() { + const dropdowns = document.querySelectorAll('.navbar-nav > .nav-item.dropdown'); + + dropdowns.forEach((dropdown) => { + const toggle = dropdown.querySelector('[data-bs-toggle="dropdown"]'); + if (!toggle) return; + + // Skip search dropdown, it has its own logic + if (toggle.id === 'searchDropdown') { + return; + } + + dropdown.addEventListener('show.bs.dropdown', () => { + // Find all other open dropdowns and hide them + const openDropdowns = document.querySelectorAll('.navbar-nav .dropdown-menu.show'); + openDropdowns.forEach((menu) => { + const parentDropdown = menu.closest('.dropdown'); + if (parentDropdown && parentDropdown !== dropdown) { + const parentToggle = parentDropdown.querySelector('[data-bs-toggle="dropdown"]'); + if (parentToggle) { + // Get or create Bootstrap dropdown instance + let instance = bootstrap.Dropdown.getInstance(parentToggle); + if (!instance) { + instance = new bootstrap.Dropdown(parentToggle); + } + instance.hide(); + } + } + }); + }); + }); +} + window.tooltipSetup = () => { const tooltipElements = document.querySelectorAll('[title]'); @@ -56,26 +89,55 @@ window.tooltipSetup = () => { document.body.appendChild(customTooltip); element.addEventListener('mouseenter', (event) => { - customTooltip.style.display = 'block'; - customTooltip.style.left = `${event.pageX + 10}px`; // Position tooltip slightly away from the cursor - customTooltip.style.top = `${event.pageY + 10}px`; + if (window.innerWidth >= 1200) { + customTooltip.style.display = 'block'; + customTooltip.style.left = `${event.pageX + 10}px`; + customTooltip.style.top = `${event.pageY + 10}px`; + } }); - // Update the position of the tooltip as the user moves the mouse element.addEventListener('mousemove', (event) => { - customTooltip.style.left = `${event.pageX + 10}px`; - customTooltip.style.top = `${event.pageY + 10}px`; + if (window.innerWidth >= 1200) { + customTooltip.style.left = `${event.pageX + 10}px`; + customTooltip.style.top = `${event.pageY + 10}px`; + } }); - // Hide the tooltip when the mouse leaves element.addEventListener('mouseleave', () => { customTooltip.style.display = 'none'; }); }); }; + +// Override the bootstrap dropdown styles for mobile +function fixNavbarDropdownStyles() { + if (window.innerWidth < 1200) { + document.querySelectorAll('.navbar .dropdown-menu').forEach(function(menu) { + menu.style.transform = 'none'; + menu.style.transformOrigin = 'none'; + menu.style.left = '0'; + menu.style.right = '0'; + menu.style.maxWidth = '95vw'; + menu.style.width = '100vw'; + menu.style.marginBottom = '0'; + }); + } else { + document.querySelectorAll('.navbar .dropdown-menu').forEach(function(menu) { + menu.style.transform = ''; + menu.style.transformOrigin = ''; + menu.style.left = ''; + menu.style.right = ''; + menu.style.maxWidth = ''; + menu.style.width = ''; + menu.style.marginBottom = ''; + }); + } +} + document.addEventListener('DOMContentLoaded', () => { tooltipSetup(); - + setupDropdowns(); + fixNavbarDropdownStyles(); // Setup logout button functionality const logoutButton = document.querySelector('a[href="/logout"]'); if (logoutButton) { @@ -89,4 +151,6 @@ document.addEventListener('DOMContentLoaded', () => { } }); } + }); +window.addEventListener('resize', fixNavbarDropdownStyles); From 13fa7475aa89c94180eaa4f3f12548de6979d5a9 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:39:04 +0100 Subject: [PATCH 11/23] Fix endpoint mapping (#3999) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. Renaming classes --- .../security/configuration/SecurityConfiguration.java | 6 +++--- .../{SAML2Configuration.java => Saml2Configuration.java} | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/{SAML2Configuration.java => Saml2Configuration.java} (100%) 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 dbf2313db..92c969056 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 @@ -43,7 +43,7 @@ import stirling.software.proprietary.security.database.repository.JPATokenReposi import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; import stirling.software.proprietary.security.filter.FirstLoginFilter; import stirling.software.proprietary.security.filter.IPRateLimitingFilter; -import stirling.software.proprietary.security.filter.JWTAuthenticationFilter; +import stirling.software.proprietary.security.filter.JwtAuthenticationFilter; import stirling.software.proprietary.security.filter.UserAuthenticationFilter; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler; @@ -367,8 +367,8 @@ public class SecurityConfiguration { } @Bean - public JWTAuthenticationFilter jwtAuthenticationFilter() { - return new JWTAuthenticationFilter( + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter( jwtService, userService, userDetailsService, 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 similarity index 100% rename from app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java From 622a0f91aaee29151ce199465b4be1c958f28cc1 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:03:31 +0100 Subject: [PATCH 12/23] 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); + } +} From eb28ca086af3f46e8bbf69d3802ee41739e2ac51 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:25:59 +0100 Subject: [PATCH 13/23] Updated test --- .../filter/JwtAuthenticationFilter.java | 213 ++++++++++++++++++ .../security/model/AuthenticationType.java | 3 +- ...l2AuthenticationRequestRepositoryTest.java | 1 + 3 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..eaa333a76 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,213 @@ +package stirling.software.proprietary.security.filter; + +import static stirling.software.proprietary.security.model.AuthenticationType.*; +import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.Map; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +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 org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.security.model.AuthenticationType; +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; + +@Slf4j +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtServiceInterface jwtService; + private final UserService userService; + private final CustomUserDetailsService userDetailsService; + private final AuthenticationEntryPoint authenticationEntryPoint; + private final ApplicationProperties.Security securityProperties; + + public JwtAuthenticationFilter( + JwtServiceInterface jwtService, + UserService userService, + CustomUserDetailsService userDetailsService, + AuthenticationEntryPoint authenticationEntryPoint, + ApplicationProperties.Security securityProperties) { + this.jwtService = jwtService; + this.userService = userService; + this.userDetailsService = userDetailsService; + this.authenticationEntryPoint = authenticationEntryPoint; + this.securityProperties = securityProperties; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!jwtService.isJwtEnabled()) { + filterChain.doFilter(request, response); + return; + } + if (shouldNotFilter(request)) { + filterChain.doFilter(request, response); + return; + } + + String jwtToken = jwtService.extractTokenFromRequest(request); + + if (jwtToken == null) { + // If they are unauthenticated and navigating to '/', redirect to '/login' instead of + // sending a 401 + if ("/".equals(request.getRequestURI()) + && "GET".equalsIgnoreCase(request.getMethod())) { + response.sendRedirect("/login"); + return; + } + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException("JWT is missing from the request")); + return; + } + + try { + jwtService.validateToken(jwtToken); + } catch (AuthenticationFailureException e) { + handleAuthenticationFailure(request, response, e); + return; + } + + Map claims = jwtService.extractAllClaims(jwtToken); + String tokenUsername = claims.get("sub").toString(); + + try { + Authentication authentication = createAuthentication(request, claims); + String jwt = jwtService.generateToken(authentication, claims); + + jwtService.addTokenToResponse(response, jwt); + } catch (SQLException | UnsupportedProviderException e) { + log.error("Error processing user authentication for user: {}", tokenUsername, e); + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException("Error processing user authentication", e)); + return; + } + + filterChain.doFilter(request, response); + } + + private Authentication createAuthentication( + HttpServletRequest request, Map claims) + throws SQLException, UnsupportedProviderException { + String username = claims.get("sub").toString(); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + processUserAuthenticationType(claims, username); + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + + if (userDetails != null) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + + log.debug("JWT authentication successful for user: {}", username); + + } else { + throw new UsernameNotFoundException("User not found: " + username); + } + } + + return SecurityContextHolder.getContext().getAuthentication(); + } + + private void processUserAuthenticationType(Map claims, String username) + throws SQLException, UnsupportedProviderException { + AuthenticationType authenticationType = + AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString()); + log.debug("Processing {} login for {} user", authenticationType, username); + + switch (authenticationType) { + case OAUTH2 -> { + ApplicationProperties.Security.OAUTH2 oauth2Properties = + securityProperties.getOauth2(); + userService.processSSOPostLogin( + username, oauth2Properties.getAutoCreateUser(), OAUTH2); + } + case SAML2 -> { + ApplicationProperties.Security.SAML2 saml2Properties = + securityProperties.getSaml2(); + userService.processSSOPostLogin( + username, saml2Properties.getAutoCreateUser(), SAML2); + } + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + String method = request.getMethod(); + + // Skip JWT processing for logout requests to prevent token refresh during logout + if ("/logout".equals(uri)) { + return true; + } + + // Allow login POST requests to be processed + if ("/login".equals(uri) && "POST".equalsIgnoreCase(method)) { + return true; + } + + String[] permitAllPatterns = { + "/login", + "/register", + "/error", + "/images/", + "/public/", + "/css/", + "/fonts/", + "/js/", + "/pdfjs/", + "/pdfjs-legacy/", + "/api/v1/info/status", + "/site.webmanifest", + "/favicon" + }; + + for (String pattern : permitAllPatterns) { + if (uri.startsWith(pattern) + || uri.endsWith(".svg") + || uri.endsWith(".png") + || uri.endsWith(".ico")) { + return true; + } + } + + return false; + } + + private void handleAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException, ServletException { + authenticationEntryPoint.commence(request, response, authException); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index b3042dd25..cf9f15e35 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,8 +2,7 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - SSO, - // TODO: Worth making a distinction between OAuth2 and SAML2? + @Deprecated(since = "1.0.2") SSO, OAUTH2, SAML2 } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java index 11f3c00d9..bce663aee 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java @@ -65,6 +65,7 @@ class JwtSaml2AuthenticationRequestRepositoryTest { String samlRequest = "testSamlRequest"; String relyingPartyRegistrationId = "stirling-pdf"; + when(jwtService.isJwtEnabled()).thenReturn(true); when(authRequest.getRelayState()).thenReturn(relayState); when(authRequest.getId()).thenReturn(id); when(authRequest.getAuthenticationRequestUri()).thenReturn(authnRequestUri); From ce7bac26d2b5fdb9399e9886a4608d85fd5dbdc4 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 17:46:42 +0100 Subject: [PATCH 14/23] Cleanup --- .claude/settings.local.json | 4 +- .github/workflows/build.yml | 121 +++++++- .gitignore | 3 - .../common/model/ApplicationProperties.java | 7 + .../software/common/util/RequestUriUtils.java | 2 + .../src/main/resources/application.properties | 2 +- .../src/main/resources/settings.yml.template | 32 ++- .../configuration/SecurityConfiguration.java | 10 +- .../repository/JwtSigningKeyRepository.java | 18 ++ .../filter/JwtAuthenticationFilter.java | 52 +--- .../filter/UserAuthenticationFilter.java | 12 +- .../security/model/AuthenticationType.java | 2 +- .../security/model/JwtSigningKey.java | 62 +++++ ...tomOAuth2AuthenticationSuccessHandler.java | 3 +- ...tSaml2AuthenticationRequestRepository.java | 2 +- .../security/service/JwtKeystoreService.java | 238 ++++++++++++++++ .../service/JwtKeystoreServiceInterface.java | 17 ++ .../security/service/JwtService.java | 70 ++++- .../security/service/UserService.java | 7 +- .../filter/JwtAuthenticationFilterTest.java | 100 +------ .../JwtKeystoreServiceInterfaceTest.java | 258 ++++++++++++++++++ .../security/service/JwtServiceTest.java | 107 +++++++- 22 files changed, 935 insertions(+), 194 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKey.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 13d8d8350..bc5358b85 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,9 @@ "Bash(find:*)", "Bash(grep:*)", "Bash(rg:*)", - "Bash(strings:*)" + "Bash(strings:*)", + "Bash(pkill:*)", + "Bash(true)" ], "deny": [] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f00b927c9..db847f570 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,9 @@ -name: Build repo +name: Build and Test Workflow on: - push: - branches: ["main"] + workflow_dispatch: + # push: + # branches: ["main"] pull_request: branches: ["main"] @@ -22,6 +23,24 @@ permissions: contents: read jobs: + files-changed: + name: detect what files changed + runs-on: ubuntu-latest + timeout-minutes: 3 + # Map a step output to a job output + outputs: + build: ${{ steps.changes.outputs.build }} + app: ${{ steps.changes.outputs.app }} + project: ${{ steps.changes.outputs.project }} + openapi: ${{ steps.changes.outputs.openapi }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check for file changes + uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: changes + with: + filters: ".github/config/.files.yaml" build: runs-on: ubuntu-latest @@ -37,7 +56,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -50,6 +69,11 @@ jobs: java-version: ${{ matrix.jdk-version }} distribution: "temurin" + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + with: + gradle-version: 8.14 + - name: Build with Gradle and spring security ${{ matrix.spring-security }} run: ./gradlew clean build env: @@ -100,14 +124,17 @@ jobs: if-no-files-found: warn check-generateOpenApiDocs: + if: needs.files-changed.outputs.openapi == 'true' + needs: [files-changed, build] runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -115,7 +142,8 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs @@ -127,10 +155,12 @@ jobs: path: ./SwaggerDoc.json check-licence: + if: needs.files-changed.outputs.build == 'true' + needs: [files-changed, build] runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -141,7 +171,7 @@ jobs: uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "17" - distribution: "adopt" + distribution: "temurin" - name: check the licenses for compatibility run: ./gradlew clean checkLicense @@ -156,6 +186,8 @@ jobs: retention-days: 3 docker-compose-tests: + if: needs.files-changed.outputs.project == 'true' + needs: files-changed # if: github.event_name == 'push' && github.ref == 'refs/heads/main' || # (github.event_name == 'pull_request' && # contains(github.event.pull_request.labels.*.name, 'licenses') == false && @@ -174,7 +206,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -185,7 +217,7 @@ jobs: uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: java-version: "17" - distribution: "adopt" + distribution: "temurin" - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 @@ -200,6 +232,7 @@ jobs: with: python-version: "3.12" cache: 'pip' # caching pip dependencies + cache-dependency-path: ./testing/cucumber/requirements.txt - name: Pip requirements run: | @@ -211,3 +244,69 @@ jobs: chmod +x ./testing/test.sh chmod +x ./testing/test_disabledEndpoints.sh ./testing/test.sh + + test-build-docker-images: + if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' + needs: [files-changed, build, check-generateOpenApiDocs, check-licence] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"] + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + java-version: "17" + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + with: + gradle-version: 8.14 + + - name: Build application + run: ./gradlew clean build + env: + DISABLE_ADDITIONAL_FEATURES: true + STIRLING_PDF_DESKTOP_UI: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Build ${{ matrix.docker-rev }} + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./${{ matrix.docker-rev }} + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Upload Reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: reports-docker-${{ matrix.docker-rev }} + path: | + build/reports/tests/ + build/test-results/ + build/reports/problems/ + retention-days: 3 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 55603719c..6ebd87c35 100644 --- a/.gitignore +++ b/.gitignore @@ -200,6 +200,3 @@ id_ed25519.pub # node_modules node_modules/ - -# Claude -CLAUDE.md 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 802a55831..aeb220dae 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 @@ -119,6 +119,7 @@ public class ApplicationProperties { private long loginResetTimeMinutes; private String loginMethod = "all"; private String customGlobalAPIKey; + private Jwt jwt = new Jwt(); public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); @@ -297,6 +298,12 @@ public class ApplicationProperties { } } } + + @Data + public static class Jwt { + private boolean enableKeystore = true; + private boolean enableKeyRotation = false; + } } @Data diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 654c78fe9..239976b66 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -14,8 +14,10 @@ public class RequestUriUtils { || requestURI.startsWith(contextPath + "/images/") || requestURI.startsWith(contextPath + "/public/") || requestURI.startsWith(contextPath + "/pdfjs/") + || requestURI.startsWith(contextPath + "/pdfjs-legacy/") || requestURI.startsWith(contextPath + "/login") || requestURI.startsWith(contextPath + "/error") + || requestURI.startsWith(contextPath + "/favicon") || requestURI.endsWith(".svg") || requestURI.endsWith(".png") || requestURI.endsWith(".ico") diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index e0273eb5a..ec3a0a390 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=false +v2=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 07e0fb7db..0814a6924 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -11,7 +11,7 @@ ############################################################################################################# security: - enableLogin: true # set to 'true' to enable login + enableLogin: false # set to 'true' to enable login csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts @@ -31,7 +31,7 @@ security: google: clientId: '' # client ID for Google OAuth2 clientSecret: '' # client secret for Google OAuth2 - scopes: email, profile # scopes for Google OAuth2 + scopes: https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile # scopes for Google OAuth2 useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name] github: clientId: '' # client ID for GitHub OAuth2 @@ -47,24 +47,26 @@ security: scopes: openid, profile, email # specify the scopes for which the application will request permissions provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak' saml2: - enabled: true # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) - provider: authentik # The name of your Provider + enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) + provider: '' # The name of your Provider autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin - registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs - idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata - idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider - idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider + registrationId: stirlingpdf-dario-saml # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs + idpMetadataUri: https://authentik.dev.stirlingpdf.com/api/v3/providers/saml/5/metadata/ # The uri for your Provider's metadata + idpSingleLoginUrl: https://authentik.dev.stirlingpdf.com/application/saml/stirlingpdf-dario-saml/sso/binding/post/ # The URL for initiating SSO. Provided by your Provider + idpSingleLogoutUrl: https://authentik.dev.stirlingpdf.com/application/saml/stirlingpdf-dario-saml/slo/binding/post/ # The URL for initiating SLO. Provided by your Provider idpIssuer: authentik # The ID of your Provider - idpCert: classpath:authentik-Self_Signed_Certificate.pem # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider - privateKey: classpath:private-key.key # Your private key. Generated from your keypair - spCert: classpath:cert.crt # Your signing certificate. Generated from your keypair + idpCert: classpath:authentik-Self-signed_Certificate_certificate.pem # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider + privateKey: classpath:private_key.key # Your private key. Generated from your keypair + spCert: classpath:certificate.crt # Your signing certificate. Generated from your keypair + jwt: + enableKeyStore: true # Set to 'true' to enable JWT key store + enableKeyRotation: true # Set to 'true' to enable JWT key rotation premium: - key: 00000000-0000-0000-0000-000000000000 - enabled: true # Enable license key checks for pro/enterprise features + key: 3R3T-WFPY-UNRW-LJFA-MMXM-YVJK-WCKY-PCRT # fixme: remove + enabled: false # Enable license key checks for pro/enterprise features proFeatures: - database: false # Enable database features SSOAutoLogin: false CustomMetadata: autoUpdateMetadata: false @@ -100,7 +102,7 @@ legal: system: defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc) googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow - enableAlphaFunctionality: true # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) + enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) showUpdate: false # see when a new update is available showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true' customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files 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 094a7986b..a24b109c9 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.addFilterAt( + http.addFilterBefore( jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .exceptionHandling( @@ -145,8 +145,8 @@ public class SecurityConfiguration { } http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(rateLimitingFilter(), userAuthenticationFilter.getClass()) - .addFilterAfter(firstLoginFilter, rateLimitingFilter().getClass()); + .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) + .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -252,7 +252,9 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/js/") || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( - "/api/v1/info/status"); + "/api/v1/info/status") + || trimmedUri.startsWith("/v1/api-docs") + || uri.contains("/v1/api-docs"); }) .permitAll() .anyRequest() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java new file mode 100644 index 000000000..cde43ff69 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java @@ -0,0 +1,18 @@ +package stirling.software.proprietary.security.database.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.JwtSigningKey; + +@Repository +public interface JwtSigningKeyRepository extends JpaRepository { + + Optional findByIsActiveTrue(); + + Optional findByKeyId(String keyId); + + Optional findByKeyIdAndIsActiveTrue(String keyId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index eaa333a76..6aea4739a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.filter; +import static stirling.software.common.util.RequestUriUtils.isStaticResource; import static stirling.software.proprietary.security.model.AuthenticationType.*; import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; @@ -62,7 +63,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); return; } - if (shouldNotFilter(request)) { + if (isStaticResource(request.getContextPath(), request.getRequestURI())) { filterChain.doFilter(request, response); return; } @@ -87,6 +88,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { try { jwtService.validateToken(jwtToken); } catch (AuthenticationFailureException e) { + // Clear invalid tokens from response + jwtService.clearTokenFromResponse(response); handleAuthenticationFailure(request, response, e); return; } @@ -128,7 +131,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); - log.debug("JWT authentication successful for user: {}", username); + log.info( + "JWT authentication successful for user: {} - Authentication set in SecurityContext", + username); } else { throw new UsernameNotFoundException("User not found: " + username); @@ -160,49 +165,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } } - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String uri = request.getRequestURI(); - String method = request.getMethod(); - - // Skip JWT processing for logout requests to prevent token refresh during logout - if ("/logout".equals(uri)) { - return true; - } - - // Allow login POST requests to be processed - if ("/login".equals(uri) && "POST".equalsIgnoreCase(method)) { - return true; - } - - String[] permitAllPatterns = { - "/login", - "/register", - "/error", - "/images/", - "/public/", - "/css/", - "/fonts/", - "/js/", - "/pdfjs/", - "/pdfjs-legacy/", - "/api/v1/info/status", - "/site.webmanifest", - "/favicon" - }; - - for (String pattern : permitAllPatterns) { - if (uri.startsWith(pattern) - || uri.endsWith(".svg") - || uri.endsWith(".png") - || uri.endsWith(".ico")) { - return true; - } - } - - return false; - } - private void handleAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 8a148e931..70c9e233e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -63,7 +63,15 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { return; } String requestURI = request.getRequestURI(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + log.info( + "UserAuthenticationFilter - Authentication from SecurityContext: {}", + authentication != null + ? authentication.getClass().getSimpleName() + + " for " + + authentication.getName() + : "null"); // Check for session expiration (unsure if needed) // if (authentication != null && authentication.isAuthenticated()) { @@ -220,11 +228,12 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); String[] permitAllPatterns = { contextPath + "/login", + contextPath + "/signup", contextPath + "/register", contextPath + "/error", contextPath + "/images/", @@ -241,6 +250,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { for (String pattern : permitAllPatterns) { if (uri.startsWith(pattern) || uri.endsWith(".svg") + || uri.endsWith(".mjs") || uri.endsWith(".png") || uri.endsWith(".ico")) { return true; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index cf9f15e35..0ef0a9235 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - @Deprecated(since = "1.0.2") SSO, + SSO, OAUTH2, SAML2 } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKey.java new file mode 100644 index 000000000..d1b78d8a9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKey.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "signing_keys") +@ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class JwtSigningKey implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "signing_key_id") + @EqualsAndHashCode.Include + @ToString.Include + private Long id; + + @Column(name = "key_id", nullable = false, unique = true) + @ToString.Include + private String keyId; + + @Column(name = "signing_key", columnDefinition = "TEXT", nullable = false) + private String signingKey; + + @Column(name = "algorithm", nullable = false) + private String algorithm = "RS256"; + + @Column(name = "created_at", nullable = false) + @ToString.Include + private LocalDateTime createdAt; + + @Column(name = "is_active", nullable = false) + @ToString.Include + private Boolean isActive = true; + + public JwtSigningKey(String keyId, String signingKey, String algorithm) { + this.keyId = keyId; + this.signingKey = signingKey; + this.algorithm = algorithm; + this.createdAt = LocalDateTime.now(); + this.isActive = true; + } +} 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 71227b618..dc489ffd2 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 @@ -85,7 +85,8 @@ public class CustomOAuth2AuthenticationSuccessHandler } if (userService.usernameExistsIgnoreCase(username) && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername(username, SSO) + && (!userService.isAuthenticationTypeByUsername(username, SSO) + || !userService.isAuthenticationTypeByUsername(username, OAUTH2)) && oauth2Properties.getAutoCreateUser()) { response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); return; 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 f1f2da6b1..68c190e64 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 @@ -39,7 +39,7 @@ public class JwtSaml2AuthenticationRequestRepository HttpServletRequest request, HttpServletResponse response) { if (!jwtService.isJwtEnabled()) { - log.warn("SAML2 v2 is not enabled, skipping saveAuthenticationRequest"); + log.debug("V2 is not enabled, skipping SAMLRequest token storage"); return; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java new file mode 100644 index 000000000..72fbaba92 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java @@ -0,0 +1,238 @@ +package stirling.software.proprietary.security.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; +import stirling.software.proprietary.security.model.JwtSigningKey; + +@Service +@Slf4j +public class JwtKeystoreService implements JwtKeystoreServiceInterface { + + public static final String KEY_SUFFIX = ".key"; + private final JwtSigningKeyRepository repository; + private final ApplicationProperties.Security.Jwt jwtProperties; + private final Path privateKeyDirectory; + + private volatile KeyPair currentKeyPair; + private volatile String currentKeyId; + + @Autowired + public JwtKeystoreService( + JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { + this.repository = repository; + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + this.privateKeyDirectory = Paths.get(InstallationPathConfig.getConfigPath(), "jwt-keys"); + } + + @PostConstruct + public void initializeKeystore() { + if (!isKeystoreEnabled()) { + log.info("JWT keystore is disabled, using in-memory key generation"); + return; + } + + try { + ensurePrivateKeyDirectoryExists(); + loadOrGenerateKeypair(); + } catch (Exception e) { + log.error("Failed to initialize JWT keystore, falling back to in-memory generation", e); + } + } + + @Override + public KeyPair getActiveKeypair() { + if (!isKeystoreEnabled() || currentKeyPair == null) { + return generateRSAKeypair(); + } + return currentKeyPair; + } + + @Override + public Optional getKeypairByKeyId(String keyId) { + if (!isKeystoreEnabled()) { + return Optional.empty(); + } + + try { + Optional signingKey = repository.findByKeyId(keyId); + if (signingKey.isEmpty()) { + return Optional.empty(); + } + + PrivateKey privateKey = loadPrivateKey(keyId); + PublicKey publicKey = decodePublicKey(signingKey.get().getSigningKey()); + + return Optional.of(new KeyPair(publicKey, privateKey)); + } catch (Exception e) { + log.error("Failed to load keypair for keyId: {}", keyId, e); + return Optional.empty(); + } + } + + @Override + public String getActiveKeyId() { + return currentKeyId; + } + + @Override + @Transactional + public void rotateKeypair() { + if (!isKeystoreEnabled()) { + log.warn("Cannot rotate keypair when keystore is disabled"); + return; + } + + try { + repository + .findByIsActiveTrue() + .ifPresent( + key -> { + key.setIsActive(false); + repository.save(key); + }); + + generateAndStoreKeypair(); + log.info("Successfully rotated JWT keypair"); + } catch (Exception e) { + log.error("Failed to rotate JWT keypair", e); + throw new RuntimeException("Keypair rotation failed", e); + } + } + + @Override + public boolean isKeystoreEnabled() { + return jwtProperties.isEnableKeystore(); + } + + private void loadOrGenerateKeypair() { + Optional activeKey = repository.findByIsActiveTrue(); + + if (activeKey.isPresent()) { + try { + currentKeyId = activeKey.get().getKeyId(); + PrivateKey privateKey = loadPrivateKey(currentKeyId); + PublicKey publicKey = decodePublicKey(activeKey.get().getSigningKey()); + currentKeyPair = new KeyPair(publicKey, privateKey); + log.info("Loaded existing JWT keypair with keyId: {}", currentKeyId); + } catch (Exception e) { + log.error("Failed to load existing keypair, generating new one", e); + generateAndStoreKeypair(); + } + } else { + generateAndStoreKeypair(); + } + } + + private void generateAndStoreKeypair() { + try { + KeyPair keyPair = generateRSAKeypair(); + String keyId = generateKeyId(); + + storePrivateKey(keyId, keyPair.getPrivate()); + + JwtSigningKey signingKey = + new JwtSigningKey(keyId, encodePublicKey(keyPair.getPublic()), "RS256"); + repository.save(signingKey); + currentKeyPair = keyPair; + currentKeyId = keyId; + + log.info("Generated and stored new JWT keypair with keyId: {}", keyId); + } catch (Exception e) { + log.error("Failed to generate and store keypair", e); + throw new RuntimeException("Keypair generation failed", e); + } + } + + private KeyPair generateRSAKeypair() { + KeyPairGenerator keyPairGenerator = null; + + try { + keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to initialize RSA key pair generator", e); + } + + return keyPairGenerator.generateKeyPair(); + } + + private String generateKeyId() { + return "jwt-key-" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")); + } + + private void ensurePrivateKeyDirectoryExists() throws IOException { + if (!Files.exists(privateKeyDirectory)) { + Files.createDirectories(privateKeyDirectory); + log.info("Created JWT private key directory: {}", privateKeyDirectory); + } + } + + private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException { + Path keyFile = privateKeyDirectory.resolve(keyId + KEY_SUFFIX); + String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + Files.writeString(keyFile, encodedKey); + + // Set read/write to only the owner + try { + keyFile.toFile().setReadable(true, true); + keyFile.toFile().setWritable(true, true); + keyFile.toFile().setExecutable(false, false); + } catch (Exception e) { + log.warn("Failed to set permissions on private key file: {}", keyFile, e); + } + } + + private PrivateKey loadPrivateKey(String keyId) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + Path keyFile = privateKeyDirectory.resolve(keyId + KEY_SUFFIX); + if (!Files.exists(keyFile)) { + throw new IOException("Private key file not found: " + keyFile); + } + + String encodedKey = Files.readString(keyFile); + byte[] keyBytes = Base64.getDecoder().decode(encodedKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } + + private String encodePublicKey(PublicKey publicKey) { + return Base64.getEncoder().encodeToString(publicKey.getEncoded()); + } + + private PublicKey decodePublicKey(String encodedKey) + throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] keyBytes = Base64.getDecoder().decode(encodedKey); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(keySpec); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java new file mode 100644 index 000000000..4df7d552d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.util.Optional; + +public interface JwtKeystoreServiceInterface { + + KeyPair getActiveKeypair(); + + Optional getKeypairByKeyId(String keyId); + + String getActiveKeyId(); + + void rotateKeypair(); + + boolean isKeystoreEnabled(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 2ae2197b8..b903767ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -4,8 +4,10 @@ import java.security.KeyPair; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.function.Function; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; @@ -40,12 +42,15 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "Stirling PDF"; private static final long EXPIRATION = 3600000; - private final KeyPair keyPair; + private final JwtKeystoreServiceInterface keystoreService; private final boolean v2Enabled; - public JwtService(@Qualifier("v2Enabled") boolean v2Enabled) { + @Autowired + public JwtService( + @Qualifier("v2Enabled") boolean v2Enabled, + JwtKeystoreServiceInterface keystoreService) { this.v2Enabled = v2Enabled; - keyPair = Jwts.SIG.RS256.keyPair().build(); + this.keystoreService = keystoreService; } @Override @@ -66,14 +71,23 @@ public class JwtService implements JwtServiceInterface { @Override public String generateToken(String username, Map claims) { - return Jwts.builder() - .claims(claims) - .subject(username) - .issuer(ISSUER) - .issuedAt(new Date()) - .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) - .signWith(keyPair.getPrivate(), Jwts.SIG.RS256) - .compact(); + KeyPair keyPair = keystoreService.getActiveKeypair(); + + var builder = + Jwts.builder() + .claims(claims) + .subject(username) + .issuer(ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); + + String keyId = keystoreService.getActiveKeyId(); + if (keyId != null) { + builder.header().keyId(keyId); + } + + return builder.compact(); } @Override @@ -112,6 +126,25 @@ public class JwtService implements JwtServiceInterface { private Claims extractAllClaimsFromToken(String token) { try { + // Extract key ID from token header if present + String keyId = extractKeyIdFromToken(token); + KeyPair keyPair; + + if (keyId != null) { + Optional specificKeyPair = keystoreService.getKeypairByKeyId(keyId); + if (specificKeyPair.isPresent()) { + keyPair = specificKeyPair.get(); + } else { + log.warn( + "Key ID {} not found in keystore, token may have been signed with a rotated key", + keyId); + throw new AuthenticationFailureException( + "JWT token signed with unknown key ID: " + keyId); + } + } else { + keyPair = keystoreService.getActiveKeypair(); + } + return Jwts.parser() .verifyWith(keyPair.getPublic()) .build() @@ -191,4 +224,19 @@ public class JwtService implements JwtServiceInterface { public boolean isJwtEnabled() { return v2Enabled; } + + private String extractKeyIdFromToken(String token) { + try { + return (String) + Jwts.parser() + .unsecured() + .build() + .parseUnsecuredClaims(token) + .getHeader() + .get("kid"); + } catch (Exception e) { + log.debug("Failed to extract key ID from token header: {}", e.getMessage()); + return null; + } + } } 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 8cddebcbf..982f551ca 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 @@ -1,5 +1,8 @@ package stirling.software.proprietary.security.service; +import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; @@ -63,10 +66,10 @@ public class UserService implements UserServiceInterface { @Transactional public void migrateOauth2ToSSO() { userRepository - .findByAuthenticationTypeIgnoreCase("OAUTH2") + .findByAuthenticationTypeIgnoreCase(OAUTH2.toString()) .forEach( user -> { - user.setAuthenticationType(AuthenticationType.SSO); + user.setAuthenticationType(SSO); userRepository.save(user); }); } 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 ecb84122a..7e67a9106 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 @@ -88,12 +88,11 @@ class JwtAuthenticationFilterTest { void shouldNotFilterWhenPageIsLogin() throws ServletException, IOException { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/login"); - when(request.getMethod()).thenReturn("POST"); + when(request.getContextPath()).thenReturn("/login"); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); - verify(filterChain).doFilter(request, response); - verify(jwtService, never()).extractTokenFromRequest(any()); + verify(filterChain, never()).doFilter(request, response); } @Test @@ -104,8 +103,8 @@ class JwtAuthenticationFilterTest { Map claims = Map.of("sub", username, "authType", "WEB"); when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getContextPath()).thenReturn("/"); 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); @@ -151,7 +150,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); - when(request.getMethod()).thenReturn("GET"); + when(request.getContextPath()).thenReturn("/"); when(jwtService.extractTokenFromRequest(request)).thenReturn(token); doThrow(new AuthenticationFailureException("Invalid token")).when(jwtService).validateToken(token); @@ -168,7 +167,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); - when(request.getMethod()).thenReturn("GET"); + when(request.getContextPath()).thenReturn("/"); when(jwtService.extractTokenFromRequest(request)).thenReturn(token); doThrow(new AuthenticationFailureException("The token has expired")).when(jwtService).validateToken(token); @@ -187,7 +186,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); - when(request.getMethod()).thenReturn("GET"); + when(request.getContextPath()).thenReturn("/"); when(jwtService.extractTokenFromRequest(request)).thenReturn(token); doNothing().when(jwtService).validateToken(token); when(jwtService.extractAllClaims(token)).thenReturn(claims); @@ -205,96 +204,11 @@ class JwtAuthenticationFilterTest { } } - @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(request.getContextPath()).thenReturn("/"); when(jwtService.extractTokenFromRequest(request)).thenReturn(null); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java new file mode 100644 index 000000000..98e2b836d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java @@ -0,0 +1,258 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +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.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; +import stirling.software.proprietary.security.model.JwtSigningKey; + +@ExtendWith(MockitoExtension.class) +class JwtKeystoreServiceInterfaceTest { + + @Mock + private JwtSigningKeyRepository repository; + + @Mock + private ApplicationProperties applicationProperties; + + @Mock + private ApplicationProperties.Security security; + + @Mock + private ApplicationProperties.Security.Jwt jwtConfig; + + @TempDir + Path tempDir; + + private JwtKeystoreService keystoreService; + private KeyPair testKeyPair; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + when(applicationProperties.getSecurity()).thenReturn(security); + when(security.getJwt()).thenReturn(jwtConfig); + when(jwtConfig.isEnableKeystore()).thenReturn(true); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testKeystoreEnabled(boolean keystoreEnabled) { + when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled()); + } + } + + @Test + void testGetActiveKeypairWhenKeystoreDisabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(false); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + KeyPair result = keystoreService.getActiveKeypair(); + + assertNotNull(result); + assertNotNull(result.getPublic()); + assertNotNull(result.getPrivate()); + } + } + + @Test + void testGetActiveKeypairWhenNoActiveKeyExists() { + when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService.initializeKeystore(); + + KeyPair result = keystoreService.getActiveKeypair(); + + assertNotNull(result); + verify(repository).save(any(JwtSigningKey.class)); + } + } + + @Test + void testGetActiveKeypairWithExistingKey() throws Exception { + String keyId = "test-key-2024-01-01-120000"; + String publicKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + String privateKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + + JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); + when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); + + Path keyFile = tempDir.resolve("jwt-keys").resolve(keyId + ".key"); + Files.createDirectories(keyFile.getParent()); + Files.writeString(keyFile, privateKeyBase64); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService.initializeKeystore(); + + KeyPair result = keystoreService.getActiveKeypair(); + + assertNotNull(result); + assertEquals(keyId, keystoreService.getActiveKeyId()); + } + } + + @Test + void testGetKeypairByKeyId() throws Exception { + String keyId = "test-key-123"; + String publicKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + String privateKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + + JwtSigningKey signingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); + when(repository.findByKeyId(keyId)).thenReturn(Optional.of(signingKey)); + + Path keyFile = tempDir.resolve("jwt-keys").resolve(keyId + ".key"); + Files.createDirectories(keyFile.getParent()); + Files.writeString(keyFile, privateKeyBase64); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + Optional result = keystoreService.getKeypairByKeyId(keyId); + + assertTrue(result.isPresent()); + assertNotNull(result.get().getPublic()); + assertNotNull(result.get().getPrivate()); + } + } + + @Test + void testGetKeypairByKeyIdNotFound() { + String keyId = "non-existent-key"; + when(repository.findByKeyId(keyId)).thenReturn(Optional.empty()); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + Optional result = keystoreService.getKeypairByKeyId(keyId); + + assertFalse(result.isPresent()); + } + } + + @Test + void testGetKeypairByKeyIdWhenKeystoreDisabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(false); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + Optional result = keystoreService.getKeypairByKeyId("any-key"); + + assertFalse(result.isPresent()); + } + } + + @Test + void testRotateKeypair() { + String oldKeyId = "old-key-123"; + JwtSigningKey oldKey = new JwtSigningKey(oldKeyId, "old-public-key", "RS256"); + when(repository.findByIsActiveTrue()).thenReturn(Optional.of(oldKey)); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + keystoreService.initializeKeystore(); + + keystoreService.rotateKeypair(); + + assertFalse(oldKey.getIsActive()); + verify(repository, atLeast(2)).save(any(JwtSigningKey.class)); // At least one for deactivation, one for new key + + assertNotNull(keystoreService.getActiveKeyId()); + assertNotEquals(oldKeyId, keystoreService.getActiveKeyId()); + } + } + + @Test + void testRotateKeypairWhenKeystoreDisabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(false); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + + assertDoesNotThrow(() -> keystoreService.rotateKeypair()); + + verify(repository, never()).save(any()); + } + } + + @Test + void testInitializeKeystoreCreatesDirectory() throws IOException { + when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService.initializeKeystore(); + + Path jwtKeysDir = tempDir.resolve("jwt-keys"); + assertTrue(Files.exists(jwtKeysDir)); + assertTrue(Files.isDirectory(jwtKeysDir)); + } + } + + @Test + void testLoadExistingKeypairWithMissingPrivateKeyFile() { + String keyId = "test-key-missing-file"; + String publicKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + + JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); + when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService.initializeKeystore(); + + KeyPair result = keystoreService.getActiveKeypair(); + assertNotNull(result); + + verify(repository).save(any(JwtSigningKey.class)); + } + } + +} 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 d108d7db9..f5017a329 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 @@ -3,7 +3,11 @@ package stirling.software.proprietary.security.service; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; import java.util.Collections; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,8 +28,10 @@ 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.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; import static org.mockito.Mockito.when; @@ -48,19 +54,28 @@ class JwtServiceTest { @Mock private HttpServletResponse response; + @Mock + private JwtKeystoreServiceInterface keystoreService; + private JwtService jwtService; + private KeyPair testKeyPair; @BeforeEach - void setUp() { - jwtService = new JwtService(true); + void setUp() throws NoSuchAlgorithmException { + // Generate a test keypair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + jwtService = new JwtService(true, keystoreService); } @Test void testGenerateTokenWithAuthentication() { String username = "testuser"; - when(authentication.getPrincipal()).thenReturn(userDetails); - when(userDetails.getUsername()).thenReturn(username); + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -78,6 +93,8 @@ class JwtServiceTest { claims.put("role", "admin"); claims.put("department", "IT"); + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -94,6 +111,11 @@ class JwtServiceTest { @Test void testValidateTokenSuccess() { + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn("testuser"); + String token = jwtService.generateToken(authentication, new HashMap<>()); assertDoesNotThrow(() -> jwtService.validateToken(token)); @@ -101,6 +123,8 @@ class JwtServiceTest { @Test void testValidateTokenWithInvalidToken() { + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken("invalid-token"); }); @@ -108,6 +132,8 @@ class JwtServiceTest { @Test void testValidateTokenWithMalformedToken() { + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken("malformed.token"); }); @@ -117,6 +143,8 @@ class JwtServiceTest { @Test void testValidateTokenWithEmptyToken() { + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken(""); }); @@ -130,6 +158,8 @@ class JwtServiceTest { User user = mock(User.class); Map claims = Map.of("sub", "testuser", "authType", "WEB"); + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(user); when(user.getUsername()).thenReturn(username); @@ -140,6 +170,8 @@ class JwtServiceTest { @Test void testExtractUsernameWithInvalidToken() { + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractUsername("invalid-token")); } @@ -148,6 +180,8 @@ class JwtServiceTest { String username = "testuser"; Map claims = Map.of("role", "admin", "department", "IT"); + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -162,6 +196,8 @@ class JwtServiceTest { @Test void testExtractAllClaimsWithInvalidToken() { + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractAllClaims("invalid-token")); } @@ -228,4 +264,67 @@ class JwtServiceTest { verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); } + + @Test + void testGenerateTokenWithKeyId() { + String username = "testuser"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertNotNull(token); + assertFalse(token.isEmpty()); + // Verify that the keystore service was called + verify(keystoreService).getActiveKeypair(); + verify(keystoreService).getActiveKeyId(); + } + + @Test + void testTokenVerificationWithSpecificKeyId() throws NoSuchAlgorithmException { + String username = "testuser"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + // Generate token with key ID + String token = jwtService.generateToken(authentication, claims); + + // Mock extraction of key ID and verification (lenient to avoid unused stubbing) + lenient().when(keystoreService.getKeypairByKeyId("test-key-id")).thenReturn(Optional.of(testKeyPair)); + + // Verify token can be validated + assertDoesNotThrow(() -> jwtService.validateToken(token)); + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testTokenVerificationFallsBackToActiveKeyWhenKeyIdNotFound() { + String username = "testuser"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + // Mock scenario where specific key ID is not found (lenient to avoid unused stubbing) + lenient().when(keystoreService.getKeypairByKeyId("test-key-id")).thenReturn(Optional.empty()); + + // Should still work using active keypair + assertDoesNotThrow(() -> jwtService.validateToken(token)); + assertEquals(username, jwtService.extractUsername(token)); + + // Verify fallback to active keypair was used (called multiple times during token operations) + verify(keystoreService, atLeast(1)).getActiveKeypair(); + } } From 5ca688fc2e7dc49b0f9fca67e9c672e641a2cd0f Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:25:59 +0100 Subject: [PATCH 15/23] Updated test --- .../software/proprietary/security/model/AuthenticationType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index 0ef0a9235..cf9f15e35 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,7 +2,7 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - SSO, + @Deprecated(since = "1.0.2") SSO, OAUTH2, SAML2 } From 72d0339588c2c5b052e5c955afd3b1098a204cbe Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 29 Jul 2025 14:14:31 +0100 Subject: [PATCH 16/23] Fix JWT display on account page --- .../configuration/InstallationPathConfig.java | 6 + .../common/model/ApplicationProperties.java | 3 + .../src/main/resources/templates/account.html | 9 +- .../configuration/SecurityConfiguration.java | 2 + .../repository/JwtSigningKeyRepository.java | 19 +- .../filter/UserAuthenticationFilter.java | 7 +- .../service/JwtKeyCleanupService.java | 133 ++++++++++ .../security/service/JwtKeystoreService.java | 54 ++-- .../service/JwtKeystoreServiceInterface.java | 2 - .../service/JwtKeyCleanupServiceTest.java | 248 ++++++++++++++++++ .../JwtKeystoreServiceInterfaceTest.java | 86 ++---- 11 files changed, 460 insertions(+), 109 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 08618329d..ebea350fc 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -24,6 +24,7 @@ public class InstallationPathConfig { private static final String STATIC_PATH; private static final String TEMPLATES_PATH; private static final String SIGNATURES_PATH; + private static final String PRIVATE_KEY_PATH; static { BASE_PATH = initializeBasePath(); @@ -43,6 +44,7 @@ public class InstallationPathConfig { STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; + PRIVATE_KEY_PATH = CUSTOM_FILES_PATH + "keys" + File.separator; } private static String initializeBasePath() { @@ -114,4 +116,8 @@ public class InstallationPathConfig { public static String getSignaturesPath() { return SIGNATURES_PATH; } + + public static String getPrivateKeyPath() { + return PRIVATE_KEY_PATH; + } } 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 aeb220dae..48df4f948 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 @@ -303,6 +303,9 @@ public class ApplicationProperties { public static class Jwt { private boolean enableKeystore = true; private boolean enableKeyRotation = false; + private boolean enableKeyCleanup = true; + private int keyRetentionDays = 7; + private int cleanupBatchSize = 100; } } diff --git a/app/core/src/main/resources/templates/account.html b/app/core/src/main/resources/templates/account.html index 33a0d9f47..db48bb3a5 100644 --- a/app/core/src/main/resources/templates/account.html +++ b/app/core/src/main/resources/templates/account.html @@ -390,8 +390,13 @@ key.includes('clientSubmissionOrder') || key.includes('lastSubmitTime') || key.includes('lastClientId') || - - + key.includes('stirling_jwt') || + key.includes('JSESSIONID') || + key.includes('XSRF-TOKEN') || + key.includes('remember-me') || + key.includes('auth') || + key.includes('token') || + key.includes('session') || key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') || key.includes('pageViews'); } 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 a24b109c9..ed309afda 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 @@ -250,6 +250,8 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/css/") || trimmedUri.startsWith("/fonts/") || trimmedUri.startsWith("/js/") + || trimmedUri.startsWith("/pdfjs/") + || trimmedUri.startsWith("/pdfjs-legacy/") || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( "/api/v1/info/status") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java index cde43ff69..ee7c3b419 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java @@ -1,8 +1,14 @@ package stirling.software.proprietary.security.database.repository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import stirling.software.proprietary.security.model.JwtSigningKey; @@ -14,5 +20,16 @@ public interface JwtSigningKeyRepository extends JpaRepository findByKeyId(String keyId); - Optional findByKeyIdAndIsActiveTrue(String keyId); + @Query( + "SELECT k FROM signing_keys k WHERE k.isActive = false AND k.createdAt < :cutoffDate ORDER BY k.createdAt ASC") + List findInactiveKeysOlderThan( + @Param("cutoffDate") LocalDateTime cutoffDate, Pageable pageable); + + @Query( + "SELECT COUNT(k) FROM signing_keys k WHERE k.isActive = false AND k.createdAt < :cutoffDate") + long countKeysEligibleForCleanup(@Param("cutoffDate") LocalDateTime cutoffDate); + + @Modifying + @Query("DELETE FROM signing_keys k WHERE k.id IN :ids") + void deleteAllByIdInBatch(@Param("ids") List ids); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 70c9e233e..f6074ceff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -124,11 +124,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { response.getWriter() .write( """ - Authentication required. Please provide a X-API-KEY in request\ - header. + Authentication required. Please provide a X-API-KEY in request header. This is found in Settings -> Account Settings -> API Key - Alternatively you can disable authentication if this is\ - unexpected"""); + Alternatively you can disable authentication if this is unexpected. + """); } return; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java new file mode 100644 index 000000000..706f7a537 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java @@ -0,0 +1,133 @@ +package stirling.software.proprietary.security.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; +import stirling.software.proprietary.security.model.JwtSigningKey; + +@Slf4j +@Service +public class JwtKeyCleanupService { + + private final JwtSigningKeyRepository signingKeyRepository; + private final JwtKeystoreService keystoreService; + private final ApplicationProperties.Security.Jwt jwtProperties; + + @Autowired + public JwtKeyCleanupService( + JwtSigningKeyRepository signingKeyRepository, + JwtKeystoreService keystoreService, + ApplicationProperties applicationProperties) { + this.signingKeyRepository = signingKeyRepository; + this.keystoreService = keystoreService; + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + } + + @Transactional + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES) + public void cleanup() { + if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) { + log.debug("Key cleanup is disabled, skipping cleanup"); + return; + } + + log.info("Removing inactive keys older than {} days", jwtProperties.getKeyRetentionDays()); + + try { + LocalDateTime cutoffDate = + LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays()); + long totalKeysEligible = signingKeyRepository.countKeysEligibleForCleanup(cutoffDate); + + if (totalKeysEligible == 0) { + log.info("No keys eligible for cleanup"); + return; + } + + log.info("{} eligible keys found", totalKeysEligible); + + batchCleanup(cutoffDate); + } catch (Exception e) { + log.error("Error during scheduled key cleanup", e); + } + } + + private void batchCleanup(LocalDateTime cutoffDate) { + int batchSize = jwtProperties.getCleanupBatchSize(); + + while (true) { + Pageable pageable = PageRequest.of(0, batchSize); + List keysToCleanup = + signingKeyRepository.findInactiveKeysOlderThan(cutoffDate, pageable); + + if (keysToCleanup.isEmpty()) { + break; + } + + cleanupKeyBatch(keysToCleanup); + + if (keysToCleanup.size() < batchSize) { + break; + } + } + } + + private void cleanupKeyBatch(List keys) { + keys.forEach( + key -> { + try { + removePrivateKey(key.getKeyId()); + } catch (IOException e) { + log.warn("Failed to cleanup private key for keyId: {}", key.getKeyId(), e); + } + }); + + List keyIds = keys.stream().map(JwtSigningKey::getId).collect(Collectors.toList()); + + signingKeyRepository.deleteAllByIdInBatch(keyIds); + log.debug("Deleted {} signing keys from database", keyIds.size()); + } + + private void removePrivateKey(String keyId) throws IOException { + if (!keystoreService.isKeystoreEnabled()) { + return; + } + + Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + Path keyFile = privateKeyDirectory.resolve(keyId + JwtKeystoreService.KEY_SUFFIX); + + if (Files.exists(keyFile)) { + Files.delete(keyFile); + log.debug("Deleted private key file: {}", keyFile); + } else { + log.debug("Private key file not found: {}", keyFile); + } + } + + public long getKeysEligibleForCleanup() { + if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) { + return 0; + } + + LocalDateTime cutoffDate = + LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays()); + return signingKeyRepository.countKeysEligibleForCleanup(cutoffDate); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java index 72fbaba92..d1d3279bc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java @@ -20,7 +20,6 @@ import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.PostConstruct; @@ -31,14 +30,13 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; import stirling.software.proprietary.security.model.JwtSigningKey; -@Service @Slf4j +@Service public class JwtKeystoreService implements JwtKeystoreServiceInterface { public static final String KEY_SUFFIX = ".key"; private final JwtSigningKeyRepository repository; private final ApplicationProperties.Security.Jwt jwtProperties; - private final Path privateKeyDirectory; private volatile KeyPair currentKeyPair; private volatile String currentKeyId; @@ -48,13 +46,12 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { this.repository = repository; this.jwtProperties = applicationProperties.getSecurity().getJwt(); - this.privateKeyDirectory = Paths.get(InstallationPathConfig.getConfigPath(), "jwt-keys"); } @PostConstruct public void initializeKeystore() { if (!isKeystoreEnabled()) { - log.info("JWT keystore is disabled, using in-memory key generation"); + log.info("Keystore is disabled, using in-memory key generation"); return; } @@ -62,7 +59,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { ensurePrivateKeyDirectoryExists(); loadOrGenerateKeypair(); } catch (Exception e) { - log.error("Failed to initialize JWT keystore, falling back to in-memory generation", e); + log.error("Failed to initialize keystore, falling back to in-memory generation", e); } } @@ -101,31 +98,6 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { return currentKeyId; } - @Override - @Transactional - public void rotateKeypair() { - if (!isKeystoreEnabled()) { - log.warn("Cannot rotate keypair when keystore is disabled"); - return; - } - - try { - repository - .findByIsActiveTrue() - .ifPresent( - key -> { - key.setIsActive(false); - repository.save(key); - }); - - generateAndStoreKeypair(); - log.info("Successfully rotated JWT keypair"); - } catch (Exception e) { - log.error("Failed to rotate JWT keypair", e); - throw new RuntimeException("Keypair rotation failed", e); - } - } - @Override public boolean isKeystoreEnabled() { return jwtProperties.isEnableKeystore(); @@ -140,7 +112,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { PrivateKey privateKey = loadPrivateKey(currentKeyId); PublicKey publicKey = decodePublicKey(activeKey.get().getSigningKey()); currentKeyPair = new KeyPair(publicKey, privateKey); - log.info("Loaded existing JWT keypair with keyId: {}", currentKeyId); + log.info("Loaded existing keypair with keyId: {}", currentKeyId); } catch (Exception e) { log.error("Failed to load existing keypair, generating new one", e); generateAndStoreKeypair(); @@ -163,7 +135,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { currentKeyPair = keyPair; currentKeyId = keyId; - log.info("Generated and stored new JWT keypair with keyId: {}", keyId); + log.info("Generated and stored new keypair with keyId: {}", keyId); } catch (Exception e) { log.error("Failed to generate and store keypair", e); throw new RuntimeException("Keypair generation failed", e); @@ -189,14 +161,16 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { } private void ensurePrivateKeyDirectoryExists() throws IOException { - if (!Files.exists(privateKeyDirectory)) { - Files.createDirectories(privateKeyDirectory); - log.info("Created JWT private key directory: {}", privateKeyDirectory); + Path keyPath = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + + if (!Files.exists(keyPath)) { + Files.createDirectories(keyPath); } } private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException { - Path keyFile = privateKeyDirectory.resolve(keyId + KEY_SUFFIX); + Path keyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); Files.writeString(keyFile, encodedKey); @@ -212,9 +186,11 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { private PrivateKey loadPrivateKey(String keyId) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { - Path keyFile = privateKeyDirectory.resolve(keyId + KEY_SUFFIX); + Path keyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); + if (!Files.exists(keyFile)) { - throw new IOException("Private key file not found: " + keyFile); + throw new IOException("Private key not found: " + keyFile); } String encodedKey = Files.readString(keyFile); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java index 4df7d552d..dfb341b28 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java @@ -11,7 +11,5 @@ public interface JwtKeystoreServiceInterface { String getActiveKeyId(); - void rotateKeypair(); - boolean isKeystoreEnabled(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java new file mode 100644 index 000000000..2483fc69b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java @@ -0,0 +1,248 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; +import stirling.software.proprietary.security.model.JwtSigningKey; + +@ExtendWith(MockitoExtension.class) +class JwtKeyCleanupServiceTest { + + @Mock + private JwtSigningKeyRepository signingKeyRepository; + + @Mock + private JwtKeystoreService keystoreService; + + @Mock + private ApplicationProperties applicationProperties; + + @Mock + private ApplicationProperties.Security security; + + @Mock + private ApplicationProperties.Security.Jwt jwtConfig; + + @TempDir + private Path tempDir; + + private JwtKeyCleanupService cleanupService; + + @BeforeEach + void setUp() { + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(security.getJwt()).thenReturn(jwtConfig); + + lenient().when(jwtConfig.isEnableKeyCleanup()).thenReturn(true); + lenient().when(jwtConfig.getKeyRetentionDays()).thenReturn(7); + lenient().when(jwtConfig.getCleanupBatchSize()).thenReturn(100); + lenient().when(keystoreService.isKeystoreEnabled()).thenReturn(true); + + cleanupService = new JwtKeyCleanupService(signingKeyRepository, keystoreService, applicationProperties); + } + + + @Test + void testCleanupDisabled_ShouldSkip() { + when(jwtConfig.isEnableKeyCleanup()).thenReturn(false); + + cleanupService.cleanup(); + + verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); + verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + } + + @Test + void testCleanup_WhenKeystoreDisabled_ShouldSkip() { + when(keystoreService.isKeystoreEnabled()).thenReturn(false); + + cleanupService.cleanup(); + + verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); + verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + } + + @Test + void testCleanup_WhenNoKeysEligible_ShouldExitEarly() { + when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(0L); + + cleanupService.cleanup(); + + verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class)); + verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + } + + @Test + void testCleanupSuccessfully() throws IOException { + JwtSigningKey key1 = createTestKey("key-1", 1L); + JwtSigningKey key2 = createTestKey("key-2", 2L); + List keysToCleanup = Arrays.asList(key1, key2); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); + + createTestKeyFile("key-1"); + createTestKeyFile("key-2"); + + when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L); + when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(keysToCleanup) + .thenReturn(Collections.emptyList()); + + cleanupService.cleanup(); + + verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class)); + verify(signingKeyRepository).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L)); + + assertFalse(Files.exists(tempDir.resolve("key-1.key"))); + assertFalse(Files.exists(tempDir.resolve("key-2.key"))); + } + } + + @Test + void testCleanup_WithBatchProcessing_ShouldProcessMultipleBatches() throws IOException { + when(jwtConfig.getCleanupBatchSize()).thenReturn(2); + + JwtSigningKey key1 = createTestKey("key-1", 1L); + JwtSigningKey key2 = createTestKey("key-2", 2L); + JwtSigningKey key3 = createTestKey("key-3", 3L); + + List firstBatch = Arrays.asList(key1, key2); + List secondBatch = Arrays.asList(key3); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); + + createTestKeyFile("key-1"); + createTestKeyFile("key-2"); + createTestKeyFile("key-3"); + + when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(3L); + when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(firstBatch) + .thenReturn(secondBatch) + .thenReturn(Collections.emptyList()); + + cleanupService.cleanup(); + + verify(signingKeyRepository, times(2)).deleteAllByIdInBatch(any()); + verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L)); + verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(3L)); + } + } + + @Test + void testCleanup() throws IOException { + JwtSigningKey key1 = createTestKey("key-1", 1L); + JwtSigningKey key2 = createTestKey("key-2", 2L); + List keysToCleanup = Arrays.asList(key1, key2); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); + + createTestKeyFile("key-1"); + + when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L); + when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) + .thenReturn(keysToCleanup) + .thenReturn(Collections.emptyList()); + + cleanupService.cleanup(); + + verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L)); + assertFalse(Files.exists(tempDir.resolve("key-1.key"))); + } + } + + @Test + void testGetKeysEligibleForCleanup() { + when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(5L); + + long result = cleanupService.getKeysEligibleForCleanup(); + + assertEquals(5L, result); + verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class)); + } + + @Test + void shouldReturnZero_WhenCleanupDisabled() { + when(jwtConfig.isEnableKeyCleanup()).thenReturn(false); + + long result = cleanupService.getKeysEligibleForCleanup(); + + assertEquals(0L, result); + verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); + } + + @Test + void shouldReturnZero_WhenKeystoreDisabled() { + when(keystoreService.isKeystoreEnabled()).thenReturn(false); + + long result = cleanupService.getKeysEligibleForCleanup(); + + assertEquals(0L, result); + verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); + } + + @Test + void testCleanup_WithRetentionDaysConfiguration_ShouldUseCorrectCutoffDate() { + when(jwtConfig.getKeyRetentionDays()).thenReturn(14); + when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(0L); + + cleanupService.cleanup(); + + verify(signingKeyRepository).countKeysEligibleForCleanup(argThat((LocalDateTime cutoffDate) -> { + LocalDateTime expectedCutoff = LocalDateTime.now().minusDays(14); + return Math.abs(java.time.Duration.between(cutoffDate, expectedCutoff).toMinutes()) <= 1; + })); + } + + @Test + void testCleanupPrivateKeyFile_WhenKeystoreDisabled_ShouldSkipFileRemove() throws IOException { + when(keystoreService.isKeystoreEnabled()).thenReturn(false); + + cleanupService.cleanup(); + + verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); + verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository, never()).deleteAllByIdInBatch(any()); + } + + private JwtSigningKey createTestKey(String keyId, Long id) { + JwtSigningKey key = new JwtSigningKey(); + key.setId(id); + key.setKeyId(keyId); + key.setSigningKey("test-public-key"); + key.setAlgorithm("RS256"); + key.setIsActive(false); + key.setCreatedAt(LocalDateTime.now().minusDays(10)); + return key; + } + + private void createTestKeyFile(String keyId) throws IOException { + Path keyFile = tempDir.resolve(keyId + ".key"); + Files.writeString(keyFile, "test-private-key-content"); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java index 98e2b836d..595c4ebf8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java @@ -1,9 +1,5 @@ package stirling.software.proprietary.security.service; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -12,7 +8,6 @@ import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; import java.util.Base64; import java.util.Optional; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,11 +17,19 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; - import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.security.database.repository.JwtSigningKeyRepository; import stirling.software.proprietary.security.model.JwtSigningKey; +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class JwtKeystoreServiceInterfaceTest { @@ -55,9 +58,9 @@ class JwtKeystoreServiceInterfaceTest { keyPairGenerator.initialize(2048); testKeyPair = keyPairGenerator.generateKeyPair(); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getJwt()).thenReturn(jwtConfig); - when(jwtConfig.isEnableKeystore()).thenReturn(true); + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(security.getJwt()).thenReturn(jwtConfig); + lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true); } @ParameterizedTest @@ -66,7 +69,7 @@ class JwtKeystoreServiceInterfaceTest { when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled()); @@ -78,7 +81,7 @@ class JwtKeystoreServiceInterfaceTest { when(jwtConfig.isEnableKeystore()).thenReturn(false); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); KeyPair result = keystoreService.getActiveKeypair(); @@ -94,7 +97,7 @@ class JwtKeystoreServiceInterfaceTest { when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); @@ -114,12 +117,11 @@ class JwtKeystoreServiceInterfaceTest { JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); - Path keyFile = tempDir.resolve("jwt-keys").resolve(keyId + ".key"); - Files.createDirectories(keyFile.getParent()); + Path keyFile = tempDir.resolve(keyId + ".key"); Files.writeString(keyFile, privateKeyBase64); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); @@ -139,12 +141,11 @@ class JwtKeystoreServiceInterfaceTest { JwtSigningKey signingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); when(repository.findByKeyId(keyId)).thenReturn(Optional.of(signingKey)); - Path keyFile = tempDir.resolve("jwt-keys").resolve(keyId + ".key"); - Files.createDirectories(keyFile.getParent()); + Path keyFile = tempDir.resolve(keyId + ".key"); Files.writeString(keyFile, privateKeyBase64); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); Optional result = keystoreService.getKeypairByKeyId(keyId); @@ -161,7 +162,7 @@ class JwtKeystoreServiceInterfaceTest { when(repository.findByKeyId(keyId)).thenReturn(Optional.empty()); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); Optional result = keystoreService.getKeypairByKeyId(keyId); @@ -175,7 +176,7 @@ class JwtKeystoreServiceInterfaceTest { when(jwtConfig.isEnableKeystore()).thenReturn(false); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); Optional result = keystoreService.getKeypairByKeyId("any-key"); @@ -184,54 +185,17 @@ class JwtKeystoreServiceInterfaceTest { } } - @Test - void testRotateKeypair() { - String oldKeyId = "old-key-123"; - JwtSigningKey oldKey = new JwtSigningKey(oldKeyId, "old-public-key", "RS256"); - when(repository.findByIsActiveTrue()).thenReturn(Optional.of(oldKey)); - - try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); - - keystoreService.initializeKeystore(); - - keystoreService.rotateKeypair(); - - assertFalse(oldKey.getIsActive()); - verify(repository, atLeast(2)).save(any(JwtSigningKey.class)); // At least one for deactivation, one for new key - - assertNotNull(keystoreService.getActiveKeyId()); - assertNotEquals(oldKeyId, keystoreService.getActiveKeyId()); - } - } - - @Test - void testRotateKeypairWhenKeystoreDisabled() { - when(jwtConfig.isEnableKeystore()).thenReturn(false); - - try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); - - assertDoesNotThrow(() -> keystoreService.rotateKeypair()); - - verify(repository, never()).save(any()); - } - } - @Test void testInitializeKeystoreCreatesDirectory() throws IOException { when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - Path jwtKeysDir = tempDir.resolve("jwt-keys"); - assertTrue(Files.exists(jwtKeysDir)); - assertTrue(Files.isDirectory(jwtKeysDir)); + assertTrue(Files.exists(tempDir)); + assertTrue(Files.isDirectory(tempDir)); } } @@ -244,7 +208,7 @@ class JwtKeystoreServiceInterfaceTest { when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); From 9d162abc8c4e22cbd2b454fe4bae890d04602527 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 29 Jul 2025 20:56:26 +0100 Subject: [PATCH 17/23] Deactivating keys --- .../common/model/ApplicationProperties.java | 4 +- .../src/main/resources/settings.yml.template | 5 +- .../CustomAuthenticationSuccessHandler.java | 2 +- .../security/CustomLogoutSuccessHandler.java | 4 +- .../repository/JwtSigningKeyRepository.java | 12 ++-- .../filter/JwtAuthenticationFilter.java | 8 +-- ...tomOAuth2AuthenticationSuccessHandler.java | 2 +- ...stomSaml2AuthenticationSuccessHandler.java | 2 +- ...tSaml2AuthenticationRequestRepository.java | 2 +- .../service/JwtKeyCleanupService.java | 8 +-- .../security/service/JwtKeystoreService.java | 38 +++++++--- .../service/JwtKeystoreServiceInterface.java | 6 +- .../security/service/JwtService.java | 62 +++++++++------- .../security/service/JwtServiceInterface.java | 8 +-- .../CustomLogoutSuccessHandlerTest.java | 10 +-- .../filter/JwtAuthenticationFilterTest.java | 22 +++--- ...l2AuthenticationRequestRepositoryTest.java | 8 +-- .../service/JwtKeyCleanupServiceTest.java | 16 ++--- .../JwtKeystoreServiceInterfaceTest.java | 32 ++++----- .../security/service/JwtServiceTest.java | 72 +++++++++---------- 20 files changed, 177 insertions(+), 146 deletions(-) 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 48df4f948..d37a7595b 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 @@ -304,8 +304,8 @@ public class ApplicationProperties { private boolean enableKeystore = true; private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; - private int keyRetentionDays = 7; - private int cleanupBatchSize = 100; + private int keyRetentionDays; + private int cleanupBatchSize; } } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 0814a6924..9ec94beb3 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -62,9 +62,12 @@ security: jwt: enableKeyStore: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable JWT key rotation + enableKeyCleanup: true # Set to 'true' to enable JWT key cleanup + keyRetentionDays: 7 # Number of days to retain old keys + cleanupBatchSize: 100 # Number of keys to clean up in each batch premium: - key: 3R3T-WFPY-UNRW-LJFA-MMXM-YVJK-WCKY-PCRT # fixme: remove + key: 00000000-0000-0000-0000-000000000000 enabled: false # Enable license key checks for pro/enterprise features proFeatures: SSOAutoLogin: 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 63c653a3a..2d36815ee 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 @@ -58,7 +58,7 @@ public class CustomAuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.WEB)); - jwtService.addTokenToResponse(response, jwt); + jwtService.addToken(response, jwt); log.debug("JWT generated for user: {}", userName); } catch (Exception e) { log.error("Failed to generate JWT token for user: {}", userName, e); 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 aaf6ea2c3..136120528 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 @@ -71,8 +71,8 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } - } else if (!jwtService.extractTokenFromRequest(request).isBlank()) { - jwtService.clearTokenFromResponse(response); + } else if (!jwtService.extractToken(request).isBlank()) { + jwtService.clearToken(response); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java index ee7c3b419..7fcb5503a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepository.java @@ -16,20 +16,18 @@ import stirling.software.proprietary.security.model.JwtSigningKey; @Repository public interface JwtSigningKeyRepository extends JpaRepository { - Optional findByIsActiveTrue(); + Optional findFirstByIsActiveTrueOrderByCreatedAtDesc(); Optional findByKeyId(String keyId); - @Query( - "SELECT k FROM signing_keys k WHERE k.isActive = false AND k.createdAt < :cutoffDate ORDER BY k.createdAt ASC") - List findInactiveKeysOlderThan( + @Query("SELECT k FROM JwtSigningKey k WHERE k.createdAt < :cutoffDate ORDER BY k.createdAt ASC") + List findKeysOlderThan( @Param("cutoffDate") LocalDateTime cutoffDate, Pageable pageable); - @Query( - "SELECT COUNT(k) FROM signing_keys k WHERE k.isActive = false AND k.createdAt < :cutoffDate") + @Query("SELECT COUNT(k) FROM JwtSigningKey k WHERE k.createdAt < :cutoffDate") long countKeysEligibleForCleanup(@Param("cutoffDate") LocalDateTime cutoffDate); @Modifying - @Query("DELETE FROM signing_keys k WHERE k.id IN :ids") + @Query("DELETE FROM JwtSigningKey k WHERE k.id IN :ids") void deleteAllByIdInBatch(@Param("ids") List ids); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 6aea4739a..4a0115c1a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -68,7 +68,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { return; } - String jwtToken = jwtService.extractTokenFromRequest(request); + String jwtToken = jwtService.extractToken(request); if (jwtToken == null) { // If they are unauthenticated and navigating to '/', redirect to '/login' instead of @@ -89,19 +89,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { jwtService.validateToken(jwtToken); } catch (AuthenticationFailureException e) { // Clear invalid tokens from response - jwtService.clearTokenFromResponse(response); + jwtService.clearToken(response); handleAuthenticationFailure(request, response, e); return; } - Map claims = jwtService.extractAllClaims(jwtToken); + Map claims = jwtService.extractClaims(jwtToken); String tokenUsername = claims.get("sub").toString(); try { Authentication authentication = createAuthentication(request, claims); String jwt = jwtService.generateToken(authentication, claims); - jwtService.addTokenToResponse(response, jwt); + jwtService.addToken(response, jwt); } catch (SQLException | UnsupportedProviderException e) { log.error("Error processing user authentication for user: {}", tokenUsername, e); handleAuthenticationFailure( 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 dc489ffd2..4e7ed9d9e 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 @@ -76,7 +76,7 @@ public class CustomOAuth2AuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.OAUTH2)); - jwtService.addTokenToResponse(response, jwt); + jwtService.addToken(response, jwt); } if (userService.isUserDisabled(username)) { getRedirectStrategy() 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 35ce832e9..57d667aa1 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 @@ -141,7 +141,7 @@ public class CustomSaml2AuthenticationSuccessHandler String jwt = jwtService.generateToken( authentication, Map.of("authType", AuthenticationType.SAML2)); - jwtService.addTokenToResponse(response, jwt); + jwtService.addToken(response, jwt); } } } 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 68c190e64..d0508151c 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 @@ -68,7 +68,7 @@ public class JwtSaml2AuthenticationRequestRepository return null; } - Map claims = jwtService.extractAllClaims(token); + Map claims = jwtService.extractClaims(token); return deserializeSamlRequest(claims); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java index 706f7a537..6f5a43b8c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java @@ -42,14 +42,14 @@ public class JwtKeyCleanupService { } @Transactional - @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.MINUTES) + @Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS) public void cleanup() { if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) { - log.debug("Key cleanup is disabled, skipping cleanup"); + log.debug("Key cleanup is disabled"); return; } - log.info("Removing inactive keys older than {} days", jwtProperties.getKeyRetentionDays()); + log.info("Removing keys older than {} day(s)", jwtProperties.getKeyRetentionDays()); try { LocalDateTime cutoffDate = @@ -75,7 +75,7 @@ public class JwtKeyCleanupService { while (true) { Pageable pageable = PageRequest.of(0, batchSize); List keysToCleanup = - signingKeyRepository.findInactiveKeysOlderThan(cutoffDate, pageable); + signingKeyRepository.findKeysOlderThan(cutoffDate, pageable); if (keysToCleanup.isEmpty()) { break; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java index d1d3279bc..64c1900e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java @@ -20,6 +20,7 @@ import java.util.Optional; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import jakarta.annotation.PostConstruct; @@ -35,7 +36,7 @@ import stirling.software.proprietary.security.model.JwtSigningKey; public class JwtKeystoreService implements JwtKeystoreServiceInterface { public static final String KEY_SUFFIX = ".key"; - private final JwtSigningKeyRepository repository; + private final JwtSigningKeyRepository signingKeyRepository; private final ApplicationProperties.Security.Jwt jwtProperties; private volatile KeyPair currentKeyPair; @@ -43,8 +44,9 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { @Autowired public JwtKeystoreService( - JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { - this.repository = repository; + JwtSigningKeyRepository signingKeyRepository, + ApplicationProperties applicationProperties) { + this.signingKeyRepository = signingKeyRepository; this.jwtProperties = applicationProperties.getSecurity().getJwt(); } @@ -64,7 +66,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { } @Override - public KeyPair getActiveKeypair() { + public KeyPair getActiveKeyPair() { if (!isKeystoreEnabled() || currentKeyPair == null) { return generateRSAKeypair(); } @@ -72,20 +74,25 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { } @Override - public Optional getKeypairByKeyId(String keyId) { + public Optional getKeyPairByKeyId(String keyId) { if (!isKeystoreEnabled()) { + log.debug("Keystore is disabled, cannot lookup key by ID: {}", keyId); return Optional.empty(); } try { - Optional signingKey = repository.findByKeyId(keyId); + log.debug("Looking up signing key in database for keyId: {}", keyId); + Optional signingKey = signingKeyRepository.findByKeyId(keyId); if (signingKey.isEmpty()) { + log.warn("No signing key found in database for keyId: {}", keyId); return Optional.empty(); } + log.debug("Found signing key in database, loading private key for keyId: {}", keyId); PrivateKey privateKey = loadPrivateKey(keyId); PublicKey publicKey = decodePublicKey(signingKey.get().getSigningKey()); + log.debug("Successfully loaded key pair for keyId: {}", keyId); return Optional.of(new KeyPair(publicKey, privateKey)); } catch (Exception e) { log.error("Failed to load keypair for keyId: {}", keyId, e); @@ -104,7 +111,8 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { } private void loadOrGenerateKeypair() { - Optional activeKey = repository.findByIsActiveTrue(); + Optional activeKey = + signingKeyRepository.findFirstByIsActiveTrueOrderByCreatedAtDesc(); if (activeKey.isPresent()) { try { @@ -112,9 +120,10 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { PrivateKey privateKey = loadPrivateKey(currentKeyId); PublicKey publicKey = decodePublicKey(activeKey.get().getSigningKey()); currentKeyPair = new KeyPair(publicKey, privateKey); - log.info("Loaded existing keypair with keyId: {}", currentKeyId); + + log.info("Loaded existing keypair: {}", currentKeyId); } catch (Exception e) { - log.error("Failed to load existing keypair, generating new one", e); + log.error("Failed to load existing keypair, generating new keypair", e); generateAndStoreKeypair(); } } else { @@ -122,6 +131,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { } } + @Transactional private void generateAndStoreKeypair() { try { KeyPair keyPair = generateRSAKeypair(); @@ -131,12 +141,12 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { JwtSigningKey signingKey = new JwtSigningKey(keyId, encodePublicKey(keyPair.getPublic()), "RS256"); - repository.save(signingKey); + signingKeyRepository.save(signingKey); currentKeyPair = keyPair; currentKeyId = keyId; log.info("Generated and stored new keypair with keyId: {}", keyId); - } catch (Exception e) { + } catch (IOException e) { log.error("Failed to generate and store keypair", e); throw new RuntimeException("Keypair generation failed", e); } @@ -155,6 +165,12 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { return keyPairGenerator.generateKeyPair(); } + @Override + public KeyPair refreshKeyPairs() { + generateAndStoreKeypair(); + return currentKeyPair; + } + private String generateKeyId() { return "jwt-key-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java index dfb341b28..4cf9d8f55 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java @@ -5,11 +5,13 @@ import java.util.Optional; public interface JwtKeystoreServiceInterface { - KeyPair getActiveKeypair(); + KeyPair getActiveKeyPair(); - Optional getKeypairByKeyId(String keyId); + Optional getKeyPairByKeyId(String keyId); String getActiveKeyId(); boolean isKeystoreEnabled(); + + KeyPair refreshKeyPairs(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index b903767ff..a8c7db2f5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.security.service; import java.security.KeyPair; +import java.security.PublicKey; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -40,7 +41,7 @@ public class JwtService implements JwtServiceInterface { private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; private static final String ISSUER = "Stirling PDF"; - private static final long EXPIRATION = 3600000; + private static final long EXPIRATION = 300000; // 5 minutes in milliseconds private final JwtKeystoreServiceInterface keystoreService; private final boolean v2Enabled; @@ -71,7 +72,7 @@ public class JwtService implements JwtServiceInterface { @Override public String generateToken(String username, Map claims) { - KeyPair keyPair = keystoreService.getActiveKeypair(); + KeyPair keyPair = keystoreService.getActiveKeyPair(); var builder = Jwts.builder() @@ -92,7 +93,7 @@ public class JwtService implements JwtServiceInterface { @Override public void validateToken(String token) throws AuthenticationFailureException { - extractAllClaimsFromToken(token); + extractAllClaims(token); if (isTokenExpired(token)) { throw new AuthenticationFailureException("The token has expired"); @@ -105,8 +106,8 @@ public class JwtService implements JwtServiceInterface { } @Override - public Map extractAllClaims(String token) { - Claims claims = extractAllClaimsFromToken(token); + public Map extractClaims(String token) { + Claims claims = extractAllClaims(token); return new HashMap<>(claims); } @@ -120,29 +121,37 @@ public class JwtService implements JwtServiceInterface { } private T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaimsFromToken(token); + final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } - private Claims extractAllClaimsFromToken(String token) { + private Claims extractAllClaims(String token) { try { // Extract key ID from token header if present - String keyId = extractKeyIdFromToken(token); + String keyId = extractKeyId(token); KeyPair keyPair; if (keyId != null) { - Optional specificKeyPair = keystoreService.getKeypairByKeyId(keyId); + log.debug("Looking up key pair for key ID: {}", keyId); + Optional specificKeyPair = keystoreService.getKeyPairByKeyId(keyId); + if (specificKeyPair.isPresent()) { keyPair = specificKeyPair.get(); + log.debug("Successfully found key pair for key ID: {}", keyId); } else { log.warn( "Key ID {} not found in keystore, token may have been signed with a rotated key", keyId); - throw new AuthenticationFailureException( - "JWT token signed with unknown key ID: " + keyId); + if (keystoreService.getActiveKeyId().equals(keyId)) { + log.debug("Rotating key pairs"); + keystoreService.refreshKeyPairs(); + } + + keyPair = keystoreService.getActiveKeyPair(); } } else { - keyPair = keystoreService.getActiveKeypair(); + log.debug("No key ID in token header, using active key pair"); + keyPair = keystoreService.getActiveKeyPair(); } return Jwts.parser() @@ -169,7 +178,7 @@ public class JwtService implements JwtServiceInterface { } @Override - public String extractTokenFromRequest(HttpServletRequest request) { + public String extractToken(HttpServletRequest request) { String authHeader = request.getHeader(AUTHORIZATION_HEADER); if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { @@ -189,7 +198,7 @@ public class JwtService implements JwtServiceInterface { } @Override - public void addTokenToResponse(HttpServletResponse response, String token) { + public void addToken(HttpServletResponse response, String token) { response.setHeader(AUTHORIZATION_HEADER, Newlines.stripAll(BEARER_PREFIX + token)); ResponseCookie cookie = @@ -205,7 +214,7 @@ public class JwtService implements JwtServiceInterface { } @Override - public void clearTokenFromResponse(HttpServletResponse response) { + public void clearToken(HttpServletResponse response) { response.setHeader(AUTHORIZATION_HEADER, null); ResponseCookie cookie = @@ -225,17 +234,22 @@ public class JwtService implements JwtServiceInterface { return v2Enabled; } - private String extractKeyIdFromToken(String token) { + private String extractKeyId(String token) { try { - return (String) - Jwts.parser() - .unsecured() - .build() - .parseUnsecuredClaims(token) - .getHeader() - .get("kid"); + PublicKey signingKey = keystoreService.getActiveKeyPair().getPublic(); + + String keyId = + (String) + Jwts.parser() + .verifyWith(signingKey) + .build() + .parse(token) + .getHeader() + .get("kid"); + log.debug("Extracted key ID from token: {}", keyId); + return keyId; } catch (Exception e) { - log.debug("Failed to extract key ID from token header: {}", e.getMessage()); + log.warn("Failed to extract key ID from token header: {}", e.getMessage()); return null; } } 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 664d812d8..7cdca8209 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 @@ -48,7 +48,7 @@ public interface JwtServiceInterface { * @param token the JWT token * @return map of claims */ - Map extractAllClaims(String token); + Map extractClaims(String token); /** * Check if token is expired @@ -64,7 +64,7 @@ public interface JwtServiceInterface { * @param request HTTP servlet request * @return JWT token if found, null otherwise */ - String extractTokenFromRequest(HttpServletRequest request); + String extractToken(HttpServletRequest request); /** * Add JWT token to HTTP response (header and cookie) @@ -72,14 +72,14 @@ public interface JwtServiceInterface { * @param response HTTP servlet response * @param token JWT token to add */ - void addTokenToResponse(HttpServletResponse response, String token); + void addToken(HttpServletResponse response, String token); /** * Clear JWT token from HTTP response (remove cookie) * * @param response HTTP servlet response */ - void clearTokenFromResponse(HttpServletResponse response); + void clearToken(HttpServletResponse response); /** * Check if JWT authentication is enabled 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 2ed4245f3..756405c20 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 @@ -33,8 +33,8 @@ class CustomLogoutSuccessHandlerTest { String logoutPath = "/login?logout=true"; when(response.isCommitted()).thenReturn(false); - when(jwtService.extractTokenFromRequest(request)).thenReturn(token); - doNothing().when(jwtService).clearTokenFromResponse(response); + when(jwtService.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -51,15 +51,15 @@ class CustomLogoutSuccessHandlerTest { String token = "token"; when(response.isCommitted()).thenReturn(false); - when(jwtService.extractTokenFromRequest(request)).thenReturn(token); - doNothing().when(jwtService).clearTokenFromResponse(response); + 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).clearTokenFromResponse(response); + 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 7e67a9106..c42dd7405 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 @@ -81,7 +81,7 @@ class JwtAuthenticationFilterTest { jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); verify(filterChain).doFilter(request, response); - verify(jwtService, never()).extractTokenFromRequest(any()); + verify(jwtService, never()).extractToken(any()); } @Test @@ -105,9 +105,9 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getContextPath()).thenReturn("/"); when(request.getRequestURI()).thenReturn("/protected"); - when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + when(jwtService.extractToken(request)).thenReturn(token); doNothing().when(jwtService).validateToken(token); - when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(jwtService.extractClaims(token)).thenReturn(claims); when(userDetails.getAuthorities()).thenReturn(Collections.emptyList()); when(userDetailsService.loadUserByUsername(username)).thenReturn(userDetails); @@ -122,11 +122,11 @@ class JwtAuthenticationFilterTest { jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); verify(jwtService).validateToken(token); - verify(jwtService).extractAllClaims(token); + verify(jwtService).extractClaims(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(jwtService).addToken(response, newToken); verify(filterChain).doFilter(request, response); } } @@ -136,7 +136,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/"); when(request.getMethod()).thenReturn("GET"); - when(jwtService.extractTokenFromRequest(request)).thenReturn(null); + when(jwtService.extractToken(request)).thenReturn(null); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -151,7 +151,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); when(request.getContextPath()).thenReturn("/"); - when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + when(jwtService.extractToken(request)).thenReturn(token); doThrow(new AuthenticationFailureException("Invalid token")).when(jwtService).validateToken(token); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -168,7 +168,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); when(request.getContextPath()).thenReturn("/"); - when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + when(jwtService.extractToken(request)).thenReturn(token); doThrow(new AuthenticationFailureException("The token has expired")).when(jwtService).validateToken(token); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); @@ -187,9 +187,9 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); when(request.getContextPath()).thenReturn("/"); - when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + when(jwtService.extractToken(request)).thenReturn(token); doNothing().when(jwtService).validateToken(token); - when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(jwtService.extractClaims(token)).thenReturn(claims); when(userDetailsService.loadUserByUsername(username)).thenReturn(null); try (MockedStatic mockedSecurityContextHolder = mockStatic(SecurityContextHolder.class)) { @@ -209,7 +209,7 @@ class JwtAuthenticationFilterTest { when(jwtService.isJwtEnabled()).thenReturn(true); when(request.getRequestURI()).thenReturn("/protected"); when(request.getContextPath()).thenReturn("/"); - when(jwtService.extractTokenFromRequest(request)).thenReturn(null); + when(jwtService.extractToken(request)).thenReturn(null); jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java index bce663aee..f08d33d8c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java @@ -7,8 +7,6 @@ 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.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockHttpServletRequest; @@ -105,7 +103,7 @@ class JwtSaml2AuthenticationRequestRepositoryTest { ); when(request.getParameter("RelayState")).thenReturn(relayState); - when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(jwtService.extractClaims(token)).thenReturn(claims); when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")).thenReturn(relyingPartyRegistration); when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf"); when(relyingPartyRegistration.getAssertingPartyMetadata()).thenReturn(assertingPartyMetadata); @@ -153,7 +151,7 @@ class JwtSaml2AuthenticationRequestRepositoryTest { ); when(request.getParameter("RelayState")).thenReturn(relayState); - when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(jwtService.extractClaims(token)).thenReturn(claims); when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")).thenReturn(null); tokenStore.put(relayState, token); @@ -179,7 +177,7 @@ class JwtSaml2AuthenticationRequestRepositoryTest { ); when(request.getParameter("RelayState")).thenReturn(relayState); - when(jwtService.extractAllClaims(token)).thenReturn(claims); + when(jwtService.extractClaims(token)).thenReturn(claims); when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf")).thenReturn(relyingPartyRegistration); when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf"); when(relyingPartyRegistration.getAssertingPartyMetadata()).thenReturn(assertingPartyMetadata); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java index 2483fc69b..bdfc25947 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java @@ -70,7 +70,7 @@ class JwtKeyCleanupServiceTest { cleanupService.cleanup(); verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); - verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository, never()).findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); } @Test @@ -80,7 +80,7 @@ class JwtKeyCleanupServiceTest { cleanupService.cleanup(); verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); - verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository, never()).findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); } @Test @@ -90,7 +90,7 @@ class JwtKeyCleanupServiceTest { cleanupService.cleanup(); verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class)); - verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository, never()).findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); } @Test @@ -106,14 +106,14 @@ class JwtKeyCleanupServiceTest { createTestKeyFile("key-2"); when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L); - when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) + when(signingKeyRepository.findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) .thenReturn(keysToCleanup) .thenReturn(Collections.emptyList()); cleanupService.cleanup(); verify(signingKeyRepository).countKeysEligibleForCleanup(any(LocalDateTime.class)); - verify(signingKeyRepository).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository).findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); verify(signingKeyRepository).deleteAllByIdInBatch(Arrays.asList(1L, 2L)); assertFalse(Files.exists(tempDir.resolve("key-1.key"))); @@ -140,7 +140,7 @@ class JwtKeyCleanupServiceTest { createTestKeyFile("key-3"); when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(3L); - when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) + when(signingKeyRepository.findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) .thenReturn(firstBatch) .thenReturn(secondBatch) .thenReturn(Collections.emptyList()); @@ -165,7 +165,7 @@ class JwtKeyCleanupServiceTest { createTestKeyFile("key-1"); when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L); - when(signingKeyRepository.findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) + when(signingKeyRepository.findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class))) .thenReturn(keysToCleanup) .thenReturn(Collections.emptyList()); @@ -226,7 +226,7 @@ class JwtKeyCleanupServiceTest { cleanupService.cleanup(); verify(signingKeyRepository, never()).countKeysEligibleForCleanup(any(LocalDateTime.class)); - verify(signingKeyRepository, never()).findInactiveKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); + verify(signingKeyRepository, never()).findKeysOlderThan(any(LocalDateTime.class), any(Pageable.class)); verify(signingKeyRepository, never()).deleteAllByIdInBatch(any()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java index 595c4ebf8..8979bcbf6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java @@ -77,14 +77,14 @@ class JwtKeystoreServiceInterfaceTest { } @Test - void testGetActiveKeypairWhenKeystoreDisabled() { + void testGetActiveKeyPairWhenKeystoreDisabled() { when(jwtConfig.isEnableKeystore()).thenReturn(false); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); - KeyPair result = keystoreService.getActiveKeypair(); + KeyPair result = keystoreService.getActiveKeyPair(); assertNotNull(result); assertNotNull(result.getPublic()); @@ -94,14 +94,14 @@ class JwtKeystoreServiceInterfaceTest { @Test void testGetActiveKeypairWhenNoActiveKeyExists() { - when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); + when(repository.findFirstByIsActiveTrueOrderByCreatedAtDesc()).thenReturn(Optional.empty()); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - KeyPair result = keystoreService.getActiveKeypair(); + KeyPair result = keystoreService.getActiveKeyPair(); assertNotNull(result); verify(repository).save(any(JwtSigningKey.class)); @@ -109,13 +109,13 @@ class JwtKeystoreServiceInterfaceTest { } @Test - void testGetActiveKeypairWithExistingKey() throws Exception { + void testGetActiveKeyPairWithExistingKey() throws Exception { String keyId = "test-key-2024-01-01-120000"; String publicKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); String privateKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); - when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); + when(repository.findFirstByIsActiveTrueOrderByCreatedAtDesc()).thenReturn(Optional.of(existingKey)); Path keyFile = tempDir.resolve(keyId + ".key"); Files.writeString(keyFile, privateKeyBase64); @@ -125,7 +125,7 @@ class JwtKeystoreServiceInterfaceTest { keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - KeyPair result = keystoreService.getActiveKeypair(); + KeyPair result = keystoreService.getActiveKeyPair(); assertNotNull(result); assertEquals(keyId, keystoreService.getActiveKeyId()); @@ -133,7 +133,7 @@ class JwtKeystoreServiceInterfaceTest { } @Test - void testGetKeypairByKeyId() throws Exception { + void testGetKeyPairByKeyId() throws Exception { String keyId = "test-key-123"; String publicKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); String privateKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); @@ -148,7 +148,7 @@ class JwtKeystoreServiceInterfaceTest { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); - Optional result = keystoreService.getKeypairByKeyId(keyId); + Optional result = keystoreService.getKeyPairByKeyId(keyId); assertTrue(result.isPresent()); assertNotNull(result.get().getPublic()); @@ -157,7 +157,7 @@ class JwtKeystoreServiceInterfaceTest { } @Test - void testGetKeypairByKeyIdNotFound() { + void testGetKeyPairByKeyIdNotFound() { String keyId = "non-existent-key"; when(repository.findByKeyId(keyId)).thenReturn(Optional.empty()); @@ -165,21 +165,21 @@ class JwtKeystoreServiceInterfaceTest { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); - Optional result = keystoreService.getKeypairByKeyId(keyId); + Optional result = keystoreService.getKeyPairByKeyId(keyId); assertFalse(result.isPresent()); } } @Test - void testGetKeypairByKeyIdWhenKeystoreDisabled() { + void testGetKeyPairByKeyIdWhenKeystoreDisabled() { when(jwtConfig.isEnableKeystore()).thenReturn(false); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); - Optional result = keystoreService.getKeypairByKeyId("any-key"); + Optional result = keystoreService.getKeyPairByKeyId("any-key"); assertFalse(result.isPresent()); } @@ -187,7 +187,7 @@ class JwtKeystoreServiceInterfaceTest { @Test void testInitializeKeystoreCreatesDirectory() throws IOException { - when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); + when(repository.findFirstByIsActiveTrueOrderByCreatedAtDesc()).thenReturn(Optional.empty()); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); @@ -205,14 +205,14 @@ class JwtKeystoreServiceInterfaceTest { String publicKeyBase64 = Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); JwtSigningKey existingKey = new JwtSigningKey(keyId, publicKeyBase64, "RS256"); - when(repository.findByIsActiveTrue()).thenReturn(Optional.of(existingKey)); + when(repository.findFirstByIsActiveTrueOrderByCreatedAtDesc()).thenReturn(Optional.of(existingKey)); try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - KeyPair result = keystoreService.getActiveKeypair(); + KeyPair result = keystoreService.getActiveKeyPair(); assertNotNull(result); verify(repository).save(any(JwtSigningKey.class)); 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 f5017a329..288dd2adb 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 @@ -74,7 +74,7 @@ class JwtServiceTest { void testGenerateTokenWithAuthentication() { String username = "testuser"; - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -93,7 +93,7 @@ class JwtServiceTest { claims.put("role", "admin"); claims.put("department", "IT"); - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -104,14 +104,14 @@ class JwtServiceTest { assertFalse(token.isEmpty()); assertEquals(username, jwtService.extractUsername(token)); - Map extractedClaims = jwtService.extractAllClaims(token); + Map extractedClaims = jwtService.extractClaims(token); assertEquals("admin", extractedClaims.get("role")); assertEquals("IT", extractedClaims.get("department")); } @Test void testValidateTokenSuccess() { - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn("testuser"); @@ -123,7 +123,7 @@ class JwtServiceTest { @Test void testValidateTokenWithInvalidToken() { - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken("invalid-token"); @@ -132,7 +132,7 @@ class JwtServiceTest { @Test void testValidateTokenWithMalformedToken() { - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken("malformed.token"); @@ -143,7 +143,7 @@ class JwtServiceTest { @Test void testValidateTokenWithEmptyToken() { - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken(""); @@ -158,7 +158,7 @@ class JwtServiceTest { User user = mock(User.class); Map claims = Map.of("sub", "testuser", "authType", "WEB"); - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(user); when(user.getUsername()).thenReturn(username); @@ -170,23 +170,23 @@ class JwtServiceTest { @Test void testExtractUsernameWithInvalidToken() { - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); assertThrows(AuthenticationFailureException.class, () -> jwtService.extractUsername("invalid-token")); } @Test - void testExtractAllClaims() { + void testExtractClaims() { String username = "testuser"; Map claims = Map.of("role", "admin", "department", "IT"); - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); String token = jwtService.generateToken(authentication, claims); - Map extractedClaims = jwtService.extractAllClaims(token); + Map extractedClaims = jwtService.extractClaims(token); assertEquals("admin", extractedClaims.get("role")); assertEquals("IT", extractedClaims.get("department")); @@ -195,60 +195,60 @@ class JwtServiceTest { } @Test - void testExtractAllClaimsWithInvalidToken() { - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + void testExtractClaimsWithInvalidToken() { + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); - assertThrows(AuthenticationFailureException.class, () -> jwtService.extractAllClaims("invalid-token")); + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractClaims("invalid-token")); } @Test - void testExtractTokenFromRequestWithAuthorizationHeader() { + void testExtractTokenWithAuthorizationHeader() { String token = "test-token"; when(request.getHeader("Authorization")).thenReturn("Bearer " + token); - assertEquals(token, jwtService.extractTokenFromRequest(request)); + assertEquals(token, jwtService.extractToken(request)); } @Test - void testExtractTokenFromRequestWithCookie() { + void testExtractTokenWithCookie() { 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)); + assertEquals(token, jwtService.extractToken(request)); } @Test - void testExtractTokenFromRequestWithNoCookies() { + void testExtractTokenWithNoCookies() { when(request.getHeader("Authorization")).thenReturn(null); when(request.getCookies()).thenReturn(null); - assertNull(jwtService.extractTokenFromRequest(request)); + assertNull(jwtService.extractToken(request)); } @Test - void testExtractTokenFromRequestWithWrongCookie() { + void testExtractTokenWithWrongCookie() { Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; when(request.getHeader("Authorization")).thenReturn(null); when(request.getCookies()).thenReturn(cookies); - assertNull(jwtService.extractTokenFromRequest(request)); + assertNull(jwtService.extractToken(request)); } @Test - void testExtractTokenFromRequestWithInvalidAuthorizationHeader() { + void testExtractTokenWithInvalidAuthorizationHeader() { when(request.getHeader("Authorization")).thenReturn("Basic token"); when(request.getCookies()).thenReturn(null); - assertNull(jwtService.extractTokenFromRequest(request)); + assertNull(jwtService.extractToken(request)); } @Test - void testAddTokenToResponse() { + void testAddToken() { String token = "test-token"; - jwtService.addTokenToResponse(response, token); + jwtService.addToken(response, token); verify(response).setHeader("Authorization", "Bearer " + token); verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token)); @@ -257,8 +257,8 @@ class JwtServiceTest { } @Test - void testClearTokenFromResponse() { - jwtService.clearTokenFromResponse(response); + void testClearToken() { + jwtService.clearToken(response); verify(response).setHeader("Authorization", null); verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); @@ -270,7 +270,7 @@ class JwtServiceTest { String username = "testuser"; Map claims = new HashMap<>(); - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -280,7 +280,7 @@ class JwtServiceTest { assertNotNull(token); assertFalse(token.isEmpty()); // Verify that the keystore service was called - verify(keystoreService).getActiveKeypair(); + verify(keystoreService).getActiveKeyPair(); verify(keystoreService).getActiveKeyId(); } @@ -289,7 +289,7 @@ class JwtServiceTest { String username = "testuser"; Map claims = new HashMap<>(); - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -298,7 +298,7 @@ class JwtServiceTest { String token = jwtService.generateToken(authentication, claims); // Mock extraction of key ID and verification (lenient to avoid unused stubbing) - lenient().when(keystoreService.getKeypairByKeyId("test-key-id")).thenReturn(Optional.of(testKeyPair)); + lenient().when(keystoreService.getKeyPairByKeyId("test-key-id")).thenReturn(Optional.of(testKeyPair)); // Verify token can be validated assertDoesNotThrow(() -> jwtService.validateToken(token)); @@ -310,7 +310,7 @@ class JwtServiceTest { String username = "testuser"; Map claims = new HashMap<>(); - when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); + when(keystoreService.getActiveKeyPair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); when(userDetails.getUsername()).thenReturn(username); @@ -318,13 +318,13 @@ class JwtServiceTest { String token = jwtService.generateToken(authentication, claims); // Mock scenario where specific key ID is not found (lenient to avoid unused stubbing) - lenient().when(keystoreService.getKeypairByKeyId("test-key-id")).thenReturn(Optional.empty()); + lenient().when(keystoreService.getKeyPairByKeyId("test-key-id")).thenReturn(Optional.empty()); // Should still work using active keypair assertDoesNotThrow(() -> jwtService.validateToken(token)); assertEquals(username, jwtService.extractUsername(token)); // Verify fallback to active keypair was used (called multiple times during token operations) - verify(keystoreService, atLeast(1)).getActiveKeypair(); + verify(keystoreService, atLeast(1)).getActiveKeyPair(); } } From 177861ce0909c8d226804fed38d1409c1d5d55c6 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 30 Jul 2025 12:45:01 +0100 Subject: [PATCH 18/23] Fixing logout --- .../software/common/model/ApplicationProperties.java | 4 ++-- app/core/src/main/resources/settings.yml.template | 6 +++--- app/core/src/main/resources/static/js/fetch-utils.js | 12 ++++++++---- app/core/src/main/resources/static/js/jwt-init.js | 5 ++++- .../security/config/AccountWebController.java | 7 +++++-- .../security/filter/UserAuthenticationFilter.java | 9 +-------- .../security/model/AuthenticationType.java | 3 ++- .../security/service/JwtKeyCleanupService.java | 2 ++ .../proprietary/security/service/JwtService.java | 3 +-- 9 files changed, 28 insertions(+), 23 deletions(-) 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 d37a7595b..48df4f948 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 @@ -304,8 +304,8 @@ public class ApplicationProperties { private boolean enableKeystore = true; private boolean enableKeyRotation = false; private boolean enableKeyCleanup = true; - private int keyRetentionDays; - private int cleanupBatchSize; + private int keyRetentionDays = 7; + private int cleanupBatchSize = 100; } } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 9ec94beb3..50a2fc98f 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -61,10 +61,10 @@ security: spCert: classpath:certificate.crt # Your signing certificate. Generated from your keypair jwt: enableKeyStore: true # Set to 'true' to enable JWT key store - enableKeyRotation: true # Set to 'true' to enable JWT key rotation + enableKeyRotation: false # Set to 'true' to enable JWT key rotation enableKeyCleanup: true # Set to 'true' to enable JWT key cleanup - keyRetentionDays: 7 # Number of days to retain old keys - cleanupBatchSize: 100 # Number of keys to clean up in each batch + keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. + cleanupBatchSize: 100 # Number of keys to clean up in each batch. The default is 100. premium: key: 00000000-0000-0000-0000-000000000000 diff --git a/app/core/src/main/resources/static/js/fetch-utils.js b/app/core/src/main/resources/static/js/fetch-utils.js index 5946dc100..73c26ffb3 100644 --- a/app/core/src/main/resources/static/js/fetch-utils.js +++ b/app/core/src/main/resources/static/js/fetch-utils.js @@ -62,11 +62,15 @@ window.JWTManager = { fetch('/logout', { method: 'POST', credentials: 'include' - }).then(() => { - window.location.href = '/login'; + }).then(response => { + if (response.redirected) { + window.location.href = response.url; + } else { + window.location.href = '/login?logout=true'; + } }).catch(() => { - // Even if logout fails, redirect to login - window.location.href = '/login'; + // If logout fails, let server handle it + window.location.href = '/logout'; }); } }; diff --git a/app/core/src/main/resources/static/js/jwt-init.js b/app/core/src/main/resources/static/js/jwt-init.js index 4a8218c47..a2733bf35 100644 --- a/app/core/src/main/resources/static/js/jwt-init.js +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -47,11 +47,14 @@ console.log('User is not authenticated or token expired'); // Only redirect to login if we're not already on login/register pages const currentPath = window.location.pathname; + const currentSearch = window.location.search; + // Don't redirect if we're on logout page or already being logged out if (!currentPath.includes('/login') && !currentPath.includes('/register') && !currentPath.includes('/oauth') && !currentPath.includes('/saml') && - !currentPath.includes('/error')) { + !currentPath.includes('/error') && + !currentSearch.includes('logout=true')) { // Redirect to login after a short delay to allow other scripts to load setTimeout(() => { window.location.href = '/login'; 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 830f8f195..46d0e7d3d 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 @@ -77,8 +77,11 @@ public class AccountWebController { @GetMapping("/login") public String login(HttpServletRequest request, Model model, Authentication authentication) { - // If the user is already authenticated, redirect them to the home page. - if (authentication != null && authentication.isAuthenticated()) { + // If the user is already authenticated and it's not a logout scenario, redirect them to the + // home page. + if (authentication != null + && authentication.isAuthenticated() + && request.getParameter("logout") == null) { return "redirect:/"; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index f6074ceff..f51a9d543 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -65,13 +65,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String requestURI = request.getRequestURI(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - log.info( - "UserAuthenticationFilter - Authentication from SecurityContext: {}", - authentication != null - ? authentication.getClass().getSimpleName() - + " for " - + authentication.getName() - : "null"); // Check for session expiration (unsure if needed) // if (authentication != null && authentication.isAuthenticated()) { @@ -117,7 +110,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String method = request.getMethod(); String contextPath = request.getContextPath(); - if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) { + if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) { response.sendRedirect(contextPath + "/login"); // redirect to the login page } else { response.setStatus(HttpStatus.UNAUTHORIZED.value()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index cf9f15e35..c92c1655e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,7 +2,8 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - @Deprecated(since = "1.0.2") SSO, + @Deprecated(since = "1.0.2") + SSO, OAUTH2, SAML2 } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java index 6f5a43b8c..0f18c66e0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java @@ -10,6 +10,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.scheduling.annotation.Scheduled; @@ -25,6 +26,7 @@ import stirling.software.proprietary.security.model.JwtSigningKey; @Slf4j @Service +@ConditionalOnBooleanProperty("v2") public class JwtKeyCleanupService { private final JwtSigningKeyRepository signingKeyRepository; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index a8c7db2f5..7ebef867f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -41,7 +41,7 @@ public class JwtService implements JwtServiceInterface { private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String BEARER_PREFIX = "Bearer "; private static final String ISSUER = "Stirling PDF"; - private static final long EXPIRATION = 300000; // 5 minutes in milliseconds + private static final long EXPIRATION = 3600000; private final JwtKeystoreServiceInterface keystoreService; private final boolean v2Enabled; @@ -127,7 +127,6 @@ public class JwtService implements JwtServiceInterface { private Claims extractAllClaims(String token) { try { - // Extract key ID from token header if present String keyId = extractKeyId(token); KeyPair keyPair; From f6d35f1c2eca6923c9ff77b986ab93c456c9669d Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 30 Jul 2025 13:13:26 +0100 Subject: [PATCH 19/23] More cleanup --- .../src/main/resources/settings.yml.template | 18 +++++++++--------- .../security/InitialSecuritySetup.java | 1 - .../filter/JwtAuthenticationFilter.java | 5 ----- ...ustomSaml2AuthenticationSuccessHandler.java | 4 ++-- .../security/service/JwtKeystoreService.java | 1 + .../security/service/UserService.java | 14 -------------- 6 files changed, 12 insertions(+), 31 deletions(-) diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 50a2fc98f..b4cbe7b87 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -31,7 +31,7 @@ security: google: clientId: '' # client ID for Google OAuth2 clientSecret: '' # client secret for Google OAuth2 - scopes: https://www.googleapis.com/auth/userinfo.email, https://www.googleapis.com/auth/userinfo.profile # scopes for Google OAuth2 + scopes: email, profile # scopes for Google OAuth2 useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name] github: clientId: '' # client ID for GitHub OAuth2 @@ -51,14 +51,14 @@ security: provider: '' # The name of your Provider autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin - registrationId: stirlingpdf-dario-saml # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs - idpMetadataUri: https://authentik.dev.stirlingpdf.com/api/v3/providers/saml/5/metadata/ # The uri for your Provider's metadata - idpSingleLoginUrl: https://authentik.dev.stirlingpdf.com/application/saml/stirlingpdf-dario-saml/sso/binding/post/ # The URL for initiating SSO. Provided by your Provider - idpSingleLogoutUrl: https://authentik.dev.stirlingpdf.com/application/saml/stirlingpdf-dario-saml/slo/binding/post/ # The URL for initiating SLO. Provided by your Provider - idpIssuer: authentik # The ID of your Provider - idpCert: classpath:authentik-Self-signed_Certificate_certificate.pem # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider - privateKey: classpath:private_key.key # Your private key. Generated from your keypair - spCert: classpath:certificate.crt # Your signing certificate. Generated from your keypair + registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs + idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata + idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider + idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider + idpIssuer: '' # The ID of your Provider + idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider + 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: enableKeyStore: true # Set to 'true' to enable JWT key store enableKeyRotation: false # Set to 'true' to enable JWT key rotation diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 4b09fe0e9..e145e2754 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -43,7 +43,6 @@ public class InitialSecuritySetup { } } - userService.migrateOauth2ToSSO(); assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 4a0115c1a..3d4b7006e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -130,11 +130,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); - - log.info( - "JWT authentication successful for user: {} - Authentication set in SecurityContext", - username); - } else { throw new UsernameNotFoundException("User not found: " + username); } 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 57d667aa1..3255cbc15 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,7 +121,7 @@ public class CustomSaml2AuthenticationSuccessHandler username, saml2Properties.getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); - generateJWT(response, authentication); + generateJwt(response, authentication); response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( @@ -136,7 +136,7 @@ public class CustomSaml2AuthenticationSuccessHandler } } - private void generateJWT(HttpServletResponse response, Authentication authentication) { + private void generateJwt(HttpServletResponse response, Authentication authentication) { if (jwtService.isJwtEnabled()) { String jwt = jwtService.generateToken( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java index 64c1900e6..a0082ec81 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java @@ -213,6 +213,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { byte[] keyBytes = Base64.getDecoder().decode(encodedKey); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); } 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 982f551ca..6f213b25e 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 @@ -1,8 +1,5 @@ package stirling.software.proprietary.security.service; -import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; -import static stirling.software.proprietary.security.model.AuthenticationType.SSO; - import java.sql.SQLException; import java.util.ArrayList; import java.util.Collection; @@ -63,17 +60,6 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; - @Transactional - public void migrateOauth2ToSSO() { - userRepository - .findByAuthenticationTypeIgnoreCase(OAUTH2.toString()) - .forEach( - user -> { - user.setAuthenticationType(SSO); - userRepository.save(user); - }); - } - // Handle OAUTH2 login and user auto creation. public void processSSOPostLogin( String username, boolean autoCreateUser, AuthenticationType type) From 9d90ff4cc30b45e2a06355ef3628dcd75144b50e Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 30 Jul 2025 13:13:26 +0100 Subject: [PATCH 20/23] More cleanup --- app/core/src/main/resources/settings.yml.template | 2 +- .../proprietary/security/service/JwtKeystoreService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index b4cbe7b87..d346be07a 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -61,7 +61,7 @@ security: spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair jwt: enableKeyStore: true # Set to 'true' to enable JWT key store - enableKeyRotation: false # Set to 'true' to enable JWT key rotation + enableKeyRotation: true # Set to 'true' to enable JWT key rotation enableKeyCleanup: true # Set to 'true' to enable JWT key cleanup keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. cleanupBatchSize: 100 # Number of keys to clean up in each batch. The default is 100. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java index a0082ec81..4155ce5cb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java @@ -153,7 +153,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { } private KeyPair generateRSAKeypair() { - KeyPairGenerator keyPairGenerator = null; + KeyPairGenerator keyPairGenerator; try { keyPairGenerator = KeyPairGenerator.getInstance("RSA"); From f58f2995355382a70e8dcb5b63934ac54eadb40b Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 30 Jul 2025 17:52:11 +0100 Subject: [PATCH 21/23] Fixing build issues --- .github/workflows/build.yml | 2 ++ .../software/proprietary/security/service/JwtService.java | 2 +- exampleYmlFiles/test_cicd.yml | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db847f570..1487b1f3d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,6 +147,8 @@ jobs: - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs + env: + DISABLE_ADDITIONAL_FEATURES: true - name: Upload OpenAPI Documentation uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 7ebef867f..f035e1385 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -204,7 +204,7 @@ public class JwtService implements JwtServiceInterface { ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) .httpOnly(true) .secure(true) - .sameSite("None") + .sameSite("Strict") .maxAge(EXPIRATION / 1000) .path("/") .build(); diff --git a/exampleYmlFiles/test_cicd.yml b/exampleYmlFiles/test_cicd.yml index 31e24da48..086f862d5 100644 --- a/exampleYmlFiles/test_cicd.yml +++ b/exampleYmlFiles/test_cicd.yml @@ -20,6 +20,7 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" + V2: "false" PUID: 1002 PGID: 1002 UMASK: "022" From d197285a562ed315400f279081c172c125b63747 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 31 Jul 2025 12:43:48 +0100 Subject: [PATCH 22/23] wip - refactoring --- .../configuration/InstallationPathConfig.java | 2 +- .../configuration/SecurityConfiguration.java | 2 +- .../filter/JwtAuthenticationFilter.java | 3 ++- .../security/service/JwtService.java | 17 +++++--------- ...ervice.java => KeyPairCleanupService.java} | 13 ++++++----- ...storeService.java => KeystoreService.java} | 7 +++--- ...ace.java => KeystoreServiceInterface.java} | 2 +- .../security/service/JwtServiceTest.java | 2 +- ...st.java => KeyPairCleanupServiceTest.java} | 14 ++++++------ ...java => KeystoreServiceInterfaceTest.java} | 22 +++++++++---------- 10 files changed, 41 insertions(+), 43 deletions(-) rename app/proprietary/src/main/java/stirling/software/proprietary/security/service/{JwtKeyCleanupService.java => KeyPairCleanupService.java} (93%) rename app/proprietary/src/main/java/stirling/software/proprietary/security/service/{JwtKeystoreService.java => KeystoreService.java} (96%) rename app/proprietary/src/main/java/stirling/software/proprietary/security/service/{JwtKeystoreServiceInterface.java => KeystoreServiceInterface.java} (86%) rename app/proprietary/src/test/java/stirling/software/proprietary/security/service/{JwtKeyCleanupServiceTest.java => KeyPairCleanupServiceTest.java} (97%) rename app/proprietary/src/test/java/stirling/software/proprietary/security/service/{JwtKeystoreServiceInterfaceTest.java => KeystoreServiceInterfaceTest.java} (90%) diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index ebea350fc..dfe814d67 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -44,7 +44,7 @@ public class InstallationPathConfig { STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; - PRIVATE_KEY_PATH = CUSTOM_FILES_PATH + "keys" + File.separator; + PRIVATE_KEY_PATH = CONFIG_PATH + "keys" + File.separator; } private static String initializeBasePath() { 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 ed309afda..7f2afc735 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 @@ -186,7 +186,7 @@ public class SecurityConfiguration { // Configure session management based on JWT setting http.sessionManagement( sessionManagement -> { - if (v2Enabled && !securityProperties.isSaml2Active()) { + if (v2Enabled) { sessionManagement.sessionCreationPolicy( SessionCreationPolicy.STATELESS); } else { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index 3d4b7006e..68bf44b3e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -69,10 +69,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } String jwtToken = jwtService.extractToken(request); - + // todo: X-API-KEY if (jwtToken == null) { // If they are unauthenticated and navigating to '/', redirect to '/login' instead of // sending a 401 + // todo: any unauthenticated requests should redirect to login if ("/".equals(request.getRequestURI()) && "GET".equalsIgnoreCase(request.getMethod())) { response.sendRedirect("/login"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index f035e1385..7e46c9b4f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -43,13 +43,12 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "Stirling PDF"; private static final long EXPIRATION = 3600000; - private final JwtKeystoreServiceInterface keystoreService; + private final KeystoreServiceInterface keystoreService; private final boolean v2Enabled; @Autowired public JwtService( - @Qualifier("v2Enabled") boolean v2Enabled, - JwtKeystoreServiceInterface keystoreService) { + @Qualifier("v2Enabled") boolean v2Enabled, KeystoreServiceInterface keystoreService) { this.v2Enabled = v2Enabled; this.keystoreService = keystoreService; } @@ -132,7 +131,8 @@ public class JwtService implements JwtServiceInterface { if (keyId != null) { log.debug("Looking up key pair for key ID: {}", keyId); - Optional specificKeyPair = keystoreService.getKeyPairByKeyId(keyId); + Optional specificKeyPair = + keystoreService.getKeyPairByKeyId(keyId); // todo: move to in-memory cache if (specificKeyPair.isPresent()) { keyPair = specificKeyPair.get(); @@ -178,13 +178,8 @@ public class JwtService implements JwtServiceInterface { @Override public String extractToken(HttpServletRequest request) { - String authHeader = request.getHeader(AUTHORIZATION_HEADER); - - if (authHeader != null && authHeader.startsWith(BEARER_PREFIX)) { - return authHeader.substring(BEARER_PREFIX.length()); - } - Cookie[] cookies = request.getCookies(); + if (cookies != null) { for (Cookie cookie : cookies) { if (JWT_COOKIE_NAME.equals(cookie.getName())) { @@ -203,7 +198,7 @@ public class JwtService implements JwtServiceInterface { ResponseCookie cookie = ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) .httpOnly(true) - .secure(true) + // .secure(true) // todo: fix, make configurable .sameSite("Strict") .maxAge(EXPIRATION / 1000) .path("/") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java similarity index 93% rename from app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java index 0f18c66e0..7727dc1bc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeyCleanupService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java @@ -17,6 +17,8 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.PostConstruct; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.configuration.InstallationPathConfig; @@ -27,16 +29,16 @@ import stirling.software.proprietary.security.model.JwtSigningKey; @Slf4j @Service @ConditionalOnBooleanProperty("v2") -public class JwtKeyCleanupService { +public class KeyPairCleanupService { private final JwtSigningKeyRepository signingKeyRepository; - private final JwtKeystoreService keystoreService; + private final KeystoreService keystoreService; private final ApplicationProperties.Security.Jwt jwtProperties; @Autowired - public JwtKeyCleanupService( + public KeyPairCleanupService( JwtSigningKeyRepository signingKeyRepository, - JwtKeystoreService keystoreService, + KeystoreService keystoreService, ApplicationProperties applicationProperties) { this.signingKeyRepository = signingKeyRepository; this.keystoreService = keystoreService; @@ -44,6 +46,7 @@ public class JwtKeyCleanupService { } @Transactional + @PostConstruct @Scheduled(fixedDelay = 24, timeUnit = TimeUnit.HOURS) public void cleanup() { if (!jwtProperties.isEnableKeyCleanup() || !keystoreService.isKeystoreEnabled()) { @@ -113,7 +116,7 @@ public class JwtKeyCleanupService { } Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); - Path keyFile = privateKeyDirectory.resolve(keyId + JwtKeystoreService.KEY_SUFFIX); + Path keyFile = privateKeyDirectory.resolve(keyId + KeystoreService.KEY_SUFFIX); if (Files.exists(keyFile)) { Files.delete(keyFile); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeystoreService.java similarity index 96% rename from app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeystoreService.java index 4155ce5cb..3cb73aadf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeystoreService.java @@ -33,7 +33,7 @@ import stirling.software.proprietary.security.model.JwtSigningKey; @Slf4j @Service -public class JwtKeystoreService implements JwtKeystoreServiceInterface { +public class KeystoreService implements KeystoreServiceInterface { public static final String KEY_SUFFIX = ".key"; private final JwtSigningKeyRepository signingKeyRepository; @@ -43,7 +43,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { private volatile String currentKeyId; @Autowired - public JwtKeystoreService( + public KeystoreService( JwtSigningKeyRepository signingKeyRepository, ApplicationProperties applicationProperties) { this.signingKeyRepository = signingKeyRepository; @@ -53,7 +53,6 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { @PostConstruct public void initializeKeystore() { if (!isKeystoreEnabled()) { - log.info("Keystore is disabled, using in-memory key generation"); return; } @@ -61,7 +60,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { ensurePrivateKeyDirectoryExists(); loadOrGenerateKeypair(); } catch (Exception e) { - log.error("Failed to initialize keystore, falling back to in-memory generation", e); + log.error("Failed to initialize keystore, using in-memory generation", e); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeystoreServiceInterface.java similarity index 86% rename from app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeystoreServiceInterface.java index 4cf9d8f55..dc0564980 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeystoreServiceInterface.java @@ -3,7 +3,7 @@ package stirling.software.proprietary.security.service; import java.security.KeyPair; import java.util.Optional; -public interface JwtKeystoreServiceInterface { +public interface KeystoreServiceInterface { KeyPair getActiveKeyPair(); 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 288dd2adb..6c7e2770a 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 @@ -55,7 +55,7 @@ class JwtServiceTest { private HttpServletResponse response; @Mock - private JwtKeystoreServiceInterface keystoreService; + private KeystoreServiceInterface keystoreService; private JwtService jwtService; private KeyPair testKeyPair; diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPairCleanupServiceTest.java similarity index 97% rename from app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java rename to app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPairCleanupServiceTest.java index bdfc25947..ae07362c8 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeyCleanupServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPairCleanupServiceTest.java @@ -27,13 +27,13 @@ import stirling.software.proprietary.security.database.repository.JwtSigningKeyR import stirling.software.proprietary.security.model.JwtSigningKey; @ExtendWith(MockitoExtension.class) -class JwtKeyCleanupServiceTest { +class KeyPairCleanupServiceTest { @Mock private JwtSigningKeyRepository signingKeyRepository; @Mock - private JwtKeystoreService keystoreService; + private KeystoreService keystoreService; @Mock private ApplicationProperties applicationProperties; @@ -47,7 +47,7 @@ class JwtKeyCleanupServiceTest { @TempDir private Path tempDir; - private JwtKeyCleanupService cleanupService; + private KeyPairCleanupService cleanupService; @BeforeEach void setUp() { @@ -59,7 +59,7 @@ class JwtKeyCleanupServiceTest { lenient().when(jwtConfig.getCleanupBatchSize()).thenReturn(100); lenient().when(keystoreService.isKeystoreEnabled()).thenReturn(true); - cleanupService = new JwtKeyCleanupService(signingKeyRepository, keystoreService, applicationProperties); + cleanupService = new KeyPairCleanupService(signingKeyRepository, keystoreService, applicationProperties); } @@ -101,7 +101,7 @@ class JwtKeyCleanupServiceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - + createTestKeyFile("key-1"); createTestKeyFile("key-2"); @@ -134,7 +134,7 @@ class JwtKeyCleanupServiceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - + createTestKeyFile("key-1"); createTestKeyFile("key-2"); createTestKeyFile("key-3"); @@ -161,7 +161,7 @@ class JwtKeyCleanupServiceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - + createTestKeyFile("key-1"); when(signingKeyRepository.countKeysEligibleForCleanup(any(LocalDateTime.class))).thenReturn(2L); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeystoreServiceInterfaceTest.java similarity index 90% rename from app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java rename to app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeystoreServiceInterfaceTest.java index 8979bcbf6..2380eb00b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeystoreServiceInterfaceTest.java @@ -32,7 +32,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -class JwtKeystoreServiceInterfaceTest { +class KeystoreServiceInterfaceTest { @Mock private JwtSigningKeyRepository repository; @@ -49,7 +49,7 @@ class JwtKeystoreServiceInterfaceTest { @TempDir Path tempDir; - private JwtKeystoreService keystoreService; + private KeystoreService keystoreService; private KeyPair testKeyPair; @BeforeEach @@ -70,7 +70,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); assertEquals(keystoreEnabled, keystoreService.isKeystoreEnabled()); } @@ -82,7 +82,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); KeyPair result = keystoreService.getActiveKeyPair(); @@ -98,7 +98,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); KeyPair result = keystoreService.getActiveKeyPair(); @@ -122,7 +122,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); KeyPair result = keystoreService.getActiveKeyPair(); @@ -146,7 +146,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); Optional result = keystoreService.getKeyPairByKeyId(keyId); @@ -163,7 +163,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); Optional result = keystoreService.getKeyPairByKeyId(keyId); @@ -177,7 +177,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); Optional result = keystoreService.getKeyPairByKeyId("any-key"); @@ -191,7 +191,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); assertTrue(Files.exists(tempDir)); @@ -209,7 +209,7 @@ class JwtKeystoreServiceInterfaceTest { try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getPrivateKeyPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService = new KeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); KeyPair result = keystoreService.getActiveKeyPair(); From 71513ba762fd6bcbcde577a9528bd2da4be34cff Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 31 Jul 2025 13:16:01 +0100 Subject: [PATCH 23/23] wip --- .../src/main/resources/settings.yml.template | 7 +-- .../security/service/JwtService.java | 8 +++- .../security/service/JwtServiceTest.java | 43 ++++++++++++------- 3 files changed, 37 insertions(+), 21 deletions(-) diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index d346be07a..3d5608bd5 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -60,11 +60,12 @@ 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: - enableKeyStore: true # Set to 'true' to enable JWT key store - enableKeyRotation: true # Set to 'true' to enable JWT key rotation - enableKeyCleanup: true # Set to 'true' to enable JWT key cleanup + 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 keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. cleanupBatchSize: 100 # Number of keys to clean up in each batch. The default is 100. + secureCookie: false # Set to 'true' to use secure cookies for JWTs premium: key: 00000000-0000-0000-0000-000000000000 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 7e46c9b4f..32701aa59 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -10,6 +10,7 @@ import java.util.function.Function; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; @@ -43,6 +44,9 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "Stirling PDF"; private static final long EXPIRATION = 3600000; + @Value("${stirling.security.jwt.secureCookie:true}") + private boolean secureCookie; + private final KeystoreServiceInterface keystoreService; private final boolean v2Enabled; @@ -198,7 +202,7 @@ public class JwtService implements JwtServiceInterface { ResponseCookie cookie = ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) .httpOnly(true) - // .secure(true) // todo: fix, make configurable + .secure(secureCookie) .sameSite("Strict") .maxAge(EXPIRATION / 1000) .path("/") @@ -214,7 +218,7 @@ public class JwtService implements JwtServiceInterface { ResponseCookie cookie = ResponseCookie.from(JWT_COOKIE_NAME, "") .httpOnly(true) - .secure(true) + .secure(secureCookie) .sameSite("None") .maxAge(0) .path("/") 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 6c7e2770a..4f8726335 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 @@ -11,6 +11,8 @@ 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; @@ -201,19 +203,10 @@ class JwtServiceTest { assertThrows(AuthenticationFailureException.class, () -> jwtService.extractClaims("invalid-token")); } - @Test - void testExtractTokenWithAuthorizationHeader() { - String token = "test-token"; - when(request.getHeader("Authorization")).thenReturn("Bearer " + token); - - assertEquals(token, jwtService.extractToken(request)); - } - @Test void testExtractTokenWithCookie() { 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.extractToken(request)); @@ -221,7 +214,6 @@ class JwtServiceTest { @Test void testExtractTokenWithNoCookies() { - when(request.getHeader("Authorization")).thenReturn(null); when(request.getCookies()).thenReturn(null); assertNull(jwtService.extractToken(request)); @@ -230,7 +222,6 @@ class JwtServiceTest { @Test void testExtractTokenWithWrongCookie() { Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; - when(request.getHeader("Authorization")).thenReturn(null); when(request.getCookies()).thenReturn(cookies); assertNull(jwtService.extractToken(request)); @@ -238,22 +229,30 @@ class JwtServiceTest { @Test void testExtractTokenWithInvalidAuthorizationHeader() { - when(request.getHeader("Authorization")).thenReturn("Basic token"); when(request.getCookies()).thenReturn(null); assertNull(jwtService.extractToken(request)); } - @Test - void testAddToken() { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testAddToken(boolean secureCookie) throws Exception { String token = "test-token"; - jwtService.addToken(response, token); + // Create new JwtService instance with the secureCookie parameter + JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie); + + testJwtService.addToken(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")); + + if (secureCookie) { + verify(response).addHeader(eq("Set-Cookie"), contains("Secure")); + } else { + verify(response, org.mockito.Mockito.never()).addHeader(eq("Set-Cookie"), contains("Secure")); + } } @Test @@ -327,4 +326,16 @@ class JwtServiceTest { // Verify fallback to active keypair was used (called multiple times during token operations) verify(keystoreService, atLeast(1)).getActiveKeyPair(); } + + 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; + } }