From 559581c59dd4b59368996dc4685f8fc344f40f4f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Fri, 15 Nov 2024 13:12:20 +0000 Subject: [PATCH] Merge branch 'bug/remember-me' of git@github.com:Stirling-Tools/Stirling-PDF.git into bug/remember-me --- .../security/saml2/CertificateUtils.java | 87 +++++++++++++++++++ ...stomSaml2AuthenticationFailureHandler.java | 50 +++++++---- .../LoggingSamlAuthenticationProvider.java | 67 ++++++++++++++ .../SPDF/model/ApplicationProperties.java | 36 ++------ .../software/SPDF/utils/GeneralUtils.java | 23 +++++ src/main/resources/static/js/downloader.js | 70 +++++++++++++-- src/main/resources/static/js/githubVersion.js | 2 +- .../resources/templates/fragments/common.html | 1 + 8 files changed, 278 insertions(+), 58 deletions(-) create mode 100644 src/main/java/stirling/software/SPDF/config/security/saml2/LoggingSamlAuthenticationProvider.java 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 4f0d2488a..b3af0e14b 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 @@ -1,18 +1,25 @@ package stirling.software.SPDF.config.security.saml2; import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.io.InputStreamReader; +import java.net.URL; 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 java.util.Scanner; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import lombok.extern.slf4j.Slf4j; + +@Slf4j public class CertificateUtils { public static X509Certificate readCertificate(Resource certificateResource) throws Exception { @@ -39,4 +46,84 @@ public class CertificateUtils { .generatePrivate(new PKCS8EncodedKeySpec(decodedKey)); } } + + + public static X509Certificate getIdPCertificate(Resource certificateResource) throws Exception { + + if (certificateResource instanceof UrlResource) { + return extractCertificateFromMetadata(certificateResource); + } else { + // Treat as file resource + return readCertificate(certificateResource); + } + } + + private static X509Certificate extractCertificateFromMetadata(Resource metadataResource) throws Exception { + log.info("Attempting to extract certificate from metadata resource: {}", metadataResource.getDescription()); + + try (InputStream is = metadataResource.getInputStream()) { + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + log.info("Retrieved metadata content, length: {}", content.length()); + + // Find the certificate data + int startIndex = content.indexOf(""); + int endIndex = content.indexOf(""); + + if (startIndex == -1 || endIndex == -1) { + log.error("Certificate tags not found in metadata"); + throw new Exception("Certificate tags not found in metadata"); + } + + // Extract certificate data + String certData = content.substring( + startIndex + "".length(), + endIndex + ).trim(); + + log.info("Found certificate data, length: {}", certData.length()); + + // Remove any whitespace and newlines from cert data + certData = certData.replaceAll("\\s+", ""); + + // Reconstruct PEM format with proper line breaks + StringBuilder pemBuilder = new StringBuilder(); + pemBuilder.append("-----BEGIN CERTIFICATE-----\n"); + + // Insert line breaks every 64 characters + int lineLength = 64; + for (int i = 0; i < certData.length(); i += lineLength) { + int end = Math.min(i + lineLength, certData.length()); + pemBuilder.append(certData, i, end).append('\n'); + } + + pemBuilder.append("-----END CERTIFICATE-----"); + String pemCert = pemBuilder.toString(); + + log.debug("Reconstructed PEM certificate:\n{}", pemCert); + + try { + ByteArrayInputStream pemStream = new ByteArrayInputStream(pemCert.getBytes(StandardCharsets.UTF_8)); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert = (X509Certificate) cf.generateCertificate(pemStream); + + log.info("Successfully parsed certificate. Subject: {}", cert.getSubjectX500Principal()); + + // Optional: check validity dates + cert.checkValidity(); // Throws CertificateExpiredException if expired + log.info("Certificate is valid (not expired)"); + + return cert; + + } catch (Exception e) { + log.error("Failed to parse certificate", e); + throw new Exception("Failed to parse X509 certificate from metadata", e); + } + } catch (Exception e) { + log.error("Error processing metadata resource", e); + throw e; + } + } + + } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java index 3b652d358..d60f70ce3 100644 --- a/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/CustomSaml2AuthenticationFailureHandler.java @@ -16,23 +16,35 @@ import lombok.extern.slf4j.Slf4j; @Slf4j public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { - @Override - public void onAuthenticationFailure( - HttpServletRequest request, - HttpServletResponse response, - AuthenticationException exception) - throws IOException, ServletException { - if (exception instanceof Saml2AuthenticationException) { - Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error(); - getRedirectStrategy() - .sendRedirect(request, response, "/login?erroroauth=" + error.getErrorCode()); - } else if (exception instanceof ProviderNotFoundException) { - getRedirectStrategy() - .sendRedirect( - request, - response, - "/login?erroroauth=not_authentication_provider_found"); - } - log.error("AuthenticationException: " + exception); - } + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) + throws IOException, ServletException { + + if (exception instanceof Saml2AuthenticationException saml2Exception) { + Saml2Error error = saml2Exception.getSaml2Error(); + + // Log detailed information about the SAML error + log.error("SAML Authentication failed with error code: {}", error.getErrorCode()); + log.error("Error description: {}", error.getDescription()); + + // Redirect to login with specific error code + getRedirectStrategy() + .sendRedirect(request, response, "/login?erroroauth=" + error.getErrorCode()); + } else if (exception instanceof ProviderNotFoundException) { + log.error("Authentication failed: No authentication provider found"); + + getRedirectStrategy() + .sendRedirect( + request, + response, + "/login?erroroauth=not_authentication_provider_found"); + } else { + log.error("Unknown AuthenticationException: {}", exception.getMessage()); + getRedirectStrategy().sendRedirect(request, response, "/login?erroroauth=unknown_error"); + } + } + } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml2/LoggingSamlAuthenticationProvider.java b/src/main/java/stirling/software/SPDF/config/security/saml2/LoggingSamlAuthenticationProvider.java new file mode 100644 index 000000000..18421a1a6 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml2/LoggingSamlAuthenticationProvider.java @@ -0,0 +1,67 @@ +package stirling.software.SPDF.config.security.saml2; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.saml2.core.Saml2ErrorCodes; + +public class LoggingSamlAuthenticationProvider implements AuthenticationProvider { + + private static final Logger log = LoggerFactory.getLogger(LoggingSamlAuthenticationProvider.class); + private final OpenSaml4AuthenticationProvider delegate; + + public LoggingSamlAuthenticationProvider(OpenSaml4AuthenticationProvider delegate) { + this.delegate = delegate; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + if (authentication instanceof Saml2AuthenticationToken token) { + String samlResponse = token.getSaml2Response(); + + // Log the raw SAML response + log.info("Raw SAML Response (Base64): {}", samlResponse); + + // Decode and log the SAML response XML + try { + String decodedResponse = new String(Base64.getDecoder().decode(samlResponse), StandardCharsets.UTF_8); + log.info("Decoded SAML Response XML:\n{}", decodedResponse); + } catch (IllegalArgumentException e) { + // If decoding fails, it’s likely already plain XML + log.warn("SAML Response appears to be different format, not Base64-encoded."); + log.debug("SAML Response XML:\n{}", samlResponse); + } + // Delegate the actual authentication to the wrapped OpenSaml4AuthenticationProvider + try { + return delegate.authenticate(authentication); + } catch (Saml2AuthenticationException e) { + log.error("SAML authentication failed: {}"); + log.error("Detailed error message: {}", e); + throw e; + } + } + + return null; + } + + @Override + public boolean supports(Class authentication) { + // Only support Saml2AuthenticationToken + return Saml2AuthenticationToken.class.isAssignableFrom(authentication); + } +} \ No newline at end of file diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index 16397965a..4753a2df4 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -20,6 +20,7 @@ import org.springframework.core.annotation.Order; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import lombok.Data; import lombok.Getter; @@ -30,6 +31,7 @@ import stirling.software.SPDF.model.provider.GithubProvider; import stirling.software.SPDF.model.provider.GoogleProvider; import stirling.software.SPDF.model.provider.KeycloakProvider; import stirling.software.SPDF.model.provider.UnsupportedProviderException; +import stirling.software.SPDF.utils.GeneralUtils; @Configuration @ConfigurationProperties(prefix = "") @@ -133,44 +135,20 @@ public class ApplicationProperties { private String privateKey; private String spCert; - public InputStream getIdpMetadataUri() throws IOException { - if (idpMetadataUri.startsWith("classpath:")) { - return new ClassPathResource(idpMetadataUri.substring("classpath".length())) - .getInputStream(); - } - try { - URI uri = new URI(idpMetadataUri); - URL url = uri.toURL(); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - return connection.getInputStream(); - } catch (URISyntaxException e) { - throw new IOException("Invalid URI format: " + idpMetadataUri, e); - } + public Resource getIdpMetadataUri() throws IOException { + return GeneralUtils.filePathToResource(idpMetadataUri); } public Resource getSpCert() { - if (spCert.startsWith("classpath:")) { - return new ClassPathResource(spCert.substring("classpath:".length())); - } else { - return new FileSystemResource(spCert); - } + return GeneralUtils.filePathToResource(spCert); } public Resource getidpCert() { - if (idpCert.startsWith("classpath:")) { - return new ClassPathResource(idpCert.substring("classpath:".length())); - } else { - return new FileSystemResource(idpCert); - } + return GeneralUtils.filePathToResource(idpCert); } public Resource getPrivateKey() { - if (privateKey.startsWith("classpath:")) { - return new ClassPathResource(privateKey.substring("classpath:".length())); - } else { - return new FileSystemResource(privateKey); - } + return GeneralUtils.filePathToResource(privateKey); } } diff --git a/src/main/java/stirling/software/SPDF/utils/GeneralUtils.java b/src/main/java/stirling/software/SPDF/utils/GeneralUtils.java index 8e56c8df6..ed62e213c 100644 --- a/src/main/java/stirling/software/SPDF/utils/GeneralUtils.java +++ b/src/main/java/stirling/software/SPDF/utils/GeneralUtils.java @@ -29,6 +29,10 @@ import org.simpleyaml.configuration.implementation.SimpleYamlImplementation; import org.simpleyaml.configuration.implementation.snakeyaml.lib.DumperOptions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; import org.springframework.web.multipart.MultipartFile; import com.fathzer.soft.javaluator.DoubleEvaluator; @@ -349,4 +353,23 @@ public class GeneralUtils { return "GenericID"; } } + + public static Resource filePathToResource(String resourceFile) { + if (resourceFile == null) { + throw new IllegalStateException("file is not configured"); + } + + if (resourceFile.startsWith("classpath:")) { + return new ClassPathResource(resourceFile.substring("classpath:".length())); + } else if (resourceFile.startsWith("http://") || resourceFile.startsWith("https://")) { + try { + return new UrlResource(resourceFile); + } catch (Exception e) { + throw new RuntimeException("Failed to create URL resource: " + resourceFile, e); + } + } else { + return new FileSystemResource(resourceFile); + } + } + } diff --git a/src/main/resources/static/js/downloader.js b/src/main/resources/static/js/downloader.js index 1933c4fbf..7dda085c7 100644 --- a/src/main/resources/static/js/downloader.js +++ b/src/main/resources/static/js/downloader.js @@ -93,20 +93,71 @@ }); }); + function getPDFPageCount(file) { + try { + if (file.type !== 'application/pdf') { + return null; + } + + // Create a URL for the file + const url = URL.createObjectURL(file); + + try { + // Ensure the worker is properly set + if (!pdfjsLib.GlobalWorkerOptions.workerSrc) { + pdfjsLib.GlobalWorkerOptions.workerSrc = './pdfjs-legacy/pdf.worker.mjs'; + } + + // Load the PDF document + const loadingTask = pdfjsLib.getDocument(url); + const pdf = await loadingTask.promise; + + // Get the page count + const pageCount = pdf.numPages; + return pageCount; + } finally { + // Clean up the URL + URL.revokeObjectURL(url); + } + } catch (error) { + console.error('Error counting PDF pages:', error); + return null; + } + } + + function trackFileProcessing(file, startTime, success, errorMessage = null) { + const endTime = performance.now(); + const processingTime = endTime - startTime; + + posthog.capture('file_processing', { + success: success, + file_type: file.type || 'unknown', + file_size: file.size, + processing_time: processingTime, + error_message: errorMessage, + pdf_pages: file.type === 'application/pdf' ? getPDFPageCount(file) : null + }); + } + async function handleSingleDownload(url, formData, isMulti = false, isZip = false) { + const startTime = performance.now(); + const file = formData.get('fileInput'); + try { const response = await fetch(url, { method: "POST", body: formData }); const contentType = response.headers.get("content-type"); if (!response.ok) { if (response.status === 401) { - // Handle 401 Unauthorized error showSessionExpiredPrompt(); + trackFileProcessing(file, startTime, false, 'unauthorized'); return; } if (contentType && contentType.includes("application/json")) { console.error("Throwing error banner, response was not okay"); - return handleJsonResponse(response); + const jsonResponse = await handleJsonResponse(response); + trackFileProcessing(file, startTime, false, jsonResponse?.error || 'unknown_error'); + return jsonResponse; } throw new Error(`HTTP error! status: ${response.status}`); } @@ -115,13 +166,15 @@ let filename = getFilenameFromContentDisposition(contentDisposition); const blob = await response.blob(); - if (contentType.includes("application/pdf") || contentType.includes("image/")) { - return handleResponse(blob, filename, !isMulti, isZip); - } else { - return handleResponse(blob, filename, false, isZip); - } + const result = await handleResponse(blob, filename, !isMulti, isZip); + + // Track successful processing + trackFileProcessing(file, startTime, true, null); + + return result; } catch (error) { console.error("Error in handleSingleDownload:", error); + trackFileProcessing(file, startTime, false, error.message); throw error; } } @@ -162,8 +215,7 @@ if (considerViewOptions) { if (downloadOption === "sameWindow") { const url = URL.createObjectURL(blob); - window.location.href = url; - return; +return { filename, blob, url }; } else if (downloadOption === "newWindow") { const url = URL.createObjectURL(blob); window.open(url, "_blank"); diff --git a/src/main/resources/static/js/githubVersion.js b/src/main/resources/static/js/githubVersion.js index eebb0ae88..3a564ecba 100644 --- a/src/main/resources/static/js/githubVersion.js +++ b/src/main/resources/static/js/githubVersion.js @@ -76,7 +76,7 @@ async function checkForUpdate() { document.getElementById("update-btn").style.display = "block"; } if (updateLink !== null) { - document.getElementById("app-update").innerHTML = updateAvailable.replace("{0}", '' + currentVersion + '').replace("{1}", '' + latestVersion + ''); + document.getElementById("app-update").innerText = updateAvailable.replace("{0}", '' + currentVersion + '').replace("{1}", '' + latestVersion + ''); if (updateLink.classList.contains("visually-hidden")) { updateLink.classList.remove("visually-hidden"); } diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index dd74d3882..e710cae81 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -201,6 +201,7 @@ window.stirlingPDF.error = /*[[#{error}]]*/ "Error"; })(); +