From 49a041adfe90579b47a5f32a99dc29d025984bb1 Mon Sep 17 00:00:00 2001 From: DarioGii Date: Fri, 4 Jul 2025 10:38:35 +0100 Subject: [PATCH 01/19] 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 e4edf2baa..53e37d89a 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 @@ -109,13 +109,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 { @@ -153,6 +154,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; @@ -275,6 +280,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 a26f256f7..6d4ca6add 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/19] 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 105ddec3c..945e68b69 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 53e37d89a..dddca220f 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 @@ -284,7 +284,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 @@ -292,12 +291,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 6d4ca6add..e0ac7db28 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 6f5f50ac3d8620684c7789b66c6714ddff972b83 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 10 Jul 2025 13:16:29 +0100 Subject: [PATCH 03/19] 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 c6be2a410fd52cd1f661cf7baf9982c25b52d288 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 10 Jul 2025 13:41:12 +0100 Subject: [PATCH 04/19] 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 dddca220f..e4edf2baa 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 @@ -109,14 +109,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 { @@ -154,10 +153,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; @@ -280,20 +275,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 e0ac7db28..c7b5724c4 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 715f3d81cb8aa122b966124139ded95a9ed1bc3e Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 18 Jul 2025 10:25:14 +0100 Subject: [PATCH 05/19] 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 96b827050e49118ce4867ea70e24e183f9e78811 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Fri, 18 Jul 2025 15:08:15 +0100 Subject: [PATCH 06/19] 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 141c7f5d2696fb0dec4c7bf3cc4514c72ab02cbb Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Mon, 21 Jul 2025 12:58:52 +0100 Subject: [PATCH 07/19] 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 51e8be5a6a8e121345383b3d85c9a53410e50d14 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/19] 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 3026dd682e9982a20580d230a0ddb25ed5516307 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 14 Jul 2025 21:53:11 +0200 Subject: [PATCH 09/19] 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 c38571abb..aa98d2a1e 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"] @@ -11,24 +10,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 @@ -44,7 +25,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 @@ -57,11 +38,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: @@ -112,17 +88,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 @@ -130,8 +103,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 @@ -143,12 +115,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 @@ -159,7 +129,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 @@ -174,8 +144,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 && @@ -194,7 +162,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 @@ -205,7 +173,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 @@ -220,7 +188,6 @@ jobs: with: python-version: "3.12" cache: 'pip' # caching pip dependencies - cache-dependency-path: ./testing/cucumber/requirements.txt - name: Pip requirements run: | @@ -232,69 +199,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 3f1dc45efc76f3fd874ef9bb6472db01f8102690 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/19] 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 79015e5d98d70df9e6f8b6eda35cb91019503951 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/19] 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 bc62c9bfa48ab689f7f38f6bdf6c74c9cd299c8a Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:03:31 +0100 Subject: [PATCH 12/19] 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 4a8b1054a145fc5553b957600564d5aa8ffd99a4 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:25:59 +0100 Subject: [PATCH 13/19] Updated test --- .../filter/JwtAuthenticationFilter.java | 213 ++++++++++++++++++ ...l2AuthenticationRequestRepositoryTest.java | 1 + 2 files changed, 214 insertions(+) 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/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 edc6aab28b764e62042196e98748ba39d98154ae Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 15:25:59 +0100 Subject: [PATCH 14/19] Updated test --- .../proprietary/security/model/AuthenticationType.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 } From b40ccb90cbe5cd506ea33c4d4dedcbb6e982a17b Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Tue, 22 Jul 2025 17:46:42 +0100 Subject: [PATCH 15/19] Cleanup --- .github/workflows/build.yml | 121 ++++++++++++++++-- .gitignore | 3 - .../software/common/util/RequestUriUtils.java | 2 + .../src/main/resources/application.properties | 2 +- .../src/main/resources/settings.yml.template | 19 ++- .../configuration/SecurityConfiguration.java | 4 +- .../filter/JwtAuthenticationFilter.java | 46 +------ .../filter/UserAuthenticationFilter.java | 31 ----- .../security/model/AuthenticationType.java | 2 +- ...tomOAuth2AuthenticationSuccessHandler.java | 3 +- ...tSaml2AuthenticationRequestRepository.java | 2 +- .../security/service/UserService.java | 7 +- .../filter/JwtAuthenticationFilterTest.java | 100 +-------------- 13 files changed, 143 insertions(+), 199 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa98d2a1e..c38571abb 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"] @@ -10,6 +11,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 @@ -25,7 +44,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 @@ -38,6 +57,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: @@ -88,14 +112,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 @@ -103,7 +130,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 @@ -115,10 +143,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 @@ -129,7 +159,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 @@ -144,6 +174,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 && @@ -162,7 +194,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 @@ -173,7 +205,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 @@ -188,6 +220,7 @@ jobs: with: python-version: "3.12" cache: 'pip' # caching pip dependencies + cache-dependency-path: ./testing/cucumber/requirements.txt - name: Pip requirements run: | @@ -199,3 +232,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 945e68b69..105ddec3c 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/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 c7b5724c4..f311dac00 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 @@ -47,24 +47,23 @@ 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 - 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 + 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 premium: key: 00000000-0000-0000-0000-000000000000 - enabled: true # Enable license key checks for pro/enterprise features + enabled: false # Enable license key checks for pro/enterprise features proFeatures: - database: false # Enable database features SSOAutoLogin: false CustomMetadata: autoUpdateMetadata: false @@ -100,7 +99,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..eeec274bf 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 @@ -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/filter/JwtAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java index eaa333a76..d3511b08b 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; } @@ -160,49 +161,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..bb22f597a 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 @@ -218,35 +218,4 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { return method; } } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - String uri = request.getRequestURI(); - String contextPath = request.getContextPath(); - String[] permitAllPatterns = { - contextPath + "/login", - contextPath + "/register", - contextPath + "/error", - contextPath + "/images/", - contextPath + "/public/", - contextPath + "/css/", - contextPath + "/fonts/", - contextPath + "/js/", - contextPath + "/pdfjs/", - contextPath + "/pdfjs-legacy/", - contextPath + "/api/v1/info/status", - contextPath + "/site.webmanifest" - }; - - for (String pattern : permitAllPatterns) { - if (uri.startsWith(pattern) - || uri.endsWith(".svg") - || uri.endsWith(".png") - || uri.endsWith(".ico")) { - return true; - } - } - - return false; - } } 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/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/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); From 981715a3c47b6075894238cb385b42aa432e8da1 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 23 Jul 2025 16:12:49 +0100 Subject: [PATCH 16/19] wip - RsaKeyProperties for keys --- .../software/proprietary/security/service/JwtService.java | 2 ++ 1 file changed, 2 insertions(+) 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..93207754d 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,6 +43,8 @@ public class JwtService implements JwtServiceInterface { private final KeyPair keyPair; private final boolean v2Enabled; + // todo: Create JWTConfig class to manage JWT properties. Use RsaKeyProperties for key generation. + public JwtService(@Qualifier("v2Enabled") boolean v2Enabled) { this.v2Enabled = v2Enabled; keyPair = Jwts.SIG.RS256.keyPair().build(); From 0c52fc5f1c024529d486850461ba5ee0064aeee7 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Thu, 24 Jul 2025 17:28:35 +0100 Subject: [PATCH 17/19] wip - keystore refactor --- .../common/model/ApplicationProperties.java | 8 + .../repository/JwtSigningKeyRepository.java | 18 ++ .../security/model/JwtSigningKey.java | 62 ++++ .../security/service/JwtKeystoreService.java | 17 ++ .../service/JwtKeystoreServiceImpl.java | 248 ++++++++++++++++ .../security/service/JwtService.java | 69 ++++- .../JwtSigningKeyRepositoryTest.java | 173 +++++++++++ .../security/model/JwtSigningKeyTest.java | 121 ++++++++ .../service/JwtKeystoreServiceTest.java | 280 ++++++++++++++++++ .../security/service/JwtServiceTest.java | 115 ++++++- 10 files changed, 1090 insertions(+), 21 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/JwtKeystoreServiceImpl.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java 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 e4edf2baa..6dfa2a57c 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 @@ -113,6 +113,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(); @@ -275,6 +276,13 @@ public class ApplicationProperties { } } } + + @Data + public static class Jwt { + private boolean enableKeystore = true; + private boolean enableKeyRotation = false; + private int keyLifetimeDays = 90; + } } @Data 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/model/JwtSigningKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtSigningKey.java new file mode 100644 index 000000000..091148da1 --- /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 +@Table(name = "jwt_signing_keys") +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(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 = "public_key", columnDefinition = "TEXT", nullable = false) + private String publicKey; + + @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 publicKey, String algorithm) { + this.keyId = keyId; + this.publicKey = publicKey; + this.algorithm = algorithm; + this.createdAt = LocalDateTime.now(); + this.isActive = true; + } +} 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..414dd7895 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.util.Optional; + +public interface JwtKeystoreService { + + 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/JwtKeystoreServiceImpl.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceImpl.java new file mode 100644 index 000000000..362d32528 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceImpl.java @@ -0,0 +1,248 @@ +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 JwtKeystoreServiceImpl implements JwtKeystoreService { + + public static final String KEY_SUFFIX = ".key"; + private final JwtSigningKeyRepository repository; + private final ApplicationProperties.Security.Jwt jwtConfig; + private final Path privateKeyDirectory; + + private volatile KeyPair currentKeyPair; + private volatile String currentKeyId; + + @Autowired + public JwtKeystoreServiceImpl( + JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { + this.repository = repository; + this.jwtConfig = 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 generateInMemoryKeypair(); + } + 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 = loadPrivateKeyFromFile(keyId); + PublicKey publicKey = decodePublicKey(signingKey.get().getPublicKey()); + + 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 { + // Deactivate current key + repository + .findByIsActiveTrue() + .ifPresent( + key -> { + key.setIsActive(false); + repository.save(key); + }); + + // Generate new keypair + 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 jwtConfig.isEnableKeystore(); + } + + private void loadOrGenerateKeypair() { + Optional activeKey = repository.findByIsActiveTrue(); + + if (activeKey.isPresent()) { + try { + currentKeyId = activeKey.get().getKeyId(); + PrivateKey privateKey = loadPrivateKeyFromFile(currentKeyId); + PublicKey publicKey = decodePublicKey(activeKey.get().getPublicKey()); + 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 { + // Generate new keypair + KeyPair keyPair = generateRSAKeypair(); + String keyId = generateKeyId(); + + // Store private key to file + storePrivateKeyToFile(keyId, keyPair.getPrivate()); + + // Store public key and metadata to database + JwtSigningKey signingKey = + new JwtSigningKey(keyId, encodePublicKey(keyPair.getPublic()), "RS256"); + repository.save(signingKey); + + // Update current references + 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() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private KeyPair generateInMemoryKeypair() { + try { + return generateRSAKeypair(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("Failed to generate in-memory keypair", e); + } + } + + 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 storePrivateKeyToFile(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 restrictive permissions (readable only by owner) + try { + keyFile.toFile().setReadable(false, false); + keyFile.toFile().setReadable(true, true); + keyFile.toFile().setWritable(false, false); + 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 loadPrivateKeyFromFile(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/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java index 93207754d..a4ab69586 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,14 +42,14 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "Stirling PDF"; private static final long EXPIRATION = 3600000; - private final KeyPair keyPair; + private final JwtKeystoreService keystoreService; private final boolean v2Enabled; - // todo: Create JWTConfig class to manage JWT properties. Use RsaKeyProperties for key generation. - - public JwtService(@Qualifier("v2Enabled") boolean v2Enabled) { + @Autowired + public JwtService( + @Qualifier("v2Enabled") boolean v2Enabled, JwtKeystoreService keystoreService) { this.v2Enabled = v2Enabled; - keyPair = Jwts.SIG.RS256.keyPair().build(); + this.keystoreService = keystoreService; } @Override @@ -68,14 +70,24 @@ 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); + + // Add key ID to header if keystore is enabled + String keyId = keystoreService.getActiveKeyId(); + if (keyId != null) { + builder.header().keyId(keyId); + } + + return builder.compact(); } @Override @@ -114,6 +126,22 @@ 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, using active keypair", keyId); + keyPair = keystoreService.getActiveKeypair(); + } + } else { + keyPair = keystoreService.getActiveKeypair(); + } + return Jwts.parser() .verifyWith(keyPair.getPublic()) .build() @@ -193,4 +221,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) { + // Token might not have a key ID or be malformed + return null; + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java new file mode 100644 index 000000000..1324499dd --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java @@ -0,0 +1,173 @@ +package stirling.software.proprietary.security.database.repository; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +import stirling.software.proprietary.security.model.JwtSigningKey; + +@DataJpaTest +class JwtSigningKeyRepositoryTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private JwtSigningKeyRepository repository; + + private JwtSigningKey activeKey; + private JwtSigningKey inactiveKey; + + @BeforeEach + void setUp() { + // Create test data + activeKey = new JwtSigningKey("active-key-123", "active-public-key", "RS256"); + activeKey.setIsActive(true); + activeKey.setCreatedAt(LocalDateTime.now().minusDays(1)); + + inactiveKey = new JwtSigningKey("inactive-key-456", "inactive-public-key", "RS256"); + inactiveKey.setIsActive(false); + inactiveKey.setCreatedAt(LocalDateTime.now().minusDays(2)); + + entityManager.persistAndFlush(activeKey); + entityManager.persistAndFlush(inactiveKey); + } + + @Test + void testFindByIsActiveTrue() { + Optional result = repository.findByIsActiveTrue(); + + assertTrue(result.isPresent()); + assertEquals("active-key-123", result.get().getKeyId()); + assertTrue(result.get().getIsActive()); + } + + @Test + void testFindByIsActiveTrueWhenNoActiveKeys() { + // Deactivate all keys + activeKey.setIsActive(false); + entityManager.persistAndFlush(activeKey); + + Optional result = repository.findByIsActiveTrue(); + + assertFalse(result.isPresent()); + } + + @Test + void testFindByKeyId() { + Optional result = repository.findByKeyId("active-key-123"); + + assertTrue(result.isPresent()); + assertEquals("active-key-123", result.get().getKeyId()); + assertEquals("active-public-key", result.get().getPublicKey()); + } + + @Test + void testFindByKeyIdNotFound() { + Optional result = repository.findByKeyId("non-existent-key"); + + assertFalse(result.isPresent()); + } + + @Test + void testFindByKeyIdAndIsActiveTrue() { + Optional result = repository.findByKeyIdAndIsActiveTrue("active-key-123"); + + assertTrue(result.isPresent()); + assertEquals("active-key-123", result.get().getKeyId()); + assertTrue(result.get().getIsActive()); + } + + @Test + void testFindByKeyIdAndIsActiveTrueWithInactiveKey() { + Optional result = repository.findByKeyIdAndIsActiveTrue("inactive-key-456"); + + assertFalse(result.isPresent()); + } + + @Test + void testSaveAndRetrieve() { + JwtSigningKey newKey = new JwtSigningKey("new-key-789", "new-public-key", "RS256"); + + JwtSigningKey saved = repository.save(newKey); + + assertNotNull(saved.getId()); + assertEquals("new-key-789", saved.getKeyId()); + assertEquals("new-public-key", saved.getPublicKey()); + assertEquals("RS256", saved.getAlgorithm()); + assertTrue(saved.getIsActive()); + assertNotNull(saved.getCreatedAt()); + + // Verify it can be retrieved + Optional retrieved = repository.findByKeyId("new-key-789"); + assertTrue(retrieved.isPresent()); + assertEquals(saved.getId(), retrieved.get().getId()); + } + + @Test + void testUpdateIsActive() { + // Update active key to inactive + activeKey.setIsActive(false); + repository.save(activeKey); + + Optional result = repository.findByIsActiveTrue(); + assertFalse(result.isPresent()); + + // Verify the key still exists but is inactive + Optional inactive = repository.findByKeyId("active-key-123"); + assertTrue(inactive.isPresent()); + assertFalse(inactive.get().getIsActive()); + } + + @Test + void testUniqueConstraintOnKeyId() { + JwtSigningKey duplicateKeyId = new JwtSigningKey("active-key-123", "duplicate-public-key", "RS256"); + + // Should throw exception due to unique constraint on keyId + assertThrows(Exception.class, () -> { + repository.saveAndFlush(duplicateKeyId); + }); + } + + @Test + void testFindAll() { + var allKeys = repository.findAll(); + + assertEquals(2, allKeys.size()); + + boolean foundActive = false; + boolean foundInactive = false; + + for (JwtSigningKey key : allKeys) { + if ("active-key-123".equals(key.getKeyId())) { + foundActive = true; + assertTrue(key.getIsActive()); + } else if ("inactive-key-456".equals(key.getKeyId())) { + foundInactive = true; + assertFalse(key.getIsActive()); + } + } + + assertTrue(foundActive); + assertTrue(foundInactive); + } + + @Test + void testDeleteByKeyId() { + repository.deleteById(activeKey.getId()); + + Optional result = repository.findByKeyId("active-key-123"); + assertFalse(result.isPresent()); + + // Verify inactive key still exists + Optional inactiveResult = repository.findByKeyId("inactive-key-456"); + assertTrue(inactiveResult.isPresent()); + } +} \ No newline at end of file diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java new file mode 100644 index 000000000..a80855ccb --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java @@ -0,0 +1,121 @@ +package stirling.software.proprietary.security.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; + +class JwtSigningKeyTest { + + @Test + void testDefaultConstructor() { + JwtSigningKey key = new JwtSigningKey(); + + assertNull(key.getId()); + assertNull(key.getKeyId()); + assertNull(key.getPublicKey()); + assertEquals("RS256", key.getAlgorithm()); + assertNull(key.getCreatedAt()); + assertTrue(key.getIsActive()); + } + + @Test + void testParameterizedConstructor() { + String keyId = "test-key-123"; + String publicKey = "test-public-key-content"; + String algorithm = "RS256"; + + JwtSigningKey key = new JwtSigningKey(keyId, publicKey, algorithm); + + assertNull(key.getId()); // Auto-generated by JPA + assertEquals(keyId, key.getKeyId()); + assertEquals(publicKey, key.getPublicKey()); + assertEquals(algorithm, key.getAlgorithm()); + assertNotNull(key.getCreatedAt()); + assertTrue(key.getIsActive()); + + // Verify created time is recent (within last few seconds) + assertTrue(key.getCreatedAt().isAfter(LocalDateTime.now().minusSeconds(5))); + assertTrue(key.getCreatedAt().isBefore(LocalDateTime.now().plusSeconds(1))); + } + + @Test + void testSettersAndGetters() { + JwtSigningKey key = new JwtSigningKey(); + + Long id = 1L; + String keyId = "test-key-456"; + String publicKey = "public-key-content"; + String algorithm = "RS512"; + LocalDateTime now = LocalDateTime.now(); + Boolean isActive = false; + + key.setId(id); + key.setKeyId(keyId); + key.setPublicKey(publicKey); + key.setAlgorithm(algorithm); + key.setCreatedAt(now); + key.setIsActive(isActive); + + assertEquals(id, key.getId()); + assertEquals(keyId, key.getKeyId()); + assertEquals(publicKey, key.getPublicKey()); + assertEquals(algorithm, key.getAlgorithm()); + assertEquals(now, key.getCreatedAt()); + assertEquals(isActive, key.getIsActive()); + } + + @Test + void testEqualsAndHashCode() { + JwtSigningKey key1 = new JwtSigningKey("key-123", "public-key", "RS256"); + key1.setId(1L); + + JwtSigningKey key2 = new JwtSigningKey("key-456", "other-public-key", "RS512"); + key2.setId(1L); + + JwtSigningKey key3 = new JwtSigningKey("key-789", "another-public-key", "RS256"); + key3.setId(2L); + + // Objects with same ID should be equal (based on @EqualsAndHashCode.Include on id) + assertEquals(key1, key2); + assertEquals(key1.hashCode(), key2.hashCode()); + + // Objects with different ID should not be equal + assertNotEquals(key1, key3); + assertNotEquals(key1.hashCode(), key3.hashCode()); + } + + @Test + void testToString() { + JwtSigningKey key = new JwtSigningKey("test-key", "public-key", "RS256"); + key.setId(1L); + + String toString = key.toString(); + + // Should include fields marked with @ToString.Include + assertTrue(toString.contains("1")); // id + assertTrue(toString.contains("test-key")); // keyId + assertTrue(toString.contains("true")); // isActive + assertFalse(toString.contains("public-key")); // publicKey should not be in toString + } + + @Test + void testAlgorithmDefault() { + JwtSigningKey key = new JwtSigningKey("key-id", "public-key", null); + + // When null is passed to constructor, it should be null (constructor doesn't set default) + assertNull(key.getAlgorithm()); + + // Default value is only set through field initialization for no-arg constructor + JwtSigningKey defaultKey = new JwtSigningKey(); + assertEquals("RS256", defaultKey.getAlgorithm()); + } + + @Test + void testIsActiveDefault() { + JwtSigningKey key = new JwtSigningKey("key-id", "public-key", "RS256"); + + assertTrue(key.getIsActive()); + } +} \ No newline at end of file diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java new file mode 100644 index 000000000..a9c7f90d2 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java @@ -0,0 +1,280 @@ +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.nio.file.Paths; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +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.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 JwtKeystoreServiceTest { + + @Mock + private JwtSigningKeyRepository repository; + + @Mock + private ApplicationProperties applicationProperties; + + @Mock + private ApplicationProperties.Security security; + + @Mock + private ApplicationProperties.Security.Jwt jwtConfig; + + @TempDir + Path tempDir; + + private JwtKeystoreServiceImpl keystoreService; + private KeyPair testKeyPair; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + // Generate test keypair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + // Mock configuration + when(applicationProperties.getSecurity()).thenReturn(security); + when(security.getJwt()).thenReturn(jwtConfig); + when(jwtConfig.isEnableKeystore()).thenReturn(true); + } + + @Test + void testIsKeystoreEnabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(true); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); + + assertTrue(keystoreService.isKeystoreEnabled()); + } + } + + @Test + void testIsKeystoreDisabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(false); + + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { + mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); + keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); + + assertFalse(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 JwtKeystoreServiceImpl(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 JwtKeystoreServiceImpl(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)); + + // Create private key file + 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 JwtKeystoreServiceImpl(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)); + + // Create private key file + 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 JwtKeystoreServiceImpl(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 JwtKeystoreServiceImpl(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 JwtKeystoreServiceImpl(repository, applicationProperties); + + Optional result = keystoreService.getKeypairByKeyId("any-key"); + + assertFalse(result.isPresent()); + } + } + + @Test + void testRotateKeypair() throws Exception { + 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 JwtKeystoreServiceImpl(repository, applicationProperties); + + // Initialize first to create directory structure + keystoreService.initializeKeystore(); + + keystoreService.rotateKeypair(); + + // Verify old key was deactivated + assertFalse(oldKey.getIsActive()); + verify(repository, atLeast(2)).save(any(JwtSigningKey.class)); // At least one for deactivation, one for new key + + // Verify new key is active + 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 JwtKeystoreServiceImpl(repository, applicationProperties); + + // Should not throw exception, just log warning + assertDoesNotThrow(() -> keystoreService.rotateKeypair()); + + // Verify no database operations + 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 JwtKeystoreServiceImpl(repository, applicationProperties); + keystoreService.initializeKeystore(); + + Path jwtKeysDir = tempDir.resolve("jwt-keys"); + assertTrue(Files.exists(jwtKeysDir)); + assertTrue(Files.isDirectory(jwtKeysDir)); + } + } + + @Test + void testLoadExistingKeypairWithMissingPrivateKeyFile() throws Exception { + 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 JwtKeystoreServiceImpl(repository, applicationProperties); + keystoreService.initializeKeystore(); + + // Should generate new keypair when private key file is missing + KeyPair result = keystoreService.getActiveKeypair(); + assertNotNull(result); + + // Verify new keypair was generated and saved + verify(repository).save(any(JwtSigningKey.class)); + } + } + +} \ No newline at end of file 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..3df1e81dd 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 JwtKeystoreService 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); @@ -77,7 +92,9 @@ class JwtServiceTest { Map claims = new HashMap<>(); 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(""); }); @@ -129,7 +157,9 @@ class JwtServiceTest { String username = "testuser"; 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")); } @@ -147,7 +179,9 @@ class JwtServiceTest { void testExtractAllClaims() { 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 6a7328ff3d91135a0b33545c6439a304ed7f3489 Mon Sep 17 00:00:00 2001 From: DarioGii Date: Fri, 25 Jul 2025 15:28:36 +0100 Subject: [PATCH 18/19] wip - keystore impl --- .../common/model/ApplicationProperties.java | 1 - .../src/main/resources/settings.yml.template | 3 + .../security/model/JwtSigningKey.java | 14 +- .../security/service/JwtKeystoreService.java | 233 +++++++++++++++- .../service/JwtKeystoreServiceImpl.java | 248 ------------------ .../service/JwtKeystoreServiceInterface.java | 17 ++ .../security/service/JwtService.java | 5 +- .../JwtSigningKeyRepositoryTest.java | 173 ------------ .../security/model/JwtSigningKeyTest.java | 121 --------- ...a => JwtKeystoreServiceInterfaceTest.java} | 142 +++++----- .../security/service/JwtServiceTest.java | 36 +-- 11 files changed, 335 insertions(+), 658 deletions(-) delete mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceImpl.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterface.java delete mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java delete mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java rename app/proprietary/src/test/java/stirling/software/proprietary/security/service/{JwtKeystoreServiceTest.java => JwtKeystoreServiceInterfaceTest.java} (75%) 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 6dfa2a57c..bb3d8fbd0 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 @@ -281,7 +281,6 @@ public class ApplicationProperties { public static class Jwt { private boolean enableKeystore = true; private boolean enableKeyRotation = false; - private int keyLifetimeDays = 90; } } diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index f311dac00..282508066 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -59,6 +59,9 @@ 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: + 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 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 index 091148da1..d1b78d8a9 100644 --- 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 @@ -17,12 +17,12 @@ import lombok.Setter; import lombok.ToString; @Entity -@Table(name = "jwt_signing_keys") -@NoArgsConstructor @Getter @Setter -@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@NoArgsConstructor +@Table(name = "signing_keys") @ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) public class JwtSigningKey implements Serializable { private static final long serialVersionUID = 1L; @@ -38,8 +38,8 @@ public class JwtSigningKey implements Serializable { @ToString.Include private String keyId; - @Column(name = "public_key", columnDefinition = "TEXT", nullable = false) - private String publicKey; + @Column(name = "signing_key", columnDefinition = "TEXT", nullable = false) + private String signingKey; @Column(name = "algorithm", nullable = false) private String algorithm = "RS256"; @@ -52,9 +52,9 @@ public class JwtSigningKey implements Serializable { @ToString.Include private Boolean isActive = true; - public JwtSigningKey(String keyId, String publicKey, String algorithm) { + public JwtSigningKey(String keyId, String signingKey, String algorithm) { this.keyId = keyId; - this.publicKey = publicKey; + 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/service/JwtKeystoreService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreService.java index 414dd7895..887f26f94 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 @@ -1,17 +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; -public interface JwtKeystoreService { +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; - KeyPair getActiveKeypair(); +import jakarta.annotation.PostConstruct; - Optional getKeypairByKeyId(String keyId); +import lombok.extern.slf4j.Slf4j; - String getActiveKeyId(); +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; - void rotateKeypair(); +@Service +@Slf4j +public class JwtKeystoreService implements JwtKeystoreServiceInterface { - boolean isKeystoreEnabled(); + public static final String KEY_SUFFIX = ".key"; + private final JwtSigningKeyRepository repository; + private final ApplicationProperties.Security.Jwt jwtConfig; + private final Path privateKeyDirectory; + + private volatile KeyPair currentKeyPair; + private volatile String currentKeyId; + + @Autowired + public JwtKeystoreService( + JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { + this.repository = repository; + this.jwtConfig = 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 jwtConfig.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/JwtKeystoreServiceImpl.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceImpl.java deleted file mode 100644 index 362d32528..000000000 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtKeystoreServiceImpl.java +++ /dev/null @@ -1,248 +0,0 @@ -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 JwtKeystoreServiceImpl implements JwtKeystoreService { - - public static final String KEY_SUFFIX = ".key"; - private final JwtSigningKeyRepository repository; - private final ApplicationProperties.Security.Jwt jwtConfig; - private final Path privateKeyDirectory; - - private volatile KeyPair currentKeyPair; - private volatile String currentKeyId; - - @Autowired - public JwtKeystoreServiceImpl( - JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { - this.repository = repository; - this.jwtConfig = 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 generateInMemoryKeypair(); - } - 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 = loadPrivateKeyFromFile(keyId); - PublicKey publicKey = decodePublicKey(signingKey.get().getPublicKey()); - - 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 { - // Deactivate current key - repository - .findByIsActiveTrue() - .ifPresent( - key -> { - key.setIsActive(false); - repository.save(key); - }); - - // Generate new keypair - 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 jwtConfig.isEnableKeystore(); - } - - private void loadOrGenerateKeypair() { - Optional activeKey = repository.findByIsActiveTrue(); - - if (activeKey.isPresent()) { - try { - currentKeyId = activeKey.get().getKeyId(); - PrivateKey privateKey = loadPrivateKeyFromFile(currentKeyId); - PublicKey publicKey = decodePublicKey(activeKey.get().getPublicKey()); - 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 { - // Generate new keypair - KeyPair keyPair = generateRSAKeypair(); - String keyId = generateKeyId(); - - // Store private key to file - storePrivateKeyToFile(keyId, keyPair.getPrivate()); - - // Store public key and metadata to database - JwtSigningKey signingKey = - new JwtSigningKey(keyId, encodePublicKey(keyPair.getPublic()), "RS256"); - repository.save(signingKey); - - // Update current references - 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() throws NoSuchAlgorithmException { - KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); - keyPairGenerator.initialize(2048); - return keyPairGenerator.generateKeyPair(); - } - - private KeyPair generateInMemoryKeypair() { - try { - return generateRSAKeypair(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("Failed to generate in-memory keypair", e); - } - } - - 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 storePrivateKeyToFile(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 restrictive permissions (readable only by owner) - try { - keyFile.toFile().setReadable(false, false); - keyFile.toFile().setReadable(true, true); - keyFile.toFile().setWritable(false, false); - 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 loadPrivateKeyFromFile(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 a4ab69586..a9b1921bd 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 @@ -42,12 +42,13 @@ public class JwtService implements JwtServiceInterface { private static final String ISSUER = "Stirling PDF"; private static final long EXPIRATION = 3600000; - private final JwtKeystoreService keystoreService; + private final JwtKeystoreServiceInterface keystoreService; private final boolean v2Enabled; @Autowired public JwtService( - @Qualifier("v2Enabled") boolean v2Enabled, JwtKeystoreService keystoreService) { + @Qualifier("v2Enabled") boolean v2Enabled, + JwtKeystoreServiceInterface keystoreService) { this.v2Enabled = v2Enabled; this.keystoreService = keystoreService; } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java deleted file mode 100644 index 1324499dd..000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/database/repository/JwtSigningKeyRepositoryTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package stirling.software.proprietary.security.database.repository; - -import static org.junit.jupiter.api.Assertions.*; - -import java.time.LocalDateTime; -import java.util.Optional; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; - -import stirling.software.proprietary.security.model.JwtSigningKey; - -@DataJpaTest -class JwtSigningKeyRepositoryTest { - - @Autowired - private TestEntityManager entityManager; - - @Autowired - private JwtSigningKeyRepository repository; - - private JwtSigningKey activeKey; - private JwtSigningKey inactiveKey; - - @BeforeEach - void setUp() { - // Create test data - activeKey = new JwtSigningKey("active-key-123", "active-public-key", "RS256"); - activeKey.setIsActive(true); - activeKey.setCreatedAt(LocalDateTime.now().minusDays(1)); - - inactiveKey = new JwtSigningKey("inactive-key-456", "inactive-public-key", "RS256"); - inactiveKey.setIsActive(false); - inactiveKey.setCreatedAt(LocalDateTime.now().minusDays(2)); - - entityManager.persistAndFlush(activeKey); - entityManager.persistAndFlush(inactiveKey); - } - - @Test - void testFindByIsActiveTrue() { - Optional result = repository.findByIsActiveTrue(); - - assertTrue(result.isPresent()); - assertEquals("active-key-123", result.get().getKeyId()); - assertTrue(result.get().getIsActive()); - } - - @Test - void testFindByIsActiveTrueWhenNoActiveKeys() { - // Deactivate all keys - activeKey.setIsActive(false); - entityManager.persistAndFlush(activeKey); - - Optional result = repository.findByIsActiveTrue(); - - assertFalse(result.isPresent()); - } - - @Test - void testFindByKeyId() { - Optional result = repository.findByKeyId("active-key-123"); - - assertTrue(result.isPresent()); - assertEquals("active-key-123", result.get().getKeyId()); - assertEquals("active-public-key", result.get().getPublicKey()); - } - - @Test - void testFindByKeyIdNotFound() { - Optional result = repository.findByKeyId("non-existent-key"); - - assertFalse(result.isPresent()); - } - - @Test - void testFindByKeyIdAndIsActiveTrue() { - Optional result = repository.findByKeyIdAndIsActiveTrue("active-key-123"); - - assertTrue(result.isPresent()); - assertEquals("active-key-123", result.get().getKeyId()); - assertTrue(result.get().getIsActive()); - } - - @Test - void testFindByKeyIdAndIsActiveTrueWithInactiveKey() { - Optional result = repository.findByKeyIdAndIsActiveTrue("inactive-key-456"); - - assertFalse(result.isPresent()); - } - - @Test - void testSaveAndRetrieve() { - JwtSigningKey newKey = new JwtSigningKey("new-key-789", "new-public-key", "RS256"); - - JwtSigningKey saved = repository.save(newKey); - - assertNotNull(saved.getId()); - assertEquals("new-key-789", saved.getKeyId()); - assertEquals("new-public-key", saved.getPublicKey()); - assertEquals("RS256", saved.getAlgorithm()); - assertTrue(saved.getIsActive()); - assertNotNull(saved.getCreatedAt()); - - // Verify it can be retrieved - Optional retrieved = repository.findByKeyId("new-key-789"); - assertTrue(retrieved.isPresent()); - assertEquals(saved.getId(), retrieved.get().getId()); - } - - @Test - void testUpdateIsActive() { - // Update active key to inactive - activeKey.setIsActive(false); - repository.save(activeKey); - - Optional result = repository.findByIsActiveTrue(); - assertFalse(result.isPresent()); - - // Verify the key still exists but is inactive - Optional inactive = repository.findByKeyId("active-key-123"); - assertTrue(inactive.isPresent()); - assertFalse(inactive.get().getIsActive()); - } - - @Test - void testUniqueConstraintOnKeyId() { - JwtSigningKey duplicateKeyId = new JwtSigningKey("active-key-123", "duplicate-public-key", "RS256"); - - // Should throw exception due to unique constraint on keyId - assertThrows(Exception.class, () -> { - repository.saveAndFlush(duplicateKeyId); - }); - } - - @Test - void testFindAll() { - var allKeys = repository.findAll(); - - assertEquals(2, allKeys.size()); - - boolean foundActive = false; - boolean foundInactive = false; - - for (JwtSigningKey key : allKeys) { - if ("active-key-123".equals(key.getKeyId())) { - foundActive = true; - assertTrue(key.getIsActive()); - } else if ("inactive-key-456".equals(key.getKeyId())) { - foundInactive = true; - assertFalse(key.getIsActive()); - } - } - - assertTrue(foundActive); - assertTrue(foundInactive); - } - - @Test - void testDeleteByKeyId() { - repository.deleteById(activeKey.getId()); - - Optional result = repository.findByKeyId("active-key-123"); - assertFalse(result.isPresent()); - - // Verify inactive key still exists - Optional inactiveResult = repository.findByKeyId("inactive-key-456"); - assertTrue(inactiveResult.isPresent()); - } -} \ No newline at end of file diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java deleted file mode 100644 index a80855ccb..000000000 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/model/JwtSigningKeyTest.java +++ /dev/null @@ -1,121 +0,0 @@ -package stirling.software.proprietary.security.model; - -import static org.junit.jupiter.api.Assertions.*; - -import java.time.LocalDateTime; - -import org.junit.jupiter.api.Test; - -class JwtSigningKeyTest { - - @Test - void testDefaultConstructor() { - JwtSigningKey key = new JwtSigningKey(); - - assertNull(key.getId()); - assertNull(key.getKeyId()); - assertNull(key.getPublicKey()); - assertEquals("RS256", key.getAlgorithm()); - assertNull(key.getCreatedAt()); - assertTrue(key.getIsActive()); - } - - @Test - void testParameterizedConstructor() { - String keyId = "test-key-123"; - String publicKey = "test-public-key-content"; - String algorithm = "RS256"; - - JwtSigningKey key = new JwtSigningKey(keyId, publicKey, algorithm); - - assertNull(key.getId()); // Auto-generated by JPA - assertEquals(keyId, key.getKeyId()); - assertEquals(publicKey, key.getPublicKey()); - assertEquals(algorithm, key.getAlgorithm()); - assertNotNull(key.getCreatedAt()); - assertTrue(key.getIsActive()); - - // Verify created time is recent (within last few seconds) - assertTrue(key.getCreatedAt().isAfter(LocalDateTime.now().minusSeconds(5))); - assertTrue(key.getCreatedAt().isBefore(LocalDateTime.now().plusSeconds(1))); - } - - @Test - void testSettersAndGetters() { - JwtSigningKey key = new JwtSigningKey(); - - Long id = 1L; - String keyId = "test-key-456"; - String publicKey = "public-key-content"; - String algorithm = "RS512"; - LocalDateTime now = LocalDateTime.now(); - Boolean isActive = false; - - key.setId(id); - key.setKeyId(keyId); - key.setPublicKey(publicKey); - key.setAlgorithm(algorithm); - key.setCreatedAt(now); - key.setIsActive(isActive); - - assertEquals(id, key.getId()); - assertEquals(keyId, key.getKeyId()); - assertEquals(publicKey, key.getPublicKey()); - assertEquals(algorithm, key.getAlgorithm()); - assertEquals(now, key.getCreatedAt()); - assertEquals(isActive, key.getIsActive()); - } - - @Test - void testEqualsAndHashCode() { - JwtSigningKey key1 = new JwtSigningKey("key-123", "public-key", "RS256"); - key1.setId(1L); - - JwtSigningKey key2 = new JwtSigningKey("key-456", "other-public-key", "RS512"); - key2.setId(1L); - - JwtSigningKey key3 = new JwtSigningKey("key-789", "another-public-key", "RS256"); - key3.setId(2L); - - // Objects with same ID should be equal (based on @EqualsAndHashCode.Include on id) - assertEquals(key1, key2); - assertEquals(key1.hashCode(), key2.hashCode()); - - // Objects with different ID should not be equal - assertNotEquals(key1, key3); - assertNotEquals(key1.hashCode(), key3.hashCode()); - } - - @Test - void testToString() { - JwtSigningKey key = new JwtSigningKey("test-key", "public-key", "RS256"); - key.setId(1L); - - String toString = key.toString(); - - // Should include fields marked with @ToString.Include - assertTrue(toString.contains("1")); // id - assertTrue(toString.contains("test-key")); // keyId - assertTrue(toString.contains("true")); // isActive - assertFalse(toString.contains("public-key")); // publicKey should not be in toString - } - - @Test - void testAlgorithmDefault() { - JwtSigningKey key = new JwtSigningKey("key-id", "public-key", null); - - // When null is passed to constructor, it should be null (constructor doesn't set default) - assertNull(key.getAlgorithm()); - - // Default value is only set through field initialization for no-arg constructor - JwtSigningKey defaultKey = new JwtSigningKey(); - assertEquals("RS256", defaultKey.getAlgorithm()); - } - - @Test - void testIsActiveDefault() { - JwtSigningKey key = new JwtSigningKey("key-id", "public-key", "RS256"); - - assertTrue(key.getIsActive()); - } -} \ No newline at end of file diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java similarity index 75% rename from app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java rename to app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java index a9c7f90d2..98e2b836d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtKeystoreServiceInterfaceTest.java @@ -7,11 +7,9 @@ import static org.mockito.Mockito.*; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.NoSuchAlgorithmException; -import java.time.LocalDateTime; import java.util.Base64; import java.util.Optional; @@ -19,6 +17,8 @@ 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; @@ -29,7 +29,7 @@ import stirling.software.proprietary.security.database.repository.JwtSigningKeyR import stirling.software.proprietary.security.model.JwtSigningKey; @ExtendWith(MockitoExtension.class) -class JwtKeystoreServiceTest { +class JwtKeystoreServiceInterfaceTest { @Mock private JwtSigningKeyRepository repository; @@ -46,56 +46,43 @@ class JwtKeystoreServiceTest { @TempDir Path tempDir; - private JwtKeystoreServiceImpl keystoreService; + private JwtKeystoreService keystoreService; private KeyPair testKeyPair; @BeforeEach void setUp() throws NoSuchAlgorithmException { - // Generate test keypair KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); testKeyPair = keyPairGenerator.generateKeyPair(); - // Mock configuration when(applicationProperties.getSecurity()).thenReturn(security); when(security.getJwt()).thenReturn(jwtConfig); when(jwtConfig.isEnableKeystore()).thenReturn(true); } - @Test - void testIsKeystoreEnabled() { - when(jwtConfig.isEnableKeystore()).thenReturn(true); - - try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { - mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); - - assertTrue(keystoreService.isKeystoreEnabled()); - } - } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testKeystoreEnabled(boolean keystoreEnabled) { + when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); - @Test - void testIsKeystoreDisabled() { - when(jwtConfig.isEnableKeystore()).thenReturn(false); - try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); - - assertFalse(keystoreService.isKeystoreEnabled()); + 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 JwtKeystoreServiceImpl(repository, applicationProperties); - + keystoreService = new JwtKeystoreService(repository, applicationProperties); + KeyPair result = keystoreService.getActiveKeypair(); - + assertNotNull(result); assertNotNull(result.getPublic()); assertNotNull(result.getPrivate()); @@ -105,14 +92,14 @@ class JwtKeystoreServiceTest { @Test void testGetActiveKeypairWhenNoActiveKeyExists() { when(repository.findByIsActiveTrue()).thenReturn(Optional.empty()); - + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); + keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - + KeyPair result = keystoreService.getActiveKeypair(); - + assertNotNull(result); verify(repository).save(any(JwtSigningKey.class)); } @@ -123,22 +110,21 @@ class JwtKeystoreServiceTest { 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)); - - // Create private key file + 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 JwtKeystoreServiceImpl(repository, applicationProperties); + keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - + KeyPair result = keystoreService.getActiveKeypair(); - + assertNotNull(result); assertEquals(keyId, keystoreService.getActiveKeyId()); } @@ -149,21 +135,20 @@ class JwtKeystoreServiceTest { 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)); - - // Create private key file + 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 JwtKeystoreServiceImpl(repository, applicationProperties); - + keystoreService = new JwtKeystoreService(repository, applicationProperties); + Optional result = keystoreService.getKeypairByKeyId(keyId); - + assertTrue(result.isPresent()); assertNotNull(result.get().getPublic()); assertNotNull(result.get().getPrivate()); @@ -174,13 +159,13 @@ class JwtKeystoreServiceTest { 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 JwtKeystoreServiceImpl(repository, applicationProperties); - + keystoreService = new JwtKeystoreService(repository, applicationProperties); + Optional result = keystoreService.getKeypairByKeyId(keyId); - + assertFalse(result.isPresent()); } } @@ -188,37 +173,34 @@ class JwtKeystoreServiceTest { @Test void testGetKeypairByKeyIdWhenKeystoreDisabled() { when(jwtConfig.isEnableKeystore()).thenReturn(false); - + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); - + keystoreService = new JwtKeystoreService(repository, applicationProperties); + Optional result = keystoreService.getKeypairByKeyId("any-key"); - + assertFalse(result.isPresent()); } } @Test - void testRotateKeypair() throws Exception { + 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 JwtKeystoreServiceImpl(repository, applicationProperties); - - // Initialize first to create directory structure + keystoreService = new JwtKeystoreService(repository, applicationProperties); + keystoreService.initializeKeystore(); - + keystoreService.rotateKeypair(); - - // Verify old key was deactivated + assertFalse(oldKey.getIsActive()); verify(repository, atLeast(2)).save(any(JwtSigningKey.class)); // At least one for deactivation, one for new key - - // Verify new key is active + assertNotNull(keystoreService.getActiveKeyId()); assertNotEquals(oldKeyId, keystoreService.getActiveKeyId()); } @@ -227,15 +209,13 @@ class JwtKeystoreServiceTest { @Test void testRotateKeypairWhenKeystoreDisabled() { when(jwtConfig.isEnableKeystore()).thenReturn(false); - + try (MockedStatic mockedStatic = mockStatic(InstallationPathConfig.class)) { mockedStatic.when(InstallationPathConfig::getConfigPath).thenReturn(tempDir.toString()); - keystoreService = new JwtKeystoreServiceImpl(repository, applicationProperties); - - // Should not throw exception, just log warning + keystoreService = new JwtKeystoreService(repository, applicationProperties); + assertDoesNotThrow(() -> keystoreService.rotateKeypair()); - - // Verify no database operations + verify(repository, never()).save(any()); } } @@ -243,12 +223,12 @@ class JwtKeystoreServiceTest { @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 JwtKeystoreServiceImpl(repository, applicationProperties); + keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - + Path jwtKeysDir = tempDir.resolve("jwt-keys"); assertTrue(Files.exists(jwtKeysDir)); assertTrue(Files.isDirectory(jwtKeysDir)); @@ -256,25 +236,23 @@ class JwtKeystoreServiceTest { } @Test - void testLoadExistingKeypairWithMissingPrivateKeyFile() throws Exception { + 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 JwtKeystoreServiceImpl(repository, applicationProperties); + keystoreService = new JwtKeystoreService(repository, applicationProperties); keystoreService.initializeKeystore(); - - // Should generate new keypair when private key file is missing + KeyPair result = keystoreService.getActiveKeypair(); assertNotNull(result); - - // Verify new keypair was generated and saved + verify(repository).save(any(JwtSigningKey.class)); } } -} \ No newline at end of file +} 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 3df1e81dd..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 @@ -55,7 +55,7 @@ class JwtServiceTest { private HttpServletResponse response; @Mock - private JwtKeystoreService keystoreService; + private JwtKeystoreServiceInterface keystoreService; private JwtService jwtService; private KeyPair testKeyPair; @@ -66,14 +66,14 @@ class JwtServiceTest { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); testKeyPair = keyPairGenerator.generateKeyPair(); - + jwtService = new JwtService(true, keystoreService); } @Test void testGenerateTokenWithAuthentication() { String username = "testuser"; - + when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); when(keystoreService.getActiveKeyId()).thenReturn("test-key-id"); when(authentication.getPrincipal()).thenReturn(userDetails); @@ -92,7 +92,7 @@ class JwtServiceTest { Map claims = new HashMap<>(); 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); @@ -115,7 +115,7 @@ class JwtServiceTest { 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)); @@ -124,7 +124,7 @@ class JwtServiceTest { @Test void testValidateTokenWithInvalidToken() { when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); - + assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken("invalid-token"); }); @@ -133,7 +133,7 @@ class JwtServiceTest { @Test void testValidateTokenWithMalformedToken() { when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); - + AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken("malformed.token"); }); @@ -144,7 +144,7 @@ class JwtServiceTest { @Test void testValidateTokenWithEmptyToken() { when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); - + AuthenticationFailureException exception = assertThrows(AuthenticationFailureException.class, () -> { jwtService.validateToken(""); }); @@ -157,7 +157,7 @@ class JwtServiceTest { String username = "testuser"; 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); @@ -171,7 +171,7 @@ class JwtServiceTest { @Test void testExtractUsernameWithInvalidToken() { when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); - + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractUsername("invalid-token")); } @@ -179,7 +179,7 @@ class JwtServiceTest { void testExtractAllClaims() { 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); @@ -197,7 +197,7 @@ class JwtServiceTest { @Test void testExtractAllClaimsWithInvalidToken() { when(keystoreService.getActiveKeypair()).thenReturn(testKeyPair); - + assertThrows(AuthenticationFailureException.class, () -> jwtService.extractAllClaims("invalid-token")); } @@ -269,7 +269,7 @@ class JwtServiceTest { 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); @@ -288,7 +288,7 @@ class JwtServiceTest { 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); @@ -296,7 +296,7 @@ class JwtServiceTest { // 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)); @@ -309,21 +309,21 @@ class JwtServiceTest { 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 017b737b72c3bcb35832707309bbd6d2c883fd3c Mon Sep 17 00:00:00 2001 From: DarioGii Date: Fri, 25 Jul 2025 19:28:14 +0100 Subject: [PATCH 19/19] wip - /view-pdf not loading properly --- .claude/settings.local.json | 4 +- .../src/main/resources/settings.yml.template | 20 ++++----- .../configuration/SecurityConfiguration.java | 6 +-- .../filter/JwtAuthenticationFilter.java | 6 ++- .../filter/UserAuthenticationFilter.java | 41 +++++++++++++++++++ .../security/service/JwtKeystoreService.java | 6 +-- .../security/service/JwtService.java | 10 +++-- 7 files changed, 71 insertions(+), 22 deletions(-) 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/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 282508066..790c03216 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: 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 @@ -51,20 +51,20 @@ 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: 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 + 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 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 + key: 3R3T-WFPY-UNRW-LJFA-MMXM-YVJK-WCKY-PCRT # fixme: remove 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/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index eeec274bf..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 = 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 d3511b08b..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 @@ -88,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; } @@ -129,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); 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 bb22f597a..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()) { @@ -218,4 +226,37 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { return method; } } + + @Override + 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/", + contextPath + "/public/", + contextPath + "/css/", + contextPath + "/fonts/", + contextPath + "/js/", + contextPath + "/pdfjs/", + contextPath + "/pdfjs-legacy/", + contextPath + "/api/v1/info/status", + contextPath + "/site.webmanifest" + }; + + for (String pattern : permitAllPatterns) { + if (uri.startsWith(pattern) + || uri.endsWith(".svg") + || uri.endsWith(".mjs") + || uri.endsWith(".png") + || uri.endsWith(".ico")) { + return true; + } + } + + return false; + } } 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 887f26f94..72fbaba92 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 @@ -37,7 +37,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { public static final String KEY_SUFFIX = ".key"; private final JwtSigningKeyRepository repository; - private final ApplicationProperties.Security.Jwt jwtConfig; + private final ApplicationProperties.Security.Jwt jwtProperties; private final Path privateKeyDirectory; private volatile KeyPair currentKeyPair; @@ -47,7 +47,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { public JwtKeystoreService( JwtSigningKeyRepository repository, ApplicationProperties applicationProperties) { this.repository = repository; - this.jwtConfig = applicationProperties.getSecurity().getJwt(); + this.jwtProperties = applicationProperties.getSecurity().getJwt(); this.privateKeyDirectory = Paths.get(InstallationPathConfig.getConfigPath(), "jwt-keys"); } @@ -128,7 +128,7 @@ public class JwtKeystoreService implements JwtKeystoreServiceInterface { @Override public boolean isKeystoreEnabled() { - return jwtConfig.isEnableKeystore(); + return jwtProperties.isEnableKeystore(); } private void loadOrGenerateKeypair() { 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 a9b1921bd..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 @@ -82,7 +82,6 @@ public class JwtService implements JwtServiceInterface { .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); - // Add key ID to header if keystore is enabled String keyId = keystoreService.getActiveKeyId(); if (keyId != null) { builder.header().keyId(keyId); @@ -136,8 +135,11 @@ public class JwtService implements JwtServiceInterface { if (specificKeyPair.isPresent()) { keyPair = specificKeyPair.get(); } else { - log.warn("Key ID {} not found in keystore, using active keypair", keyId); - keyPair = keystoreService.getActiveKeypair(); + 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(); @@ -233,7 +235,7 @@ public class JwtService implements JwtServiceInterface { .getHeader() .get("kid"); } catch (Exception e) { - // Token might not have a key ID or be malformed + log.debug("Failed to extract key ID from token header: {}", e.getMessage()); return null; } }