From 3633a979d3631a82c4933e627e7c260226b8ce79 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 29 Nov 2024 15:11:59 +0000 Subject: [PATCH] fixes and other changes and debug of WIP SAML (#2360) * backup * remove debugs * oauth to saml and compare fixes etc * ee flag for saml * more fixes * info to debug * remove unused repo * spring dev fix for saml * debugs * saml stuff * debugs * fix --- build.gradle | 15 +- cucumber/features/external.feature | 38 ---- .../software/SPDF/EE/EEAppConfig.java | 5 +- .../software/SPDF/EE/LicenseKeyChecker.java | 3 +- .../SPDF/config/ExternalAppDepConfig.java | 3 +- .../config/security/InitialSecuritySetup.java | 1 + .../security/SecurityConfiguration.java | 183 ++++++++++++++---- .../SPDF/config/security/UserService.java | 16 +- ...tomOAuth2AuthenticationSuccessHandler.java | 5 +- .../security/saml2/CertificateUtils.java | 31 ++- ...stomSaml2AuthenticationSuccessHandler.java | 56 ++++-- ...mSaml2ResponseAuthenticationConverter.java | 85 +++++--- .../SPDF/controller/api/UserController.java | 4 +- .../controller/api/misc/OCRController.java | 8 +- .../controller/api/misc/RepairController.java | 5 +- .../SPDF/model/AuthenticationType.java | 2 +- .../SPDF/repository/UserRepository.java | 3 + .../SPDF/service/PdfMetadataService.java | 15 +- .../software/SPDF/utils/FileToPdf.java | 2 +- src/main/resources/application.properties | 6 + src/main/resources/settings.yml.template | 8 +- src/main/resources/static/js/fileInput.js | 15 +- src/main/resources/templates/addUsers.html | 4 +- .../resources/templates/fragments/common.html | 2 +- .../resources/templates/misc/compare.html | 2 +- test.sh | 12 +- 26 files changed, 346 insertions(+), 183 deletions(-) diff --git a/build.gradle b/build.gradle index fdfb5c8f833..a33a3d3a13e 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,8 @@ ext { imageioVersion = "3.12.0" lombokVersion = "1.18.36" bouncycastleVersion = "1.79" + springSecuritySamlVersion = "6.4.1" + openSamlVersion = "4.3.2" } group = "stirling.software" @@ -144,17 +146,18 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion" implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion" - implementation 'org.springframework.security:spring-security-saml2-service-provider:6.4.1' + implementation "org.springframework.session:spring-session-core:$springBootVersion" + implementation 'com.unboundid.product.scim2:scim2-sdk-client:2.3.5' // Don't upgrade h2database runtimeOnly "com.h2database:h2:2.3.232" constraints { - implementation "org.opensaml:opensaml-core" - implementation "org.opensaml:opensaml-saml-api" - implementation "org.opensaml:opensaml-saml-impl" + implementation "org.opensaml:opensaml-core:$openSamlVersion" + implementation "org.opensaml:opensaml-saml-api:$openSamlVersion" + implementation "org.opensaml:opensaml-saml-impl:$openSamlVersion" } - implementation "org.springframework.security:spring-security-saml2-service-provider" - + implementation "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion" +// implementation 'org.springframework.security:spring-security-core:$springSecuritySamlVersion' implementation 'com.coveo:saml-client:5.0.0' diff --git a/cucumber/features/external.feature b/cucumber/features/external.feature index 081d7f0e302..d06f3f40a60 100644 --- a/cucumber/features/external.feature +++ b/cucumber/features/external.feature @@ -48,24 +48,6 @@ Feature: API Validation And the response status code should be 200 - - @ocr @negative - Scenario: Process PDF with text and OCR with type normal - Given I generate a PDF file as "fileInput" - And the pdf contains 3 pages with random text - And the request data includes - | parameter | value | - | languages | eng | - | sidecar | false | - | deskew | true | - | clean | true | - | cleanFinal | true | - | ocrType | Normal | - | ocrRenderType | hocr | - | removeImagesAfter| false | - When I send the API request to the endpoint "/api/v1/misc/ocr-pdf" - Then the response status code should be 500 - @ocr @positive Scenario: Process PDF with OCR Given I generate a PDF file as "fileInput" @@ -83,26 +65,6 @@ Feature: API Validation Then the response content type should be "application/pdf" And the response file should have size greater than 0 And the response status code should be 200 - - @ocr @positive - Scenario: Process PDF with OCR with sidecar - Given I generate a PDF file as "fileInput" - And the request data includes - | parameter | value | - | languages | eng | - | sidecar | true | - | deskew | true | - | clean | true | - | cleanFinal | true | - | ocrType | Force | - | ocrRenderType | hocr | - | removeImagesAfter| false | - When I send the API request to the endpoint "/api/v1/misc/ocr-pdf" - Then the response content type should be "application/octet-stream" - And the response file should have extension ".zip" - And the response ZIP should contain 2 files - And the response file should have size greater than 0 - And the response status code should be 200 @libre @positive diff --git a/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java b/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java index 8c3c4fd0ef8..b5fb3556f38 100644 --- a/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java +++ b/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java @@ -3,13 +3,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Lazy; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.ApplicationProperties; @Configuration -@Lazy +@Order(Ordered.HIGHEST_PRECEDENCE) @Slf4j public class EEAppConfig { diff --git a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java index 3da7da05d9d..9217e7ce527 100644 --- a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java +++ b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java @@ -25,9 +25,10 @@ public LicenseKeyChecker( KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) { this.licenseService = licenseService; this.applicationProperties = applicationProperties; + this.checkLicense(); } - @Scheduled(fixedRate = 604800000, initialDelay = 1000) // 7 days in milliseconds + @Scheduled(fixedRate = 604800000) // 7 days in milliseconds public void checkLicensePeriodically() { checkLicense(); } diff --git a/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index 8ed708ea376..968e60c134f 100644 --- a/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -43,7 +43,6 @@ private boolean isCommandAvailable(String command) { put("unoconv", List.of("Unoconv")); put("qpdf", List.of("qpdf")); put("tesseract", List.of("tesseract")); - } }; @@ -98,7 +97,7 @@ private void checkDependencyAndDisableGroup(String command) { public void checkDependencies() { // Check core dependencies - checkDependencyAndDisableGroup("tesseract"); + checkDependencyAndDisableGroup("tesseract"); checkDependencyAndDisableGroup("soffice"); checkDependencyAndDisableGroup("qpdf"); checkDependencyAndDisableGroup("weasyprint"); diff --git a/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java b/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java index f43baf0a392..7e542a003cb 100644 --- a/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java +++ b/src/main/java/stirling/software/SPDF/config/security/InitialSecuritySetup.java @@ -30,6 +30,7 @@ public void init() throws IllegalArgumentException, IOException { initializeAdminUser(); } else { databaseBackupHelper.exportDatabase(); + userService.migrateOauth2ToSSO(); } initializeInternalApiUser(); } diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index 57c1d3f22b0..6c9db62880b 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -1,16 +1,20 @@ package stirling.software.SPDF.config.security; +import java.io.IOException; import java.security.cert.X509Certificate; import java.util.*; +import org.opensaml.saml.saml2.core.AuthnRequest; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; import org.springframework.context.annotation.Lazy; import org.springframework.core.io.Resource; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; 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; @@ -28,19 +32,35 @@ import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; 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.OpenSaml4AuthenticationProvider; 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.web.authentication.Saml2WebSsoAuthenticationFilter; +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 org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy; +import org.springframework.security.web.context.SecurityContextHolderFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; import org.springframework.security.web.savedrequest.NullRequestCache; +import org.springframework.security.web.session.ForceEagerSessionCreationFilter; +import org.springframework.security.web.session.HttpSessionEventPublisher; +import org.springframework.security.web.session.SessionManagementFilter; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; - +import org.springframework.session.web.http.CookieSerializer; +import org.springframework.session.web.http.DefaultCookieSerializer; +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.SPDF.config.security.oauth2.CustomOAuth2AuthenticationFailureHandler; import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationSuccessHandler; @@ -64,6 +84,7 @@ @EnableWebSecurity @EnableMethodSecurity @Slf4j +@DependsOn("runningEE") public class SecurityConfiguration { @Autowired private CustomUserDetailsService userDetailsService; @@ -79,6 +100,10 @@ public PasswordEncoder passwordEncoder() { @Qualifier("loginEnabled") public boolean loginEnabledValue; + @Autowired + @Qualifier("runningEE") + public boolean runningEE; + @Autowired ApplicationProperties applicationProperties; @Autowired private UserAuthenticationFilter userAuthenticationFilter; @@ -90,13 +115,14 @@ public PasswordEncoder passwordEncoder() { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + if (applicationProperties.getSecurity().getCsrfDisabled()) { + http.csrf(csrf -> csrf.disable()); + } if (loginEnabledValue) { http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - if (applicationProperties.getSecurity().getCsrfDisabled()) { - http.csrf(csrf -> csrf.disable()); - } else { + if (!applicationProperties.getSecurity().getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = @@ -137,7 +163,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement( sessionManagement -> sessionManagement - .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) .maximumSessions(10) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry) @@ -245,12 +271,23 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } // Handle SAML - if (applicationProperties.getSecurity().isSaml2Activ() - && applicationProperties.getSystem().getEnableAlphaFunctionality()) { - http.authenticationProvider(samlAuthenticationProvider()); - http.saml2Login( - saml2 -> + if (applicationProperties.getSecurity().isSaml2Activ()) { // && runningEE + // Configure the authentication provider + OpenSaml4AuthenticationProvider authenticationProvider = + new OpenSaml4AuthenticationProvider(); + authenticationProvider.setResponseAuthenticationConverter( + new CustomSaml2ResponseAuthenticationConverter(userService)); + + http.authenticationProvider(authenticationProvider) + .saml2Login( + saml2 -> { + try { saml2.loginPage("/saml2") + .relyingPartyRegistrationRepository( + relyingPartyRegistrations()) + .authenticationManager( + new ProviderManager(authenticationProvider)) + .successHandler( new CustomSaml2AuthenticationSuccessHandler( loginAttemptService, @@ -258,14 +295,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { userService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) - .permitAll()) - .addFilterBefore( - userAuthenticationFilter, Saml2WebSsoAuthenticationFilter.class); + .authenticationRequestResolver( + authenticationRequestResolver( + relyingPartyRegistrations())); + } catch (Exception e) { + log.error("Error configuring SAML2 login", e); + throw new RuntimeException(e); + } + }); } + } else { - if (applicationProperties.getSecurity().getCsrfDisabled()) { - http.csrf(csrf -> csrf.disable()); - } else { + if (!applicationProperties.getSecurity().getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = CookieCsrfTokenRepository.withHttpOnlyFalse(); CsrfTokenRequestAttributeHandler requestHandler = @@ -282,20 +323,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { return http.build(); } - @Bean - @ConditionalOnProperty( - name = "security.saml2.enabled", - havingValue = "true", - matchIfMissing = false) - public AuthenticationProvider samlAuthenticationProvider() { - OpenSaml4AuthenticationProvider authenticationProvider = - new OpenSaml4AuthenticationProvider(); - authenticationProvider.setResponseAuthenticationConverter( - new CustomSaml2ResponseAuthenticationConverter(userService)); - return authenticationProvider; - } - - // Client Registration Repository for OAUTH2 OIDC Login @Bean @ConditionalOnProperty( value = "security.oauth2.enabled", @@ -425,18 +452,19 @@ private Optional oidcClientRegistration() { .clientName("OIDC") .build()); } - + @Bean @ConditionalOnProperty( name = "security.saml2.enabled", havingValue = "true", matchIfMissing = false) public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { - SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - Resource privateKeyResource = samlConf.getPrivateKey(); + X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getidpCert()); + Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); + Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); Saml2X509Credential signingCredential = @@ -445,26 +473,97 @@ public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exc CertificateUtils.readCertificate(certificateResource), Saml2X509CredentialType.SIGNING); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getidpCert()); - - Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); - RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) - .signingX509Credentials((c) -> c.add(signingCredential)) + .signingX509Credentials(c -> c.add(signingCredential)) .assertingPartyMetadata( - (details) -> - details.entityId(samlConf.getIdpIssuer()) + metadata -> + metadata.entityId(samlConf.getIdpIssuer()) .singleSignOnServiceLocation( samlConf.getIdpSingleLoginUrl()) .verificationX509Credentials( - (c) -> c.add(verificationCredential)) + c -> c.add(verificationCredential)) + .singleSignOnServiceBinding( + Saml2MessageBinding.POST) .wantAuthnRequestsSigned(true)) .build(); + return new InMemoryRelyingPartyRegistrationRepository(rp); } @Bean + @ConditionalOnProperty( + name = "security.saml2.enabled", + havingValue = "true", + matchIfMissing = false) + public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + OpenSaml4AuthenticationRequestResolver resolver = + new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); + resolver.setAuthnRequestCustomizer( + customizer -> { + log.debug("Customizing SAML Authentication request"); + + AuthnRequest authnRequest = customizer.getAuthnRequest(); + log.debug("AuthnRequest ID: {}", authnRequest.getID()); + + if (authnRequest.getID() == null) { + authnRequest.setID("ARQ" + UUID.randomUUID().toString()); + } + log.debug("AuthnRequest new ID after set: {}", authnRequest.getID()); + log.debug("AuthnRequest IssueInstant: {}", authnRequest.getIssueInstant()); + log.debug( + "AuthnRequest Issuer: {}", + authnRequest.getIssuer() != null + ? authnRequest.getIssuer().getValue() + : "null"); + + HttpServletRequest request = customizer.getRequest(); + + // Log HTTP request details + log.debug("HTTP Request Method: {}", request.getMethod()); + log.debug("Request URI: {}", request.getRequestURI()); + log.debug("Request URL: {}", request.getRequestURL().toString()); + log.debug("Query String: {}", request.getQueryString()); + log.debug("Remote Address: {}", request.getRemoteAddr()); + + // Log headers + Collections.list(request.getHeaderNames()) + .forEach( + headerName -> { + log.debug( + "Header - {}: {}", + headerName, + request.getHeader(headerName)); + }); + + // Log SAML specific parameters + log.debug("SAML Request Parameters:"); + log.debug("SAMLRequest: {}", request.getParameter("SAMLRequest")); + log.debug("RelayState: {}", request.getParameter("RelayState")); + + // Log session debugrmation if exists + if (request.getSession(false) != null) { + log.debug("Session ID: {}", request.getSession().getId()); + } + + // Log any assertions consumer service details if present + if (authnRequest.getAssertionConsumerServiceURL() != null) { + log.debug( + "AssertionConsumerServiceURL: {}", + authnRequest.getAssertionConsumerServiceURL()); + } + + // Log NameID policy if present + if (authnRequest.getNameIDPolicy() != null) { + log.debug( + "NameIDPolicy Format: {}", + authnRequest.getNameIDPolicy().getFormat()); + } + }); + return resolver; + } + public DaoAuthenticationProvider daoAuthenticationProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); diff --git a/src/main/java/stirling/software/SPDF/config/security/UserService.java b/src/main/java/stirling/software/SPDF/config/security/UserService.java index 6b1457dc382..d7f35d387ac 100644 --- a/src/main/java/stirling/software/SPDF/config/security/UserService.java +++ b/src/main/java/stirling/software/SPDF/config/security/UserService.java @@ -18,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.interfaces.DatabaseBackupInterface; @@ -50,8 +51,19 @@ public class UserService implements UserServiceInterface { @Autowired ApplicationProperties applicationProperties; + @Transactional + public void migrateOauth2ToSSO() { + userRepository + .findByAuthenticationTypeIgnoreCase("OAUTH2") + .forEach( + user -> { + user.setAuthenticationType(AuthenticationType.SSO); + userRepository.save(user); + }); + } + // Handle OAUTH2 login and user auto creation. - public boolean processOAuth2PostLogin(String username, boolean autoCreateUser) + public boolean processSSOPostLogin(String username, boolean autoCreateUser) throws IllegalArgumentException, IOException { if (!isUsernameValid(username)) { return false; @@ -61,7 +73,7 @@ public boolean processOAuth2PostLogin(String username, boolean autoCreateUser) return true; } if (autoCreateUser) { - saveUser(username, AuthenticationType.OAUTH2); + saveUser(username, AuthenticationType.SSO); return true; } return false; diff --git a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 36c4cfb366c..9f3f6e35984 100644 --- a/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -82,8 +82,7 @@ public void onAuthenticationSuccess( } if (userService.usernameExistsIgnoreCase(username) && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername( - username, AuthenticationType.OAUTH2) + && !userService.isAuthenticationTypeByUsername(username, AuthenticationType.SSO) && oAuth.getAutoCreateUser()) { response.sendRedirect(contextPath + "/logout?oauth2AuthenticationErrorWeb=true"); return; @@ -95,7 +94,7 @@ public void onAuthenticationSuccess( return; } if (principal instanceof OAuth2User) { - userService.processOAuth2PostLogin(username, oAuth.getAutoCreateUser()); + userService.processSSOPostLogin(username, oAuth.getAutoCreateUser()); } response.sendRedirect(contextPath + "/"); return; diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CertificateUtils.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CertificateUtils.java index 4f0d2488ad4..8ec5122651e 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CertificateUtils.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CertificateUtils.java @@ -3,12 +3,14 @@ import java.io.ByteArrayInputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.security.interfaces.RSAPrivateKey; -import java.security.spec.PKCS8EncodedKeySpec; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.openssl.PEMKeyPair; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; import org.springframework.core.io.Resource; @@ -28,15 +30,26 @@ public static X509Certificate readCertificate(Resource certificateResource) thro } public static RSAPrivateKey readPrivateKey(Resource privateKeyResource) throws Exception { - try (PemReader pemReader = - new PemReader( + try (PEMParser pemParser = + new PEMParser( new InputStreamReader( privateKeyResource.getInputStream(), StandardCharsets.UTF_8))) { - PemObject pemObject = pemReader.readPemObject(); - byte[] decodedKey = pemObject.getContent(); - return (RSAPrivateKey) - KeyFactory.getInstance("RSA") - .generatePrivate(new PKCS8EncodedKeySpec(decodedKey)); + + Object object = pemParser.readObject(); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter(); + + if (object instanceof PEMKeyPair) { + // Handle traditional RSA private key format + PEMKeyPair keypair = (PEMKeyPair) object; + return (RSAPrivateKey) converter.getPrivateKey(keypair.getPrivateKeyInfo()); + } else if (object instanceof PrivateKeyInfo) { + // Handle PKCS#8 format + return (RSAPrivateKey) converter.getPrivateKey((PrivateKeyInfo) object); + } else { + throw new IllegalArgumentException( + "Unsupported key format: " + + (object != null ? object.getClass().getName() : "null")); + } } } } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index d4b917581cf..faa5e67ee74 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -12,6 +12,7 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.security.LoginAttemptService; import stirling.software.SPDF.config.security.UserService; import stirling.software.SPDF.model.ApplicationProperties; @@ -20,11 +21,11 @@ import stirling.software.SPDF.utils.RequestUriUtils; @AllArgsConstructor +@Slf4j public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private LoginAttemptService loginAttemptService; - private ApplicationProperties applicationProperties; private UserService userService; @@ -34,10 +35,12 @@ public void onAuthenticationSuccess( throws ServletException, IOException { Object principal = authentication.getPrincipal(); + log.debug("Starting SAML2 authentication success handling"); if (principal instanceof CustomSaml2AuthenticatedPrincipal) { String username = ((CustomSaml2AuthenticatedPrincipal) principal).getName(); - // Get the saved request + log.debug("Authenticated principal found for user: {}", username); + HttpSession session = request.getSession(false); String contextPath = request.getContextPath(); SavedRequest savedRequest = @@ -45,46 +48,77 @@ public void onAuthenticationSuccess( ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") : null; + log.debug( + "Session exists: {}, Saved request exists: {}", + session != null, + savedRequest != null); + if (savedRequest != null && !RequestUriUtils.isStaticResource( contextPath, savedRequest.getRedirectUrl())) { - // Redirect to the original destination + log.debug( + "Valid saved request found, redirecting to original destination: {}", + savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { SAML2 saml2 = applicationProperties.getSecurity().getSaml2(); + log.debug( + "Processing SAML2 authentication with autoCreateUser: {}", + saml2.getAutoCreateUser()); if (loginAttemptService.isBlocked(username)) { + log.debug("User {} is blocked due to too many login attempts", username); if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); } throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - if (userService.usernameExistsIgnoreCase(username) - && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername( - username, AuthenticationType.OAUTH2) - && saml2.getAutoCreateUser()) { + + boolean userExists = userService.usernameExistsIgnoreCase(username); + boolean hasPassword = userExists && userService.hasPassword(username); + boolean isSSOUser = + userExists + && userService.isAuthenticationTypeByUsername( + username, AuthenticationType.SSO); + + log.debug( + "User status - Exists: {}, Has password: {}, Is SSO user: {}", + userExists, + hasPassword, + isSSOUser); + + if (userExists && hasPassword && !isSSOUser && saml2.getAutoCreateUser()) { + log.debug( + "User {} exists with password but is not SSO user, redirecting to logout", + username); response.sendRedirect( contextPath + "/logout?oauth2AuthenticationErrorWeb=true"); return; } + try { - if (saml2.getBlockRegistration() - && !userService.usernameExistsIgnoreCase(username)) { + if (saml2.getBlockRegistration() && !userExists) { + log.debug("Registration blocked for new user: {}", username); response.sendRedirect( contextPath + "/login?erroroauth=oauth2_admin_blocked_user"); return; } - userService.processOAuth2PostLogin(username, saml2.getAutoCreateUser()); + log.debug("Processing SSO post-login for user: {}", username); + userService.processSSOPostLogin(username, saml2.getAutoCreateUser()); + log.debug("Successfully processed authentication for user: {}", username); response.sendRedirect(contextPath + "/"); return; } catch (IllegalArgumentException e) { + log.debug( + "Invalid username detected for user: {}, redirecting to logout", + username); response.sendRedirect(contextPath + "/logout?invalidUsername=true"); return; } } } else { + log.debug("Non-SAML2 principal detected, delegating to parent handler"); super.onAuthenticationSuccess(request, response, authentication); } } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b447eb8970b..bfd35c640c2 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -3,8 +3,6 @@ import java.util.*; import org.opensaml.core.xml.XMLObject; -import org.opensaml.core.xml.schema.XSBoolean; -import org.opensaml.core.xml.schema.XSString; import org.opensaml.saml.saml2.core.Assertion; import org.opensaml.saml.saml2.core.Attribute; import org.opensaml.saml.saml2.core.AttributeStatement; @@ -30,15 +28,60 @@ public CustomSaml2ResponseAuthenticationConverter(UserService userService) { this.userService = userService; } + private Map> extractAttributes(Assertion assertion) { + Map> attributes = new HashMap<>(); + + for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) { + for (Attribute attribute : attributeStatement.getAttributes()) { + String attributeName = attribute.getName(); + List values = new ArrayList<>(); + + for (XMLObject xmlObject : attribute.getAttributeValues()) { + // Get the text content directly + String value = xmlObject.getDOM().getTextContent(); + if (value != null && !value.trim().isEmpty()) { + values.add(value); + } + } + + if (!values.isEmpty()) { + // Store with both full URI and last part of the URI + attributes.put(attributeName, values); + String shortName = attributeName.substring(attributeName.lastIndexOf('/') + 1); + attributes.put(shortName, values); + } + } + } + + return attributes; + } + @Override public Saml2Authentication convert(ResponseToken responseToken) { - // Extract the assertion from the response Assertion assertion = responseToken.getResponse().getAssertions().get(0); + Map> attributes = extractAttributes(assertion); + + // Debug log with actual values + log.debug("Extracted SAML Attributes: " + attributes); - // Extract the NameID - String nameId = assertion.getSubject().getNameID().getValue(); + // Try to get username/identifier in order of preference + String userIdentifier = null; + if (hasAttribute(attributes, "username")) { + userIdentifier = getFirstAttributeValue(attributes, "username"); + } else if (hasAttribute(attributes, "emailaddress")) { + userIdentifier = getFirstAttributeValue(attributes, "emailaddress"); + } else if (hasAttribute(attributes, "name")) { + userIdentifier = getFirstAttributeValue(attributes, "name"); + } else if (hasAttribute(attributes, "upn")) { + userIdentifier = getFirstAttributeValue(attributes, "upn"); + } else if (hasAttribute(attributes, "uid")) { + userIdentifier = getFirstAttributeValue(attributes, "uid"); + } else { + userIdentifier = assertion.getSubject().getNameID().getValue(); + } - Optional userOpt = userService.findByUsernameIgnoreCase(nameId); + // Rest of your existing code... + Optional userOpt = userService.findByUsernameIgnoreCase(userIdentifier); SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_USER"); if (userOpt.isPresent()) { User user = userOpt.get(); @@ -48,39 +91,27 @@ public Saml2Authentication convert(ResponseToken responseToken) { } } - // Extract the SessionIndexes List sessionIndexes = new ArrayList<>(); for (AuthnStatement authnStatement : assertion.getAuthnStatements()) { sessionIndexes.add(authnStatement.getSessionIndex()); } - // Extract the Attributes - Map> attributes = extractAttributes(assertion); - - // Create the custom principal CustomSaml2AuthenticatedPrincipal principal = - new CustomSaml2AuthenticatedPrincipal(nameId, attributes, nameId, sessionIndexes); + new CustomSaml2AuthenticatedPrincipal( + userIdentifier, attributes, userIdentifier, sessionIndexes); - // Create the Saml2Authentication return new Saml2Authentication( principal, responseToken.getToken().getSaml2Response(), Collections.singletonList(simpleGrantedAuthority)); } - private Map> extractAttributes(Assertion assertion) { - Map> attributes = new HashMap<>(); - for (AttributeStatement attributeStatement : assertion.getAttributeStatements()) { - for (Attribute attribute : attributeStatement.getAttributes()) { - String attributeName = attribute.getName(); - List values = new ArrayList<>(); - for (XMLObject xmlObject : attribute.getAttributeValues()) { - log.info("BOOL: " + ((XSBoolean) xmlObject).getValue()); - values.add(((XSString) xmlObject).getValue()); - } - attributes.put(attributeName, values); - } - } - return attributes; + private boolean hasAttribute(Map> attributes, String name) { + return attributes.containsKey(name) && !attributes.get(name).isEmpty(); + } + + private String getFirstAttributeValue(Map> attributes, String name) { + List values = attributes.get(name); + return values != null && !values.isEmpty() ? values.get(0).toString() : null; } } diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index d27534875b1..c7d19f51810 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -244,8 +244,8 @@ public RedirectView saveUser( return new RedirectView("/addUsers?messageType=invalidRole", true); } - if (authType.equalsIgnoreCase(AuthenticationType.OAUTH2.toString())) { - userService.saveUser(username, AuthenticationType.OAUTH2, role); + if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) { + userService.saveUser(username, AuthenticationType.SSO, role); } else { if (password.isBlank()) { return new RedirectView("/addUsers?messageType=invalidPassword", true); diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java index b2601ec5552..6c5f3993ac7 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/OCRController.java @@ -1,7 +1,5 @@ package stirling.software.SPDF.controller.api.misc; -import io.github.pixee.security.BoundedLineReader; -import io.github.pixee.security.Filenames; import java.awt.image.BufferedImage; import java.io.BufferedReader; import java.io.File; @@ -35,6 +33,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import io.github.pixee.security.BoundedLineReader; +import io.github.pixee.security.Filenames; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; @@ -176,7 +176,9 @@ public ResponseEntity processPdfWithOCR( // Read the final PDF file byte[] pdfContent = Files.readAllBytes(finalOutputFile); String outputFilename = - Filenames.toSimpleFileName(inputFile.getOriginalFilename()).replaceFirst("[.][^.]+$", "") + "_OCR.pdf"; + Filenames.toSimpleFileName(inputFile.getOriginalFilename()) + .replaceFirst("[.][^.]+$", "") + + "_OCR.pdf"; return ResponseEntity.ok() .header( diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java index 7f79fcba0be..b59bd5b477c 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/RepairController.java @@ -50,7 +50,6 @@ public ResponseEntity repairPdf(@ModelAttribute PDFFile request) MultipartFile inputFile = request.getFileInput(); // Save the uploaded file to a temporary location Path tempInputFile = Files.createTempFile("input_", ".pdf"); - Path tempOutputFile = Files.createTempFile("output_", ".pdf"); byte[] pdfBytes = null; inputFile.transferTo(tempInputFile.toFile()); try { @@ -61,14 +60,13 @@ public ResponseEntity repairPdf(@ModelAttribute PDFFile request) command.add("--qdf"); // Linearizes and normalizes PDF structure command.add("--object-streams=disable"); // Can help with some corruptions command.add(tempInputFile.toString()); - command.add(tempOutputFile.toString()); ProcessExecutorResult returnCode = ProcessExecutor.getInstance(ProcessExecutor.Processes.QPDF) .runCommandWithOutputHandling(command); // Read the optimized PDF file - pdfBytes = pdfDocumentFactory.loadToBytes(tempOutputFile.toFile()); + pdfBytes = pdfDocumentFactory.loadToBytes(tempInputFile.toFile()); // Return the optimized PDF as a response String outputFilename = @@ -79,7 +77,6 @@ public ResponseEntity repairPdf(@ModelAttribute PDFFile request) } finally { // Clean up the temporary files Files.deleteIfExists(tempInputFile); - Files.deleteIfExists(tempOutputFile); } } } diff --git a/src/main/java/stirling/software/SPDF/model/AuthenticationType.java b/src/main/java/stirling/software/SPDF/model/AuthenticationType.java index 58e7befb0fe..80419cdd24c 100644 --- a/src/main/java/stirling/software/SPDF/model/AuthenticationType.java +++ b/src/main/java/stirling/software/SPDF/model/AuthenticationType.java @@ -2,5 +2,5 @@ public enum AuthenticationType { WEB, - OAUTH2 + SSO } diff --git a/src/main/java/stirling/software/SPDF/repository/UserRepository.java b/src/main/java/stirling/software/SPDF/repository/UserRepository.java index 0f5387f79ba..e1f53efb838 100644 --- a/src/main/java/stirling/software/SPDF/repository/UserRepository.java +++ b/src/main/java/stirling/software/SPDF/repository/UserRepository.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -19,4 +20,6 @@ public interface UserRepository extends JpaRepository { Optional findByUsername(String username); Optional findByApiKey(String apiKey); + + List findByAuthenticationTypeIgnoreCase(String authenticationType); } diff --git a/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java b/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java index 373b4c916ff..f6e8a5b57e1 100644 --- a/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java +++ b/src/main/java/stirling/software/SPDF/service/PdfMetadataService.java @@ -17,15 +17,18 @@ public class PdfMetadataService { private final ApplicationProperties applicationProperties; private final String stirlingPDFLabel; private final UserServiceInterface userService; + private final boolean runningEE; @Autowired public PdfMetadataService( ApplicationProperties applicationProperties, @Qualifier("StirlingPDFLabel") String stirlingPDFLabel, + @Qualifier("runningEE") boolean runningEE, @Autowired(required = false) UserServiceInterface userService) { this.applicationProperties = applicationProperties; this.stirlingPDFLabel = stirlingPDFLabel; this.userService = userService; + this.runningEE = runningEE; } public PdfMetadata extractMetadataFromPdf(PDDocument pdf) { @@ -61,10 +64,8 @@ private void setNewDocumentMetadata(PDDocument pdf, PdfMetadata pdfMetadata) { String creator = stirlingPDFLabel; - if (applicationProperties - .getEnterpriseEdition() - .getCustomMetadata() - .isAutoUpdateMetadata()) { + if (applicationProperties.getEnterpriseEdition().getCustomMetadata().isAutoUpdateMetadata() + && runningEE) { creator = applicationProperties.getEnterpriseEdition().getCustomMetadata().getCreator(); pdf.getDocumentInformation().setProducer(stirlingPDFLabel); @@ -83,10 +84,8 @@ private void setCommonMetadata(PDDocument pdf, PdfMetadata pdfMetadata) { pdf.getDocumentInformation().setModificationDate(Calendar.getInstance()); String author = pdfMetadata.getAuthor(); - if (applicationProperties - .getEnterpriseEdition() - .getCustomMetadata() - .isAutoUpdateMetadata()) { + if (applicationProperties.getEnterpriseEdition().getCustomMetadata().isAutoUpdateMetadata() + && runningEE) { author = applicationProperties.getEnterpriseEdition().getCustomMetadata().getAuthor(); if (userService != null) { diff --git a/src/main/java/stirling/software/SPDF/utils/FileToPdf.java b/src/main/java/stirling/software/SPDF/utils/FileToPdf.java index 3dabef54908..ecfe3e5d969 100644 --- a/src/main/java/stirling/software/SPDF/utils/FileToPdf.java +++ b/src/main/java/stirling/software/SPDF/utils/FileToPdf.java @@ -241,7 +241,7 @@ public static byte[] convertBookTypeToPdf(byte[] bytes, String originalFilename) Files.deleteIfExists(tempOutputFile); } } - + static String sanitizeZipFilename(String entryName) { if (entryName == null || entryName.trim().isEmpty()) { return entryName; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index aba201e124d..41cb3832d00 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,6 +3,10 @@ multipart.enabled=true logging.level.org.springframework=WARN logging.level.org.hibernate=WARN 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.com.zaxxer.hikari=WARN spring.jpa.open-in-view=false @@ -27,6 +31,8 @@ server.servlet.context-path=${SYSTEM_ROOTURIPATH:/} spring.devtools.restart.enabled=true spring.devtools.livereload.enabled=true +spring.devtools.restart.exclude=stirling.software.SPDF.config.security/** + spring.thymeleaf.encoding=UTF-8 spring.web.resources.mime-mappings.webmanifest=application/manifest+json diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 837acd895b6..eded10e7e85 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -16,7 +16,7 @@ security: csrfDisabled: true # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts - loginMethod: all # 'all' (Login Username/Password and OAuth2[must be enabled and configured]), 'normal'(only Login with Username/Password) or 'oauth2'(only Login with OAuth2) + loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) initialLogin: username: '' # initial username for the first login password: '' # initial password for the first login @@ -42,14 +42,14 @@ security: issuer: '' # set to any provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint clientId: '' # client ID from your provider clientSecret: '' # client secret from your provider - autoCreateUser: false # set to 'true' to allow auto-creation of non-existing users + autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin useAsUsername: email # default is 'email'; custom fields can be used as the username scopes: openid, profile, email # specify the scopes for which the application will request permissions provider: google # set this to your OAuth provider's name, e.g., 'google' or 'keycloak' saml2: - enabled: false # currently in alpha, not recommended for use yet, enableAlphaFunctionality must be set to true - autoCreateUser: false # set to 'true' to allow auto-creation of non-existing users + enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) + autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin registrationId: stirling idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata diff --git a/src/main/resources/static/js/fileInput.js b/src/main/resources/static/js/fileInput.js index 671bb039a76..4553128d52b 100644 --- a/src/main/resources/static/js/fileInput.js +++ b/src/main/resources/static/js/fileInput.js @@ -83,21 +83,22 @@ function setupFileInput(chooser) { $("#" + elementId).on("change", function (e) { let element = e.target; const isDragAndDrop = e.detail?.source == 'drag-drop'; + if (element instanceof HTMLInputElement && element.hasAttribute("multiple")) { allFiles = isDragAndDrop ? allFiles : [... allFiles, ... element.files]; - } else { - allFiles = Array.from(isDragAndDrop ? allFiles : [element.files[0]]); - } + } else { + allFiles = Array.from(isDragAndDrop ? allFiles : [element.files[0]]); + } if (!isDragAndDrop) { - let dataTransfer = new DataTransfer(); - allFiles.forEach(file => dataTransfer.items.add(file)); - element.files = dataTransfer.files; + let dataTransfer = new DataTransfer(); + allFiles.forEach(file => dataTransfer.items.add(file)); + element.files = dataTransfer.files; } handleFileInputChange(this); this.dispatchEvent(new CustomEvent("file-input-change", { bubbles: true })); - }); +}); function handleFileInputChange(inputElement) { const files = allFiles; diff --git a/src/main/resources/templates/addUsers.html b/src/main/resources/templates/addUsers.html index ba020c4f649..c94fa04163a 100644 --- a/src/main/resources/templates/addUsers.html +++ b/src/main/resources/templates/addUsers.html @@ -189,7 +189,7 @@