From 74c92ef215b9af63e7c1a50c2699d23eaee67ee0 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 11 Aug 2025 11:26:57 +0200 Subject: [PATCH 01/12] chore(labeler): add new 'v2' label and expand matching rules (#4172) # Description of Changes - **Added** a new `v2` label with `base-branch` targeting `V2` - **Extended** the 'UI' label matching to include `frontend/**` files - **Extended** the 'Scripts' label matching to include `docker/**` files - **Removed** duplicate `devTools/.*` entry from 'Devtools' label configuration --- ## 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) - [ ] 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/labeler-config-srvaroa.yml | 6 +++++- .github/labels.yml | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 3719c0ad8..6c9e029bd 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -46,6 +46,9 @@ labels: - label: 'API' title: '.*openapi.*|.*swagger.*|.*api.*' + - label: 'v2' + base-branch: 'V2' + - label: 'Translation' files: - 'app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}.properties' @@ -62,6 +65,7 @@ labels: - 'app/core/src/main/java/stirling/software/SPDF/controller/web/.*' - 'app/core/src/main/java/stirling/software/SPDF/UI/.*' - 'app/proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*' + - 'frontend/**' - label: 'Java' files: @@ -120,6 +124,7 @@ labels: - 'scripts/installFonts.sh' - 'test.sh' - 'test2.sh' + - 'docker/**' - label: 'Devtools' files: @@ -131,7 +136,6 @@ labels: - '.github/workflows/pre_commit.yml' - 'devGuide/.*' - 'devTools/.*' - - 'devTools/.*' - label: 'Test' files: diff --git a/.github/labels.yml b/.github/labels.yml index a79fb8be5..842e3fb5c 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -83,6 +83,7 @@ color: "DEDEDE" - name: "v2" color: "FFFF00" + description: "Issues or pull requests related to the v2 branch" - name: "wontfix" description: "This will not be worked on" color: "FFFFFF" From 6699facc24a27db7e6b655835c13f195e59c64b0 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Mon, 11 Aug 2025 12:27:42 +0100 Subject: [PATCH 02/12] JWT Authentication (#3921) This PR introduces JWT (JSON Web Token) authentication for Stirling-PDF, allowing for stateless authentication capabilities alongside the existing session-based authentication system. ### Key Features & Changes JWT Authentication System - Core Service: JwtService.java - Token generation, validation, and cookie management - Authentication Filter: JwtAuthenticationFilter.java - Request interceptor for JWT validation - Key Management: KeyPersistenceService.java + KeyPairCleanupService.java - RSA key rotation and persistence - Frontend: jwt-init.js - Client-side JWT handling and URL cleanup Security Integration - SAML2: JwtSaml2AuthenticationRequestRepository.java - JWT-backed SAML request storage - OAuth2: Updated CustomAuthenticationSuccessHandler. java, CustomOAuth2AuthenticationSuccessHandler.java & CustomSaml2AuthenticationSuccessHandler.java for JWT integration - Configuration: Enhanced SecurityConfiguration.java with JWT filter chain Infrastructure - Caching: CacheConfig.java - Caffeine cache for JWT keys - Database: New JwtVerificationKey.java entity for key storage - Error Handling: JwtAuthenticationEntryPoint.java for unauthorized access ### Challenges Encountered - Configured SecurityConfiguration to use either `UsernamePasswordAuthenticationFilter` or `JWTAuthenticationFilter` based on whether JWTs are enabled to prevent the former intercepting requests while in stateless mode. - Removed the `.defaultSuccessUrl("/")` from login configuration as its inclusion was preventing overriding the use of the `CustomAuthenticationSuccessHandler` and preventing proper authentication flows. --- ## 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) - [x] 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 - [x] 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) - [x] 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) - [x] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) Screenshot 2025-07-10 at 13 35 56 Screenshot 2025-07-10 at 13 36 10 eb750e8c3954fc47b2dd2e6e76ddb7d5 Screenshot 2025-07-10 at 13 30 57 ### Testing (if applicable) - [x] 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. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ludy Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Co-authored-by: Ethan Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .claude/settings.local.json | 8 +- .github/workflows/build.yml | 4 +- .../common/configuration/AppConfig.java | 15 +- .../configuration/InstallationPathConfig.java | 6 + .../common/model/ApplicationProperties.java | 10 + .../software/common/util/RequestUriUtils.java | 2 + .../src/main/resources/application.properties | 7 +- .../main/resources/messages_en_GB.properties | 3 +- .../src/main/resources/settings.yml.template | 7 +- .../main/resources/static/js/DecryptFiles.js | 3 +- .../main/resources/static/js/downloader.js | 2 +- .../main/resources/static/js/fetch-utils.js | 42 +- .../src/main/resources/static/js/jwt-init.js | 44 ++ .../src/main/resources/static/js/navbar.js | 14 + .../src/main/resources/static/js/usage.js | 2 +- .../src/main/resources/templates/account.html | 9 +- app/proprietary/build.gradle | 12 + .../CustomAuthenticationSuccessHandler.java | 51 ++- .../security/CustomLogoutSuccessHandler.java | 13 +- .../security/InitialSecuritySetup.java | 1 - .../security/JwtAuthenticationEntryPoint.java | 22 + .../security/config/AccountWebController.java | 9 +- .../security/configuration/CacheConfig.java | 31 ++ .../configuration/SecurityConfiguration.java | 101 +++-- .../filter/JwtAuthenticationFilter.java | 204 +++++++++ .../filter/UserAuthenticationFilter.java | 32 +- .../security/model/AuthenticationType.java | 5 +- .../proprietary/security/model/Authority.java | 4 +- .../security/model/JwtVerificationKey.java | 33 ++ .../proprietary/security/model/User.java | 4 +- .../AuthenticationFailureException.java | 13 + ...tomOAuth2AuthenticationSuccessHandler.java | 28 +- .../security/oauth2/OAuth2Configuration.java | 15 +- ...stomSaml2AuthenticationSuccessHandler.java | 44 +- ...tSaml2AuthenticationRequestRepository.java | 135 ++++++ ...iguration.java => Saml2Configuration.java} | 32 +- .../service/CustomOAuth2UserService.java | 8 +- .../service/CustomUserDetailsService.java | 27 +- .../security/service/JwtService.java | 330 +++++++++++++++ .../security/service/JwtServiceInterface.java | 90 ++++ .../service/KeyPairCleanupService.java | 88 ++++ .../service/KeyPersistenceService.java | 243 +++++++++++ .../KeyPersistenceServiceInterface.java | 29 ++ .../security/service/UserService.java | 22 +- .../resources/static/js/audit/dashboard.js | 6 +- .../CustomLogoutSuccessHandlerTest.java | 64 +-- .../JwtAuthenticationEntryPointTest.java | 38 ++ .../filter/JwtAuthenticationFilterTest.java | 242 +++++++++++ ...l2AuthenticationRequestRepositoryTest.java | 247 +++++++++++ .../security/service/JwtServiceTest.java | 389 ++++++++++++++++++ .../KeyPersistenceServiceInterfaceTest.java | 232 +++++++++++ exampleYmlFiles/test_cicd.yml | 1 + 52 files changed, 2827 insertions(+), 196 deletions(-) create mode 100644 app/core/src/main/resources/static/js/jwt-init.js create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/JwtAuthenticationEntryPoint.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/exception/AuthenticationFailureException.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java rename app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/{SAML2Configuration.java => Saml2Configuration.java} (85%) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.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/filter/JwtAuthenticationFilterTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6e006423a..bc5358b85 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,13 @@ "Bash(mkdir:*)", "Bash(./gradlew:*)", "Bash(grep:*)", - "Bash(cat:*)" + "Bash(cat:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(rg:*)", + "Bash(strings:*)", + "Bash(pkill:*)", + "Bash(true)" ], "deny": [] } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d87e478d3..c229ee40e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,7 +147,9 @@ jobs: - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs - + env: + DISABLE_ADDITIONAL_FEATURES: true + - name: Upload OpenAPI Documentation uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: 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/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 247a012ad..64fbc41b7 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -25,6 +25,7 @@ public class InstallationPathConfig { private static final String STATIC_PATH; private static final String TEMPLATES_PATH; private static final String SIGNATURES_PATH; + private static final String PRIVATE_KEY_PATH; static { BASE_PATH = initializeBasePath(); @@ -45,6 +46,7 @@ public class InstallationPathConfig { STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator; TEMPLATES_PATH = CUSTOM_FILES_PATH + "templates" + File.separator; SIGNATURES_PATH = CUSTOM_FILES_PATH + "signatures" + File.separator; + PRIVATE_KEY_PATH = CONFIG_PATH + "db" + File.separator + "keys" + File.separator; } private static String initializeBasePath() { @@ -120,4 +122,8 @@ public class InstallationPathConfig { public static String getSignaturesPath() { return SIGNATURES_PATH; } + + public static String getPrivateKeyPath() { + return PRIVATE_KEY_PATH; + } } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index ee893c575..5845c6d16 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -119,6 +119,7 @@ public class ApplicationProperties { private long loginResetTimeMinutes; private String loginMethod = "all"; private String customGlobalAPIKey; + private Jwt jwt = new Jwt(); public Boolean isAltLogin() { return saml2.getEnabled() || oauth2.getEnabled(); @@ -298,6 +299,15 @@ public class ApplicationProperties { } } } + + @Data + public static class Jwt { + private boolean enableKeystore = true; + private boolean enableKeyRotation = false; + private boolean enableKeyCleanup = true; + private int keyRetentionDays = 7; + private boolean secureCookie; + } } @Data diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 654c78fe9..239976b66 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -14,8 +14,10 @@ public class RequestUriUtils { || requestURI.startsWith(contextPath + "/images/") || requestURI.startsWith(contextPath + "/public/") || requestURI.startsWith(contextPath + "/pdfjs/") + || requestURI.startsWith(contextPath + "/pdfjs-legacy/") || requestURI.startsWith(contextPath + "/login") || requestURI.startsWith(contextPath + "/error") + || requestURI.startsWith(contextPath + "/favicon") || requestURI.endsWith(".svg") || requestURI.endsWith(".png") || requestURI.endsWith(".ico") diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index ea30bf78e..0ca864985 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,7 @@ 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} + +# V2 features +v2=false diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index d6056e856..599dd0989 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -893,7 +893,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 @@ -908,6 +908,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/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1af95f852..bbbac5fcd 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -59,12 +59,17 @@ 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: # This feature is currently under development and not yet fully supported. Do not use in production. + persistence: true # Set to 'true' to enable JWT key store + enableKeyRotation: true # Set to 'true' to enable key pair rotation + enableKeyCleanup: true # Set to 'true' to enable key pair cleanup + keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days. + secureCookie: false # Set to 'true' to use secure cookies for JWTs premium: key: 00000000-0000-0000-0000-000000000000 enabled: false # Enable license key checks for pro/enterprise features proFeatures: - database: true # Enable database features SSOAutoLogin: false CustomMetadata: autoUpdateMetadata: false 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..2a2fe894c 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,29 @@ +// Authentication utility for cookie-based JWT +window.JWTManager = { + + // Logout - clear cookies and redirect to login + logout: function() { + + // 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(response => { + if (response.redirected) { + window.location.href = response.url; + } else { + window.location.href = '/login?logout=true'; + } + }).catch(() => { + // If logout fails, let server handle it + window.location.href = '/logout'; + }); + } +}; + window.fetchWithCsrf = async function(url, options = {}) { function getCsrfToken() { const cookieValue = document.cookie @@ -24,5 +50,19 @@ window.fetchWithCsrf = async function(url, options = {}) { fetchOptions.headers['X-XSRF-TOKEN'] = csrfToken; } - return fetch(url, fetchOptions); + // Always include credentials to send JWT cookies + fetchOptions.credentials = 'include'; + + // Make the request + const response = await fetch(url, fetchOptions); + + // Handle 401 responses (unauthorized) + if (response.status === 401) { + console.warn('Authentication failed, redirecting to login'); + window.JWTManager.logout(); + return response; + } + + return response; } + 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..8cd63e189 --- /dev/null +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -0,0 +1,44 @@ +// JWT Authentication Management Script +// This script handles cookie-based JWT authentication and page access control + +(function() { + // Clean up JWT token from URL parameters after OAuth/Login flows + function cleanupTokenFromUrl() { + const urlParams = new URLSearchParams(window.location.search); + const hasToken = urlParams.get('jwt') || urlParams.get('token'); + if (hasToken) { + // Clean up URL by removing token parameter + // Token should now be set as cookie by server + urlParams.delete('jwt'); + urlParams.delete('token'); + const newUrl = window.location.pathname + (urlParams.toString() ? '?' + urlParams.toString() : ''); + window.history.replaceState({}, '', newUrl); + } + } + + // Initialize JWT handling when page loads + function initializeJWT() { + // Clean up any JWT tokens from URL (OAuth flow) + cleanupTokenFromUrl(); + + // Authentication is handled server-side + // If user is not authenticated, server will redirect to login + console.log('JWT initialization complete - authentication handled server-side'); + } + + // No form enhancement needed for cookie-based JWT + // Cookies are automatically sent with form submissions + function enhanceFormSubmissions() { + // Cookie-based JWT is automatically included in form submissions + // No additional processing needed + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + initializeJWT(); + }); + } else { + initializeJWT(); + } +})(); \ 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..1fd46ed70 100644 --- a/app/core/src/main/resources/static/js/navbar.js +++ b/app/core/src/main/resources/static/js/navbar.js @@ -138,5 +138,19 @@ 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/core/src/main/resources/templates/account.html b/app/core/src/main/resources/templates/account.html index 33a0d9f47..db48bb3a5 100644 --- a/app/core/src/main/resources/templates/account.html +++ b/app/core/src/main/resources/templates/account.html @@ -390,8 +390,13 @@ key.includes('clientSubmissionOrder') || key.includes('lastSubmitTime') || key.includes('lastClientId') || - - + key.includes('stirling_jwt') || + key.includes('JSESSIONID') || + key.includes('XSRF-TOKEN') || + key.includes('remember-me') || + key.includes('auth') || + key.includes('token') || + key.includes('session') || key.includes('posthog') || key.includes('ssoRedirectAttempts') || key.includes('lastRedirectAttempt') || key.includes('surveyVersion') || key.includes('pageViews'); } diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index 719f74127..b8862bdd8 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 'src/**/java/**/*.java' @@ -41,6 +47,8 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-oauth2-client' api 'org.springframework.boot:spring-boot-starter-mail' + api 'org.springframework.boot:spring-boot-starter-cache' + api 'com.github.ben-manes.caffeine:caffeine' api 'io.swagger.core.v3:swagger-core-jakarta:2.2.35' implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' @@ -50,6 +58,10 @@ 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' + + 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..51908ef03 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,8 @@ 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; @@ -24,13 +27,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 +53,31 @@ 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 (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.WEB)); + jwtService.addToken(response, jwt); + log.debug("JWT generated for user: {}", userName); - 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, "/"); - } + } else { + // Get the saved request + HttpSession session = request.getSession(false); + SavedRequest savedRequest = + (session != null) + ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") + : null; - // super.onAuthenticationSuccess(request, response, authentication); + if (savedRequest != null + && !RequestUriUtils.isStaticResource( + 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/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 033ea913c..136120528 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -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,18 @@ 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 { + if (!response.isCommitted()) { if (authentication != null) { if (authentication instanceof Saml2Authentication samlAuthentication) { @@ -67,6 +71,9 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } + } else if (!jwtService.extractToken(request).isBlank()) { + jwtService.clearToken(response); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout String path = checkForErrors(request); @@ -82,7 +89,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 +134,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/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 4b09fe0e9..e145e2754 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -43,7 +43,6 @@ public class InitialSecuritySetup { } } - userService.migrateOauth2ToSSO(); assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/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..46d0e7d3d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -77,8 +77,11 @@ public class AccountWebController { @GetMapping("/login") public String login(HttpServletRequest request, Model model, Authentication authentication) { - // If the user is already authenticated, redirect them to the home page. - if (authentication != null && authentication.isAuthenticated()) { + // If the user is already authenticated and it's not a logout scenario, redirect them to the + // home page. + if (authentication != null + && authentication.isAuthenticated() + && request.getParameter("logout") == null) { return "redirect:/"; } @@ -184,7 +187,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/CacheConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java new file mode 100644 index 000000000..ba074a5da --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/CacheConfig.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.security.configuration; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.cache.Caffeine; + +@Configuration +@EnableCaching +public class CacheConfig { + + @Value("${security.jwt.keyRetentionDays}") + private int keyRetentionDays; + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + cacheManager.setCaffeine( + Caffeine.newBuilder() + .maximumSize(1000) // Make configurable? + .expireAfterWrite(Duration.ofDays(keyRetentionDays)) + .recordStats()); + return cacheManager; + } +} 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..aceb3b712 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 @@ -13,6 +13,7 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider 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; @@ -35,10 +36,12 @@ 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; 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 +51,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 +68,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 JwtServiceInterface jwtService; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final LoginAttemptService loginAttemptService; private final FirstLoginFilter firstLoginFilter; private final SessionPersistentRegistry sessionRegistry; @@ -82,8 +88,10 @@ public class SecurityConfiguration { @Qualifier("loginEnabled") boolean loginEnabledValue, @Qualifier("runningProOrHigher") boolean runningProOrHigher, AppConfig appConfig, - ApplicationProperties applicationProperties, + ApplicationProperties.Security securityProperties, UserAuthenticationFilter userAuthenticationFilter, + JwtServiceInterface jwtService, + JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, LoginAttemptService loginAttemptService, FirstLoginFilter firstLoginFilter, SessionPersistentRegistry sessionRegistry, @@ -97,8 +105,10 @@ public class SecurityConfiguration { this.loginEnabledValue = loginEnabledValue; this.runningProOrHigher = runningProOrHigher; this.appConfig = appConfig; - this.applicationProperties = applicationProperties; + this.securityProperties = securityProperties; this.userAuthenticationFilter = userAuthenticationFilter; + this.jwtService = jwtService; + this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint; this.loginAttemptService = loginAttemptService; this.firstLoginFilter = firstLoginFilter; this.sessionRegistry = sessionRegistry; @@ -115,14 +125,28 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - if (applicationProperties.getSecurity().getCsrfDisabled() || !loginEnabledValue) { - http.csrf(csrf -> csrf.disable()); + if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { + http.csrf(CsrfConfigurer::disable); } if (loginEnabledValue) { + boolean v2Enabled = appConfig.v2Enabled(); + + if (v2Enabled) { + http.addFilterBefore( + jwtAuthenticationFilter(), + UsernamePasswordAuthenticationFilter.class) + .exceptionHandling( + exceptionHandling -> + exceptionHandling.authenticationEntryPoint( + jwtAuthenticationEntryPoint)); + } http.addFilterBefore( - userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - if (!applicationProperties.getSecurity().getCsrfDisabled()) { + userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class) + .addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + + if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = @@ -156,16 +180,21 @@ public class SecurityConfiguration { .csrfTokenRepository(cookieRepo) .csrfTokenRequestHandler(requestHandler)); } - http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); - http.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class); + http.sessionManagement( - sessionManagement -> + sessionManagement -> { + if (v2Enabled) { + 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())); http.logout( @@ -175,10 +204,10 @@ public class SecurityConfiguration { .matcher("/logout")) .logoutSuccessHandler( new CustomLogoutSuccessHandler( - applicationProperties, appConfig)) + securityProperties, appConfig, jwtService)) .clearAuthentication(true) .invalidateHttpSession(true) - .deleteCookies("JSESSIONID", "remember-me")); + .deleteCookies("JSESSIONID", "remember-me", "stirling_jwt")); http.rememberMe( rememberMeConfigurer -> // Use the configurator directly rememberMeConfigurer @@ -200,6 +229,7 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); + // Remove the context path from the URI String trimmedUri = uri.startsWith(contextPath) @@ -217,29 +247,35 @@ public class SecurityConfiguration { || trimmedUri.startsWith("/css/") || trimmedUri.startsWith("/fonts/") || trimmedUri.startsWith("/js/") + || trimmedUri.startsWith("/pdfjs/") + || trimmedUri.startsWith("/pdfjs-legacy/") + || trimmedUri.startsWith("/favicon") || trimmedUri.startsWith( - "/api/v1/info/status"); + "/api/v1/info/status") + || trimmedUri.startsWith("/v1/api-docs") + || uri.contains("/v1/api-docs"); }) .permitAll() .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 +287,18 @@ public class SecurityConfiguration { .successHandler( new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, - applicationProperties, - userService)) + securityProperties.getOauth2(), + userService, + jwtService)) .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,8 +306,7 @@ public class SecurityConfiguration { .permitAll()); } // Handle SAML - if (applicationProperties.getSecurity().isSaml2Active() && runningProOrHigher) { - // Configure the authentication provider + if (securityProperties.isSaml2Active() && runningProOrHigher) { OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider(); authenticationProvider.setResponseAuthenticationConverter( @@ -287,8 +323,9 @@ public class SecurityConfiguration { .successHandler( new CustomSaml2AuthenticationSuccessHandler( loginAttemptService, - applicationProperties, - userService)) + securityProperties.getSaml2(), + userService, + jwtService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( @@ -323,4 +360,14 @@ public class SecurityConfiguration { public PersistentTokenRepository persistentTokenRepository() { return new JPATokenRepositoryImpl(persistentLoginRepository); } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter() { + return new JwtAuthenticationFilter( + jwtService, + userService, + userDetailsService, + jwtAuthenticationEntryPoint, + securityProperties); + } } 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..faf50832f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,204 @@ +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; + +import java.io.IOException; +import java.sql.SQLException; +import java.util.Map; +import java.util.Optional; + +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.ApiKeyAuthenticationToken; +import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.User; +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 (isStaticResource(request.getContextPath(), request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + + if (!apiKeyExists(request, response)) { + String jwtToken = jwtService.extractToken(request); + + if (jwtToken == null) { + // Any unauthenticated requests should redirect to /login + String requestURI = request.getRequestURI(); + String contextPath = request.getContextPath(); + + if (!requestURI.startsWith(contextPath + "/login")) { + response.sendRedirect("/login"); + return; + } + } + + try { + jwtService.validateToken(jwtToken); + } catch (AuthenticationFailureException e) { + jwtService.clearToken(response); + handleAuthenticationFailure(request, response, e); + return; + } + + Map claims = jwtService.extractClaims(jwtToken); + String tokenUsername = claims.get("sub").toString(); + + try { + authenticate(request, claims); + } 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 boolean apiKeyExists(HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated()) { + String apiKey = request.getHeader("X-API-KEY"); + + if (apiKey != null && !apiKey.isBlank()) { + try { + Optional user = userService.getUserByApiKey(apiKey); + + if (user.isEmpty()) { + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException("Invalid API Key")); + return false; + } + + authentication = + new ApiKeyAuthenticationToken( + user.get(), apiKey, user.get().getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + return true; + } catch (AuthenticationException e) { + handleAuthenticationFailure( + request, + response, + new AuthenticationFailureException("Invalid API Key", e)); + return false; + } + } + + return false; + } + + return true; + } + + private void authenticate(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); + } else { + throw new UsernameNotFoundException("User not found: " + username); + } + } + } + + 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); + } + } + } + + private void handleAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) + throws IOException, ServletException { + authenticationEntryPoint.commence(request, response, authException); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index e9addd239..f51a9d543 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -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; @@ -64,6 +63,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { return; } String requestURI = request.getRequestURI(); + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // Check for session expiration (unsure if needed) @@ -92,14 +92,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 @@ -115,20 +110,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String method = request.getMethod(); String contextPath = request.getContextPath(); - if ("GET".equalsIgnoreCase(method) && !(contextPath + "/login").equals(requestURI)) { + if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) { response.sendRedirect(contextPath + "/login"); // redirect to the login page - 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 @@ -226,11 +220,12 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { } @Override - protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); String contextPath = request.getContextPath(); String[] permitAllPatterns = { contextPath + "/login", + contextPath + "/signup", contextPath + "/register", contextPath + "/error", contextPath + "/images/", @@ -247,6 +242,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { for (String pattern : permitAllPatterns) { if (uri.startsWith(pattern) || uri.endsWith(".svg") + || uri.endsWith(".mjs") || uri.endsWith(".png") || uri.endsWith(".ico")) { return true; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index ca8140bca..c92c1655e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,5 +2,8 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - SSO + @Deprecated(since = "1.0.2") + SSO, + 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/JwtVerificationKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java new file mode 100644 index 000000000..632c5f13a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/JwtVerificationKey.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDateTime; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@NoArgsConstructor +@ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class JwtVerificationKey implements Serializable { + + @Serial private static final long serialVersionUID = 1L; + + @ToString.Include private String keyId; + + private String verifyingKey; + + @ToString.Include private LocalDateTime createdAt; + + public JwtVerificationKey(String keyId, String verifyingKey) { + this.keyId = keyId; + this.verifyingKey = verifyingKey; + this.createdAt = LocalDateTime.now(); + } +} 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/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/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..4e7ed9d9e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -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 applicationProperties; + 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 = applicationProperties.getSecurity().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.addToken(response, jwt); + } if (userService.isUserDisabled(username)) { getRedirectStrategy() .sendRedirect(request, response, "/logout?userIsDisabled=true"); @@ -77,20 +85,22 @@ public class CustomOAuth2AuthenticationSuccessHandler } if (userService.usernameExistsIgnoreCase(username) && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername(username, AuthenticationType.SSO) - && oAuth.getAutoCreateUser()) { + && (!userService.isAuthenticationTypeByUsername(username, SSO) + || !userService.isAuthenticationTypeByUsername(username, OAUTH2)) + && 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 2170a9632..3255cbc15 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -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 applicationProperties; + private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; + private final JwtServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -65,10 +70,9 @@ public class CustomSaml2AuthenticationSuccessHandler savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { - SAML2 saml2 = applicationProperties.getSecurity().getSaml2(); 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 +86,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,15 +110,18 @@ public class CustomSaml2AuthenticationSuccessHandler } try { - if (saml2.getBlockRegistration() && !userExists) { + if (!userExists || saml2Properties.getBlockRegistration()) { 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); + + generateJwt(response, authentication); response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.debug( @@ -124,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.addToken(response, jwt); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java new file mode 100644 index 000000000..d0508151c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepository.java @@ -0,0 +1,135 @@ +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 (!jwtService.isJwtEnabled()) { + log.debug("V2 is not enabled, skipping SAMLRequest token storage"); + return; + } + + 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.extractClaims(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/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 85% 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 index 7fd4768b3..9d21f88a3 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 @@ -3,6 +3,7 @@ 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; @@ -11,12 +12,12 @@ 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.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.HttpSessionSaml2AuthenticationRequestRepository; +import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository; import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver; import jakarta.servlet.http.HttpServletRequest; @@ -26,12 +27,13 @@ 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 { +public class Saml2Configuration { private final ApplicationProperties applicationProperties; @@ -58,6 +60,7 @@ public class SAML2Configuration { .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") + .authnRequestsSigned(true) .assertingPartyMetadata( metadata -> metadata.entityId(samlConf.getIdpIssuer()) @@ -71,15 +74,29 @@ public class SAML2Configuration { 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) { + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, + Saml2AuthenticationRequestRepository + saml2AuthenticationRequestRepository) { OpenSaml4AuthenticationRequestResolver resolver = new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); @@ -87,10 +104,8 @@ public class SAML2Configuration { customizer -> { HttpServletRequest request = customizer.getRequest(); AuthnRequest authnRequest = customizer.getAuthnRequest(); - HttpSessionSaml2AuthenticationRequestRepository requestRepository = - new HttpSessionSaml2AuthenticationRequestRepository(); - AbstractSaml2AuthenticationRequest saml2AuthenticationRequest = - requestRepository.loadAuthenticationRequest(request); + Saml2PostAuthenticationRequest saml2AuthenticationRequest = + saml2AuthenticationRequestRepository.loadAuthenticationRequest(request); if (saml2AuthenticationRequest != null) { String sessionId = request.getSession(false).getId(); @@ -113,7 +128,6 @@ public class SAML2Configuration { log.debug("Generating new authentication request ID"); authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1)); } - logAuthnRequestDetails(authnRequest); logHttpRequestDetails(request); }); 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 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/JwtService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java new file mode 100644 index 000000000..8724da9a8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtService.java @@ -0,0 +1,330 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +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.beans.factory.annotation.Value; +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.JwtVerificationKey; +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 ISSUER = "Stirling PDF"; + private static final long EXPIRATION = 3600000; + + @Value("${stirling.security.jwt.secureCookie:true}") + private boolean secureCookie; + + private final KeyPersistenceServiceInterface keyPersistenceService; + private final boolean v2Enabled; + + @Autowired + public JwtService( + @Qualifier("v2Enabled") boolean v2Enabled, + KeyPersistenceServiceInterface keyPersistenceService) { + this.v2Enabled = v2Enabled; + this.keyPersistenceService = keyPersistenceService; + } + + @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) { + try { + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + Optional keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId()); + + if (keyPairOpt.isEmpty()) { + throw new RuntimeException("Unable to retrieve key pair for active key"); + } + + KeyPair keyPair = keyPairOpt.get(); + + var builder = + Jwts.builder() + .claims(claims) + .subject(username) + .issuer(ISSUER) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION)) + .signWith(keyPair.getPrivate(), Jwts.SIG.RS256); + + String keyId = activeKey.getKeyId(); + if (keyId != null) { + builder.header().keyId(keyId); + } + + return builder.compact(); + } catch (Exception e) { + throw new RuntimeException("Failed to generate token", e); + } + } + + @Override + public void validateToken(String token) throws AuthenticationFailureException { + extractAllClaims(token); + + if (isTokenExpired(token)) { + throw new AuthenticationFailureException("The token has expired"); + } + } + + @Override + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + @Override + public Map extractClaims(String token) { + Claims claims = extractAllClaims(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 = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + try { + String keyId = extractKeyId(token); + KeyPair keyPair; + + if (keyId != null) { + Optional specificKeyPair = keyPersistenceService.getKeyPair(keyId); + + if (specificKeyPair.isPresent()) { + keyPair = specificKeyPair.get(); + } else { + log.warn( + "Key ID {} not found in keystore, token may have been signed with an expired key", + keyId); + + if (keyId.equals(keyPersistenceService.getActiveKey().getKeyId())) { + JwtVerificationKey verificationKey = + keyPersistenceService.refreshActiveKeyPair(); + Optional refreshedKeyPair = + keyPersistenceService.getKeyPair(verificationKey.getKeyId()); + if (refreshedKeyPair.isPresent()) { + keyPair = refreshedKeyPair.get(); + } else { + throw new AuthenticationFailureException( + "Failed to retrieve refreshed key pair"); + } + } else { + // Try to use active key as fallback + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + Optional activeKeyPair = + keyPersistenceService.getKeyPair(activeKey.getKeyId()); + if (activeKeyPair.isPresent()) { + keyPair = activeKeyPair.get(); + } else { + throw new AuthenticationFailureException( + "Failed to retrieve active key pair"); + } + } + } + } else { + log.debug("No key ID in token header, trying all available keys"); + // Try all available keys when no keyId is present + return tryAllKeys(token); + } + + 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); + } + } + + private Claims tryAllKeys(String token) throws AuthenticationFailureException { + // First try the active key + try { + JwtVerificationKey activeKey = keyPersistenceService.getActiveKey(); + PublicKey publicKey = + keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey()); + return Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException + | NoSuchAlgorithmException + | InvalidKeySpecException activeKeyException) { + log.debug("Active key failed, trying all available keys from cache"); + + // If active key fails, try all available keys from cache + List allKeys = + keyPersistenceService.getKeysEligibleForCleanup( + LocalDateTime.now().plusDays(1)); + + for (JwtVerificationKey verificationKey : allKeys) { + try { + PublicKey publicKey = + keyPersistenceService.decodePublicKey( + verificationKey.getVerifyingKey()); + return Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SignatureException + | NoSuchAlgorithmException + | InvalidKeySpecException e) { + log.debug( + "Key {} failed to verify token, trying next key", + verificationKey.getKeyId()); + // Continue to next key + } + } + + throw new AuthenticationFailureException( + "Token signature could not be verified with any available key", + activeKeyException); + } + } + + @Override + public String extractToken(HttpServletRequest request) { + 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 addToken(HttpServletResponse response, String token) { + ResponseCookie cookie = + ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token)) + .httpOnly(true) + .secure(secureCookie) + .sameSite("Strict") + .maxAge(EXPIRATION / 1000) + .path("/") + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + @Override + public void clearToken(HttpServletResponse response) { + ResponseCookie cookie = + ResponseCookie.from(JWT_COOKIE_NAME, "") + .httpOnly(true) + .secure(secureCookie) + .sameSite("None") + .maxAge(0) + .path("/") + .build(); + + response.addHeader("Set-Cookie", cookie.toString()); + } + + @Override + public boolean isJwtEnabled() { + return v2Enabled; + } + + private String extractKeyId(String token) { + try { + PublicKey signingKey = + keyPersistenceService.decodePublicKey( + keyPersistenceService.getActiveKey().getVerifyingKey()); + + String keyId = + (String) + Jwts.parser() + .verifyWith(signingKey) + .build() + .parse(token) + .getHeader() + .get("kid"); + log.debug("Extracted key ID from token: {}", keyId); + return keyId; + } catch (Exception e) { + log.warn("Failed to extract key ID from token header: {}", e.getMessage()); + return null; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/JwtServiceInterface.java new file mode 100644 index 000000000..7cdca8209 --- /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 extractClaims(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 extractToken(HttpServletRequest request); + + /** + * Add JWT token to HTTP response (header and cookie) + * + * @param response HTTP servlet response + * @param token JWT token to add + */ + void addToken(HttpServletResponse response, String token); + + /** + * Clear JWT token from HTTP response (remove cookie) + * + * @param response HTTP servlet response + */ + void clearToken(HttpServletResponse response); + + /** + * Check if JWT authentication is enabled + * + * @return true if JWT is enabled, false otherwise + */ + boolean isJwtEnabled(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java new file mode 100644 index 000000000..b419f78fe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPairCleanupService.java @@ -0,0 +1,88 @@ +package stirling.software.proprietary.security.service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.annotation.PostConstruct; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtVerificationKey; + +@Slf4j +@Service +@ConditionalOnBooleanProperty("v2") +public class KeyPairCleanupService { + + private final KeyPersistenceService keyPersistenceService; + private final ApplicationProperties.Security.Jwt jwtProperties; + + @Autowired + public KeyPairCleanupService( + KeyPersistenceService keyPersistenceService, + ApplicationProperties applicationProperties) { + this.keyPersistenceService = keyPersistenceService; + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + } + + @Transactional + @PostConstruct + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void cleanup() { + if (!jwtProperties.isEnableKeyCleanup() || !keyPersistenceService.isKeystoreEnabled()) { + return; + } + + LocalDateTime cutoffDate = + LocalDateTime.now().minusDays(jwtProperties.getKeyRetentionDays()); + + List eligibleKeys = + keyPersistenceService.getKeysEligibleForCleanup(cutoffDate); + if (eligibleKeys.isEmpty()) { + return; + } + + log.info("Removing keys older than retention period"); + removeKeys(eligibleKeys); + keyPersistenceService.refreshActiveKeyPair(); + } + + private void removeKeys(List keys) { + keys.forEach( + key -> { + try { + keyPersistenceService.removeKey(key.getKeyId()); + removePrivateKey(key.getKeyId()); + } catch (IOException e) { + log.warn("Failed to remove key: {}", key.getKeyId(), e); + } + }); + } + + private void removePrivateKey(String keyId) throws IOException { + if (!keyPersistenceService.isKeystoreEnabled()) { + return; + } + + Path privateKeyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + Path keyFile = privateKeyDirectory.resolve(keyId + KeyPersistenceService.KEY_SUFFIX); + + if (Files.exists(keyFile)) { + Files.delete(keyFile); + log.debug("Deleted private key: {}", keyFile); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java new file mode 100644 index 000000000..48bcddac0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceService.java @@ -0,0 +1,243 @@ +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.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.caffeine.CaffeineCache; +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.model.JwtVerificationKey; + +@Slf4j +@Service +public class KeyPersistenceService implements KeyPersistenceServiceInterface { + + public static final String KEY_SUFFIX = ".key"; + + private final ApplicationProperties.Security.Jwt jwtProperties; + private final CacheManager cacheManager; + private final Cache verifyingKeyCache; + + private volatile JwtVerificationKey activeKey; + + @Autowired + public KeyPersistenceService( + ApplicationProperties applicationProperties, CacheManager cacheManager) { + this.jwtProperties = applicationProperties.getSecurity().getJwt(); + this.cacheManager = cacheManager; + this.verifyingKeyCache = cacheManager.getCache("verifyingKeys"); + } + + @PostConstruct + public void initializeKeystore() { + if (!isKeystoreEnabled()) { + return; + } + + try { + ensurePrivateKeyDirectoryExists(); + loadKeyPair(); + } catch (Exception e) { + log.error("Failed to initialize keystore, using in-memory generation", e); + } + } + + private void loadKeyPair() { + if (activeKey == null) { + generateAndStoreKeypair(); + } + } + + @Transactional + private JwtVerificationKey generateAndStoreKeypair() { + JwtVerificationKey verifyingKey = null; + + try { + KeyPair keyPair = generateRSAKeypair(); + String keyId = generateKeyId(); + + storePrivateKey(keyId, keyPair.getPrivate()); + verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic())); + verifyingKeyCache.put(keyId, verifyingKey); + activeKey = verifyingKey; + } catch (IOException e) { + log.error("Failed to generate and store keypair", e); + } + + return verifyingKey; + } + + @Override + public JwtVerificationKey getActiveKey() { + if (activeKey == null) { + return generateAndStoreKeypair(); + } + return activeKey; + } + + @Override + public Optional getKeyPair(String keyId) { + if (!isKeystoreEnabled()) { + return Optional.empty(); + } + + try { + JwtVerificationKey verifyingKey = + verifyingKeyCache.get(keyId, JwtVerificationKey.class); + + if (verifyingKey == null) { + log.warn("No signing key found in database for keyId: {}", keyId); + return Optional.empty(); + } + + PrivateKey privateKey = loadPrivateKey(keyId); + PublicKey publicKey = decodePublicKey(verifyingKey.getVerifyingKey()); + + 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 boolean isKeystoreEnabled() { + return jwtProperties.isEnableKeystore(); + } + + @Override + public JwtVerificationKey refreshActiveKeyPair() { + return generateAndStoreKeypair(); + } + + @Override + @CacheEvict( + value = {"verifyingKeys"}, + key = "#keyId", + condition = "#root.target.isKeystoreEnabled()") + public void removeKey(String keyId) { + verifyingKeyCache.evict(keyId); + } + + @Override + public List getKeysEligibleForCleanup(LocalDateTime cutoffDate) { + CaffeineCache caffeineCache = (CaffeineCache) verifyingKeyCache; + com.github.benmanes.caffeine.cache.Cache nativeCache = + caffeineCache.getNativeCache(); + + log.debug( + "Cache size: {}, Checking {} keys for cleanup", + nativeCache.estimatedSize(), + nativeCache.asMap().size()); + + return nativeCache.asMap().values().stream() + .filter(value -> value instanceof JwtVerificationKey) + .map(value -> (JwtVerificationKey) value) + .filter( + key -> { + boolean eligible = key.getCreatedAt().isBefore(cutoffDate); + log.debug( + "Key {} created at {}, eligible for cleanup: {}", + key.getKeyId(), + key.getCreatedAt(), + eligible); + return eligible; + }) + .collect(Collectors.toList()); + } + + private String generateKeyId() { + return "jwt-key-" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss")); + } + + private KeyPair generateRSAKeypair() { + KeyPairGenerator keyPairGenerator = null; + + try { + keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + } catch (NoSuchAlgorithmException e) { + log.error("Failed to initialize RSA key pair generator", e); + } + + return keyPairGenerator.generateKeyPair(); + } + + private void ensurePrivateKeyDirectoryExists() throws IOException { + Path keyPath = Paths.get(InstallationPathConfig.getPrivateKeyPath()); + + if (!Files.exists(keyPath)) { + Files.createDirectories(keyPath); + } + } + + private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException { + Path keyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); + String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded()); + Files.writeString(keyFile, encodedKey); + + // Set read/write to only the owner + keyFile.toFile().setReadable(true, true); + keyFile.toFile().setWritable(true, true); + keyFile.toFile().setExecutable(false, false); + } + + private PrivateKey loadPrivateKey(String keyId) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + Path keyFile = + Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX); + + if (!Files.exists(keyFile)) { + throw new IOException("Private key 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()); + } + + public 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/KeyPersistenceServiceInterface.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.java new file mode 100644 index 000000000..f3050472e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterface.java @@ -0,0 +1,29 @@ +package stirling.software.proprietary.security.service; + +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import stirling.software.proprietary.security.model.JwtVerificationKey; + +public interface KeyPersistenceServiceInterface { + + JwtVerificationKey getActiveKey(); + + Optional getKeyPair(String keyId); + + boolean isKeystoreEnabled(); + + JwtVerificationKey refreshActiveKeyPair(); + + List getKeysEligibleForCleanup(LocalDateTime cutoffDate); + + void removeKey(String keyId); + + PublicKey decodePublicKey(String encodedKey) + throws NoSuchAlgorithmException, InvalidKeySpecException; +} 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..6f213b25e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -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; @@ -61,19 +60,9 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; - @Transactional - public void migrateOauth2ToSSO() { - userRepository - .findByAuthenticationTypeIgnoreCase("OAUTH2") - .forEach( - user -> { - user.setAuthenticationType(AuthenticationType.SSO); - userRepository.save(user); - }); - } - // 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 +72,7 @@ public class UserService implements UserServiceInterface { return; } if (autoCreateUser) { - saveUser(username, AuthenticationType.SSO); + saveUser(username, type); } } @@ -100,10 +89,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/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/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 04ca4c35f..7a4076260 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 @@ -14,12 +14,18 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.service.JwtServiceInterface; @ExtendWith(MockitoExtension.class) class CustomLogoutSuccessHandlerTest { - @Mock private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties.Security securityProperties; + + @Mock private AppConfig appConfig; + + @Mock private JwtServiceInterface jwtService; @InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler; @@ -27,9 +33,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.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).clearToken(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -38,12 +47,30 @@ 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"; + String token = "token"; + + when(response.isCommitted()).thenReturn(false); + when(jwtService.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).clearToken(response); + when(request.getContextPath()).thenReturn(""); + when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); + + customLogoutSuccessHandler.onLogoutSuccess(request, response, null); + + verify(response).sendRedirect(logoutPath); + verify(jwtService).clearToken(response); + } + @Test void testSuccessfulLogoutViaOAuth2() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken oAuth2AuthenticationToken = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -54,8 +81,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(oAuth2AuthenticationToken.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, oAuth2AuthenticationToken); @@ -70,7 +96,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -84,8 +109,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -101,7 +125,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -111,8 +134,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -127,7 +149,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -138,8 +159,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -154,7 +174,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -167,8 +186,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -183,7 +201,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -198,8 +215,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -214,7 +230,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -230,8 +245,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); @@ -246,7 +260,6 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); OAuth2AuthenticationToken authentication = mock(OAuth2AuthenticationToken.class); - ApplicationProperties.Security security = mock(ApplicationProperties.Security.class); ApplicationProperties.Security.OAUTH2 oauth = mock(ApplicationProperties.Security.OAUTH2.class); @@ -259,8 +272,7 @@ class CustomLogoutSuccessHandlerTest { when(request.getServerName()).thenReturn("localhost"); when(request.getServerPort()).thenReturn(8080); when(request.getContextPath()).thenReturn(""); - when(applicationProperties.getSecurity()).thenReturn(security); - when(security.getOauth2()).thenReturn(oauth); + when(securityProperties.getOauth2()).thenReturn(oauth); when(authentication.getAuthorizedClientRegistrationId()).thenReturn("test"); customLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); 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..a47f45318 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/JwtAuthenticationEntryPointTest.java @@ -0,0 +1,38 @@ +package stirling.software.proprietary.security; + +import static org.mockito.Mockito.*; + +import java.io.IOException; + +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 jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; + +@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/filter/JwtAuthenticationFilterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java new file mode 100644 index 000000000..d3f484486 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/filter/JwtAuthenticationFilterTest.java @@ -0,0 +1,242 @@ +package stirling.software.proprietary.security.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +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; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Disabled; +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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +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; + +@Disabled +@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()).extractToken(any()); + } + + @Test + void shouldNotFilterWhenPageIsLogin() throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/login"); + when(request.getContextPath()).thenReturn("/login"); + + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + verify(filterChain, never()).doFilter(request, response); + } + + @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.getContextPath()).thenReturn("/"); + when(request.getRequestURI()).thenReturn("/protected"); + when(jwtService.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).validateToken(token); + when(jwtService.extractClaims(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).extractClaims(token); + verify(userDetailsService).loadUserByUsername(username); + verify(securityContext) + .setAuthentication(any(UsernamePasswordAuthenticationToken.class)); + verify(jwtService) + .generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims)); + verify(jwtService).addToken(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.extractToken(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.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(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.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(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 exceptionThrown_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.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(request)).thenReturn(token); + doNothing().when(jwtService).validateToken(token); + when(jwtService.extractClaims(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 testAuthenticationEntryPointCalledWithCorrectException() + throws ServletException, IOException { + when(jwtService.isJwtEnabled()).thenReturn(true); + when(request.getRequestURI()).thenReturn("/protected"); + when(request.getContextPath()).thenReturn("/"); + when(jwtService.extractToken(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); + } +} 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 new file mode 100644 index 000000000..1aa083cc0 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/saml2/JwtSaml2AuthenticationRequestRepositoryTest.java @@ -0,0 +1,247 @@ +package stirling.software.proprietary.security.saml2; + +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; + +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.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.AssertingPartyMetadata; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.proprietary.security.service.JwtServiceInterface; + +@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(jwtService.isJwtEnabled()).thenReturn(true); + 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.extractClaims(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.extractClaims(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.extractClaims(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)); + } +} 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..6f9af4c54 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/JwtServiceTest.java @@ -0,0 +1,389 @@ +package stirling.software.proprietary.security.service; + +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.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; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import stirling.software.proprietary.security.model.JwtVerificationKey; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.model.exception.AuthenticationFailureException; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + @Mock private Authentication authentication; + + @Mock private User userDetails; + + @Mock private HttpServletRequest request; + + @Mock private HttpServletResponse response; + + @Mock private KeyPersistenceServiceInterface keystoreService; + + private JwtService jwtService; + private KeyPair testKeyPair; + private JwtVerificationKey testVerificationKey; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + // Generate a test keypair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + // Create test verification key + String encodedPublicKey = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey); + + jwtService = new JwtService(true, keystoreService); + } + + @Test + void testGenerateTokenWithAuthentication() throws Exception { + String username = "testuser"; + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, Collections.emptyMap()); + + assertNotNull(token); + assertFalse(token.isEmpty()); + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testGenerateTokenWithUsernameAndClaims() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + claims.put("role", "admin"); + claims.put("department", "IT"); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + 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.extractClaims(token); + assertEquals("admin", extractedClaims.get("role")); + assertEquals("IT", extractedClaims.get("department")); + } + + @Test + void testValidateTokenSuccess() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn("testuser"); + + String token = jwtService.generateToken(authentication, new HashMap<>()); + + assertDoesNotThrow(() -> jwtService.validateToken(token)); + } + + @Test + void testValidateTokenWithInvalidToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + assertThrows( + AuthenticationFailureException.class, + () -> { + jwtService.validateToken("invalid-token"); + }); + } + + @Test + void testValidateTokenWithMalformedToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + AuthenticationFailureException exception = + assertThrows( + AuthenticationFailureException.class, + () -> { + jwtService.validateToken("malformed.token"); + }); + + assertTrue(exception.getMessage().contains("Invalid")); + } + + @Test + void testValidateTokenWithEmptyToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + AuthenticationFailureException exception = + assertThrows( + AuthenticationFailureException.class, + () -> { + jwtService.validateToken(""); + }); + + assertTrue( + exception.getMessage().contains("Claims are empty") + || exception.getMessage().contains("Invalid")); + } + + @Test + void testExtractUsername() throws Exception { + String username = "testuser"; + User user = mock(User.class); + Map claims = Map.of("sub", "testuser", "authType", "WEB"); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(user); + when(user.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testExtractUsernameWithInvalidToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + assertThrows( + AuthenticationFailureException.class, + () -> jwtService.extractUsername("invalid-token")); + } + + @Test + void testExtractClaims() throws Exception { + String username = "testuser"; + Map claims = Map.of("role", "admin", "department", "IT"); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + Map extractedClaims = jwtService.extractClaims(token); + + assertEquals("admin", extractedClaims.get("role")); + assertEquals("IT", extractedClaims.get("department")); + assertEquals(username, extractedClaims.get("sub")); + assertEquals("Stirling PDF", extractedClaims.get("iss")); + } + + @Test + void testExtractClaimsWithInvalidToken() throws Exception { + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + + assertThrows( + AuthenticationFailureException.class, + () -> jwtService.extractClaims("invalid-token")); + } + + @Test + void testExtractTokenWithCookie() { + String token = "test-token"; + Cookie[] cookies = {new Cookie("stirling_jwt", token)}; + when(request.getCookies()).thenReturn(cookies); + + assertEquals(token, jwtService.extractToken(request)); + } + + @Test + void testExtractTokenWithNoCookies() { + when(request.getCookies()).thenReturn(null); + + assertNull(jwtService.extractToken(request)); + } + + @Test + void testExtractTokenWithWrongCookie() { + Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")}; + when(request.getCookies()).thenReturn(cookies); + + assertNull(jwtService.extractToken(request)); + } + + @Test + void testExtractTokenWithInvalidAuthorizationHeader() { + when(request.getCookies()).thenReturn(null); + + assertNull(jwtService.extractToken(request)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testAddToken(boolean secureCookie) throws Exception { + String token = "test-token"; + + // Create new JwtService instance with the secureCookie parameter + JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie); + + testJwtService.addToken(response, token); + + verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token)); + verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly")); + + if (secureCookie) { + verify(response).addHeader(eq("Set-Cookie"), contains("Secure")); + } + } + + @Test + void testClearToken() { + jwtService.clearToken(response); + + verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=")); + verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0")); + } + + @Test + void testGenerateTokenWithKeyId() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + 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).getActiveKey(); + verify(keystoreService).getKeyPair("test-key-id"); + } + + @Test + void testTokenVerificationWithSpecificKeyId() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + 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.getKeyPair("test-key-id")) + .thenReturn(Optional.of(testKeyPair)); + + // Verify token can be validated + assertDoesNotThrow(() -> jwtService.validateToken(token)); + assertEquals(username, jwtService.extractUsername(token)); + } + + @Test + void testTokenVerificationFallsBackToActiveKeyWhenKeyIdNotFound() throws Exception { + String username = "testuser"; + Map claims = new HashMap<>(); + + // First, generate a token successfully + when(keystoreService.getActiveKey()).thenReturn(testVerificationKey); + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair)); + when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey())) + .thenReturn(testKeyPair.getPublic()); + when(authentication.getPrincipal()).thenReturn(userDetails); + when(userDetails.getUsername()).thenReturn(username); + + String token = jwtService.generateToken(authentication, claims); + + // Now mock the scenario for validation - key not found, but fallback works + // Create a fallback key pair that can be used + JwtVerificationKey fallbackKey = + new JwtVerificationKey( + "fallback-key", + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded())); + + // Mock the specific key lookup to fail, but the active key should work + when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.empty()); + when(keystoreService.refreshActiveKeyPair()).thenReturn(fallbackKey); + when(keystoreService.getKeyPair("fallback-key")).thenReturn(Optional.of(testKeyPair)); + + // Should still work by falling back to the active keypair + assertDoesNotThrow(() -> jwtService.validateToken(token)); + assertEquals(username, jwtService.extractUsername(token)); + + // Verify fallback logic was used + verify(keystoreService, atLeast(1)).getActiveKey(); + } + + private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception { + // Use reflection to create JwtService with custom secureCookie value + JwtService testService = new JwtService(true, keystoreService); + + // Set the secureCookie field using reflection + java.lang.reflect.Field secureCookieField = + JwtService.class.getDeclaredField("secureCookie"); + secureCookieField.setAccessible(true); + secureCookieField.set(testService, secureCookie); + + return testService; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java new file mode 100644 index 000000000..33b971e5a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/KeyPersistenceServiceInterfaceTest.java @@ -0,0 +1,232 @@ +package stirling.software.proprietary.security.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.model.JwtVerificationKey; + +@ExtendWith(MockitoExtension.class) +class KeyPersistenceServiceInterfaceTest { + + @Mock private ApplicationProperties applicationProperties; + + @Mock private ApplicationProperties.Security security; + + @Mock private ApplicationProperties.Security.Jwt jwtConfig; + + @TempDir Path tempDir; + + private KeyPersistenceService keyPersistenceService; + private KeyPair testKeyPair; + private CacheManager cacheManager; + + @BeforeEach + void setUp() throws NoSuchAlgorithmException { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + testKeyPair = keyPairGenerator.generateKeyPair(); + + cacheManager = new ConcurrentMapCacheManager("verifyingKeys"); + + lenient().when(applicationProperties.getSecurity()).thenReturn(security); + lenient().when(security.getJwt()).thenReturn(jwtConfig); + lenient().when(jwtConfig.isEnableKeystore()).thenReturn(true); // Default value + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testKeystoreEnabled(boolean keystoreEnabled) { + when(jwtConfig.isEnableKeystore()).thenReturn(keystoreEnabled); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + assertEquals(keystoreEnabled, keyPersistenceService.isKeystoreEnabled()); + } + } + + @Test + void testGetActiveKeypairWhenNoActiveKeyExists() { + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + JwtVerificationKey result = keyPersistenceService.getActiveKey(); + + assertNotNull(result); + assertNotNull(result.getKeyId()); + assertNotNull(result.getVerifyingKey()); + } + } + + @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()); + + JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64); + + Path keyFile = tempDir.resolve(keyId + ".key"); + Files.writeString(keyFile, privateKeyBase64); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + JwtVerificationKey result = keyPersistenceService.getActiveKey(); + + assertNotNull(result); + assertNotNull(result.getKeyId()); + } + } + + @Test + void testGetKeyPair() throws Exception { + String keyId = "test-key-123"; + String publicKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + String privateKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded()); + + JwtVerificationKey signingKey = new JwtVerificationKey(keyId, publicKeyBase64); + + Path keyFile = tempDir.resolve(keyId + ".key"); + Files.writeString(keyFile, privateKeyBase64); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + keyPersistenceService + .getClass() + .getDeclaredField("verifyingKeyCache") + .setAccessible(true); + var cache = cacheManager.getCache("verifyingKeys"); + cache.put(keyId, signingKey); + + Optional result = keyPersistenceService.getKeyPair(keyId); + + assertTrue(result.isPresent()); + assertNotNull(result.get().getPublic()); + assertNotNull(result.get().getPrivate()); + } + } + + @Test + void testGetKeyPairNotFound() { + String keyId = "non-existent-key"; + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + Optional result = keyPersistenceService.getKeyPair(keyId); + + assertFalse(result.isPresent()); + } + } + + @Test + void testGetKeyPairWhenKeystoreDisabled() { + when(jwtConfig.isEnableKeystore()).thenReturn(false); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + + Optional result = keyPersistenceService.getKeyPair("any-key"); + + assertFalse(result.isPresent()); + } + } + + @Test + void testInitializeKeystoreCreatesDirectory() throws IOException { + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + assertTrue(Files.exists(tempDir)); + assertTrue(Files.isDirectory(tempDir)); + } + } + + @Test + void testLoadExistingKeypairWithMissingPrivateKeyFile() throws Exception { + String keyId = "test-key-missing-file"; + String publicKeyBase64 = + Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded()); + + JwtVerificationKey existingKey = new JwtVerificationKey(keyId, publicKeyBase64); + + try (MockedStatic mockedStatic = + mockStatic(InstallationPathConfig.class)) { + mockedStatic + .when(InstallationPathConfig::getPrivateKeyPath) + .thenReturn(tempDir.toString()); + keyPersistenceService = new KeyPersistenceService(applicationProperties, cacheManager); + keyPersistenceService.initializeKeystore(); + + JwtVerificationKey result = keyPersistenceService.getActiveKey(); + assertNotNull(result); + assertNotNull(result.getKeyId()); + assertNotNull(result.getVerifyingKey()); + } + } +} diff --git a/exampleYmlFiles/test_cicd.yml b/exampleYmlFiles/test_cicd.yml index 31e24da48..086f862d5 100644 --- a/exampleYmlFiles/test_cicd.yml +++ b/exampleYmlFiles/test_cicd.yml @@ -20,6 +20,7 @@ services: environment: DISABLE_ADDITIONAL_FEATURES: "false" SECURITY_ENABLELOGIN: "true" + V2: "false" PUID: 1002 PGID: 1002 UMASK: "022" From 901218cdb2c6eadafa8426b526a5e1c68c37611e Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:29:51 +0100 Subject: [PATCH 03/12] :globe_with_meridians: Sync Translations + Update README Progress Table (#4174) ### Description of Changes This Pull Request was automatically generated to synchronize updates to translation files and documentation. Below are the details of the changes made: #### **1. Synchronization of Translation Files** - Updated translation files (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`. - Ensured consistency and synchronization across all supported language files. - Highlighted any missing or incomplete translations. #### **2. Update README.md** - Generated the translation progress table in `README.md`. - Added a summary of the current translation status for all supported languages. - Included up-to-date statistics on translation coverage. #### **Why these changes are necessary** - Keeps translation files aligned with the latest reference updates. - Ensures the documentation reflects the current translation progress. --- Auto-generated by [create-pull-request][1]. [1]: https://github.com/peter-evans/create-pull-request Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- app/core/src/main/resources/messages_ar_AR.properties | 1 + app/core/src/main/resources/messages_az_AZ.properties | 1 + app/core/src/main/resources/messages_bg_BG.properties | 1 + app/core/src/main/resources/messages_bo_CN.properties | 1 + app/core/src/main/resources/messages_ca_CA.properties | 1 + app/core/src/main/resources/messages_cs_CZ.properties | 1 + app/core/src/main/resources/messages_da_DK.properties | 1 + app/core/src/main/resources/messages_de_DE.properties | 1 + app/core/src/main/resources/messages_el_GR.properties | 1 + app/core/src/main/resources/messages_en_US.properties | 1 + app/core/src/main/resources/messages_es_ES.properties | 1 + app/core/src/main/resources/messages_eu_ES.properties | 1 + app/core/src/main/resources/messages_fa_IR.properties | 1 + app/core/src/main/resources/messages_fr_FR.properties | 1 + app/core/src/main/resources/messages_ga_IE.properties | 1 + app/core/src/main/resources/messages_hi_IN.properties | 1 + app/core/src/main/resources/messages_hr_HR.properties | 1 + app/core/src/main/resources/messages_hu_HU.properties | 1 + app/core/src/main/resources/messages_id_ID.properties | 1 + app/core/src/main/resources/messages_it_IT.properties | 1 + app/core/src/main/resources/messages_ja_JP.properties | 1 + app/core/src/main/resources/messages_ko_KR.properties | 1 + app/core/src/main/resources/messages_ml_IN.properties | 1 + app/core/src/main/resources/messages_nl_NL.properties | 1 + app/core/src/main/resources/messages_no_NB.properties | 1 + app/core/src/main/resources/messages_pl_PL.properties | 1 + app/core/src/main/resources/messages_pt_BR.properties | 1 + app/core/src/main/resources/messages_pt_PT.properties | 1 + app/core/src/main/resources/messages_ro_RO.properties | 1 + app/core/src/main/resources/messages_ru_RU.properties | 1 + app/core/src/main/resources/messages_sk_SK.properties | 1 + app/core/src/main/resources/messages_sl_SI.properties | 1 + app/core/src/main/resources/messages_sr_LATN_RS.properties | 1 + app/core/src/main/resources/messages_sv_SE.properties | 1 + app/core/src/main/resources/messages_th_TH.properties | 1 + app/core/src/main/resources/messages_tr_TR.properties | 1 + app/core/src/main/resources/messages_uk_UA.properties | 1 + app/core/src/main/resources/messages_vi_VN.properties | 1 + app/core/src/main/resources/messages_zh_CN.properties | 1 + app/core/src/main/resources/messages_zh_TW.properties | 1 + 40 files changed, 40 insertions(+) diff --git a/app/core/src/main/resources/messages_ar_AR.properties b/app/core/src/main/resources/messages_ar_AR.properties index 1cd554cd1..ed0bc1228 100644 --- a/app/core/src/main/resources/messages_ar_AR.properties +++ b/app/core/src/main/resources/messages_ar_AR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=لقد تسجل دخولًا إلى login.alreadyLoggedIn2=أجهزة أخرى. يرجى تسجيل الخروج من الأجهزة وحاول مرة أخرى. login.toManySessions=لديك عدة جلسات نشطة 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=حجب تلقائي diff --git a/app/core/src/main/resources/messages_az_AZ.properties b/app/core/src/main/resources/messages_az_AZ.properties index 2304a13d1..f0e3f5ea9 100644 --- a/app/core/src/main/resources/messages_az_AZ.properties +++ b/app/core/src/main/resources/messages_az_AZ.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Siz artıq daxil olmusunuz login.alreadyLoggedIn2=cihazlar. Zəhmət olmasa, cihazlardan çıxış edin və yenidən cəhd edin. login.toManySessions=Həddindən artıq aktiv sessiyanız var 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=Avtomatik Gizlətmə diff --git a/app/core/src/main/resources/messages_bg_BG.properties b/app/core/src/main/resources/messages_bg_BG.properties index a99e9447e..d7964e792 100644 --- a/app/core/src/main/resources/messages_bg_BG.properties +++ b/app/core/src/main/resources/messages_bg_BG.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Вече сте влезли в login.alreadyLoggedIn2=устройства. Моля, излезте от устройствата и опитайте отново. login.toManySessions=Имате твърде много активни сесии 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=Автоматично редактиране diff --git a/app/core/src/main/resources/messages_bo_CN.properties b/app/core/src/main/resources/messages_bo_CN.properties index aef66f128..32df39257 100644 --- a/app/core/src/main/resources/messages_bo_CN.properties +++ b/app/core/src/main/resources/messages_bo_CN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=ཁྱེད་རང་ login.alreadyLoggedIn2=སྒྲིག་ཆས་ནང་ནང་འཛུལ་བྱས་ཟིན། སྒྲིག་ཆས་ནས་ཕྱིར་འཐེན་བྱས་ནས་ཡང་བསྐྱར་ཚོད་ལྟ་བྱེད་རོགས། login.toManySessions=ཁྱེད་ལ་འཛུལ་ཞུགས་བྱས་པའི་གནས་སྐབས་མང་དྲགས་འདུག 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=རང་འགུལ་སྒྲིབ་སྲུང་། diff --git a/app/core/src/main/resources/messages_ca_CA.properties b/app/core/src/main/resources/messages_ca_CA.properties index ff7f2b64b..dda522bdd 100644 --- a/app/core/src/main/resources/messages_ca_CA.properties +++ b/app/core/src/main/resources/messages_ca_CA.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Ja has iniciat sessió a login.alreadyLoggedIn2=dispositius. Si us plau, tanca la sessió en els dispositius i torna-ho a intentar. login.toManySessions=Tens massa sessions actives 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=Redacció Automàtica diff --git a/app/core/src/main/resources/messages_cs_CZ.properties b/app/core/src/main/resources/messages_cs_CZ.properties index a68fbcb78..7ce4b77a2 100644 --- a/app/core/src/main/resources/messages_cs_CZ.properties +++ b/app/core/src/main/resources/messages_cs_CZ.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Již jste přihlášeni na login.alreadyLoggedIn2=zařízeních. Odhlaste se prosím z těchto zařízení a zkuste to znovu. login.toManySessions=Máte příliš mnoho aktivních relací 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=Automatické začernění diff --git a/app/core/src/main/resources/messages_da_DK.properties b/app/core/src/main/resources/messages_da_DK.properties index 8d55cc8d1..b82f1d761 100644 --- a/app/core/src/main/resources/messages_da_DK.properties +++ b/app/core/src/main/resources/messages_da_DK.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Du er allerede logget ind på login.alreadyLoggedIn2=enheder. Log ud af disse enheder og prøv igen. login.toManySessions=Du har for mange aktive sessoner 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 Rediger diff --git a/app/core/src/main/resources/messages_de_DE.properties b/app/core/src/main/resources/messages_de_DE.properties index 63b54fa74..db91b8dc7 100644 --- a/app/core/src/main/resources/messages_de_DE.properties +++ b/app/core/src/main/resources/messages_de_DE.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Sie sind bereits an login.alreadyLoggedIn2=Geräten angemeldet. Bitte melden Sie sich dort ab und versuchen es dann erneut. login.toManySessions=Sie haben zu viele aktive Sitzungen login.logoutMessage=Sie wurden erfolgreich abgemeldet. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatisch zensieren/schwärzen diff --git a/app/core/src/main/resources/messages_el_GR.properties b/app/core/src/main/resources/messages_el_GR.properties index a9fbee538..7f59f217e 100644 --- a/app/core/src/main/resources/messages_el_GR.properties +++ b/app/core/src/main/resources/messages_el_GR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Είστε ήδη συνδεδεμένοι σε login.alreadyLoggedIn2=συσκευές. Παρακαλώ αποσυνδεθείτε από τις συσκευές και προσπαθήστε ξανά. login.toManySessions=Έχετε πάρα πολλές ενεργές συνεδρίες 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=Αυτόματη απόκρυψη diff --git a/app/core/src/main/resources/messages_en_US.properties b/app/core/src/main/resources/messages_en_US.properties index 250dd51c5..8ccbd7c99 100644 --- a/app/core/src/main/resources/messages_en_US.properties +++ b/app/core/src/main/resources/messages_en_US.properties @@ -908,6 +908,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/core/src/main/resources/messages_es_ES.properties b/app/core/src/main/resources/messages_es_ES.properties index 4ccb6d758..ae63d5107 100644 --- a/app/core/src/main/resources/messages_es_ES.properties +++ b/app/core/src/main/resources/messages_es_ES.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Ya ha iniciado sesión en login.alreadyLoggedIn2=dispositivos. Cierre sesión en los dispositivos y vuelva a intentarlo. login.toManySessions=Tiene demasiadas sesiones activas 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 Censurar Texto diff --git a/app/core/src/main/resources/messages_eu_ES.properties b/app/core/src/main/resources/messages_eu_ES.properties index 27dbfdb08..17bd70a93 100644 --- a/app/core/src/main/resources/messages_eu_ES.properties +++ b/app/core/src/main/resources/messages_eu_ES.properties @@ -908,6 +908,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 Idatzi diff --git a/app/core/src/main/resources/messages_fa_IR.properties b/app/core/src/main/resources/messages_fa_IR.properties index dccb7fc0b..a3b7cfec3 100644 --- a/app/core/src/main/resources/messages_fa_IR.properties +++ b/app/core/src/main/resources/messages_fa_IR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=شما قبلاً وارد شده‌اید در login.alreadyLoggedIn2=دستگاه‌ها. لطفاً از دستگاه‌ها خارج شده و دوباره تلاش کنید. login.toManySessions=شما تعداد زیادی نشست فعال دارید. 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=سانسور خودکار diff --git a/app/core/src/main/resources/messages_fr_FR.properties b/app/core/src/main/resources/messages_fr_FR.properties index 86e6c0d95..b9db7ff5c 100644 --- a/app/core/src/main/resources/messages_fr_FR.properties +++ b/app/core/src/main/resources/messages_fr_FR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Vous êtes déjà connecté sur login.alreadyLoggedIn2=appareils. Veuillez vous déconnecter des appareils et réessayer. login.toManySessions=Vous avez trop de sessions actives. login.logoutMessage=Vous avez été déconnecté. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Caviarder automatiquement diff --git a/app/core/src/main/resources/messages_ga_IE.properties b/app/core/src/main/resources/messages_ga_IE.properties index 816932ff1..b0363acb4 100644 --- a/app/core/src/main/resources/messages_ga_IE.properties +++ b/app/core/src/main/resources/messages_ga_IE.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Tá tú logáilte isteach cheana login.alreadyLoggedIn2=gléasanna. Logáil amach as na gléasanna agus bain triail eile as. login.toManySessions=Tá an iomarca seisiún gníomhach agat 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/core/src/main/resources/messages_hi_IN.properties b/app/core/src/main/resources/messages_hi_IN.properties index e2f9b2c19..32885740c 100644 --- a/app/core/src/main/resources/messages_hi_IN.properties +++ b/app/core/src/main/resources/messages_hi_IN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=आप पहले से ही login.alreadyLoggedIn2=उपकरणों में लॉग इन हैं। कृपया उपकरणों से लॉग आउट करें और पुनः प्रयास करें। login.toManySessions=आपके बहुत सारे सक्रिय सत्र हैं 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=स्वतः गोपनीयकरण diff --git a/app/core/src/main/resources/messages_hr_HR.properties b/app/core/src/main/resources/messages_hr_HR.properties index 7ea02b909..cb06aba43 100644 --- a/app/core/src/main/resources/messages_hr_HR.properties +++ b/app/core/src/main/resources/messages_hr_HR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Već ste se prijavili na login.alreadyLoggedIn2=ure. Odjavite se s ure i pokušajte ponovo. login.toManySessions=Imate preko mrežne sesije aktivnih 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=Automatsko uređivanje diff --git a/app/core/src/main/resources/messages_hu_HU.properties b/app/core/src/main/resources/messages_hu_HU.properties index c5488bc2b..7845c3fce 100644 --- a/app/core/src/main/resources/messages_hu_HU.properties +++ b/app/core/src/main/resources/messages_hu_HU.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Már be van jelentkezve login.alreadyLoggedIn2=eszközön. Kérjük, jelentkezzen ki az eszközökről és próbálja újra. login.toManySessions=Túl sok aktív munkamenet login.logoutMessage=Sikeresen kijelentkezett. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatikus kitakarás diff --git a/app/core/src/main/resources/messages_id_ID.properties b/app/core/src/main/resources/messages_id_ID.properties index 541226f69..d06da87ab 100644 --- a/app/core/src/main/resources/messages_id_ID.properties +++ b/app/core/src/main/resources/messages_id_ID.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Anda sudah login ke login.alreadyLoggedIn2=perangkat. Silakan keluar dari perangkat dan coba lagi. login.toManySessions=Anda memiliki terlalu banyak sesi aktif 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=Redaksional Otomatis diff --git a/app/core/src/main/resources/messages_it_IT.properties b/app/core/src/main/resources/messages_it_IT.properties index 74952b670..26d492be3 100644 --- a/app/core/src/main/resources/messages_it_IT.properties +++ b/app/core/src/main/resources/messages_it_IT.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Hai già effettuato l'accesso a login.alreadyLoggedIn2=dispositivi. Esci dai dispositivi e riprova. login.toManySessions=Hai troppe sessioni attive login.logoutMessage=Sei stato disconnesso. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Redazione automatica diff --git a/app/core/src/main/resources/messages_ja_JP.properties b/app/core/src/main/resources/messages_ja_JP.properties index a5af895fd..f0c987c9d 100644 --- a/app/core/src/main/resources/messages_ja_JP.properties +++ b/app/core/src/main/resources/messages_ja_JP.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=すでにログインしています login.alreadyLoggedIn2=デバイスからログアウトしてもう一度お試しください。 login.toManySessions=アクティブなセッションが多すぎます login.logoutMessage=ログアウトしました +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=自動墨消し diff --git a/app/core/src/main/resources/messages_ko_KR.properties b/app/core/src/main/resources/messages_ko_KR.properties index 7de79d52c..77517a000 100644 --- a/app/core/src/main/resources/messages_ko_KR.properties +++ b/app/core/src/main/resources/messages_ko_KR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=이미 다음에 로그인되어 있습니다 login.alreadyLoggedIn2=개의 기기. 해당 기기에서 로그아웃한 후 다시 시도하세요. login.toManySessions=활성 세션이 너무 많습니다 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=자동 검열 diff --git a/app/core/src/main/resources/messages_ml_IN.properties b/app/core/src/main/resources/messages_ml_IN.properties index 123f5a53f..356e5f99b 100644 --- a/app/core/src/main/resources/messages_ml_IN.properties +++ b/app/core/src/main/resources/messages_ml_IN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=നിങ്ങൾ ഇതിനകം ലോഗിൻ ച login.alreadyLoggedIn2=ഉപകരണങ്ങളിൽ. ദയവായി ഉപകരണങ്ങളിൽ നിന്ന് ലോഗ് ഔട്ട് ചെയ്ത് വീണ്ടും ശ്രമിക്കുക. login.toManySessions=നിങ്ങൾക്ക് വളരെയധികം സജീവ സെഷനുകൾ ഉണ്ട് login.logoutMessage=നിങ്ങൾ ലോഗ് ഔട്ട് ചെയ്തു. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=സ്വയം റെഡാക്റ്റ് ചെയ്യുക diff --git a/app/core/src/main/resources/messages_nl_NL.properties b/app/core/src/main/resources/messages_nl_NL.properties index 44418eb0f..f7aa1e805 100644 --- a/app/core/src/main/resources/messages_nl_NL.properties +++ b/app/core/src/main/resources/messages_nl_NL.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=U zit reeds ingelogd bij login.alreadyLoggedIn2=apparaten. U moet u a.u.b. uitloggen van de apparaten en opnieuw proberen. login.toManySessions=U heeft te veel actieve sessies 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=Automatisch censureren diff --git a/app/core/src/main/resources/messages_no_NB.properties b/app/core/src/main/resources/messages_no_NB.properties index ed830ec3f..ae9091cf5 100644 --- a/app/core/src/main/resources/messages_no_NB.properties +++ b/app/core/src/main/resources/messages_no_NB.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Du er allerede innlogget på login.alreadyLoggedIn2=enheter. Logg ut og forsøk igjen login.toManySessions=Du har for mange aktive økter 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=Automatisk Sensurering diff --git a/app/core/src/main/resources/messages_pl_PL.properties b/app/core/src/main/resources/messages_pl_PL.properties index 0eefb4ccc..9c5dc670e 100644 --- a/app/core/src/main/resources/messages_pl_PL.properties +++ b/app/core/src/main/resources/messages_pl_PL.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Jesteś już zalogowany na login.alreadyLoggedIn2=urządzeniach. Wyloguj się z tych urządzeń i spróbuj ponownie. login.toManySessions=Masz zbyt wiele aktywnych sesji 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=Automatyczne zaciemnienie diff --git a/app/core/src/main/resources/messages_pt_BR.properties b/app/core/src/main/resources/messages_pt_BR.properties index 57e8dd93e..bf2cb6a17 100644 --- a/app/core/src/main/resources/messages_pt_BR.properties +++ b/app/core/src/main/resources/messages_pt_BR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Você já está conectado em login.alreadyLoggedIn2=aparelhos. Por favor saia dos aparelhos e tente novamente. login.toManySessions=Você tem muitas sessões ativas login.logoutMessage=Você foi desconectado. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Ocultação de Texto Automática diff --git a/app/core/src/main/resources/messages_pt_PT.properties b/app/core/src/main/resources/messages_pt_PT.properties index 2c78fa93b..7b73092f1 100644 --- a/app/core/src/main/resources/messages_pt_PT.properties +++ b/app/core/src/main/resources/messages_pt_PT.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Já tem sessão iniciada em login.alreadyLoggedIn2=dispositivos. Por favor termine sessão nesses dispositivos e tente novamente. login.toManySessions=Tem demasiadas sessões ativas 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=Redação Automática diff --git a/app/core/src/main/resources/messages_ro_RO.properties b/app/core/src/main/resources/messages_ro_RO.properties index 5a904a9c8..07fee9b86 100644 --- a/app/core/src/main/resources/messages_ro_RO.properties +++ b/app/core/src/main/resources/messages_ro_RO.properties @@ -908,6 +908,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=Redactare Automată diff --git a/app/core/src/main/resources/messages_ru_RU.properties b/app/core/src/main/resources/messages_ru_RU.properties index 4580f3933..14dd4121a 100644 --- a/app/core/src/main/resources/messages_ru_RU.properties +++ b/app/core/src/main/resources/messages_ru_RU.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Вы уже вошли в login.alreadyLoggedIn2=устройств(а). Пожалуйста, выйдите из этих устройств и попробуйте снова. login.toManySessions=У вас слишком много активных сессий login.logoutMessage=Вы вышли из системы. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Автоматическое редактирование diff --git a/app/core/src/main/resources/messages_sk_SK.properties b/app/core/src/main/resources/messages_sk_SK.properties index 68faeab85..4b84511f5 100644 --- a/app/core/src/main/resources/messages_sk_SK.properties +++ b/app/core/src/main/resources/messages_sk_SK.properties @@ -908,6 +908,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=Automatické redigovanie diff --git a/app/core/src/main/resources/messages_sl_SI.properties b/app/core/src/main/resources/messages_sl_SI.properties index fe95a4165..72987dfcd 100644 --- a/app/core/src/main/resources/messages_sl_SI.properties +++ b/app/core/src/main/resources/messages_sl_SI.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Prijavljeni ste že v login.alreadyLoggedIn2=naprave. Odjavite se iz naprav in poskusite znova. login.toManySessions=Imate preveč aktivnih sej 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=Samodejno redigiraj diff --git a/app/core/src/main/resources/messages_sr_LATN_RS.properties b/app/core/src/main/resources/messages_sr_LATN_RS.properties index f15d8397a..4a6e987ca 100644 --- a/app/core/src/main/resources/messages_sr_LATN_RS.properties +++ b/app/core/src/main/resources/messages_sr_LATN_RS.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Već si prijavljen na login.alreadyLoggedIn2=uređaja. Odjavi se sa uređaja i pokušaj ponovo. login.toManySessions=Imaš previše aktivnih sesija login.logoutMessage=Odjavljen si. +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=Automatsko cenzurisanje diff --git a/app/core/src/main/resources/messages_sv_SE.properties b/app/core/src/main/resources/messages_sv_SE.properties index 7a786add6..0182c8f98 100644 --- a/app/core/src/main/resources/messages_sv_SE.properties +++ b/app/core/src/main/resources/messages_sv_SE.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Du är redan inloggad på login.alreadyLoggedIn2=enheter. Logga ut från enheterna och försök igen. login.toManySessions=Du har för många aktiva sessioner 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-redigera diff --git a/app/core/src/main/resources/messages_th_TH.properties b/app/core/src/main/resources/messages_th_TH.properties index 9b332982c..a0473bdef 100644 --- a/app/core/src/main/resources/messages_th_TH.properties +++ b/app/core/src/main/resources/messages_th_TH.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=คุณได้เข้าสู่ระบบใน login.alreadyLoggedIn2=อุปกรณ์แล้ว กรุณาออกจากระบบจากอุปกรณ์ที่ใช้งานอยู่แล้ว จากนั้นลองใหม่อีกครั้ง login.toManySessions=คุณมีการเข้าสู่ระบบพร้อมกันเกินกว่ากำหนด 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=ซ่อนข้อมูลอัตโนมัติ diff --git a/app/core/src/main/resources/messages_tr_TR.properties b/app/core/src/main/resources/messages_tr_TR.properties index 72e78f1b3..155b4365d 100644 --- a/app/core/src/main/resources/messages_tr_TR.properties +++ b/app/core/src/main/resources/messages_tr_TR.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Zaten şu cihazlarda oturum açılmış: login.alreadyLoggedIn2=Lütfen bu cihazlardan çıkış yaparak tekrar deneyin. login.toManySessions=Çok fazla aktif oturumunuz var 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=Otomatik Karartma diff --git a/app/core/src/main/resources/messages_uk_UA.properties b/app/core/src/main/resources/messages_uk_UA.properties index db5739fe3..cf0cc7115 100644 --- a/app/core/src/main/resources/messages_uk_UA.properties +++ b/app/core/src/main/resources/messages_uk_UA.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=Ви вже увійшли до login.alreadyLoggedIn2=пристроїв (а). Будь ласка, вийдіть із цих пристроїв і спробуйте знову. login.toManySessions=У вас дуже багато активних сесій 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=Автоматичне редагування diff --git a/app/core/src/main/resources/messages_vi_VN.properties b/app/core/src/main/resources/messages_vi_VN.properties index 0a1e9b392..ba7ba416b 100644 --- a/app/core/src/main/resources/messages_vi_VN.properties +++ b/app/core/src/main/resources/messages_vi_VN.properties @@ -908,6 +908,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=Tự động biên tập diff --git a/app/core/src/main/resources/messages_zh_CN.properties b/app/core/src/main/resources/messages_zh_CN.properties index 4eeac6483..80abcef7a 100644 --- a/app/core/src/main/resources/messages_zh_CN.properties +++ b/app/core/src/main/resources/messages_zh_CN.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=您已经登录到了 login.alreadyLoggedIn2=设备,请注销设备后重试。 login.toManySessions=你已经有太多的会话了。请注销一些设备后重试。 login.logoutMessage=您已退出登录。 +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=自动删除 diff --git a/app/core/src/main/resources/messages_zh_TW.properties b/app/core/src/main/resources/messages_zh_TW.properties index cee6b9c7d..c2cf4518c 100644 --- a/app/core/src/main/resources/messages_zh_TW.properties +++ b/app/core/src/main/resources/messages_zh_TW.properties @@ -908,6 +908,7 @@ login.alreadyLoggedIn=您已經登入了 login.alreadyLoggedIn2=部裝置。請先從這些裝置登出後再試一次。 login.toManySessions=您有太多使用中的工作階段 login.logoutMessage=您已登出。 +login.invalidInResponseTo=The requested SAML response is invalid or has expired. Please contact the administrator. #auto-redact autoRedact.title=自動塗黑 From bb07eced6ebaff67c6206d2afdf29018023f2c4f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:08:52 +0100 Subject: [PATCH 04/12] build(deps): bump gradle/actions from 4.4.1 to 4.4.2 (#4177) Bumps [gradle/actions](https://github.com/gradle/actions) from 4.4.1 to 4.4.2.
Release notes

Sourced from gradle/actions's releases.

v4.4.2

This patch release updates a bunch of dependency versions

What's Changed

  • Bump github/codeql-action from 3.29.4 to 3.29.5 in the github-actions group across 1 directory (gradle/actions#703)
  • Bumps the npm-dependencies group in /sources with 4 updates (gradle/actions#702)
  • Upgrade to gradle 9 in workflows and tests (gradle/actions#704)
  • Update known wrapper checksums (gradle/actions#701)
  • Bump Gradle Wrapper from 8.14.3 to 9.0.0 in /.github/workflow-samples/gradle-plugin (gradle/actions#695)
  • Bump Gradle Wrapper from 8.14.3 to 9.0.0 in /.github/workflow-samples/groovy-dsl (gradle/actions#696)
  • Bump Gradle Wrapper from 8.14.3 to 9.0.0 in /.github/workflow-samples/java-toolchain (gradle/actions#697)
  • Bump com.fasterxml.jackson.dataformat:jackson-dataformat-smile from 2.19.1 to 2.19.2 in /sources/test/init-scripts in the gradle group across 1 directory (gradle/actions#693)
  • Bump github/codeql-action from 3.29.0 to 3.29.4 in the github-actions group across 1 directory (gradle/actions#691)
  • Bump the npm-dependencies group in /sources with 5 updates (gradle/actions#692)
  • Bump references to Develocity Gradle plugin from 4.0.2 to 4.1 (gradle/actions#685)
  • Bump the npm-dependencies group across 1 directory with 8 updates (gradle/actions#684)
  • Run Gradle release candidate tests with JDK 17 (gradle/actions#690)
  • Update Develocity npm agent to version 1.0.1 (gradle/actions#687)
  • Update known wrapper checksums (gradle/actions#688)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/kotlin-dsl (gradle/actions#683
  • Bump the github-actions group across 1 directory with 3 updates (gradle/actions#675)
  • Bump the gradle group across 3 directories with 2 updates (gradle/actions#674)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /sources/test/init-scripts (gradle/actions#679)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/java-toolchain (gradle/actions#682)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/groovy-dsl (gradle/actions#681)
  • Bump Gradle Wrapper from 8.14.2 to 8.14.3 in /.github/workflow-samples/gradle-plugin (gradle/actions#680)
  • Update known wrapper checksums (gradle/actions#676)

Full Changelog: https://github.com/gradle/actions/compare/v4.4.1...v4.4.2

Commits
  • 017a9ef Bump github/codeql-action from 3.29.4 to 3.29.5 in the github-actions group a...
  • d5397cf Merge branch 'main' into dependabot/github_actions/github-actions-12d2e1d0cf
  • 559dfbd Bump the npm-dependencies group in /sources with 4 updates (#702)
  • 075ee28 Merge branch 'main' into dependabot/npm_and_yarn/sources/npm-dependencies-fda...
  • c3e68c5 Upgrade to gradle 9 in workflows and tests (#704)
  • d7e674f Fix init script tests dependencies
  • 3e65128 Upgrade init script tests to Gradle 9
  • 896b9fa Run tests on Gradle release candidate and current with JDK 17 as required sin...
  • 431b3e3 Bump github/codeql-action in the github-actions group across 1 directory
  • 44c3664 Bump the npm-dependencies group in /sources with 4 updates
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=gradle/actions&package-manager=github_actions&previous-version=4.4.1&new-version=4.4.2)](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> --- .github/workflows/build.yml | 6 +++--- .github/workflows/licenses-update.yml | 2 +- .github/workflows/multiOSReleases.yml | 4 ++-- .github/workflows/push-docker.yml | 2 +- .github/workflows/releaseArtifacts.yml | 2 +- .github/workflows/sonarqube.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/testdriver.yml | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c229ee40e..b6c5237c2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,7 +70,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -143,7 +143,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs @@ -271,7 +271,7 @@ jobs: distribution: "temurin" - name: Set up Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 49486c4d5..4db087539 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -54,7 +54,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Check licenses for compatibility run: ./gradlew clean checkLicense diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index b55c7d402..d705ced09 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -72,7 +72,7 @@ jobs: java-version: "21" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 @@ -160,7 +160,7 @@ jobs: java-version: "21" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 2a04ba33e..a766f5d5b 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -42,7 +42,7 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index ba970e885..3dd8bb5ea 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -35,7 +35,7 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 71f01438c..0ea32af59 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -39,7 +39,7 @@ jobs: fetch-depth: 0 - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Build and analyze with Gradle env: diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 85a7f10f1..74d3ec471 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -38,7 +38,7 @@ jobs: java-version: "17" distribution: "temurin" - - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + - uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 - name: Generate Swagger documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index b5759ed54..5841879b4 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -38,7 +38,7 @@ jobs: distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + uses: gradle/actions/setup-gradle@017a9effdb900e5b5b2fddfb590a105619dca3c3 # v4.4.2 with: gradle-version: 8.14 From 84142bb42ad1165ca8ab2f414d29cb74343437b6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:09 +0100 Subject: [PATCH 05/12] build(deps): bump github/codeql-action from 3.29.7 to 3.29.8 (#4178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3.29.7 to 3.29.8.
Release notes

Sourced from github/codeql-action's releases.

v3.29.8

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

3.29.8 - 08 Aug 2025

  • Fix an issue where the Action would autodetect unsupported languages such as HTML. #3015

See the full CHANGELOG.md for more information.

Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

3.29.8 - 08 Aug 2025

  • Fix an issue where the Action would autodetect unsupported languages such as HTML. #3015

3.29.7 - 07 Aug 2025

This release rolls back 3.29.6 to address issues with language autodetection. It is identical to 3.29.5.

3.29.6 - 07 Aug 2025

  • The cleanup-level input to the analyze Action is now deprecated. The CodeQL Action has written a limited amount of intermediate results to the database since version 2.2.5, and now automatically manages cleanup. #2999
  • Update default CodeQL bundle version to 2.22.3. #3000

3.29.5 - 29 Jul 2025

  • Update default CodeQL bundle version to 2.22.2. #2986

3.29.4 - 23 Jul 2025

No user facing changes.

3.29.3 - 21 Jul 2025

No user facing changes.

3.29.2 - 30 Jun 2025

  • Experimental: When the quality-queries input for the init action is provided with an argument, separate .quality.sarif files are produced and uploaded for each language with the results of the specified queries. Do not use this in production as it is part of an internal experiment and subject to change at any time. #2935

3.29.1 - 27 Jun 2025

  • Fix bug in PR analysis where user-provided include query filter fails to exclude non-included queries. #2938
  • Update default CodeQL bundle version to 2.22.1. #2950

3.29.0 - 11 Jun 2025

  • Update default CodeQL bundle version to 2.22.0. #2925
  • Bump minimum CodeQL bundle version to 2.16.6. #2912

3.28.21 - 28 July 2025

No user facing changes.

... (truncated)

Commits
  • 76621b6 Merge pull request #3019 from github/update-v3.29.8-679a40d33
  • 29ac3ce Add release notes for 3.29.7
  • 737cfde Update changelog for v3.29.8
  • 679a40d Merge pull request #3014 from github/henrymercer/rebuild-dispatch
  • 6fe50b2 Merge pull request #3015 from github/henrymercer/language-autodetection-worka...
  • 6bc91d6 Add changelog note
  • 6b4fedc Bump Action patch version
  • 5794ffc Fix auto-detection of extractors that aren't languages
  • bd62bf4 Finish in-progress merges
  • 2afb4e6 Avoid specifying branch unnecessarily
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=3.29.7&new-version=3.29.8)](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> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 47fae4f83..4922cac2a 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 + uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.5 with: sarif_file: results.sarif From 2c293d2231ae0fdf9e895376857a737170327a2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:19 +0100 Subject: [PATCH 06/12] build(deps): bump actions/download-artifact from 4.3.0 to 5.0.0 (#4179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 5.0.0.
Release notes

Sourced from actions/download-artifact's releases.

v5.0.0

What's Changed

v5.0.0

🚨 Breaking Change

This release fixes an inconsistency in path behavior for single artifact downloads by ID. If you're downloading single artifacts by ID, the output path may change.

What Changed

Previously, single artifact downloads behaved differently depending on how you specified the artifact:

  • By name: name: my-artifact → extracted to path/ (direct)
  • By ID: artifact-ids: 12345 → extracted to path/my-artifact/ (nested)

Now both methods are consistent:

  • By name: name: my-artifact → extracted to path/ (unchanged)
  • By ID: artifact-ids: 12345 → extracted to path/ (fixed - now direct)

Migration Guide

✅ No Action Needed If:
  • You download artifacts by name
  • You download multiple artifacts by ID
  • You already use merge-multiple: true as a workaround
⚠️ Action Required If:

You download single artifacts by ID and your workflows expect the nested directory structure.

Before v5 (nested structure):

- uses: actions/download-artifact@v4
  with:
    artifact-ids: 12345
    path: dist
# Files were in: dist/my-artifact/

Where my-artifact is the name of the artifact you previously uploaded

To maintain old behavior (if needed):

</tr></table>

... (truncated)

Commits
  • 634f93c Merge pull request #416 from actions/single-artifact-id-download-path
  • b19ff43 refactor: resolve download path correctly in artifact download tests (mainly ...
  • e262cbe bundle dist
  • bff23f9 update docs
  • fff8c14 fix download path logic when downloading a single artifact by id
  • 448e3f8 Merge pull request #407 from actions/nebuk89-patch-1
  • 47225c4 Update README.md
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/download-artifact&package-manager=github_actions&previous-version=4.3.0&new-version=5.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> --- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/releaseArtifacts.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index d705ced09..f13bb5c16 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -115,7 +115,7 @@ jobs: egress-policy: audit - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: stirling-${{ matrix.file_suffix }}binaries @@ -243,7 +243,7 @@ jobs: egress-policy: audit - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: ${{ matrix.platform }}binaries @@ -306,7 +306,7 @@ jobs: egress-policy: audit - name: Download signed artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - name: Display structure of downloaded files run: ls -R - name: Upload binaries, attestations and signatures to Release and create GitHub Release diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 3dd8bb5ea..7ae70e1ec 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -88,7 +88,7 @@ jobs: egress-policy: audit - name: Download build artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: binaries${{ matrix.file_suffix }} - name: Display structure of downloaded files @@ -166,7 +166,7 @@ jobs: egress-policy: audit - name: Download signed artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: name: signed${{ matrix.file_suffix }} From 1dd5e9c64912fbefa0b800a29a96ebfa00cce0e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:30 +0100 Subject: [PATCH 07/12] build(deps): bump actions/checkout from 4.2.2 to 4.3.0 (#4180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 4.3.0.
Release notes

Sourced from actions/checkout's releases.

v4.3.0

What's Changed

New Contributors

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.0

Changelog

Sourced from actions/checkout's changelog.

Changelog

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4.2.2&new-version=4.3.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> --- .github/workflows/PR-Demo-Comment-with-react.yml | 6 +++--- .github/workflows/PR-Demo-cleanup.yml | 2 +- .github/workflows/ai_pr_title_review.yml | 2 +- .github/workflows/auto-labelerV2.yml | 2 +- .github/workflows/build.yml | 12 ++++++------ .github/workflows/check_properties.yml | 2 +- .github/workflows/dependency-review.yml | 2 +- .github/workflows/licenses-update.yml | 2 +- .github/workflows/manage-label.yml | 2 +- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/releaseArtifacts.yml | 2 +- .github/workflows/scorecards.yml | 2 +- .github/workflows/sonarqube.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync_files.yml | 2 +- .github/workflows/testdriver.yml | 4 ++-- 18 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 066d85ef2..ff653ad15 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -46,7 +46,7 @@ jobs: egress-policy: audit - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -157,7 +157,7 @@ jobs: egress-policy: audit - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' @@ -169,7 +169,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: repository: ${{ needs.check-comment.outputs.pr_repository }} ref: ${{ needs.check-comment.outputs.pr_ref }} diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 29aea4389..67625c0a5 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -26,7 +26,7 @@ jobs: egress-policy: audit - name: Checkout PR - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 8a2e8b8ef..3f57edee5 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -23,7 +23,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index bd998d197..fae92940f 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -17,7 +17,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b6c5237c2..60085f9c9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Check for file changes uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 @@ -61,7 +61,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK ${{ matrix.jdk-version }} uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -134,7 +134,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -167,7 +167,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -213,7 +213,7 @@ jobs: egress-policy: audit - name: Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Java 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -262,7 +262,7 @@ jobs: egress-policy: audit - name: Checkout Repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index 32a970ef1..8633d2d62 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -35,7 +35,7 @@ jobs: egress-policy: audit - name: Checkout main branch first - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 30c96a1b0..8d938011d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -22,6 +22,6 @@ jobs: egress-policy: audit - name: "Checkout Repository" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: "Dependency Review" uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 4db087539..1f920e2da 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -36,7 +36,7 @@ jobs: egress-policy: audit - name: Check out code - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 1388ef0fb..3f25fbaf1 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -20,7 +20,7 @@ jobs: egress-policy: audit - name: Check out the repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Run Labeler uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0 diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index f13bb5c16..e043fd094 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -25,7 +25,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -64,7 +64,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 21 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -152,7 +152,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 21 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 6560e9226..eccf235d1 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -22,7 +22,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index a766f5d5b..9a583c7b9 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -34,7 +34,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 7ae70e1ec..7839ffd64 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -27,7 +27,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 4922cac2a..a3a355845 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -39,7 +39,7 @@ jobs: egress-policy: audit - name: "Checkout code" - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 0ea32af59..1e0e3ec32 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -34,7 +34,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: fetch-depth: 0 diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 74d3ec471..ebb51704c 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -30,7 +30,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK 17 uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml index a76cd4acf..d2ff7e827 100644 --- a/.github/workflows/sync_files.yml +++ b/.github/workflows/sync_files.yml @@ -36,7 +36,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Setup GitHub App Bot id: setup-bot diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 5841879b4..209ce7435 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -29,7 +29,7 @@ jobs: egress-policy: audit - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up JDK uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -126,7 +126,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - name: Set up Node uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 From 91b2f5da5300d415915c87b895aa0d59753c8c94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:09:47 +0100 Subject: [PATCH 08/12] build(deps): bump actions/ai-inference from 1.2.7 to 1.2.8 (#4181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [actions/ai-inference](https://github.com/actions/ai-inference) from 1.2.7 to 1.2.8.
Release notes

Sourced from actions/ai-inference's releases.

v1.2.8

What's Changed

Full Changelog: https://github.com/actions/ai-inference/compare/v1...v1.2.8

Commits
  • b81b2af Merge pull request #88 from actions/sgoedecke/force-exit-once-inference-finishes
  • 9133f81 package
  • 7923b92 Merge pull request #89 from actions/sgoedecke/ensure-mcp-loops-output-desired...
  • e44da10 fixup format parsing
  • 866ae2b Ensure MCP loops output the right response format
  • 4685e0d Force exit once inference finishes in case we are holding any connections open
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/ai-inference&package-manager=github_actions&previous-version=1.2.7&new-version=1.2.8)](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> --- .github/workflows/ai_pr_title_review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 3f57edee5..59a69ae5f 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -87,7 +87,7 @@ jobs: - name: AI PR Title Analysis if: steps.actor.outputs.is_repo_dev == 'true' id: ai-title-analysis - uses: actions/ai-inference@0cbed4a10641c75090de5968e66d70eb4660f751 # v1.2.7 + uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8 with: model: openai/gpt-4o system-prompt-file: ".github/config/system-prompt.txt" From 0afbd148cd31fd69193feae9ffcdcc873cefa1d7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:12:47 +0100 Subject: [PATCH 09/12] build(deps): bump edu.sc.seis.launch4j from 3.0.7 to 4.0.0 (#4182) Bumps edu.sc.seis.launch4j from 3.0.7 to 4.0.0. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=edu.sc.seis.launch4j&package-manager=gradle&previous-version=3.0.7&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> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 2c151d11b..1cd58b00e 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { id "org.springframework.boot" version "3.5.4" id "org.springdoc.openapi-gradle-plugin" version "1.9.0" id "io.swagger.swaggerhub" version "1.3.2" - id "edu.sc.seis.launch4j" version "3.0.7" + id "edu.sc.seis.launch4j" version "4.0.0" id "com.diffplug.spotless" version "7.2.1" id "com.github.jk1.dependency-license-report" version "2.9" //id "nebula.lint" version "19.0.3" From 8211fd8dc44367343cab032e0639c5e4dafbb1a3 Mon Sep 17 00:00:00 2001 From: albanobattistella <34811668+albanobattistella@users.noreply.github.com> Date: Mon, 11 Aug 2025 15:13:58 +0200 Subject: [PATCH 10/12] Update messages_it_IT.properties (#4183) # 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. --- app/core/src/main/resources/messages_it_IT.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/src/main/resources/messages_it_IT.properties b/app/core/src/main/resources/messages_it_IT.properties index 26d492be3..7491624f0 100644 --- a/app/core/src/main/resources/messages_it_IT.properties +++ b/app/core/src/main/resources/messages_it_IT.properties @@ -1774,7 +1774,7 @@ audit.dashboard.filter.userPlaceholder=Filtra per utente audit.dashboard.filter.startDate=Data di inizio audit.dashboard.filter.endDate=Data di fine audit.dashboard.filter.apply=Applica filtri -audit.dashboard.filter.reset=Resetta Filtri +audit.dashboard.filter.reset=Resetta filtri # Table Headers audit.dashboard.table.id=ID @@ -1864,7 +1864,7 @@ scannerEffect.submit=Crea una falsa scansione #home.scannerEffect home.scannerEffect.title=Falsa scansione home.scannerEffect.desc=Crea un PDF che sembra scansionato -scannerEffect.tags=scansiona, simula, realistico, converti +scannerEffect.tags=scansiona,simula,realistico,converti # ScannerEffect advanced settings (frontend) scannerEffect.advancedSettings=Abilita impostazioni di scansione avanzate @@ -1886,7 +1886,7 @@ scannerEffect.resolution=Risoluzione (DPI) home.editTableOfContents.title=Modifica indice home.editTableOfContents.desc=Aggiungi o modifica segnalibri e sommario nei documenti PDF -editTableOfContents.tags=segnalibri, indice, navigazione, indice analitico, sommario, capitoli, sezioni, struttura +editTableOfContents.tags=segnalibri,indice,navigazione,indice analitico,sommario,capitoli,sezioni,struttura editTableOfContents.title=Modifica indice editTableOfContents.header=Aggiungi o modifica sommario PDF editTableOfContents.replaceExisting=Sostituisci i segnalibri esistenti (deseleziona per aggiungerli a quelli esistenti) From b41230db53faa7f95eacf65c8285c2e07bf08b6c Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:14:41 +0100 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=A4=96=20format=20everything=20with?= =?UTF-8?q?=20pre-commit=20by=20stirlingbot=20(#4175)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-generated by [create-pull-request][1] with **stirlingbot** [1]: https://github.com/peter-evans/create-pull-request Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- app/core/src/main/resources/static/js/fetch-utils.js | 11 +++++------ app/core/src/main/resources/static/js/jwt-init.js | 4 ++-- 2 files changed, 7 insertions(+), 8 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 2a2fe894c..2cccbd19d 100644 --- a/app/core/src/main/resources/static/js/fetch-utils.js +++ b/app/core/src/main/resources/static/js/fetch-utils.js @@ -1,12 +1,12 @@ // Authentication utility for cookie-based JWT window.JWTManager = { - + // Logout - clear cookies and redirect to login logout: function() { - + // 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', @@ -55,14 +55,13 @@ window.fetchWithCsrf = async function(url, options = {}) { // Make the request const response = await fetch(url, fetchOptions); - + // Handle 401 responses (unauthorized) if (response.status === 401) { console.warn('Authentication failed, redirecting to login'); window.JWTManager.logout(); return response; } - + return response; } - 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 8cd63e189..35b736fd6 100644 --- a/app/core/src/main/resources/static/js/jwt-init.js +++ b/app/core/src/main/resources/static/js/jwt-init.js @@ -20,7 +20,7 @@ function initializeJWT() { // Clean up any JWT tokens from URL (OAuth flow) cleanupTokenFromUrl(); - + // Authentication is handled server-side // If user is not authenticated, server will redirect to login console.log('JWT initialization complete - authentication handled server-side'); @@ -41,4 +41,4 @@ } else { initializeJWT(); } -})(); \ No newline at end of file +})(); From 12ad8211feb4a5eff39ddc750a02c5c425b58462 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:18:27 +0100 Subject: [PATCH 12/12] Update 3rd Party Licenses (#4184) Auto-generated by stirlingbot[bot] Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- .../resources/static/3rdPartyLicenses.json | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index 23278a23f..062818603 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -132,6 +132,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, + { + "moduleName": "com.github.ben-manes.caffeine:caffeine", + "moduleUrl": "https://github.com/ben-manes/caffeine", + "moduleVersion": "3.2.2", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" + }, { "moduleName": "com.github.jai-imageio:jai-imageio-core", "moduleUrl": "https://github.com/jai-imageio/jai-imageio-core", @@ -168,7 +175,7 @@ { "moduleName": "com.google.errorprone:error_prone_annotations", "moduleUrl": "https://errorprone.info/error_prone_annotations", - "moduleVersion": "2.38.0", + "moduleVersion": "2.40.0", "moduleLicense": "Apache 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -549,6 +556,27 @@ "moduleLicense": "MIT License", "moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php" }, + { + "moduleName": "io.jsonwebtoken:jjwt-api", + "moduleUrl": "https://github.com/jwtk/jjwt", + "moduleVersion": "0.12.6", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "io.jsonwebtoken:jjwt-impl", + "moduleUrl": "https://github.com/jwtk/jjwt", + "moduleVersion": "0.12.6", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, + { + "moduleName": "io.jsonwebtoken:jjwt-jackson", + "moduleUrl": "https://github.com/jwtk/jjwt", + "moduleVersion": "0.12.6", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "io.micrometer:micrometer-commons", "moduleUrl": "https://github.com/micrometer-metrics/micrometer", @@ -1507,6 +1535,13 @@ "moduleLicense": "Apache License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" }, + { + "moduleName": "org.springframework.boot:spring-boot-starter-cache", + "moduleUrl": "https://spring.io/projects/spring-boot", + "moduleVersion": "3.5.4", + "moduleLicense": "Apache License, Version 2.0", + "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0" + }, { "moduleName": "org.springframework.boot:spring-boot-starter-data-jpa", "moduleUrl": "https://spring.io/projects/spring-boot",