Compare commits
61
Commits
v2.3.1
...
fix/saml-login
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaafa21842 | ||
|
|
1cc562a6b1 | ||
|
|
d90e51233d | ||
|
|
1cd3e2846e | ||
|
|
080faf9353 | ||
|
|
d486bb4939 | ||
|
|
41f9929fd2 | ||
|
|
0b0db1793e | ||
|
|
f3cf747cfe | ||
|
|
e3982ed4c5 | ||
|
|
5c7d675960 | ||
|
|
7fc6ec5fe1 | ||
|
|
7722001463 | ||
|
|
43d4b46b31 | ||
|
|
eee17d4d19 | ||
|
|
1a62680dcc | ||
|
|
789642a824 | ||
|
|
e1199a1fd3 | ||
|
|
c0f9c9570e | ||
|
|
a8c4a0bfb1 | ||
|
|
99d728d815 | ||
|
|
3317babe5c | ||
|
|
5ad5d851b1 | ||
|
|
6cedc8c994 | ||
|
|
eba24b4d6c | ||
|
|
68e95c2652 | ||
|
|
de23752b44 | ||
|
|
b44bb7c50c | ||
|
|
537aee3ab6 | ||
|
|
a6ae74e794 | ||
|
|
50ec2d82d4 | ||
|
|
f95e2fdaa3 | ||
|
|
70c9cb36c3 | ||
|
|
1b737cbce5 | ||
|
|
4492fa39be | ||
|
|
c40144db19 | ||
|
|
c77cd73deb | ||
|
|
b76f3662e4 | ||
|
|
188408fc1e | ||
|
|
0b86dd79d3 | ||
|
|
0436460c03 | ||
|
|
4d84dcdd42 | ||
|
|
039e3b5fa8 | ||
|
|
15c5b0eb92 | ||
|
|
36c9369404 | ||
|
|
43f3261972 | ||
|
|
3d7efc5d94 | ||
|
|
ebf256fd50 | ||
|
|
f678e1d2e3 | ||
|
|
81c14351ee | ||
|
|
3711d8d6b1 | ||
|
|
a3b2a9b3e3 | ||
|
|
dd7925fbf4 | ||
|
|
1b1d17f6f5 | ||
|
|
58a00dde24 | ||
|
|
a95583b9e0 | ||
|
|
56f788957a | ||
|
|
df1295059a | ||
|
|
853161e891 | ||
|
|
64b33ea62b | ||
|
|
0ca4600371 |
+1
-1
@@ -213,7 +213,7 @@ id_ed25519.pub
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ipynb_checkpoints
|
||||
|
||||
.build-cache
|
||||
|
||||
|
||||
**/jcef-bundle/
|
||||
|
||||
@@ -199,7 +199,6 @@ return useToolOperation({
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
- **Lombok**: Used extensively - ensure IDE plugin is installed
|
||||
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
|
||||
- **File Persistence**:
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
|
||||
@@ -41,8 +41,8 @@ dependencies {
|
||||
api "org.apache.pdfbox:preflight:$pdfboxVersion"
|
||||
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:2.10'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
|
||||
@@ -303,22 +303,23 @@ public class EndpointConfiguration {
|
||||
// Adding endpoints to "PageOps" group
|
||||
addEndpointToGroup("PageOps", "remove-pages");
|
||||
addEndpointToGroup("PageOps", "merge-pdfs");
|
||||
addEndpointToGroup("PageOps", "split-pdfs");
|
||||
addEndpointToGroup("PageOps", "pdf-organizer");
|
||||
addEndpointToGroup("PageOps", "split-pages");
|
||||
addEndpointToGroup("PageOps", "rearrange-pages");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
addEndpointToGroup("PageOps", "crop");
|
||||
addEndpointToGroup("PageOps", "extract-page");
|
||||
addEndpointToGroup("PageOps", "pdf-to-single-page");
|
||||
addEndpointToGroup("PageOps", "auto-split-pdf");
|
||||
addEndpointToGroup("PageOps", "split-by-size-or-count");
|
||||
addEndpointToGroup("PageOps", "overlay-pdf");
|
||||
addEndpointToGroup("PageOps", "split-pdf-by-sections");
|
||||
addEndpointToGroup("PageOps", "split-pdf-by-chapters");
|
||||
addEndpointToGroup("PageOps", "add-page-numbers");
|
||||
addEndpointToGroup("PageOps", "extract-pages");
|
||||
|
||||
// Adding endpoints to "Convert" group
|
||||
// Adding endpoints to "Convert" group (Frontend has 15 convert endpoints)
|
||||
addEndpointToGroup("Convert", "pdf-to-img");
|
||||
addEndpointToGroup("Convert", "img-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-pdfa");
|
||||
@@ -334,6 +335,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-epub");
|
||||
// Backend-only endpoints (not in frontend tool registry)
|
||||
addEndpointToGroup("Convert", "pdf-to-vector");
|
||||
addEndpointToGroup("Convert", "vector-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-video");
|
||||
@@ -341,6 +344,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
addEndpointToGroup("Convert", "pdf-to-json");
|
||||
addEndpointToGroup("Convert", "json-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-rtf");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -351,51 +355,49 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Security", "remove-cert-sign");
|
||||
addEndpointToGroup("Security", "sanitize-pdf");
|
||||
addEndpointToGroup("Security", "auto-redact");
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "validate-signature");
|
||||
addEndpointToGroup("Security", "add-stamp");
|
||||
addEndpointToGroup("Security", "unlock-pdf-forms");
|
||||
// Backend-only endpoints (not in frontend tool registry endpoints)
|
||||
addEndpointToGroup("Security", "redact");
|
||||
addEndpointToGroup("Security", "verify-pdf");
|
||||
addEndpointToGroup("Security", "stamp");
|
||||
addEndpointToGroup("Security", "sign");
|
||||
|
||||
// Adding endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "ocr-pdf");
|
||||
addEndpointToGroup("Other", "add-image");
|
||||
addEndpointToGroup("Other", "extract-images");
|
||||
addEndpointToGroup("Other", "change-metadata");
|
||||
addEndpointToGroup("Other", "update-metadata");
|
||||
addEndpointToGroup("Other", "flatten");
|
||||
addEndpointToGroup("Other", "unlock-pdf-forms");
|
||||
addEndpointToGroup("Other", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Other", "remove-annotations");
|
||||
addEndpointToGroup("Other", "compare");
|
||||
addEndpointToGroup("Other", "add-page-numbers");
|
||||
addEndpointToGroup("Other", "get-info-on-pdf");
|
||||
addEndpointToGroup("Other", "remove-image-pdf");
|
||||
addEndpointToGroup("Other", "add-attachments");
|
||||
addEndpointToGroup("Other", "replace-invert-pdf");
|
||||
addEndpointToGroup("Other", "edit-table-of-contents");
|
||||
addEndpointToGroup("Other", "text-editor-pdf");
|
||||
// Backend-only endpoints (not in frontend tool registry endpoints)
|
||||
addEndpointToGroup("Other", "add-image");
|
||||
addEndpointToGroup("Other", "compare");
|
||||
addEndpointToGroup("Other", "view-pdf");
|
||||
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
|
||||
addEndpointToGroup("Other", "multi-tool");
|
||||
|
||||
// Adding form-related endpoints to "Other" group
|
||||
addEndpointToGroup("Other", "fields");
|
||||
addEndpointToGroup("Other", "modify-fields");
|
||||
addEndpointToGroup("Other", "delete-fields");
|
||||
addEndpointToGroup("Other", "fill");
|
||||
|
||||
// Adding endpoints to "Advance" group
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "compress-pdf");
|
||||
addEndpointToGroup("Advance", "extract-image-scans");
|
||||
addEndpointToGroup("Advance", "repair");
|
||||
addEndpointToGroup("Advance", "auto-rename");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
addEndpointToGroup("Advance", "handleData");
|
||||
addEndpointToGroup("Advance", "scanner-effect");
|
||||
addEndpointToGroup("Advance", "auto-split-pdf");
|
||||
addEndpointToGroup("Advance", "show-javascript");
|
||||
addEndpointToGroup("Advance", "split-by-size-or-count");
|
||||
addEndpointToGroup("Advance", "overlay-pdf");
|
||||
addEndpointToGroup("Advance", "split-pdf-by-sections");
|
||||
addEndpointToGroup("Advance", "edit-table-of-contents");
|
||||
addEndpointToGroup("Advance", "split-pdf-by-chapters");
|
||||
// Backend-only endpoints
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
@@ -436,8 +438,8 @@ public class EndpointConfiguration {
|
||||
// Java
|
||||
addEndpointToGroup("Java", "merge-pdfs");
|
||||
addEndpointToGroup("Java", "remove-pages");
|
||||
addEndpointToGroup("Java", "split-pdfs");
|
||||
addEndpointToGroup("Java", "pdf-organizer");
|
||||
addEndpointToGroup("Java", "split-pages");
|
||||
addEndpointToGroup("Java", "rearrange-pages");
|
||||
addEndpointToGroup("Java", "rotate-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-img");
|
||||
addEndpointToGroup("Java", "img-to-pdf");
|
||||
@@ -445,9 +447,10 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "remove-password");
|
||||
addEndpointToGroup("Java", "change-permissions");
|
||||
addEndpointToGroup("Java", "add-watermark");
|
||||
addEndpointToGroup("Java", "add-stamp");
|
||||
addEndpointToGroup("Java", "add-image");
|
||||
addEndpointToGroup("Java", "extract-images");
|
||||
addEndpointToGroup("Java", "change-metadata");
|
||||
addEndpointToGroup("Java", "update-metadata");
|
||||
addEndpointToGroup("Java", "cert-sign");
|
||||
addEndpointToGroup("Java", "remove-cert-sign");
|
||||
addEndpointToGroup("Java", "multi-page-layout");
|
||||
@@ -459,7 +462,6 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "sanitize-pdf");
|
||||
addEndpointToGroup("Java", "crop");
|
||||
addEndpointToGroup("Java", "get-info-on-pdf");
|
||||
addEndpointToGroup("Java", "extract-page");
|
||||
addEndpointToGroup("Java", "pdf-to-single-page");
|
||||
addEndpointToGroup("Java", "markdown-to-pdf");
|
||||
addEndpointToGroup("Java", "show-javascript");
|
||||
@@ -469,7 +471,9 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "split-by-size-or-count");
|
||||
addEndpointToGroup("Java", "overlay-pdf");
|
||||
addEndpointToGroup("Java", "split-pdf-by-sections");
|
||||
addEndpointToGroup("Java", "split-pdf-by-chapters");
|
||||
addEndpointToGroup("Java", REMOVE_BLANKS);
|
||||
addEndpointToGroup("Java", "remove-annotations");
|
||||
addEndpointToGroup("Java", "pdf-to-text");
|
||||
addEndpointToGroup("Java", "remove-image-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
@@ -479,15 +483,24 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("Java", "pdf-to-json");
|
||||
addEndpointToGroup("Java", "json-to-pdf");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
addEndpointToGroup("Java", "pdf-to-video");
|
||||
addEndpointToGroup("Java", "verify-pdf");
|
||||
addEndpointToGroup("Java", "flatten");
|
||||
addEndpointToGroup("Java", "unlock-pdf-forms");
|
||||
addEndpointToGroup("Java", "validate-signature");
|
||||
addEndpointToGroup("Java", "text-editor-pdf");
|
||||
addEndpointToGroup("Java", "edit-table-of-contents");
|
||||
addEndpointToGroup("Java", "pdf-to-epub");
|
||||
addEndpointToGroup("Java", "eml-to-pdf");
|
||||
addEndpointToGroup("Java", "handleData");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
addEndpointToGroup("Javascript", "pdf-organizer");
|
||||
addEndpointToGroup("Javascript", "rearrange-pages");
|
||||
addEndpointToGroup("Javascript", "sign");
|
||||
addEndpointToGroup("Javascript", "compare");
|
||||
addEndpointToGroup("Javascript", "adjust-contrast");
|
||||
addEndpointToGroup("Javascript", "text-editor-pdf");
|
||||
|
||||
/* qpdf */
|
||||
addEndpointToGroup("qpdf", "repair");
|
||||
@@ -498,6 +511,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Ghostscript", "compress-pdf");
|
||||
addEndpointToGroup("Ghostscript", "crop");
|
||||
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
|
||||
addEndpointToGroup("Ghostscript", "scanner-effect");
|
||||
addEndpointToGroup("Ghostscript", "pdf-to-vector");
|
||||
addEndpointToGroup("Ghostscript", "vector-to-pdf");
|
||||
|
||||
@@ -516,6 +530,8 @@ public class EndpointConfiguration {
|
||||
addEndpointAlternative("compress-pdf", "qpdf");
|
||||
addEndpointAlternative("compress-pdf", "Ghostscript");
|
||||
addEndpointAlternative("compress-pdf", "Java");
|
||||
addEndpointAlternative("crop", "Ghostscript");
|
||||
addEndpointAlternative("crop", "Java");
|
||||
addEndpointAlternative("ocr-pdf", "tesseract");
|
||||
addEndpointAlternative("ocr-pdf", "OCRmyPDF");
|
||||
|
||||
@@ -544,6 +560,9 @@ public class EndpointConfiguration {
|
||||
// Pdftohtml dependent endpoints
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-html");
|
||||
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
|
||||
|
||||
// Calibre dependent endpoints
|
||||
addEndpointToGroup("Calibre", "pdf-to-epub");
|
||||
}
|
||||
|
||||
private void processEnvironmentConfigs() {
|
||||
|
||||
-24
@@ -1,8 +1,6 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Locale;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -61,28 +59,6 @@ public class InstallationPathConfig {
|
||||
}
|
||||
|
||||
private static String initializeBasePath() {
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
|
||||
if (os.contains("win")) {
|
||||
return Paths.get(
|
||||
System.getenv("APPDATA"), // parent path
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
} else if (os.contains("mac")) {
|
||||
return Paths.get(
|
||||
System.getProperty("user.home"),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
} else {
|
||||
return Paths.get(
|
||||
System.getProperty("user.home"), // parent path
|
||||
".config",
|
||||
"Stirling-PDF")
|
||||
+ File.separator;
|
||||
}
|
||||
}
|
||||
return "." + File.separator;
|
||||
}
|
||||
|
||||
|
||||
+144
-36
@@ -164,6 +164,7 @@ public class ApplicationProperties {
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
private String xFrameOptions = "DENY";
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -214,75 +215,182 @@ public class ApplicationProperties {
|
||||
@Setter
|
||||
@ToString
|
||||
public static class SAML2 {
|
||||
private String provider;
|
||||
private Boolean enabled = false;
|
||||
private Boolean autoCreateUser = false;
|
||||
private Boolean blockRegistration = false;
|
||||
private String registrationId = "stirling";
|
||||
private Boolean enableSingleLogout = false;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("idpMetadataUri")
|
||||
@ToString.Exclude private String metadataUri;
|
||||
|
||||
private Provider provider = new Provider();
|
||||
private SP sp = new SP();
|
||||
|
||||
// Legacy field mappings for backward compatibility
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpMetadataUri;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpSingleLogoutUrl;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpSingleLoginUrl;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpIssuer;
|
||||
|
||||
@JsonProperty("idpCert")
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpEntityId;
|
||||
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String idpCert;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("privateKey")
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String privateKey;
|
||||
|
||||
@ToString.Exclude
|
||||
@JsonProperty("spCert")
|
||||
@Deprecated(since = "2.3.1", forRemoval = true)
|
||||
@JsonIgnore
|
||||
private String spCert;
|
||||
|
||||
/** Migrate legacy flat properties to new nested structure on set. */
|
||||
public void setIdpMetadataUri(String value) {
|
||||
this.idpMetadataUri = value;
|
||||
if (value != null
|
||||
&& !value.isBlank()
|
||||
&& (metadataUri == null || metadataUri.isBlank())) {
|
||||
this.metadataUri = value;
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpSingleLoginUrl(String value) {
|
||||
this.idpSingleLoginUrl = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setSingleLoginUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpSingleLogoutUrl(String value) {
|
||||
this.idpSingleLogoutUrl = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setSingleLogoutUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpIssuer(String value) {
|
||||
this.idpIssuer = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setEntityId(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpEntityId(String value) {
|
||||
this.idpEntityId = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setEntityId(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setIdpCert(String value) {
|
||||
this.idpCert = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.provider.setCert(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setPrivateKey(String value) {
|
||||
this.privateKey = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.sp.setPrivateKey(value);
|
||||
}
|
||||
}
|
||||
|
||||
public void setSpCert(String value) {
|
||||
this.spCert = value;
|
||||
if (value != null && !value.isBlank()) {
|
||||
this.sp.setCert(value);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public InputStream getIdpMetadataUri() throws IOException {
|
||||
if (idpMetadataUri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
|
||||
public InputStream getMetadataUriAsStream() throws IOException {
|
||||
String uri = getEffectiveMetadataUri();
|
||||
if (uri == null || uri.isBlank()) {
|
||||
throw new IOException("security.saml2.metadataUri is not configured");
|
||||
}
|
||||
if (uri.startsWith("classpath:")) {
|
||||
return new ClassPathResource(uri.substring("classpath:".length()))
|
||||
.getInputStream();
|
||||
}
|
||||
try {
|
||||
URI uri = new URI(idpMetadataUri);
|
||||
URL url = uri.toURL();
|
||||
URI parsedUri = new URI(uri);
|
||||
URL url = parsedUri.toURL();
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("GET");
|
||||
return connection.getInputStream();
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IOException("Invalid URI format: " + idpMetadataUri, e);
|
||||
throw new IOException("Invalid URI format: " + uri, e);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getSpCert() {
|
||||
if (spCert == null) return null;
|
||||
if (spCert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(spCert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(spCert);
|
||||
public String getEffectiveMetadataUri() {
|
||||
if (metadataUri != null && !metadataUri.isBlank()) {
|
||||
return metadataUri;
|
||||
}
|
||||
return idpMetadataUri; // Legacy fallback
|
||||
}
|
||||
|
||||
/** IdP configuration - manual fallback when metadata is unavailable. */
|
||||
@Data
|
||||
public static class Provider {
|
||||
private String name = ""; // Display name only
|
||||
private String singleLoginUrl;
|
||||
private String singleLogoutUrl;
|
||||
private String entityId;
|
||||
@ToString.Exclude private String cert;
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getCertResource() {
|
||||
if (cert == null) return null;
|
||||
if (cert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(cert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(cert);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getIdpCert() {
|
||||
if (idpCert == null) return null;
|
||||
if (idpCert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(idpCert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(idpCert);
|
||||
}
|
||||
}
|
||||
/** Service Provider (SP) credentials for signing SAML requests. */
|
||||
@Data
|
||||
public static class SP {
|
||||
@ToString.Exclude private String privateKey;
|
||||
@ToString.Exclude private String cert;
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getPrivateKey() {
|
||||
if (privateKey == null) return null;
|
||||
if (privateKey.startsWith("classpath:")) {
|
||||
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(privateKey);
|
||||
@JsonIgnore
|
||||
public Resource getPrivateKeyResource() {
|
||||
if (privateKey == null) return null;
|
||||
if (privateKey.startsWith("classpath:")) {
|
||||
return new ClassPathResource(privateKey.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Resource getCertResource() {
|
||||
if (cert == null) return null;
|
||||
if (cert.startsWith("classpath:")) {
|
||||
return new ClassPathResource(cert.substring("classpath:".length()));
|
||||
} else {
|
||||
return new FileSystemResource(cert);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
@@ -38,6 +40,10 @@ public class GeneralUtils {
|
||||
*/
|
||||
private static final int MAX_DNS_ADDRESSES = 20;
|
||||
|
||||
// Constants for size conversion
|
||||
private static final BigDecimal KIB = BigDecimal.valueOf(1024L);
|
||||
private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
|
||||
|
||||
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
|
||||
private final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
Set.of(
|
||||
@@ -539,39 +545,26 @@ public class GeneralUtils {
|
||||
|
||||
try {
|
||||
if (sizeStr.endsWith("TB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 4);
|
||||
} else if (sizeStr.endsWith("GB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 3);
|
||||
} else if (sizeStr.endsWith("MB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 2);
|
||||
} else if (sizeStr.endsWith("KB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 1);
|
||||
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
|
||||
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
|
||||
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 1)), 0);
|
||||
} else {
|
||||
// Use provided default unit or fall back to MB
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB";
|
||||
double value = Double.parseDouble(sizeStr);
|
||||
BigDecimal value = parseSizeValue(sizeStr);
|
||||
return switch (unit) {
|
||||
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
|
||||
case "GB" -> (long) (value * 1024L * 1024L * 1024L);
|
||||
case "MB" -> (long) (value * 1024L * 1024L);
|
||||
case "KB" -> (long) (value * 1024L);
|
||||
case "B" -> (long) value;
|
||||
default -> (long) (value * 1024L * 1024L); // Default to MB
|
||||
case "TB" -> toBytes(value, 4);
|
||||
case "GB" -> toBytes(value, 3);
|
||||
case "MB" -> toBytes(value, 2);
|
||||
case "KB" -> toBytes(value, 1);
|
||||
case "B" -> toBytes(value, 0);
|
||||
default -> toBytes(value, 2); // Default to MB
|
||||
};
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
@@ -590,6 +583,30 @@ public class GeneralUtils {
|
||||
return convertSizeToBytes(sizeStr, "MB");
|
||||
}
|
||||
|
||||
private Long toBytes(BigDecimal value, int powerOf1024) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.compareTo(BigDecimal.ZERO) < 0) {
|
||||
log.warn("Size value cannot be negative: {}", value);
|
||||
return null;
|
||||
}
|
||||
if (powerOf1024 < 0 || powerOf1024 > 4) {
|
||||
throw new IllegalArgumentException("Invalid power for size conversion: " + powerOf1024);
|
||||
}
|
||||
BigDecimal multiplier = powerOf1024 == 0 ? BigDecimal.ONE : KIB.pow(powerOf1024);
|
||||
BigDecimal bytes = value.multiply(multiplier).setScale(0, RoundingMode.DOWN);
|
||||
if (bytes.compareTo(LONG_MAX_DECIMAL) > 0) {
|
||||
log.warn("Size value too large to fit in long: {}", bytes);
|
||||
return null;
|
||||
}
|
||||
return bytes.longValue();
|
||||
}
|
||||
|
||||
private BigDecimal parseSizeValue(String value) {
|
||||
return new BigDecimal(value);
|
||||
}
|
||||
|
||||
/* Validates if a string represents a valid size unit. */
|
||||
private boolean isValidSizeUnit(String unit) {
|
||||
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
|
||||
|
||||
@@ -29,7 +29,7 @@ public class RequestUriUtils {
|
||||
|| normalizedUri.startsWith("/pdfium/")
|
||||
|| normalizedUri.startsWith("/assets/")
|
||||
|| normalizedUri.startsWith("/locales/")
|
||||
|| normalizedUri.startsWith("/Login/")
|
||||
|| normalizedUri.startsWith("/login/")
|
||||
|| normalizedUri.startsWith("/samples/")
|
||||
|| normalizedUri.startsWith("/classic-logo/")
|
||||
|| normalizedUri.startsWith("/modern-logo/")
|
||||
@@ -38,12 +38,12 @@ public class RequestUriUtils {
|
||||
}
|
||||
|
||||
// Specific static files bundled with the frontend
|
||||
if (normalizedUri.equals("/robots.txt")
|
||||
|| normalizedUri.equals("/favicon.ico")
|
||||
|| normalizedUri.equals("/manifest.json")
|
||||
|| normalizedUri.equals("/site.webmanifest")
|
||||
|| normalizedUri.equals("/manifest-classic.json")
|
||||
|| normalizedUri.equals("/index.html")) {
|
||||
if ("/robots.txt".equals(normalizedUri)
|
||||
|| "/favicon.ico".equals(normalizedUri)
|
||||
|| "/manifest.json".equals(normalizedUri)
|
||||
|| "/site.webmanifest".equals(normalizedUri)
|
||||
|| "/manifest-classic.json".equals(normalizedUri)
|
||||
|| "/index.html".equals(normalizedUri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -165,14 +165,20 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith("/logout")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/ui-data/footer-info") // Public footer configuration
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
// Health Endoints
|
||||
|| trimmedUri.startsWith("/actuator/health")
|
||||
|| trimmedUri.startsWith("/health")
|
||||
|| trimmedUri.startsWith("/healthz")
|
||||
|| trimmedUri.startsWith("/liveness")
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
|
||||
+8
-8
@@ -31,9 +31,9 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
String url = server.url("/meta").toString();
|
||||
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
s.setIdpMetadataUri(url);
|
||||
s.setMetadataUri(url);
|
||||
|
||||
try (InputStream in = s.getIdpMetadataUri()) {
|
||||
try (InputStream in = s.getMetadataUriAsStream()) {
|
||||
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(body.contains("EntityDescriptor"));
|
||||
}
|
||||
@@ -44,9 +44,9 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
|
||||
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
|
||||
s.setMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
|
||||
|
||||
assertThrows(IOException.class, s::getIdpMetadataUri);
|
||||
assertThrows(IOException.class, s::getMetadataUriAsStream);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,8 +57,8 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
|
||||
Files.writeString(tmp, "CERT");
|
||||
|
||||
s.setSpCert(tmp.toString());
|
||||
Resource r = s.getSpCert();
|
||||
s.getSp().setCert(tmp.toString());
|
||||
Resource r = s.getSp().getCertResource();
|
||||
|
||||
assertNotNull(r);
|
||||
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
|
||||
@@ -71,8 +71,8 @@ class ApplicationPropertiesSaml2HttpTest {
|
||||
|
||||
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
|
||||
String missing = "/this/path/does/not/exist/idp.crt";
|
||||
s.setIdpCert(missing);
|
||||
Resource r = s.getIdpCert();
|
||||
s.getProvider().setCert(missing);
|
||||
Resource r = s.getProvider().getCertResource();
|
||||
|
||||
assertNotNull(r);
|
||||
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
|
||||
|
||||
+15
-16
@@ -15,9 +15,9 @@ class ApplicationPropertiesSaml2ResourceTest {
|
||||
@Test
|
||||
void idpMetadataUri_classpath_is_resolved() throws Exception {
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
s.setIdpMetadataUri("classpath:saml/dummy.txt");
|
||||
s.setMetadataUri("classpath:saml/dummy.txt");
|
||||
|
||||
try (InputStream in = s.getIdpMetadataUri()) {
|
||||
try (InputStream in = s.getMetadataUriAsStream()) {
|
||||
assertNotNull(in, "Classpath InputStream should not be null");
|
||||
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertTrue(txt.contains("ok"));
|
||||
@@ -28,27 +28,26 @@ class ApplicationPropertiesSaml2ResourceTest {
|
||||
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
|
||||
var s = new ApplicationProperties.Security.SAML2();
|
||||
|
||||
s.setSpCert(null);
|
||||
s.setIdpCert(null);
|
||||
s.setPrivateKey(null);
|
||||
assertNull(s.getSpCert());
|
||||
assertNull(s.getIdpCert());
|
||||
assertNull(s.getPrivateKey());
|
||||
// Test null values via the nested structure
|
||||
assertNull(s.getSp().getCertResource());
|
||||
assertNull(s.getProvider().getCertResource());
|
||||
assertNull(s.getSp().getPrivateKeyResource());
|
||||
|
||||
s.setSpCert("classpath:saml/dummy.txt");
|
||||
s.setIdpCert("classpath:saml/dummy.txt");
|
||||
s.setPrivateKey("classpath:saml/dummy.txt");
|
||||
Resource sp = s.getSpCert();
|
||||
Resource idp = s.getIdpCert();
|
||||
Resource pk = s.getPrivateKey();
|
||||
// Set classpath resources via the nested structure
|
||||
s.getSp().setCert("classpath:saml/dummy.txt");
|
||||
s.getProvider().setCert("classpath:saml/dummy.txt");
|
||||
s.getSp().setPrivateKey("classpath:saml/dummy.txt");
|
||||
Resource sp = s.getSp().getCertResource();
|
||||
Resource idp = s.getProvider().getCertResource();
|
||||
Resource pk = s.getSp().getPrivateKeyResource();
|
||||
assertTrue(sp.exists());
|
||||
assertTrue(idp.exists());
|
||||
assertTrue(pk.exists());
|
||||
|
||||
Path tmp = Files.createTempFile("spdf-key-", ".pem");
|
||||
Files.writeString(tmp, "KEY");
|
||||
s.setPrivateKey(tmp.toString());
|
||||
Resource pkFs = s.getPrivateKey();
|
||||
s.getSp().setPrivateKey(tmp.toString());
|
||||
Resource pkFs = s.getSp().getPrivateKeyResource();
|
||||
assertNotNull(pkFs);
|
||||
assertTrue(pkFs.exists());
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ class GeneralUtilsAdditionalTest {
|
||||
assertNull(GeneralUtils.convertSizeToBytes(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testConvertSizeToBytesEdgeCases() {
|
||||
assertNull(GeneralUtils.convertSizeToBytes("-10MB"));
|
||||
assertNull(GeneralUtils.convertSizeToBytes("10000000TB")); // overflow beyond long
|
||||
assertEquals(1099511627776L, GeneralUtils.convertSizeToBytes("1TB"));
|
||||
assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2.5GB"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testFormatBytes() {
|
||||
assertEquals("512 B", GeneralUtils.formatBytes(512));
|
||||
|
||||
+2
-19
@@ -38,13 +38,6 @@ spotless {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|
||||
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
|
||||
&& project.getProperty('STIRLING_PDF_DESKTOP_UI') != 'false')) {
|
||||
implementation 'org.openjfx:javafx-controls:21'
|
||||
implementation 'org.openjfx:javafx-swing:21'
|
||||
}
|
||||
|
||||
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|
||||
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
|
||||
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
|
||||
@@ -58,7 +51,7 @@ dependencies {
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.16.1'
|
||||
implementation 'io.micrometer:micrometer-core:1.16.2'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
@@ -115,19 +108,8 @@ sourceSets {
|
||||
resources {
|
||||
srcDirs += ['../configs']
|
||||
}
|
||||
java {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
|
||||
exclude 'stirling/software/SPDF/UI/impl/**'
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
test {
|
||||
java {
|
||||
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
|
||||
exclude 'stirling/software/SPDF/UI/impl/**'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -239,6 +221,7 @@ tasks.register('npmBuild', Exec) {
|
||||
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
|
||||
dependsOn npmInstall
|
||||
inputs.dir(new File(frontendDir, 'src'))
|
||||
inputs.dir(new File(frontendDir, 'public'))
|
||||
inputs.file(new File(frontendDir, 'package.json'))
|
||||
outputs.dir(frontendDistDir)
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import io.github.pixee.security.SystemCommand;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -28,7 +27,6 @@ import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.ConfigInitializer;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.UrlUtils;
|
||||
|
||||
@Slf4j
|
||||
@EnableScheduling
|
||||
@@ -60,19 +58,6 @@ public class SPDFApplication {
|
||||
|
||||
Properties props = new Properties();
|
||||
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
System.setProperty("java.awt.headless", "false");
|
||||
app.setHeadless(false);
|
||||
props.put("java.awt.headless", "false");
|
||||
props.put("spring.main.web-application-type", "servlet");
|
||||
|
||||
int desiredPort = 8080;
|
||||
String port = UrlUtils.findAvailablePort(desiredPort);
|
||||
props.put("server.port", port);
|
||||
System.setProperty("server.port", port);
|
||||
log.info("Desktop UI mode: Using port {}", port);
|
||||
}
|
||||
|
||||
app.setAdditionalProfiles(getActiveProfile(args));
|
||||
|
||||
ConfigInitializer initializer = new ConfigInitializer();
|
||||
@@ -153,13 +138,6 @@ public class SPDFApplication {
|
||||
"Running in Tauri mode. Parent process PID: {}",
|
||||
parentPid != null ? parentPid : "not set");
|
||||
}
|
||||
// Desktop UI initialization removed - webBrowser dependency eliminated
|
||||
// Keep backwards compatibility for STIRLING_PDF_DESKTOP_UI system property
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
|
||||
log.info("Desktop UI mode enabled, but WebBrowser functionality has been removed");
|
||||
// webBrowser.initWebUI(url); // Removed - desktop UI eliminated
|
||||
}
|
||||
|
||||
// Standard browser opening logic
|
||||
String browserOpenEnv = env.getProperty("BROWSER_OPEN");
|
||||
boolean browserOpen = browserOpenEnv != null && "true".equalsIgnoreCase(browserOpenEnv);
|
||||
@@ -192,14 +170,6 @@ public class SPDFApplication {
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void cleanup() {
|
||||
// webBrowser cleanup removed - desktop UI eliminated
|
||||
// if (webBrowser != null) {
|
||||
// webBrowser.cleanup();
|
||||
// }
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onWebServerInitialized(WebServerInitializedEvent event) {
|
||||
int actualPort = event.getWebServer().getPort();
|
||||
|
||||
@@ -118,7 +118,8 @@ public class ExternalAppDepConfig {
|
||||
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(
|
||||
group, EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -143,7 +144,8 @@ public class ExternalAppDepConfig {
|
||||
commandToGroupMapping.getOrDefault(
|
||||
command, List.of("Weasyprint"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(
|
||||
group, EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
log.warn(
|
||||
"WeasyPrint version {} is below required {} - disabling"
|
||||
@@ -172,7 +174,8 @@ public class ExternalAppDepConfig {
|
||||
List<String> affectedGroups =
|
||||
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
|
||||
for (String group : affectedGroups) {
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(
|
||||
group, EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
}
|
||||
log.warn(
|
||||
"qpdf version {} is below required {} - disabling group(s): {}",
|
||||
@@ -226,7 +229,8 @@ public class ExternalAppDepConfig {
|
||||
int ec = runAndWait(List.of(python, "-c", "import cv2"), DEFAULT_TIMEOUT).exitCode();
|
||||
if (ec != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup(
|
||||
"OpenCV", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
@@ -236,8 +240,10 @@ public class ExternalAppDepConfig {
|
||||
private void disablePythonAndOpenCV(String reason) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup(
|
||||
"Python", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup(
|
||||
"OpenCV", EndpointConfiguration.DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python (reason: {}) - Disabling Python features: {} and OpenCV"
|
||||
+ " features: {}",
|
||||
|
||||
@@ -18,26 +18,22 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CropController {
|
||||
@@ -122,7 +118,7 @@ public class CropController {
|
||||
return r >= threshold && g >= threshold && b >= threshold;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Crops a PDF document",
|
||||
description =
|
||||
|
||||
+9
-6
@@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -27,21 +26,23 @@ import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class EditTableOfContentsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostMapping(value = "/extract-bookmarks", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/extract-bookmarks",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@@ -142,7 +143,9 @@ public class EditTableOfContentsController {
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/edit-table-of-contents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/edit-table-of-contents",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Edit Table of Contents",
|
||||
description = "Add or edit bookmarks/table of contents in a PDF document.")
|
||||
|
||||
+6
-8
@@ -14,34 +14,32 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralFormCopyUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MultiPageLayoutController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(
|
||||
value = "/multi-page-layout",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Merge multiple pages of a PDF document into a single page",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -15,26 +15,22 @@ import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@GeneralApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class ScalePagesController {
|
||||
@@ -116,7 +112,7 @@ public class ScalePagesController {
|
||||
return sizeMap;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Change the size of a PDF page/document",
|
||||
description =
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
@@ -52,385 +49,4 @@ public class SettingsController {
|
||||
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
|
||||
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
|
||||
}
|
||||
|
||||
// ========== GENERAL SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/general")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("ui", applicationProperties.getUi());
|
||||
settings.put(
|
||||
"system",
|
||||
Map.of(
|
||||
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
|
||||
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
|
||||
"showUpdateOnlyAdmin",
|
||||
applicationProperties.getSystem().isShowUpdateOnlyAdmin(),
|
||||
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
|
||||
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/general")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
// Update UI settings
|
||||
if (settings.containsKey("ui")) {
|
||||
Map<String, String> ui = (Map<String, String>) settings.get("ui");
|
||||
if (ui.containsKey("appNameNavbar")) {
|
||||
GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar"));
|
||||
applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar"));
|
||||
}
|
||||
}
|
||||
|
||||
// Update System settings
|
||||
if (settings.containsKey("system")) {
|
||||
Map<String, Object> system = (Map<String, Object>) settings.get("system");
|
||||
if (system.containsKey("defaultLocale")) {
|
||||
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setDefaultLocale((String) system.get("defaultLocale"));
|
||||
}
|
||||
if (system.containsKey("showUpdate")) {
|
||||
GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate"));
|
||||
applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate"));
|
||||
}
|
||||
if (system.containsKey("showUpdateOnlyAdmin")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin"));
|
||||
}
|
||||
if (system.containsKey("fileUploadLimit")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.fileUploadLimit", system.get("fileUploadLimit"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setFileUploadLimit((String) system.get("fileUploadLimit"));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"General settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== SECURITY SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/security")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getSecuritySettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
settings.put("enableLogin", security.isEnableLogin());
|
||||
settings.put("loginMethod", security.getLoginMethod());
|
||||
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
||||
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
||||
settings.put(
|
||||
"initialLogin",
|
||||
Map.of(
|
||||
"username",
|
||||
security.getInitialLogin().getUsername() != null
|
||||
? security.getInitialLogin().getUsername()
|
||||
: ""));
|
||||
|
||||
// JWT settings
|
||||
ApplicationProperties.Security.Jwt jwt = security.getJwt();
|
||||
settings.put(
|
||||
"jwt",
|
||||
Map.of(
|
||||
"enableKeystore", jwt.isEnableKeystore(),
|
||||
"enableKeyRotation", jwt.isEnableKeyRotation(),
|
||||
"enableKeyCleanup", jwt.isEnableKeyCleanup(),
|
||||
"keyRetentionDays", jwt.getKeyRetentionDays()));
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/security")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableLogin")) {
|
||||
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setEnableLogin((Boolean) settings.get("enableLogin"));
|
||||
}
|
||||
if (settings.containsKey("loginMethod")) {
|
||||
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setLoginMethod((String) settings.get("loginMethod"));
|
||||
}
|
||||
if (settings.containsKey("loginAttemptCount")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.loginAttemptCount", settings.get("loginAttemptCount"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setLoginAttemptCount((Integer) settings.get("loginAttemptCount"));
|
||||
}
|
||||
if (settings.containsKey("loginResetTimeMinutes")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setLoginResetTimeMinutes(
|
||||
((Number) settings.get("loginResetTimeMinutes")).longValue());
|
||||
}
|
||||
|
||||
// JWT settings
|
||||
if (settings.containsKey("jwt")) {
|
||||
Map<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
|
||||
if (jwt.containsKey("keyRetentionDays")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getJwt()
|
||||
.setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Security settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== CONNECTIONS SETTINGS (OAuth/SAML) ==========
|
||||
|
||||
@GetMapping("/admin/settings/connections")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getConnectionsSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||
|
||||
// OAuth2 settings
|
||||
ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2();
|
||||
settings.put(
|
||||
"oauth2",
|
||||
Map.of(
|
||||
"enabled", oauth2.getEnabled(),
|
||||
"issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "",
|
||||
"clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "",
|
||||
"provider", oauth2.getProvider() != null ? oauth2.getProvider() : "",
|
||||
"autoCreateUser", oauth2.getAutoCreateUser(),
|
||||
"blockRegistration", oauth2.getBlockRegistration(),
|
||||
"useAsUsername",
|
||||
oauth2.getUseAsUsername() != null
|
||||
? oauth2.getUseAsUsername()
|
||||
: ""));
|
||||
|
||||
// SAML2 settings
|
||||
ApplicationProperties.Security.SAML2 saml2 = security.getSaml2();
|
||||
settings.put(
|
||||
"saml2",
|
||||
Map.of(
|
||||
"enabled", saml2.getEnabled(),
|
||||
"provider", saml2.getProvider() != null ? saml2.getProvider() : "",
|
||||
"autoCreateUser", saml2.getAutoCreateUser(),
|
||||
"blockRegistration", saml2.getBlockRegistration(),
|
||||
"registrationId", saml2.getRegistrationId()));
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/connections")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateConnectionsSettings(
|
||||
@RequestBody Map<String, Object> settings) throws IOException {
|
||||
// OAuth2 settings
|
||||
if (settings.containsKey("oauth2")) {
|
||||
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
|
||||
if (oauth2.containsKey("enabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setEnabled((Boolean) oauth2.get("enabled"));
|
||||
}
|
||||
if (oauth2.containsKey("issuer")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setIssuer((String) oauth2.get("issuer"));
|
||||
}
|
||||
if (oauth2.containsKey("clientId")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setClientId((String) oauth2.get("clientId"));
|
||||
}
|
||||
if (oauth2.containsKey("clientSecret")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.clientSecret", oauth2.get("clientSecret"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setClientSecret((String) oauth2.get("clientSecret"));
|
||||
}
|
||||
if (oauth2.containsKey("provider")) {
|
||||
GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setProvider((String) oauth2.get("provider"));
|
||||
}
|
||||
if (oauth2.containsKey("autoCreateUser")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.autoCreateUser", oauth2.get("autoCreateUser"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setAutoCreateUser((Boolean) oauth2.get("autoCreateUser"));
|
||||
}
|
||||
if (oauth2.containsKey("blockRegistration")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.blockRegistration", oauth2.get("blockRegistration"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setBlockRegistration((Boolean) oauth2.get("blockRegistration"));
|
||||
}
|
||||
if (oauth2.containsKey("useAsUsername")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.oauth2.useAsUsername", oauth2.get("useAsUsername"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getOauth2()
|
||||
.setUseAsUsername((String) oauth2.get("useAsUsername"));
|
||||
}
|
||||
}
|
||||
|
||||
// SAML2 settings
|
||||
if (settings.containsKey("saml2")) {
|
||||
Map<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
|
||||
if (saml2.containsKey("enabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setEnabled((Boolean) saml2.get("enabled"));
|
||||
}
|
||||
if (saml2.containsKey("provider")) {
|
||||
GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setProvider((String) saml2.get("provider"));
|
||||
}
|
||||
if (saml2.containsKey("autoCreateUser")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.autoCreateUser", saml2.get("autoCreateUser"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setAutoCreateUser((Boolean) saml2.get("autoCreateUser"));
|
||||
}
|
||||
if (saml2.containsKey("blockRegistration")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.blockRegistration", saml2.get("blockRegistration"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getSaml2()
|
||||
.setBlockRegistration((Boolean) saml2.get("blockRegistration"));
|
||||
}
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Connection settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== PRIVACY SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/privacy")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getPrivacySettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
|
||||
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
settings.put("googleVisibility", applicationProperties.getSystem().isGooglevisibility());
|
||||
settings.put("metricsEnabled", applicationProperties.getMetrics().isEnabled());
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/privacy")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableAnalytics")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAnalytics", settings.get("enableAnalytics"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
|
||||
}
|
||||
if (settings.containsKey("googleVisibility")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.googlevisibility", settings.get("googleVisibility"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setGooglevisibility((Boolean) settings.get("googleVisibility"));
|
||||
}
|
||||
if (settings.containsKey("metricsEnabled")) {
|
||||
GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled"));
|
||||
applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Privacy settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
|
||||
// ========== ADVANCED SETTINGS ==========
|
||||
|
||||
@GetMapping("/admin/settings/advanced")
|
||||
@Hidden
|
||||
public ResponseEntity<Map<String, Object>> getAdvancedSettings() {
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
|
||||
settings.put("endpoints", applicationProperties.getEndpoints());
|
||||
settings.put(
|
||||
"enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().isEnableAlphaFunctionality());
|
||||
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
|
||||
settings.put("enableUrlToPDF", applicationProperties.getSystem().isEnableUrlToPDF());
|
||||
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
|
||||
settings.put(
|
||||
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
|
||||
|
||||
return ResponseEntity.ok(settings);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/settings/advanced")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings)
|
||||
throws IOException {
|
||||
if (settings.containsKey("enableAlphaFunctionality")) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setEnableAlphaFunctionality(
|
||||
(Boolean) settings.get("enableAlphaFunctionality"));
|
||||
}
|
||||
if (settings.containsKey("maxDPI")) {
|
||||
GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI"));
|
||||
applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI"));
|
||||
}
|
||||
if (settings.containsKey("enableUrlToPDF")) {
|
||||
GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF"));
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF"));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Advanced settings updated. Restart required for changes to take effect.");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-8
@@ -15,20 +15,18 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
@@ -36,9 +34,7 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertEbookToPDFController {
|
||||
@@ -58,7 +54,7 @@ public class ConvertEbookToPDFController {
|
||||
return endpointConfiguration.isGroupEnabled("Ghostscript");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
|
||||
@Operation(
|
||||
summary = "Convert an eBook file to PDF",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -12,14 +12,10 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -28,15 +24,15 @@ import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
|
||||
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ConvertPDFToEpubController {
|
||||
@@ -77,7 +73,7 @@ public class ConvertPDFToEpubController {
|
||||
return command;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
|
||||
@Operation(
|
||||
summary = "Convert PDF to EPUB/AZW3",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -3,31 +3,27 @@ package stirling.software.SPDF.controller.api.converters;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToHtml {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
|
||||
@Operation(
|
||||
summary = "Convert PDF to HTML",
|
||||
description =
|
||||
|
||||
+7
-11
@@ -7,19 +7,17 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
|
||||
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -28,9 +26,7 @@ import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToOffice {
|
||||
|
||||
@@ -38,7 +34,7 @@ public class ConvertPDFToOffice {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Presentation format",
|
||||
description =
|
||||
@@ -53,7 +49,7 @@ public class ConvertPDFToOffice {
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Text or RTF format",
|
||||
description =
|
||||
@@ -79,7 +75,7 @@ public class ConvertPDFToOffice {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Word document",
|
||||
description =
|
||||
@@ -93,7 +89,7 @@ public class ConvertPDFToOffice {
|
||||
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
|
||||
@Operation(
|
||||
summary = "Convert PDF to XML",
|
||||
description =
|
||||
|
||||
+17
-16
@@ -74,30 +74,26 @@ import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPDFToPDFA {
|
||||
|
||||
@@ -483,15 +479,22 @@ public class ConvertPDFToPDFA {
|
||||
command.add("-dCompatibilityLevel=" + profile.getCompatibilityLevel());
|
||||
command.add("-sDEVICE=pdfwrite");
|
||||
command.add("-sColorConversionStrategy=RGB");
|
||||
command.add("-dProcessColorModel=/DeviceRGB");
|
||||
command.add("-sOutputICCProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
command.add("-sDefaultRGBProfile=" + colorProfiles.rgb().toAbsolutePath());
|
||||
command.add("-sDefaultGrayProfile=" + colorProfiles.gray().toAbsolutePath());
|
||||
command.add("-dEmbedAllFonts=true");
|
||||
command.add("-dSubsetFonts=false"); // Embed complete fonts to avoid incomplete glyphs
|
||||
command.add("-dSubsetFonts=true");
|
||||
command.add("-dCompressFonts=true");
|
||||
command.add("-dNOSUBSTFONTS=false"); // Allow font substitution for problematic fonts
|
||||
command.add("-dPDFSETTINGS=/prepress");
|
||||
|
||||
// Explicitly tune downsampling/compression for high-quality print
|
||||
command.add("-dColorImageDownsampleType=/Bicubic");
|
||||
command.add("-dColorImageResolution=300");
|
||||
command.add("-dGrayImageDownsampleType=/Bicubic");
|
||||
command.add("-dGrayImageResolution=300");
|
||||
command.add("-dMonoImageDownsampleType=/Bicubic");
|
||||
command.add("-dMonoImageResolution=1200");
|
||||
|
||||
command.add("-dNOPAUSE");
|
||||
command.add("-dBATCH");
|
||||
command.add("-dNOOUTERSAVE");
|
||||
@@ -562,7 +565,7 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
|
||||
@Operation(
|
||||
summary = "Convert a PDF to a PDF/A or PDF/X",
|
||||
description =
|
||||
@@ -2449,9 +2452,7 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
@Getter
|
||||
private enum PdfXProfile {
|
||||
PDF_X_1("PDF/X-1", "_PDFX-1.pdf", "1.3", "2001", "pdfx-1", "pdfx"),
|
||||
PDF_X_3("PDF/X-3", "_PDFX-3.pdf", "1.3", "2003", "pdfx-3"),
|
||||
PDF_X_4("PDF/X-4", "_PDFX-4.pdf", "1.4", "2008", "pdfx-4");
|
||||
PDF_X("PDF/X", "_PDFX.pdf", "1.6", "2008", "pdfx");
|
||||
|
||||
private final String displayName;
|
||||
private final String suffix;
|
||||
@@ -2477,7 +2478,7 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
static PdfXProfile fromRequest(String requestToken) {
|
||||
if (requestToken == null) {
|
||||
return PDF_X_4;
|
||||
return PDF_X;
|
||||
}
|
||||
String normalized = requestToken.trim().toLowerCase(Locale.ROOT);
|
||||
Optional<PdfXProfile> match =
|
||||
@@ -2485,7 +2486,7 @@ public class ConvertPDFToPDFA {
|
||||
.filter(profile -> profile.requestTokens.contains(normalized))
|
||||
.findFirst();
|
||||
|
||||
return match.orElse(PDF_X_4);
|
||||
return match.orElse(PDF_X);
|
||||
}
|
||||
|
||||
String outputSuffix() {
|
||||
|
||||
+3
-4
@@ -9,7 +9,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
@@ -89,7 +88,7 @@ public class ConvertPdfJsonController {
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
|
||||
@Operation(
|
||||
summary = "Extract PDF metadata for text editor lazy loading",
|
||||
description =
|
||||
@@ -127,7 +126,7 @@ public class ConvertPdfJsonController {
|
||||
.body(jsonBytes);
|
||||
}
|
||||
|
||||
@PostMapping(
|
||||
@AutoJobPostMapping(
|
||||
value = "/pdf/text-editor/partial/{jobId}",
|
||||
consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@StandardPdfResponse
|
||||
@@ -180,7 +179,7 @@ public class ConvertPdfJsonController {
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -27,18 +27,16 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
@@ -51,9 +49,7 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfToVideoController {
|
||||
|
||||
@@ -68,7 +64,7 @@ public class ConvertPdfToVideoController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Video Slideshow",
|
||||
description =
|
||||
|
||||
+4
-8
@@ -21,19 +21,17 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -43,10 +41,8 @@ import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertWebsiteToPDF {
|
||||
|
||||
@@ -59,7 +55,7 @@ public class ConvertWebsiteToPDF {
|
||||
|
||||
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
|
||||
@Operation(
|
||||
summary = "Convert a URL to a PDF",
|
||||
description =
|
||||
|
||||
+5
-9
@@ -13,12 +13,8 @@ import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@@ -27,6 +23,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
@@ -35,10 +33,8 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/convert")
|
||||
@ConvertApi
|
||||
@Slf4j
|
||||
@Tag(name = "Convert", description = "Convert APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class PdfVectorExportController {
|
||||
|
||||
@@ -49,7 +45,7 @@ public class PdfVectorExportController {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
|
||||
@Operation(
|
||||
summary = "Convert PostScript formats to PDF",
|
||||
description =
|
||||
@@ -94,7 +90,7 @@ public class PdfVectorExportController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
|
||||
@Operation(
|
||||
summary = "Convert PDF to vector format",
|
||||
description =
|
||||
|
||||
+17
-13
@@ -8,9 +8,6 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
@@ -18,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -28,20 +24,22 @@ import stirling.software.SPDF.model.api.filter.ContainsTextRequest;
|
||||
import stirling.software.SPDF.model.api.filter.FileSizeRequest;
|
||||
import stirling.software.SPDF.model.api.filter.PageRotationRequest;
|
||||
import stirling.software.SPDF.model.api.filter.PageSizeRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.FilterApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/filter")
|
||||
@Tag(name = "Filter", description = "Filter APIs")
|
||||
@FilterApi
|
||||
@RequiredArgsConstructor
|
||||
public class FilterController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-text")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-contains-text")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF contains set text, returns true if does",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -70,7 +68,9 @@ public class FilterController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-image")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-contains-image")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF contains an image",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -98,7 +98,9 @@ public class FilterController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-count")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-page-count")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -129,7 +131,7 @@ public class FilterController {
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is of a certain size",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -166,7 +168,7 @@ public class FilterController {
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is a set file size",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
@@ -194,7 +196,9 @@ public class FilterController {
|
||||
: ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-rotation")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/filter-page-rotation")
|
||||
@Operation(
|
||||
summary = "Checks if a PDF is of a certain rotation",
|
||||
description = "Input:PDF Output:Boolean Type:SISO")
|
||||
|
||||
+4
-8
@@ -36,20 +36,18 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.LineArtConversionService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
@@ -60,10 +58,8 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@MiscApi
|
||||
@Slf4j
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class CompressController {
|
||||
|
||||
@@ -922,7 +918,7 @@ public class CompressController {
|
||||
return Math.min(9, currentLevel + 1);
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
|
||||
@Operation(
|
||||
summary = "Optimize PDF file",
|
||||
description =
|
||||
|
||||
+10
@@ -112,6 +112,16 @@ public class ConfigController {
|
||||
"showSettingsWhenNoLogin",
|
||||
applicationProperties.getSystem().isShowSettingsWhenNoLogin());
|
||||
|
||||
// SSO Provider settings
|
||||
boolean enableOAuth =
|
||||
applicationProperties.getSecurity().getOauth2() != null
|
||||
&& applicationProperties.getSecurity().getOauth2().getEnabled();
|
||||
boolean enableSaml =
|
||||
applicationProperties.getSecurity().getSaml2() != null
|
||||
&& applicationProperties.getSecurity().getSaml2().getEnabled();
|
||||
configData.put("enableOAuth", enableOAuth);
|
||||
configData.put("enableSaml", enableSaml);
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
|
||||
|
||||
+4
-8
@@ -24,20 +24,18 @@ import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
@@ -50,9 +48,7 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@MiscApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class OCRController {
|
||||
@@ -85,7 +81,7 @@ public class OCRController {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
|
||||
@Operation(
|
||||
summary = "Process a PDF file with OCR",
|
||||
description =
|
||||
|
||||
+202
-65
@@ -7,11 +7,13 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
@@ -34,17 +36,15 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.WebDataBinder;
|
||||
import org.springframework.web.bind.annotation.InitBinder;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.misc.AddStampRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.MiscApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -53,15 +53,20 @@ import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/misc")
|
||||
@Tag(name = "Misc", description = "Miscellaneous APIs")
|
||||
@MiscApi
|
||||
@RequiredArgsConstructor
|
||||
public class StampController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
private static final int MAX_DATE_FORMAT_LENGTH = 50;
|
||||
private static final Pattern SAFE_DATE_FORMAT_PATTERN =
|
||||
Pattern.compile("^[yMdHhmsS/\\-:\\s.,'+EGuwWDFzZXa]+$");
|
||||
private static final Pattern CUSTOM_DATE_PATTERN = Pattern.compile("@date\\{([^}]{1,50})\\}");
|
||||
// Placeholder for escaped @ symbol (using Unicode private use area)
|
||||
private static final String ESCAPED_AT_PLACEHOLDER = "\uE000ESCAPED_AT\uE000";
|
||||
|
||||
/**
|
||||
* Initialize data binder for multipart file uploads. This method registers a custom editor for
|
||||
* MultipartFile to handle file uploads. It sets the MultipartFile to null if the uploaded file
|
||||
@@ -79,7 +84,7 @@ public class StampController {
|
||||
});
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
|
||||
@Operation(
|
||||
summary = "Add stamp to a PDF file",
|
||||
description =
|
||||
@@ -170,7 +175,9 @@ public class StampController {
|
||||
overrideX,
|
||||
overrideY,
|
||||
margin,
|
||||
customColor);
|
||||
customColor,
|
||||
pageIndex,
|
||||
pdfFileName);
|
||||
} else if ("image".equalsIgnoreCase(stampType)) {
|
||||
addImageStamp(
|
||||
contentStream,
|
||||
@@ -205,9 +212,11 @@ public class StampController {
|
||||
float fontSize,
|
||||
String alphabet,
|
||||
float overrideX, // X override
|
||||
float overrideY,
|
||||
float overrideY, // Y override
|
||||
float margin,
|
||||
String colorString) // Y override
|
||||
String colorString,
|
||||
int currentPageNumber,
|
||||
String filename)
|
||||
throws IOException {
|
||||
String resourceDir;
|
||||
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
@@ -235,8 +244,6 @@ public class StampController {
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.setFont(font, fontSize);
|
||||
|
||||
Color redactColor;
|
||||
try {
|
||||
if (!colorString.startsWith("#")) {
|
||||
@@ -244,45 +251,54 @@ public class StampController {
|
||||
}
|
||||
redactColor = Color.decode(colorString);
|
||||
} catch (NumberFormatException e) {
|
||||
|
||||
redactColor = Color.LIGHT_GRAY;
|
||||
}
|
||||
|
||||
contentStream.setNonStrokingColor(redactColor);
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
float x, y;
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
// Use override values if provided
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
|
||||
y =
|
||||
calculatePositionY(
|
||||
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
|
||||
}
|
||||
|
||||
String currentDate = LocalDate.now().toString();
|
||||
String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
String processedStampText =
|
||||
stampText
|
||||
.replace("@date", currentDate)
|
||||
.replace("@time", currentTime)
|
||||
.replace("@page_count", String.valueOf(pageCount));
|
||||
processStampText(stampText, currentPageNumber, pageCount, filename, document);
|
||||
|
||||
// Split the stampText into multiple lines
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(stampText);
|
||||
String normalizedText =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getEscapedNewlinePattern()
|
||||
.matcher(processedStampText)
|
||||
.replaceAll("\n");
|
||||
String[] lines = normalizedText.split("\\r?\\n");
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
// Use fontSize directly (default 40 if not specified)
|
||||
float effectiveFontSize = fontSize > 0 ? fontSize : 40f;
|
||||
|
||||
contentStream.setFont(font, effectiveFontSize);
|
||||
|
||||
// Calculate dynamic line height based on font ascent and descent
|
||||
float ascent = font.getFontDescriptor().getAscent();
|
||||
float descent = font.getFontDescriptor().getDescent();
|
||||
float lineHeight = ((ascent - descent) / 1000) * fontSize;
|
||||
float lineHeight = ((ascent - descent) / 1000) * effectiveFontSize;
|
||||
|
||||
float maxLineWidth = 0;
|
||||
for (String line : lines) {
|
||||
float lineWidth = calculateTextWidth(line, font, effectiveFontSize);
|
||||
if (lineWidth > maxLineWidth) {
|
||||
maxLineWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
float totalTextHeight = lines.length * lineHeight;
|
||||
|
||||
float x, y;
|
||||
|
||||
if (overrideX >= 0 && overrideY >= 0) {
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, maxLineWidth, margin);
|
||||
y = calculatePositionY(pageSize, position, totalTextHeight, margin);
|
||||
}
|
||||
|
||||
contentStream.beginText();
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
@@ -295,6 +311,140 @@ public class StampController {
|
||||
contentStream.endText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process stamp text by replacing all @commands with their actual values. Supported commands:
|
||||
*
|
||||
* <p>Date & Time:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@date - Current date (YYYY-MM-DD)
|
||||
* <li>@time - Current time (HH:mm:ss)
|
||||
* <li>@datetime - Current date and time (YYYY-MM-DD HH:mm:ss)
|
||||
* <li>@date{format} - Custom date/time format (e.g., @date{dd/MM/yyyy})
|
||||
* <li>@year - Current year (4 digits)
|
||||
* <li>@month - Current month (01-12)
|
||||
* <li>@day - Current day of month (01-31)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Page Information:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@page_number or @page - Current page number
|
||||
* <li>@total_pages or @page_count - Total number of pages
|
||||
* </ul>
|
||||
*
|
||||
* <p>File Information:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@filename - Original filename (without extension)
|
||||
* <li>@filename_full - Original filename (with extension)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Document Metadata:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@author - Document author (from PDF metadata)
|
||||
* <li>@title - Document title (from PDF metadata)
|
||||
* <li>@subject - Document subject (from PDF metadata)
|
||||
* </ul>
|
||||
*
|
||||
* <p>Other:
|
||||
*
|
||||
* <ul>
|
||||
* <li>@uuid - Short unique identifier (8 characters)
|
||||
* </ul>
|
||||
*/
|
||||
private String processStampText(
|
||||
String stampText,
|
||||
int currentPageNumber,
|
||||
int totalPages,
|
||||
String filename,
|
||||
PDDocument document) {
|
||||
if (stampText == null || stampText.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Handle escaped @@ sequences first - replace with placeholder to preserve literal @
|
||||
String result = stampText.replace("@@", ESCAPED_AT_PLACEHOLDER);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String currentDate = now.toLocalDate().toString();
|
||||
String currentTime = now.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
|
||||
String currentDateTime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
String filenameWithoutExt = filename != null ? filename : "";
|
||||
if (filename != null && filename.contains(".")) {
|
||||
int lastDot = filename.lastIndexOf('.');
|
||||
if (lastDot > 0) { // Ensure there's actually a name before the dot
|
||||
filenameWithoutExt = filename.substring(0, lastDot);
|
||||
}
|
||||
}
|
||||
|
||||
String author = "";
|
||||
String title = "";
|
||||
String subject = "";
|
||||
if (document != null && document.getDocumentInformation() != null) {
|
||||
var info = document.getDocumentInformation();
|
||||
author = info.getAuthor() != null ? info.getAuthor() : "";
|
||||
title = info.getTitle() != null ? info.getTitle() : "";
|
||||
subject = info.getSubject() != null ? info.getSubject() : "";
|
||||
}
|
||||
|
||||
String uuid = UUID.randomUUID().toString().substring(0, 8);
|
||||
|
||||
// Process @date{format} with custom format first (must be before simple @date)
|
||||
Matcher matcher = CUSTOM_DATE_PATTERN.matcher(result);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (matcher.find()) {
|
||||
String format = matcher.group(1);
|
||||
String replacement = processCustomDateFormat(format, now);
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
result = sb.toString();
|
||||
|
||||
result =
|
||||
result.replace("@datetime", currentDateTime)
|
||||
.replace("@date", currentDate)
|
||||
.replace("@time", currentTime)
|
||||
.replace("@year", String.valueOf(now.getYear()))
|
||||
.replace("@month", String.format("%02d", now.getMonthValue()))
|
||||
.replace("@day", String.format("%02d", now.getDayOfMonth()))
|
||||
.replace("@page_number", String.valueOf(currentPageNumber))
|
||||
.replace(
|
||||
"@page_count", String.valueOf(totalPages)) // Must come before @page
|
||||
.replace("@total_pages", String.valueOf(totalPages))
|
||||
.replace(
|
||||
"@page",
|
||||
String.valueOf(currentPageNumber)) // Must come after @page_count
|
||||
.replace("@filename_full", filename != null ? filename : "")
|
||||
.replace("@filename", filenameWithoutExt)
|
||||
.replace("@author", author)
|
||||
.replace("@title", title)
|
||||
.replace("@subject", subject)
|
||||
.replace("@uuid", uuid);
|
||||
|
||||
result = result.replace(ESCAPED_AT_PLACEHOLDER, "@");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private String processCustomDateFormat(String format, LocalDateTime now) {
|
||||
if (format == null || format.length() > MAX_DATE_FORMAT_LENGTH) {
|
||||
return "[invalid format: too long]";
|
||||
}
|
||||
|
||||
if (!SAFE_DATE_FORMAT_PATTERN.matcher(format).matches()) {
|
||||
return "[invalid format]";
|
||||
}
|
||||
|
||||
try {
|
||||
return now.format(DateTimeFormatter.ofPattern(format));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "[invalid format: " + format + "]";
|
||||
}
|
||||
}
|
||||
|
||||
private void addImageStamp(
|
||||
PDPageContentStream contentStream,
|
||||
MultipartFile stampImage,
|
||||
@@ -331,8 +481,8 @@ public class StampController {
|
||||
x = overrideX;
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, null, 0, null, margin);
|
||||
y = calculatePositionY(pageSize, position, fontSize, margin);
|
||||
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
|
||||
y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
|
||||
}
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
@@ -343,23 +493,14 @@ public class StampController {
|
||||
}
|
||||
|
||||
private float calculatePositionX(
|
||||
PDRectangle pageSize,
|
||||
int position,
|
||||
float contentWidth,
|
||||
PDFont font,
|
||||
float fontSize,
|
||||
String text,
|
||||
float margin)
|
||||
throws IOException {
|
||||
float actualWidth =
|
||||
(text != null) ? calculateTextWidth(text, font, fontSize) : contentWidth;
|
||||
PDRectangle pageSize, int position, float contentWidth, float margin) {
|
||||
return switch (position % 3) {
|
||||
case 1: // Left
|
||||
yield pageSize.getLowerLeftX() + margin;
|
||||
case 2: // Center
|
||||
yield (pageSize.getWidth() - actualWidth) / 2;
|
||||
yield (pageSize.getWidth() - contentWidth) / 2;
|
||||
case 0: // Right
|
||||
yield pageSize.getUpperRightX() - actualWidth - margin;
|
||||
yield pageSize.getUpperRightX() - contentWidth - margin;
|
||||
default:
|
||||
yield 0;
|
||||
};
|
||||
@@ -368,12 +509,12 @@ public class StampController {
|
||||
private float calculatePositionY(
|
||||
PDRectangle pageSize, int position, float height, float margin) {
|
||||
return switch ((position - 1) / 3) {
|
||||
case 0: // Top
|
||||
yield pageSize.getUpperRightY() - height - margin;
|
||||
case 1: // Middle
|
||||
yield (pageSize.getHeight() - height) / 2;
|
||||
case 2: // Bottom
|
||||
yield pageSize.getLowerLeftY() + margin;
|
||||
case 0: // Top - first line near the top
|
||||
yield pageSize.getUpperRightY() - margin;
|
||||
case 1: // Middle - center of text block at page center
|
||||
yield (pageSize.getHeight() + height) / 2;
|
||||
case 2: // Bottom - first line positioned so last line is at bottom margin
|
||||
yield pageSize.getLowerLeftY() + margin + height;
|
||||
default:
|
||||
yield 0;
|
||||
};
|
||||
@@ -382,8 +523,4 @@ public class StampController {
|
||||
private float calculateTextWidth(String text, PDFont font, float fontSize) throws IOException {
|
||||
return font.getStringWidth(text) / 1000 * fontSize;
|
||||
}
|
||||
|
||||
private float calculateTextCapHeight(PDFont font, float fontSize) {
|
||||
return font.getFontDescriptor().getCapHeight() / 1000 * fontSize;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-279
@@ -3,21 +3,15 @@ package stirling.software.SPDF.controller.api.security;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.cos.COSInputStream;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.cos.COSString;
|
||||
import org.apache.pdfbox.io.RandomAccessRead;
|
||||
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDMetadata;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
@@ -35,8 +29,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
|
||||
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.*;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
@@ -44,23 +37,14 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
|
||||
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
|
||||
import org.apache.pdfbox.preflight.PreflightDocument;
|
||||
import org.apache.pdfbox.preflight.ValidationResult;
|
||||
import org.apache.pdfbox.preflight.exception.SyntaxValidationException;
|
||||
import org.apache.pdfbox.preflight.exception.ValidationException;
|
||||
import org.apache.pdfbox.preflight.parser.PreflightParser;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.xmpbox.XMPMetadata;
|
||||
import org.apache.xmpbox.schema.PDFAIdentificationSchema;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpParsingException;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -68,21 +52,22 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
@SecurityApi
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class GetInfoOnPDF {
|
||||
|
||||
@@ -95,6 +80,7 @@ public class GetInfoOnPDF {
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final VeraPDFService veraPDFService;
|
||||
|
||||
private static void addOutlinesToArray(PDOutlineItem outline, ArrayNode arrayNode) {
|
||||
if (outline == null) return;
|
||||
@@ -111,214 +97,8 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean checkForStandard(PDDocument document, String standardKeyword) {
|
||||
if ("PDF/A".equalsIgnoreCase(standardKeyword)) {
|
||||
return getPdfAConformanceLevel(document) != null;
|
||||
}
|
||||
|
||||
return checkStandardInMetadata(document, standardKeyword);
|
||||
}
|
||||
|
||||
public static String getPdfAConformanceLevel(PDDocument document) {
|
||||
if (document == null || document.isEncrypted()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getPdfAVersionFromMetadata(document);
|
||||
}
|
||||
|
||||
private static String getPdfAVersionFromMetadata(PDDocument document) {
|
||||
try {
|
||||
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
|
||||
if (pdMetadata != null) {
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
DomXmpParser domXmpParser = new DomXmpParser();
|
||||
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
|
||||
|
||||
PDFAIdentificationSchema pdfId = xmpMeta.getPDFAIdentificationSchema();
|
||||
if (pdfId != null) {
|
||||
Integer part = pdfId.getPart();
|
||||
String conformance = pdfId.getConformance();
|
||||
|
||||
if (part != null && conformance != null) {
|
||||
return part + conformance.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
} else {
|
||||
try (COSInputStream rawStream = pdMetadata.createInputStream()) {
|
||||
byte[] metadataBytes = rawStream.readAllBytes();
|
||||
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
|
||||
String extracted = extractPdfAVersionFromRawXml(rawMetadata);
|
||||
if (extracted != null) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (XmpParsingException e) {
|
||||
log.debug("XMP parsing failed, trying raw metadata search: {}", e.getMessage());
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
byte[] metadataBytes = metaStream.readAllBytes();
|
||||
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
|
||||
String extracted = extractPdfAVersionFromRawXml(rawMetadata);
|
||||
if (extracted != null) {
|
||||
return extracted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error reading PDF/A metadata: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String extractPdfAVersionFromRawXml(String rawXml) {
|
||||
if (rawXml == null || rawXml.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
Pattern partPattern = RegexPatternUtils.getInstance().getPdfAidPartPattern();
|
||||
Pattern confPattern = RegexPatternUtils.getInstance().getPdfAidConformancePattern();
|
||||
|
||||
Matcher partMatcher = partPattern.matcher(rawXml);
|
||||
Matcher confMatcher = confPattern.matcher(rawXml);
|
||||
|
||||
if (partMatcher.find() && confMatcher.find()) {
|
||||
String part = partMatcher.group(1);
|
||||
String conformance = confMatcher.group(1).toUpperCase(Locale.ROOT);
|
||||
return part + conformance;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error parsing raw XMP for PDF/A version: {}", e.getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean validatePdfAWithPreflight(PDDocument document, String version) {
|
||||
if (document == null || document.isEncrypted()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use Stream-to-File pattern: save to temp file instead of loading into memory
|
||||
// This prevents OutOfMemoryError on large PDFs
|
||||
Path tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("preflight-", ".pdf");
|
||||
|
||||
// Save document to temp file (avoids loading entire document into memory)
|
||||
try (var outputStream = Files.newOutputStream(tempFile)) {
|
||||
document.save(outputStream);
|
||||
}
|
||||
|
||||
// Use RandomAccessReadBufferedFile for efficient file-based reading
|
||||
// This avoids Windows file locking issues that occur with memory-mapped files
|
||||
try (RandomAccessRead source = new RandomAccessReadBufferedFile(tempFile.toFile())) {
|
||||
PreflightParser parser = new PreflightParser(source);
|
||||
|
||||
try (PDDocument parsedDocument = parser.parse()) {
|
||||
if (!(parsedDocument instanceof PreflightDocument preflightDocument)) {
|
||||
log.debug(
|
||||
"Parsed document is not a PreflightDocument; unable to validate claimed PDF/A {}",
|
||||
version);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
ValidationResult result = preflightDocument.validate();
|
||||
if (!result.isValid() && log.isDebugEnabled()) {
|
||||
log.debug(
|
||||
"PDF/A validation found {} errors for claimed version {}",
|
||||
result.getErrorsList().size(),
|
||||
version);
|
||||
int logged = 0;
|
||||
for (ValidationResult.ValidationError error : result.getErrorsList()) {
|
||||
log.debug(
|
||||
" Error {}: {}", error.getErrorCode(), error.getDetails());
|
||||
if (++logged >= MAX_LOGGED_ERRORS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.isValid();
|
||||
} catch (ValidationException e) {
|
||||
log.debug(
|
||||
"Validation exception during PDF/A validation: {}", e.getMessage());
|
||||
}
|
||||
} catch (SyntaxValidationException e) {
|
||||
log.debug(
|
||||
"Syntax validation failed during PDF/A validation: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("IOException during PDF/A validation: {}", e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.debug("Unexpected error during PDF/A validation: {}", e.getMessage());
|
||||
} finally {
|
||||
// Explicitly clean up temp file to prevent disk exhaustion
|
||||
// This must be in finally block to ensure cleanup even on exceptions
|
||||
if (tempFile != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempFile);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temp file during PDF/A validation cleanup: {}",
|
||||
tempFile,
|
||||
e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean checkStandardInMetadata(PDDocument document, String standardKeyword) {
|
||||
// Check XMP Metadata
|
||||
try {
|
||||
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
|
||||
if (pdMetadata != null) {
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
// First try to read raw metadata as string to check for standard keywords
|
||||
byte[] metadataBytes = metaStream.readAllBytes();
|
||||
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
|
||||
|
||||
if (rawMetadata.contains(standardKeyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// If raw check doesn't find it, try parsing with XMP parser
|
||||
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
|
||||
try {
|
||||
DomXmpParser domXmpParser = new DomXmpParser();
|
||||
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
new XmpSerializer().serialize(xmpMeta, baos, true);
|
||||
String xmpString = baos.toString(StandardCharsets.UTF_8);
|
||||
|
||||
if (xmpString.contains(standardKeyword)) {
|
||||
return true;
|
||||
}
|
||||
} catch (XmpParsingException e) {
|
||||
// XMP parsing failed, but we already checked raw metadata above
|
||||
log.debug(
|
||||
"XMP parsing failed for standard check, but raw metadata was already checked: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF standard checking", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ObjectNode generatePDFSummaryData(
|
||||
PDDocument document, String pdfaConformanceLevel, Boolean pdfaValidationPassed) {
|
||||
PDDocument document, List<PDFVerificationResult> verificationResults) {
|
||||
ObjectNode summaryData = objectMapper.createObjectNode();
|
||||
|
||||
// Check if encrypted
|
||||
@@ -346,24 +126,16 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
|
||||
// Check standard compliance
|
||||
if (pdfaConformanceLevel != null) {
|
||||
summaryData.put("standardCompliance", "PDF/A-" + pdfaConformanceLevel);
|
||||
summaryData.put("standardPurpose", "long-term archiving");
|
||||
if (pdfaValidationPassed != null) {
|
||||
summaryData.put("standardValidationPassed", pdfaValidationPassed);
|
||||
if (verificationResults != null && !verificationResults.isEmpty()) {
|
||||
ArrayNode complianceArray = objectMapper.createArrayNode();
|
||||
for (PDFVerificationResult result : verificationResults) {
|
||||
ObjectNode complianceNode = objectMapper.createObjectNode();
|
||||
complianceNode.put("Standard", result.getStandard());
|
||||
complianceNode.put("Compliant", result.isCompliant());
|
||||
complianceNode.put("Summary", result.getComplianceSummary());
|
||||
complianceArray.add(complianceNode);
|
||||
}
|
||||
} else if (checkForStandard(document, "PDF/X")) {
|
||||
summaryData.put("standardCompliance", "PDF/X");
|
||||
summaryData.put("standardPurpose", "graphic exchange");
|
||||
} else if (checkForStandard(document, "PDF/UA")) {
|
||||
summaryData.put("standardCompliance", "PDF/UA");
|
||||
summaryData.put("standardPurpose", "universal accessibility");
|
||||
} else if (checkForStandard(document, "PDF/E")) {
|
||||
summaryData.put("standardCompliance", "PDF/E");
|
||||
summaryData.put("standardPurpose", "engineering workflows");
|
||||
} else if (checkForStandard(document, "PDF/VT")) {
|
||||
summaryData.put("standardCompliance", "PDF/VT");
|
||||
summaryData.put("standardPurpose", "variable and transactional printing");
|
||||
summaryData.set("Compliance", complianceArray);
|
||||
}
|
||||
|
||||
return summaryData;
|
||||
@@ -591,40 +363,113 @@ public class GetInfoOnPDF {
|
||||
return docInfoNode;
|
||||
}
|
||||
|
||||
private static ObjectNode extractComplianceInfo(PDDocument document) {
|
||||
private static ObjectNode extractComplianceInfo(
|
||||
PDDocument doc, List<PDFVerificationResult> verificationResults) {
|
||||
ObjectNode compliancy = objectMapper.createObjectNode();
|
||||
|
||||
try {
|
||||
String pdfaConformanceLevel = getPdfAConformanceLevel(document);
|
||||
boolean isPdfACompliant = pdfaConformanceLevel != null;
|
||||
boolean isPdfXCompliant = checkForStandard(document, "PDF/X");
|
||||
boolean isPdfECompliant = checkForStandard(document, "PDF/E");
|
||||
boolean isPdfVTCompliant = checkForStandard(document, "PDF/VT");
|
||||
boolean isPdfUACompliant = checkForStandard(document, "PDF/UA");
|
||||
boolean isPdfBCompliant = checkForStandard(document, "PDF/B");
|
||||
boolean isPdfSECCompliant = checkForStandard(document, "PDF/SEC");
|
||||
boolean isPdfA = false;
|
||||
boolean isPdfUA = false;
|
||||
boolean isPdfX = false;
|
||||
boolean isPdfE = false;
|
||||
boolean isPdfB = false;
|
||||
String pdfAConformanceLevel = null;
|
||||
|
||||
compliancy.put("IsPDF/ACompliant", isPdfACompliant);
|
||||
if (pdfaConformanceLevel != null) {
|
||||
compliancy.put("PDF/AConformanceLevel", pdfaConformanceLevel);
|
||||
Boolean pdfaValidationPassed =
|
||||
validatePdfAWithPreflight(document, pdfaConformanceLevel);
|
||||
compliancy.put("IsPDF/AValidated", pdfaValidationPassed);
|
||||
if (verificationResults != null) {
|
||||
for (PDFVerificationResult result : verificationResults) {
|
||||
if (result == null) continue;
|
||||
if (result.isCompliant()) {
|
||||
String std = result.getStandard().toLowerCase();
|
||||
if (std.contains("pdf_a") || std.contains("pdfa")) {
|
||||
isPdfA = true;
|
||||
String profile = result.getValidationProfile();
|
||||
if (profile != null) {
|
||||
if (profile.contains("1b")
|
||||
|| profile.contains("2b")
|
||||
|| profile.contains("3b")) {
|
||||
isPdfB = true;
|
||||
}
|
||||
// Simple extraction: remove "pdfa-" prefix
|
||||
pdfAConformanceLevel = profile.replace("pdfa-", "");
|
||||
}
|
||||
}
|
||||
if (std.contains("pdf_ua") || std.contains("pdfua")) isPdfUA = true;
|
||||
if (std.contains("pdf_x") || std.contains("pdfx")) isPdfX = true;
|
||||
if (std.contains("pdf_e") || std.contains("pdfe")) isPdfE = true;
|
||||
}
|
||||
}
|
||||
compliancy.put("IsPDF/XCompliant", isPdfXCompliant);
|
||||
compliancy.put("IsPDF/ECompliant", isPdfECompliant);
|
||||
compliancy.put("IsPDF/VTCompliant", isPdfVTCompliant);
|
||||
compliancy.put("IsPDF/UACompliant", isPdfUACompliant);
|
||||
compliancy.put("IsPDF/BCompliant", isPdfBCompliant);
|
||||
compliancy.put("IsPDF/SECCompliant", isPdfSECCompliant);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting compliance info: {}", e.getMessage());
|
||||
compliancy.put("IsPDF/ACompliant", isPdfA);
|
||||
compliancy.put("IsPDF/UACompliant", isPdfUA);
|
||||
compliancy.put("IsPDF/ECompliant", isPdfE);
|
||||
compliancy.put("IsPDF/VTCompliant", false); // Not currently implemented
|
||||
compliancy.put("IsPDF/BCompliant", isPdfB);
|
||||
if (pdfAConformanceLevel != null) {
|
||||
compliancy.put("PDF/AConformanceLevel", pdfAConformanceLevel);
|
||||
}
|
||||
|
||||
compliancy.put("IsPDF/SECCompliant", isSECCompliant(doc));
|
||||
|
||||
if (verificationResults != null && !verificationResults.isEmpty()) {
|
||||
// Keep original simple structure as backup or extra info
|
||||
for (PDFVerificationResult result : verificationResults) {
|
||||
if (result == null) continue;
|
||||
String standard = result.getStandard();
|
||||
boolean isCompliant = result.isCompliant();
|
||||
|
||||
if (standard != null) {
|
||||
// Avoid overwriting specific keys if collision, but here keys are distinct
|
||||
// enough
|
||||
compliancy.put(standard, isCompliant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return compliancy;
|
||||
}
|
||||
|
||||
private static boolean isSECCompliant(PDDocument doc) {
|
||||
try {
|
||||
// 1. Check Encryption
|
||||
if (doc.isEncrypted()) return false;
|
||||
|
||||
PDDocumentCatalog catalog = doc.getDocumentCatalog();
|
||||
|
||||
// 2. Check for JavaScript (Active Content)
|
||||
if (catalog.getOpenAction() instanceof PDActionJavaScript) return false;
|
||||
if (catalog.getNames() != null && catalog.getNames().getJavaScript() != null)
|
||||
return false;
|
||||
|
||||
// Check for AcroForm
|
||||
if (catalog.getAcroForm() != null) return false;
|
||||
|
||||
// 3. Check for Embedded Files
|
||||
if (catalog.getNames() != null && catalog.getNames().getEmbeddedFiles() != null)
|
||||
return false;
|
||||
|
||||
// 4. Check for External Links or Navigation Actions
|
||||
for (PDPage page : doc.getPages()) {
|
||||
for (PDAnnotation annotation : page.getAnnotations()) {
|
||||
if (annotation instanceof PDAnnotationLink) {
|
||||
PDAnnotationLink link = (PDAnnotationLink) annotation;
|
||||
PDAction action = link.getAction();
|
||||
if (action instanceof PDActionURI
|
||||
|| action instanceof PDActionLaunch
|
||||
|| action instanceof PDActionRemoteGoTo
|
||||
|| action instanceof PDActionSubmitForm) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("Error checking SEC compliance: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectNode extractEncryptionInfo(PDDocument document) {
|
||||
ObjectNode encryption = objectMapper.createObjectNode();
|
||||
|
||||
@@ -1215,7 +1060,7 @@ public class GetInfoOnPDF {
|
||||
return stats;
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
|
||||
@Operation(
|
||||
summary = "Get comprehensive PDF information",
|
||||
description =
|
||||
@@ -1231,6 +1076,13 @@ public class GetInfoOnPDF {
|
||||
return createErrorResponse("Invalid PDF file: " + e.getMessage());
|
||||
}
|
||||
|
||||
List<PDFVerificationResult> verificationResults = null;
|
||||
try {
|
||||
verificationResults = veraPDFService.validatePDF(inputFile.getInputStream());
|
||||
} catch (Exception e) {
|
||||
log.error("VeraPDF validation failed", e);
|
||||
}
|
||||
|
||||
boolean readonly = true;
|
||||
|
||||
try (PDDocument pdfBoxDoc = pdfDocumentFactory.load(inputFile, readonly)) {
|
||||
@@ -1239,20 +1091,13 @@ public class GetInfoOnPDF {
|
||||
ObjectNode metadata = extractMetadata(pdfBoxDoc);
|
||||
ObjectNode basicInfo = extractBasicInfo(pdfBoxDoc, inputFile.getSize());
|
||||
ObjectNode docInfoNode = extractDocumentInfo(pdfBoxDoc);
|
||||
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc);
|
||||
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc, verificationResults);
|
||||
ObjectNode encryption = extractEncryptionInfo(pdfBoxDoc);
|
||||
ObjectNode permissionsNode = extractPermissions(pdfBoxDoc);
|
||||
ObjectNode other = extractOtherInfo(pdfBoxDoc);
|
||||
ObjectNode formFieldsNode = extractFormFields(pdfBoxDoc);
|
||||
|
||||
// Generate summary data
|
||||
String pdfaConformanceLevel = getPdfAConformanceLevel(pdfBoxDoc);
|
||||
Boolean pdfaValidationPassed = null;
|
||||
if (pdfaConformanceLevel != null) {
|
||||
pdfaValidationPassed = validatePdfAWithPreflight(pdfBoxDoc, pdfaConformanceLevel);
|
||||
}
|
||||
ObjectNode summaryData =
|
||||
generatePDFSummaryData(pdfBoxDoc, pdfaConformanceLevel, pdfaValidationPassed);
|
||||
ObjectNode summaryData = generatePDFSummaryData(pdfBoxDoc, verificationResults);
|
||||
|
||||
// Extract per-page information
|
||||
ObjectNode pageInfoParent = extractPerPageInfo(pdfBoxDoc);
|
||||
|
||||
+4
-8
@@ -6,16 +6,12 @@ import java.util.List;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.verapdf.core.EncryptedPdfException;
|
||||
import org.verapdf.core.ModelParsingException;
|
||||
import org.verapdf.core.ValidationException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -23,11 +19,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationRequest;
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@SecurityApi
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class VerifyPDFController {
|
||||
@@ -41,7 +37,7 @@ public class VerifyPDFController {
|
||||
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
|
||||
+ "from the document's XMP metadata and validates compliance. "
|
||||
+ "Input:PDF Output:JSON Type:SISO")
|
||||
@PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@AutoJobPostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
|
||||
@ModelAttribute PDFVerificationRequest request) {
|
||||
|
||||
|
||||
+1
-4
@@ -14,9 +14,6 @@ public class PdfToPdfARequest extends PDFFile {
|
||||
@Schema(
|
||||
description = "The output format type (PDF/A or PDF/X)",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {
|
||||
"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx", "pdfx-1",
|
||||
"pdfx-3", "pdfx-4"
|
||||
})
|
||||
allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"})
|
||||
private String outputFormat;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ public class AddStampRequest extends PDFWithPageNums {
|
||||
private String alphabet = "roman";
|
||||
|
||||
@Schema(
|
||||
description = "The font size of the stamp text and image",
|
||||
defaultValue = "30",
|
||||
description = "The font size of the stamp text and image in points.",
|
||||
defaultValue = "40",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private float fontSize;
|
||||
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
@@ -510,7 +511,7 @@ public class CertificateValidationService {
|
||||
private byte[] downloadTrustList(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
@@ -700,7 +701,7 @@ public class CertificateValidationService {
|
||||
private byte[] downloadXml(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
|
||||
+2
-7
@@ -82,7 +82,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
|
||||
@PostConstruct
|
||||
public void register() {
|
||||
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
|
||||
if (StringUtils.isAnyBlank(getBotUsername(), this.telegramProperties.getBotToken())) {
|
||||
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
|
||||
return;
|
||||
}
|
||||
@@ -392,7 +392,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
new URI(
|
||||
"https",
|
||||
"api.telegram.org",
|
||||
"/file/bot" + getBotToken() + "/" + filePath,
|
||||
"/file/bot" + this.telegramProperties.getBotToken() + "/" + filePath,
|
||||
null);
|
||||
return uri.toURL();
|
||||
} catch (URISyntaxException e) {
|
||||
@@ -516,9 +516,4 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
|
||||
public String getBotUsername() {
|
||||
return telegramProperties.getBotUsername();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBotToken() {
|
||||
return telegramProperties.getBotToken();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
multipart.enabled=true
|
||||
logging.level.org.springframework=WARN
|
||||
logging.level.org.springframework.security=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
logging.level.com.zaxxer.hikari=WARN
|
||||
|
||||
@@ -47,20 +47,23 @@ security:
|
||||
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
|
||||
saml2:
|
||||
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
|
||||
provider: "" # The name of your Provider
|
||||
enableSingleLogout: false # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
|
||||
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
|
||||
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
|
||||
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
|
||||
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
|
||||
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
|
||||
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
|
||||
idpIssuer: "" # The ID of your Provider
|
||||
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
|
||||
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
|
||||
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
|
||||
metadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # RECOMMENDED: Your IdP's metadata URI. When provided, IdP config is auto-discovered.
|
||||
provider: # IdP manual configuration - use if metadataUri is not available
|
||||
name: "" # Display name for your IdP (optional)
|
||||
singleLoginUrl: "" # SSO URL
|
||||
singleLogoutUrl: "" # SLO URL
|
||||
entityId: "" # IdP Entity ID
|
||||
cert: classpath:okta.cert # IdP signing certificate
|
||||
sp: # Service Provider (your app) credentials for signing SAML requests. Generated from your keypair
|
||||
privateKey: classpath:saml-private-key.key # Your private key.
|
||||
cert: classpath:saml-public-cert.crt # Your signing certificate.
|
||||
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
|
||||
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
|
||||
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
|
||||
jwt:
|
||||
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
|
||||
@@ -81,6 +84,7 @@ security:
|
||||
revocation:
|
||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
||||
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
|
||||
|
||||
premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+567
@@ -0,0 +1,567 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
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.CsvSource;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StampControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private StampController stampController;
|
||||
|
||||
private Method processStampTextMethod;
|
||||
private Method processCustomDateFormatMethod;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchMethodException {
|
||||
processStampTextMethod =
|
||||
StampController.class.getDeclaredMethod(
|
||||
"processStampText",
|
||||
String.class,
|
||||
int.class,
|
||||
int.class,
|
||||
String.class,
|
||||
PDDocument.class);
|
||||
processStampTextMethod.setAccessible(true);
|
||||
|
||||
processCustomDateFormatMethod =
|
||||
StampController.class.getDeclaredMethod(
|
||||
"processCustomDateFormat", String.class, LocalDateTime.class);
|
||||
processCustomDateFormatMethod.setAccessible(true);
|
||||
}
|
||||
|
||||
private String invokeProcessStampText(
|
||||
String stampText, int pageNumber, int totalPages, String filename, PDDocument document)
|
||||
throws Exception {
|
||||
try {
|
||||
return (String)
|
||||
processStampTextMethod.invoke(
|
||||
stampController, stampText, pageNumber, totalPages, filename, document);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private String invokeProcessCustomDateFormat(String format, LocalDateTime now)
|
||||
throws Exception {
|
||||
try {
|
||||
return (String) processCustomDateFormatMethod.invoke(stampController, format, now);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Basic Variable Substitution Tests")
|
||||
class BasicVariableTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @page_number with current page")
|
||||
void testPageNumberReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Page @page_number", 5, 20, "test.pdf", null);
|
||||
assertEquals("Page 5", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @total_pages with total page count")
|
||||
void testTotalPagesReplacement() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("of @total_pages pages", 1, 100, "test.pdf", null);
|
||||
assertEquals("of 100 pages", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace combined page variables")
|
||||
void testCombinedPageVariables() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText(
|
||||
"Page @page_number of @total_pages", 5, 20, "test.pdf", null);
|
||||
assertEquals("Page 5 of 20", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @page alias")
|
||||
void testPageAlias() throws Exception {
|
||||
String result = invokeProcessStampText("Page @page", 7, 10, "test.pdf", null);
|
||||
assertEquals("Page 7", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @page_count alias for total pages")
|
||||
void testPageCountAlias() throws Exception {
|
||||
String result = invokeProcessStampText("Total: @page_count", 1, 50, "test.pdf", null);
|
||||
assertEquals("Total: 50", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Filename Variable Tests")
|
||||
class FilenameTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @filename with filename without extension")
|
||||
void testFilenameWithoutExtension() throws Exception {
|
||||
String result = invokeProcessStampText("File: @filename", 1, 1, "document.pdf", null);
|
||||
assertEquals("File: document", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @filename_full with full filename")
|
||||
void testFilenameWithExtension() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("File: @filename_full", 1, 1, "document.pdf", null);
|
||||
assertEquals("File: document.pdf", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle filename without extension")
|
||||
void testFilenameWithoutDot() throws Exception {
|
||||
String result = invokeProcessStampText("@filename", 1, 1, "document", null);
|
||||
assertEquals("document", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null filename")
|
||||
void testNullFilename() throws Exception {
|
||||
String result = invokeProcessStampText("File: @filename", 1, 1, null, null);
|
||||
assertEquals("File: ", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle filename with multiple dots")
|
||||
void testFilenameMultipleDots() throws Exception {
|
||||
String result = invokeProcessStampText("@filename", 1, 1, "my.document.v2.pdf", null);
|
||||
assertEquals("my.document.v2", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle hidden file (starts with dot)")
|
||||
void testHiddenFile() throws Exception {
|
||||
String result = invokeProcessStampText("@filename", 1, 1, ".hidden.pdf", null);
|
||||
assertEquals(".hidden", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Date/Time Variable Tests")
|
||||
class DateTimeTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @date with current date")
|
||||
void testDateReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
|
||||
"Date should match YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @time with current time")
|
||||
void testTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
|
||||
"Time should match HH:mm:ss format");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @datetime with combined date and time")
|
||||
void testDateTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
|
||||
// DateTime format: YYYY-MM-DD HH:mm:ss
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
|
||||
"DateTime should match YYYY-MM-DD HH:mm:ss format");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @year with current year")
|
||||
void testYearReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("© @year", 1, 1, "test.pdf", null);
|
||||
int currentYear = LocalDateTime.now().getYear();
|
||||
assertEquals("© " + currentYear, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @month with zero-padded month")
|
||||
void testMonthReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @day with zero-padded day")
|
||||
void testDayReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom Date Format Tests")
|
||||
class CustomDateFormatTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle custom date format dd/MM/yyyy")
|
||||
void testCustomDateFormatSlash() throws Exception {
|
||||
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{2}/\\d{2}/\\d{4}"),
|
||||
"Should match dd/MM/yyyy format: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle custom date format with time")
|
||||
void testCustomDateFormatWithTime() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
|
||||
"Should match yyyy-MM-dd HH:mm format: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple custom date formats in same text")
|
||||
void testMultipleCustomDateFormats() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText(
|
||||
"Start: @date{dd/MM/yyyy} End: @date{yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.contains("/"), "Should contain slash from first format");
|
||||
// Should have year twice (once with slashes, once alone)
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Custom Date Format Security Tests")
|
||||
class CustomDateFormatSecurityTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should not match format that is too long - regex won't capture it")
|
||||
void testFormatTooLong() throws Exception {
|
||||
String longFormat = "y".repeat(51); // 51 chars, over the 50 char regex limit
|
||||
String result =
|
||||
invokeProcessStampText("@date{" + longFormat + "}", 1, 1, "test.pdf", null);
|
||||
// The CUSTOM_DATE_PATTERN only captures up to 50 chars, so this won't match
|
||||
// The @date part will be replaced by simple replacement, leaving {yyy...}
|
||||
assertTrue(
|
||||
result.contains("{"), "Should contain { because regex didn't match: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject format with unsafe characters - shell injection attempt")
|
||||
void testShellInjectionAttempt() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd$(rm -rf /)}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject format with unsafe characters - semicolon")
|
||||
void testSemicolonInjection() throws Exception {
|
||||
String result = invokeProcessStampText("@date{yyyy;rm}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject format with unsafe characters - backticks")
|
||||
void testBacktickInjection() throws Exception {
|
||||
String result = invokeProcessStampText("@date{`whoami`}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = {"$(cmd)", "`cmd`", ";cmd", "|cmd", "&cmd", "<cmd", ">cmd"})
|
||||
@DisplayName("Should reject various injection attempts")
|
||||
void testVariousInjectionAttempts(String injection) throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy" + injection + "}", 1, 1, "test.pdf", null);
|
||||
assertEquals("[invalid format]", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should accept valid format characters")
|
||||
void testValidFormatCharacters() throws Exception {
|
||||
// All these should be valid based on SAFE_DATE_FORMAT_PATTERN: yMdHhmsS/-:.,
|
||||
// '+EGuwWDFzZXa and space
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm:ss}", 1, 1, "test.pdf", null);
|
||||
assertFalse(
|
||||
result.startsWith("[invalid"), "Valid format should be accepted: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle invalid DateTimeFormatter pattern gracefully")
|
||||
void testInvalidFormatterPattern() throws Exception {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// Use 'sssss' - too many seconds digits will throw IllegalArgumentException from
|
||||
// DateTimeFormatter
|
||||
// Note: The pattern 'sssss' passes the SAFE_DATE_FORMAT_PATTERN but fails
|
||||
// DateTimeFormatter.ofPattern()
|
||||
String result = invokeProcessCustomDateFormat("sssss", now);
|
||||
assertTrue(
|
||||
result.startsWith("[invalid format:"),
|
||||
"Invalid pattern should return error message: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Escape Sequence Tests")
|
||||
class EscapeSequenceTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should convert @@ to literal @")
|
||||
void testDoubleAtEscape() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("Email: test@@example.com", 1, 1, "test.pdf", null);
|
||||
assertEquals("Email: test@example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve @@ before variable")
|
||||
void testEscapeBeforeVariable() throws Exception {
|
||||
String result = invokeProcessStampText("@@date is @date", 1, 1, "test.pdf", null);
|
||||
// @@date should become @date, and @date should be replaced with actual date
|
||||
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
|
||||
assertTrue(
|
||||
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
|
||||
"Should have date after: " + result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple escape sequences")
|
||||
void testMultipleEscapes() throws Exception {
|
||||
String result = invokeProcessStampText("@@one @@two @@three", 1, 1, "test.pdf", null);
|
||||
assertEquals("@one @two @three", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle escape at end of string")
|
||||
void testEscapeAtEnd() throws Exception {
|
||||
String result = invokeProcessStampText("Contact: user@@", 1, 1, "test.pdf", null);
|
||||
assertEquals("Contact: user@", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Document Metadata Tests")
|
||||
class DocumentMetadataTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @author with document author")
|
||||
void testAuthorReplacement() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setAuthor("John Doe");
|
||||
doc.setDocumentInformation(info);
|
||||
|
||||
try {
|
||||
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", doc);
|
||||
assertEquals("Author: John Doe", result);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @title with document title")
|
||||
void testTitleReplacement() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setTitle("My Document Title");
|
||||
doc.setDocumentInformation(info);
|
||||
|
||||
try {
|
||||
String result = invokeProcessStampText("Title: @title", 1, 1, "test.pdf", doc);
|
||||
assertEquals("Title: My Document Title", result);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @subject with document subject")
|
||||
void testSubjectReplacement() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
PDDocumentInformation info = new PDDocumentInformation();
|
||||
info.setSubject("Important Subject");
|
||||
doc.setDocumentInformation(info);
|
||||
|
||||
try {
|
||||
String result = invokeProcessStampText("Subject: @subject", 1, 1, "test.pdf", doc);
|
||||
assertEquals("Subject: Important Subject", result);
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null metadata gracefully")
|
||||
void testNullMetadata() throws Exception {
|
||||
PDDocument doc = new PDDocument();
|
||||
// Don't set any document information
|
||||
|
||||
try {
|
||||
String result =
|
||||
invokeProcessStampText("@author @title @subject", 1, 1, "test.pdf", doc);
|
||||
assertEquals(" ", result); // All should be empty strings
|
||||
} finally {
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null document gracefully")
|
||||
void testNullDocument() throws Exception {
|
||||
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", null);
|
||||
assertEquals("Author: ", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("UUID Variable Tests")
|
||||
class UuidTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate 8-character UUID")
|
||||
void testUuidLength() throws Exception {
|
||||
String result = invokeProcessStampText("ID: @uuid", 1, 1, "test.pdf", null);
|
||||
// UUID format: "ID: " + 8 chars
|
||||
assertEquals(12, result.length(), "Should be 'ID: ' + 8 char UUID");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate different UUIDs for each call")
|
||||
void testUuidUniqueness() throws Exception {
|
||||
String result1 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
String result2 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertNotEquals(result1, result2, "UUIDs should be unique");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("UUID should contain only hex characters")
|
||||
void testUuidFormat() throws Exception {
|
||||
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Edge Cases and Error Handling")
|
||||
class EdgeCaseTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null stamp text")
|
||||
void testNullStampText() throws Exception {
|
||||
String result = invokeProcessStampText(null, 1, 1, "test.pdf", null);
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle empty stamp text")
|
||||
void testEmptyStampText() throws Exception {
|
||||
String result = invokeProcessStampText("", 1, 1, "test.pdf", null);
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle text with no variables")
|
||||
void testNoVariables() throws Exception {
|
||||
String result = invokeProcessStampText("Just plain text", 1, 1, "test.pdf", null);
|
||||
assertEquals("Just plain text", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle unknown variables")
|
||||
void testUnknownVariable() throws Exception {
|
||||
String result = invokeProcessStampText("@unknown_var", 1, 1, "test.pdf", null);
|
||||
assertEquals("@unknown_var", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should preserve text around variables")
|
||||
void testPreservesSurroundingText() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("Before @page_number After", 5, 10, "test.pdf", null);
|
||||
assertEquals("Before 5 After", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle multiple same variables")
|
||||
void testMultipleSameVariables() throws Exception {
|
||||
String result =
|
||||
invokeProcessStampText("@page_number / @page_number", 3, 10, "test.pdf", null);
|
||||
assertEquals("3 / 3", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle variables adjacent to each other")
|
||||
void testAdjacentVariables() throws Exception {
|
||||
String result = invokeProcessStampText("@page@page_number", 5, 10, "test.pdf", null);
|
||||
// @page should be replaced first (it's in the order), then @page_number
|
||||
// Since @page_number is longer and comes first in replace chain, should work
|
||||
assertEquals("55", result);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Complex Scenario Tests")
|
||||
class ComplexScenarioTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle legal footer template")
|
||||
void testLegalFooterTemplate() throws Exception {
|
||||
String template = "© @year - All Rights Reserved\\n@filename - Page @page_number";
|
||||
String result = invokeProcessStampText(template, 3, 15, "contract.pdf", null);
|
||||
|
||||
int year = LocalDateTime.now().getYear();
|
||||
String expected = "© " + year + " - All Rights Reserved\\ncontract - Page 3";
|
||||
assertEquals(expected, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle Brazilian date format template")
|
||||
void testBrazilianDateFormat() throws Exception {
|
||||
String template = "Documento criado em @date{dd/MM/yyyy} às @time";
|
||||
String result = invokeProcessStampText(template, 1, 1, "doc.pdf", null);
|
||||
|
||||
assertTrue(result.startsWith("Documento criado em "));
|
||||
assertTrue(result.contains("/"));
|
||||
assertTrue(result.contains(":"));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"'Page @page_number of @total_pages', 1, 10, 'Page 1 of 10'",
|
||||
"'Page @page_number of @total_pages', 5, 20, 'Page 5 of 20'",
|
||||
"'Page @page_number of @total_pages', 100, 1000, 'Page 100 of 1000'"
|
||||
})
|
||||
@DisplayName("Should handle page number template with various values")
|
||||
void testPageNumberTemplates(String template, int page, int total, String expected)
|
||||
throws Exception {
|
||||
String result = invokeProcessStampText(template, page, total, "test.pdf", null);
|
||||
assertEquals(expected, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
-41
@@ -1,22 +1,30 @@
|
||||
package stirling.software.SPDF.controller.api.security;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.*;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.ProtectionPolicy;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
|
||||
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
@@ -35,6 +43,8 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
|
||||
import stirling.software.SPDF.service.VeraPDFService;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@@ -43,6 +53,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
class GetInfoOnPDFTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private VeraPDFService veraPDFService;
|
||||
|
||||
@InjectMocks private GetInfoOnPDF getInfoOnPDF;
|
||||
|
||||
@@ -661,41 +672,6 @@ class GetInfoOnPDFTest {
|
||||
void testGetPageSize(float width, float height, String expected) {
|
||||
Assertions.assertEquals(expected, GetInfoOnPDF.getPageSize(width, height));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should check for PDF/A standard")
|
||||
void testCheckForStandard_PdfA() throws IOException {
|
||||
// This would require a real PDF/A document or mocking
|
||||
PDDocument document = createSimplePdfWithText("Test");
|
||||
boolean result = GetInfoOnPDF.checkForStandard(document, "PDF/A");
|
||||
Assertions.assertFalse(result); // Simple PDF is not PDF/A compliant
|
||||
document.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle null document in checkForStandard")
|
||||
void testCheckForStandard_NullDocument() {
|
||||
boolean result = GetInfoOnPDF.checkForStandard(null, "PDF/A");
|
||||
Assertions.assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should get PDF/A conformance level")
|
||||
void testGetPdfAConformanceLevel() throws IOException {
|
||||
PDDocument document = createSimplePdfWithText("Test");
|
||||
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
|
||||
Assertions.assertNull(level);
|
||||
document.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle encrypted document in getPdfAConformanceLevel")
|
||||
void testGetPdfAConformanceLevel_EncryptedDocument() throws IOException {
|
||||
PDDocument document = createEncryptedPdf();
|
||||
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
|
||||
Assertions.assertNull(level); // Encrypted documents return null
|
||||
document.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@@ -776,8 +752,8 @@ class GetInfoOnPDFTest {
|
||||
class ComplianceTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Should check PDF/A compliance")
|
||||
void testCompliance_PdfA() throws IOException {
|
||||
@DisplayName("Should extract compliance info using VeraPDF")
|
||||
void testCompliance_PdfA() throws Exception {
|
||||
PDDocument document = createSimplePdfWithText("Test PDF/A");
|
||||
MockMultipartFile mockFile = documentToMultipartFile(document, "pdfa.pdf");
|
||||
|
||||
@@ -791,16 +767,22 @@ class GetInfoOnPDFTest {
|
||||
ArgumentMatchers.anyBoolean()))
|
||||
.thenReturn(loadedDoc);
|
||||
|
||||
// Mock VeraPDFService
|
||||
PDFVerificationResult result = new PDFVerificationResult();
|
||||
result.setStandard("pdfa-1b");
|
||||
result.setCompliant(true);
|
||||
result.setComplianceSummary("PDF/A-1b compliant");
|
||||
Mockito.when(veraPDFService.validatePDF(ArgumentMatchers.any(InputStream.class)))
|
||||
.thenReturn(List.of(result));
|
||||
|
||||
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
|
||||
|
||||
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
|
||||
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
|
||||
JsonNode compliancy = jsonNode.get("Compliancy");
|
||||
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/ACompliant"));
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/XCompliant"));
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/ECompliant"));
|
||||
Assertions.assertTrue(compliancy.has("IsPDF/UACompliant"));
|
||||
Assertions.assertTrue(compliancy.has("pdfa-1b"));
|
||||
Assertions.assertTrue(compliancy.get("pdfa-1b").asBoolean());
|
||||
|
||||
loadedDoc.close();
|
||||
}
|
||||
@@ -840,4 +822,104 @@ class GetInfoOnPDFTest {
|
||||
loadedDoc.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: Clean document should pass")
|
||||
void testSecCompliance_Clean() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
checkSecCompliance(doc, true);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: JavaScript action should fail")
|
||||
void testSecCompliance_JavaScript() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
PDActionJavaScript jsAction = new PDActionJavaScript("app.alert('Hi')");
|
||||
doc.getDocumentCatalog().setOpenAction(jsAction);
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: External URI Link should fail")
|
||||
void testSecCompliance_ExternalLink() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
|
||||
PDAnnotationLink link = new PDAnnotationLink();
|
||||
PDActionURI action = new PDActionURI();
|
||||
action.setURI("http://google.com");
|
||||
link.setAction(action);
|
||||
|
||||
page.getAnnotations().add(link);
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: Launch Action should fail")
|
||||
void testSecCompliance_LaunchAction() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
PDPage page = new PDPage();
|
||||
doc.addPage(page);
|
||||
|
||||
PDAnnotationLink link = new PDAnnotationLink();
|
||||
PDActionLaunch action = new PDActionLaunch();
|
||||
link.setAction(action);
|
||||
|
||||
page.getAnnotations().add(link);
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SEC Compliance: Embedded File should fail")
|
||||
void testSecCompliance_EmbeddedFile() throws Exception {
|
||||
try (PDDocument doc = new PDDocument()) {
|
||||
doc.addPage(new PDPage());
|
||||
|
||||
PDComplexFileSpecification fs = new PDComplexFileSpecification();
|
||||
fs.setFile("test.txt");
|
||||
PDEmbeddedFile ef =
|
||||
new PDEmbeddedFile(doc, new ByteArrayInputStream("test".getBytes()));
|
||||
fs.setEmbeddedFile(ef);
|
||||
|
||||
PDEmbeddedFilesNameTreeNode efTree = new PDEmbeddedFilesNameTreeNode();
|
||||
efTree.setNames(Collections.singletonMap("test", fs));
|
||||
|
||||
PDDocumentNameDictionary names = new PDDocumentNameDictionary(doc.getDocumentCatalog());
|
||||
names.setEmbeddedFiles(efTree);
|
||||
doc.getDocumentCatalog().setNames(names);
|
||||
|
||||
checkSecCompliance(doc, false);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkSecCompliance(PDDocument doc, boolean expected) throws Exception {
|
||||
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
doc.save(baos);
|
||||
byte[] bytes = baos.toByteArray();
|
||||
|
||||
Mockito.when(
|
||||
pdfDocumentFactory.load(
|
||||
ArgumentMatchers.any(MultipartFile.class),
|
||||
ArgumentMatchers.anyBoolean()))
|
||||
.thenReturn(Loader.loadPDF(bytes));
|
||||
|
||||
PDFFile request = new PDFFile();
|
||||
request.setFileInput(
|
||||
new MockMultipartFile("file", "test.pdf", "application/pdf", bytes));
|
||||
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
|
||||
|
||||
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
|
||||
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
|
||||
boolean actual = jsonNode.get("Compliancy").get("IsPDF/SECCompliant").asBoolean();
|
||||
|
||||
Assertions.assertEquals(expected, actual, "SEC Compliance check failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
repositories {
|
||||
maven { url "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
}
|
||||
|
||||
ext {
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ public class AuditDashboardController {
|
||||
@Schema(
|
||||
description = "Number of days to look back for audit events",
|
||||
example = "7",
|
||||
required = true)
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@RequestParam(value = "days", defaultValue = "7")
|
||||
int days) {
|
||||
|
||||
|
||||
+39
-14
@@ -49,6 +49,7 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -68,6 +69,7 @@ public class ProprietaryUIDataController {
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserLicenseSettingsService licenseSettingsService;
|
||||
private final PersistentAuditEventRepository auditRepository;
|
||||
private final MfaService mfaService;
|
||||
|
||||
public ProprietaryUIDataController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -80,7 +82,8 @@ public class ProprietaryUIDataController {
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("runningEE") boolean runningEE,
|
||||
UserLicenseSettingsService licenseSettingsService,
|
||||
PersistentAuditEventRepository auditRepository) {
|
||||
PersistentAuditEventRepository auditRepository,
|
||||
MfaService mfaService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.auditConfig = auditConfig;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
@@ -92,6 +95,7 @@ public class ProprietaryUIDataController {
|
||||
this.runningEE = runningEE;
|
||||
this.licenseSettingsService = licenseSettingsService;
|
||||
this.auditRepository = auditRepository;
|
||||
this.mfaService = mfaService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +210,10 @@ public class ProprietaryUIDataController {
|
||||
SAML2 saml2 = securityProps.getSaml2();
|
||||
// Only add SAML2 providers if loginMethod allows it
|
||||
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
|
||||
String samlIdp = saml2.getProvider();
|
||||
String samlIdp =
|
||||
saml2.getProvider().getEntityId() != null
|
||||
? saml2.getProvider().getEntityId()
|
||||
: saml2.getIdpIssuer(); // legacy fallback
|
||||
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
|
||||
|
||||
// For SAML, we need to use the backend URL directly, not a relative path
|
||||
@@ -245,12 +252,14 @@ public class ProprietaryUIDataController {
|
||||
|
||||
Map<String, Boolean> userSessions = new HashMap<>();
|
||||
Map<String, Date> userLastRequest = new HashMap<>();
|
||||
Map<String, Map<String, String>> userSettings = new HashMap<>();
|
||||
int activeUsers = 0;
|
||||
int disabledUsers = 0;
|
||||
|
||||
while (iterator.hasNext()) {
|
||||
User user = iterator.next();
|
||||
if (user != null) {
|
||||
String username = user.getUsername();
|
||||
boolean shouldRemove = false;
|
||||
|
||||
// Check if user is an INTERNAL_API_USER
|
||||
@@ -264,7 +273,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
// Check if user is part of the Internal team
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
shouldRemove = true;
|
||||
}
|
||||
|
||||
@@ -278,7 +287,7 @@ public class ProprietaryUIDataController {
|
||||
boolean hasActiveSession = false;
|
||||
Date lastRequest = null;
|
||||
Optional<SessionEntity> latestSession =
|
||||
sessionPersistentRegistry.findLatestSession(user.getUsername());
|
||||
sessionPersistentRegistry.findLatestSession(username);
|
||||
|
||||
if (latestSession.isPresent()) {
|
||||
SessionEntity sessionEntity = latestSession.get();
|
||||
@@ -299,8 +308,21 @@ public class ProprietaryUIDataController {
|
||||
lastRequest = new Date(0);
|
||||
}
|
||||
|
||||
userSessions.put(user.getUsername(), hasActiveSession);
|
||||
userLastRequest.put(user.getUsername(), lastRequest);
|
||||
User userWithSettings =
|
||||
userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
|
||||
// Mask mfaSecret if present in settings
|
||||
Map<String, String> originalSettings = userWithSettings.getSettings();
|
||||
Map<String, String> settingsCopy =
|
||||
originalSettings != null
|
||||
? new HashMap<>(originalSettings)
|
||||
: new HashMap<>();
|
||||
if (settingsCopy.containsKey("mfaSecret")) {
|
||||
settingsCopy.put("mfaSecret", "********");
|
||||
}
|
||||
userSettings.put(username, settingsCopy);
|
||||
userSessions.put(username, hasActiveSession);
|
||||
userLastRequest.put(username, lastRequest);
|
||||
|
||||
if (hasActiveSession) activeUsers++;
|
||||
if (!user.isEnabled()) disabledUsers++;
|
||||
@@ -329,7 +351,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
List<Team> allTeams =
|
||||
teamRepository.findAll().stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
|
||||
.toList();
|
||||
|
||||
// Calculate license limits
|
||||
@@ -356,6 +378,7 @@ public class ProprietaryUIDataController {
|
||||
data.setLicenseMaxUsers(licenseMaxUsers);
|
||||
data.setPremiumEnabled(premiumEnabled);
|
||||
data.setMailEnabled(applicationProperties.getMail().isEnabled());
|
||||
data.setUserSettings(userSettings);
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -407,6 +430,8 @@ public class ProprietaryUIDataController {
|
||||
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
|
||||
data.setOAuth2Login(isOAuth2Login);
|
||||
data.setSaml2Login(isSaml2Login);
|
||||
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
|
||||
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
|
||||
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
@@ -418,7 +443,7 @@ public class ProprietaryUIDataController {
|
||||
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
|
||||
List<TeamWithUserCountDTO> teamsWithCounts =
|
||||
allTeamsWithCounts.stream()
|
||||
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
|
||||
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
|
||||
.toList();
|
||||
|
||||
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
|
||||
@@ -446,7 +471,7 @@ public class ProprietaryUIDataController {
|
||||
.findById(id)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
|
||||
@@ -459,11 +484,8 @@ public class ProprietaryUIDataController {
|
||||
(user.getTeam() == null
|
||||
|| !user.getTeam().getId().equals(id))
|
||||
&& (user.getTeam() == null
|
||||
|| !user.getTeam()
|
||||
.getName()
|
||||
.equals(
|
||||
TeamService
|
||||
.INTERNAL_TEAM_NAME)))
|
||||
|| !TeamService.INTERNAL_TEAM_NAME.equals(
|
||||
user.getTeam().getName())))
|
||||
.toList();
|
||||
|
||||
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
|
||||
@@ -541,6 +563,7 @@ public class ProprietaryUIDataController {
|
||||
private int licenseMaxUsers;
|
||||
private boolean premiumEnabled;
|
||||
private boolean mailEnabled;
|
||||
private Map<String, Map<String, String>> userSettings;
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -551,6 +574,8 @@ public class ProprietaryUIDataController {
|
||||
private boolean changeCredsFlag;
|
||||
private boolean oAuth2Login;
|
||||
private boolean saml2Login;
|
||||
private boolean mfaEnabled;
|
||||
private boolean mfaRequired;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ public class PersistentAuditEvent {
|
||||
private String principal;
|
||||
private String type;
|
||||
|
||||
@Lob private String data; // JSON blob
|
||||
@Column(columnDefinition = "text")
|
||||
private String data; // JSON blob
|
||||
|
||||
private Instant timestamp;
|
||||
}
|
||||
|
||||
+153
-90
@@ -1,53 +1,58 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
|
||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
|
||||
|
||||
import com.coveo.saml.SamlClient;
|
||||
import com.coveo.saml.SamlException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.UrlUtils;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
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
|
||||
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
|
||||
public static final String LOGOUT_PATH = "/login?logout=true";
|
||||
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
private final AppConfig appConfig;
|
||||
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final LogoutSuccessHandler samlLogoutHandler;
|
||||
|
||||
public CustomLogoutSuccessHandler(
|
||||
ApplicationProperties.Security securityProperties, JwtServiceInterface jwtService) {
|
||||
this(securityProperties, jwtService, null);
|
||||
}
|
||||
|
||||
public CustomLogoutSuccessHandler(
|
||||
ApplicationProperties.Security securityProperties,
|
||||
JwtServiceInterface jwtService,
|
||||
LogoutSuccessHandler samlLogoutHandler) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.jwtService = jwtService;
|
||||
this.samlLogoutHandler = samlLogoutHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
|
||||
@@ -56,13 +61,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
throws IOException {
|
||||
|
||||
if (!response.isCommitted()) {
|
||||
if (handleSamlLogout(request, response, authentication)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authentication != null) {
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
// Handle SAML2 logout redirection
|
||||
getRedirect_saml2(request, response, samlAuthentication);
|
||||
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
|
||||
// Handle OAuth2 logout redirection
|
||||
getRedirect_oauth2(request, response, oAuthToken);
|
||||
getRedirectOauth2(request, response, oAuthToken);
|
||||
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
|
||||
// Handle Username/Password logout
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
@@ -74,71 +80,152 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else {
|
||||
if (jwtService != null) {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Redirect to login page after logout
|
||||
String path = checkForErrors(request);
|
||||
getRedirectStrategy().sendRedirect(request, response, path);
|
||||
// Redirect to login page after logout (handles error parameters if present)
|
||||
String queryParams = checkForErrors(request);
|
||||
getRedirectStrategy().sendRedirect(request, response, "/login?" + queryParams);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect for SAML2 authentication logout
|
||||
private void getRedirect_saml2(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
Saml2Authentication samlAuthentication)
|
||||
/**
|
||||
* Handles SAML logout - either via IdP Single Logout (SLO) or local logout.
|
||||
*
|
||||
* @return true if this was a SAML user and logout was handled, false otherwise
|
||||
*/
|
||||
private boolean handleSamlLogout(
|
||||
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
|
||||
throws IOException {
|
||||
// Logout locally if this is a SAMLResponse from to /logout instead of /logout/saml2/slo
|
||||
String samlResponse = request.getParameter("SAMLResponse");
|
||||
if (samlResponse != null && !samlResponse.isBlank()) {
|
||||
if (samlResponse.contains("/saml2/slo")) {
|
||||
log.info(
|
||||
"Received SAML LogoutResponse at /logout endpoint, completing logout locally");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
SAML2 samlConf = securityProperties.getSaml2();
|
||||
String registrationId = samlConf.getRegistrationId();
|
||||
if (securityProperties.getSaml2().getEnableSingleLogout()) {
|
||||
log.info("SP-initiated SLO detected, logging out via IdP");
|
||||
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
|
||||
if (authentication instanceof Saml2Authentication samlAuthentication) {
|
||||
if (samlLogoutHandler != null) {
|
||||
try {
|
||||
samlLogoutHandler.onLogoutSuccess(request, response, samlAuthentication);
|
||||
} catch (Exception e) {
|
||||
log.error("SP-initiated SLO failed, falling back to local logout", e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
"SAML SLO enabled but handler not configured, performing local logout only");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
|
||||
String nameIdValue = principal.name();
|
||||
return true;
|
||||
} else {
|
||||
// Reconstruct Saml2Authentication from JWT claims for SLO
|
||||
Optional<Saml2Authentication> reconstructedAuth =
|
||||
reconstructSaml2AuthenticationFromJwt(request);
|
||||
|
||||
if (reconstructedAuth.isPresent()) {
|
||||
Saml2Authentication samlAuth = reconstructedAuth.get();
|
||||
|
||||
if (samlLogoutHandler != null) {
|
||||
try {
|
||||
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
|
||||
} catch (Exception e) {
|
||||
log.error("SP-initiated SLO failed, falling back to local logout", e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else {
|
||||
log.warn(
|
||||
"SAML SLO enabled but handler not configured, performing local logout only");
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs a Saml2Authentication from JWT claims for SAML Single Logout. This allows SLO to
|
||||
* work even with stateless JWT sessions by extracting the SAML attributes that were stored in
|
||||
* the JWT during initial authentication.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Optional<Saml2Authentication> reconstructSaml2AuthenticationFromJwt(
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
// Read certificate from the resource
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token == null || token.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
List<X509Certificate> certificates = new ArrayList<>();
|
||||
certificates.add(certificate);
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
Object authType = claims.get("authType");
|
||||
|
||||
// Construct URLs required for SAML configuration
|
||||
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
|
||||
if (authType == null || !"SAML2".equalsIgnoreCase(authType.toString())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Read private key for service provider
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
|
||||
// Extract SAML claims from JWT
|
||||
String username = (String) claims.get("sub");
|
||||
String nameId = (String) claims.get("samlNameId");
|
||||
String registrationId = (String) claims.get("samlRegistrationId");
|
||||
Object sessionIndexesObj = claims.get("samlSessionIndexes");
|
||||
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
if (nameId == null || registrationId == null) {
|
||||
log.debug(
|
||||
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
|
||||
nameId,
|
||||
registrationId);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
List<String> sessionIndexes = Collections.emptyList();
|
||||
if (sessionIndexesObj instanceof List<?>) {
|
||||
sessionIndexes =
|
||||
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
|
||||
}
|
||||
|
||||
// Redirect to identity provider for logout with relay state
|
||||
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error retrieving logout URL from Provider {} for user {}",
|
||||
samlConf.getProvider(),
|
||||
nameIdValue,
|
||||
e);
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
// Create principal with all SAML attributes needed for SLO
|
||||
CustomSaml2AuthenticatedPrincipal principal =
|
||||
new CustomSaml2AuthenticatedPrincipal(
|
||||
username,
|
||||
Collections.emptyMap(), // Attributes not needed for logout
|
||||
nameId,
|
||||
sessionIndexes,
|
||||
registrationId);
|
||||
|
||||
// Create Saml2Authentication with the reconstructed principal
|
||||
// The saml2Response parameter is not used by the logout handler, but constructor
|
||||
// requires non-empty value, so we provide a placeholder
|
||||
Saml2Authentication samlAuth =
|
||||
new Saml2Authentication(
|
||||
principal,
|
||||
"<!-- reconstructed for logout -->",
|
||||
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
|
||||
log.debug(
|
||||
"Reconstructed Saml2Authentication from JWT for user {} with registrationId {}",
|
||||
username,
|
||||
registrationId);
|
||||
return Optional.of(samlAuth);
|
||||
|
||||
} catch (Exception ex) {
|
||||
log.error("Unable to reconstruct Saml2Authentication from JWT", ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect for OAuth2 authentication logout
|
||||
private void getRedirect_oauth2(
|
||||
private void getRedirectOauth2(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
OAuth2AuthenticationToken oAuthToken)
|
||||
@@ -169,7 +256,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
logoutUrl +=
|
||||
"/protocol/openid-connect/logout"
|
||||
+ "?client_id="
|
||||
+ oauth.getClientId()
|
||||
+ keycloak.getClientId()
|
||||
+ "&post_logout_redirect_uri="
|
||||
+ response.encodeRedirectURL(redirectUrl);
|
||||
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
|
||||
@@ -196,30 +283,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private SamlClient getSamlClient(
|
||||
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
||||
throws SamlException {
|
||||
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
|
||||
|
||||
String relyingPartyIdentifier =
|
||||
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
|
||||
|
||||
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
|
||||
|
||||
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
|
||||
|
||||
String idpIssuer = samlConf.getIdpIssuer();
|
||||
|
||||
// Create SamlClient instance for SAML logout
|
||||
return new SamlClient(
|
||||
relyingPartyIdentifier,
|
||||
assertionConsumerServiceUrl,
|
||||
idpSLOUrl,
|
||||
idpIssuer,
|
||||
certificates,
|
||||
SamlClient.SamlIdpBinding.POST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles different error scenarios during logout. Will return a <code>String</code> containing
|
||||
* the error request parameter.
|
||||
|
||||
+25
-10
@@ -19,6 +19,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
@@ -113,8 +114,14 @@ public class InitialSecuritySetup {
|
||||
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
|
||||
|
||||
Team team = teamService.getOrCreateDefaultTeam();
|
||||
userService.saveUser(
|
||||
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(initialUsername)
|
||||
.password(initialPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(false);
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Admin user created: {}", initialUsername);
|
||||
} else {
|
||||
createDefaultAdminUser();
|
||||
@@ -127,8 +134,14 @@ public class InitialSecuritySetup {
|
||||
|
||||
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
|
||||
Team team = teamService.getOrCreateDefaultTeam();
|
||||
userService.saveUser(
|
||||
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(defaultUsername)
|
||||
.password(defaultPassword)
|
||||
.team(team)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true);
|
||||
userService.saveUserCore(builder.build());
|
||||
log.info("Default admin user created: {}", defaultUsername);
|
||||
}
|
||||
}
|
||||
@@ -137,12 +150,14 @@ public class InitialSecuritySetup {
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
Team team = teamService.getOrCreateInternalTeam();
|
||||
userService.saveUser(
|
||||
Role.INTERNAL_API_USER.getRoleId(),
|
||||
UUID.randomUUID().toString(),
|
||||
team,
|
||||
Role.INTERNAL_API_USER.getRoleId(),
|
||||
false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(Role.INTERNAL_API_USER.getRoleId())
|
||||
.password(UUID.randomUUID().toString())
|
||||
.team(team)
|
||||
.role(Role.INTERNAL_API_USER.getRoleId())
|
||||
.firstLogin(false);
|
||||
userService.saveUserCore(builder.build());
|
||||
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
|
||||
} else {
|
||||
|
||||
+69
-17
@@ -8,11 +8,13 @@ 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.http.HttpMethod;
|
||||
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;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
|
||||
@@ -22,8 +24,12 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
|
||||
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
|
||||
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestResolver;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2RelyingPartyInitiatedLogoutSuccessHandler;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutFilter;
|
||||
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
@@ -33,7 +39,6 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
|
||||
@@ -56,7 +61,6 @@ 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;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@@ -72,12 +76,10 @@ public class SecurityConfiguration {
|
||||
|
||||
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 SessionPersistentRegistry sessionRegistry;
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
|
||||
@@ -92,14 +94,12 @@ public class SecurityConfiguration {
|
||||
@Lazy UserService userService,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
|
||||
AppConfig appConfig,
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationProperties.Security securityProperties,
|
||||
UserAuthenticationFilter userAuthenticationFilter,
|
||||
JwtServiceInterface jwtService,
|
||||
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||
LoginAttemptService loginAttemptService,
|
||||
SessionPersistentRegistry sessionRegistry,
|
||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||
@Autowired(required = false)
|
||||
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
|
||||
@@ -112,14 +112,12 @@ public class SecurityConfiguration {
|
||||
this.userService = userService;
|
||||
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.sessionRegistry = sessionRegistry;
|
||||
this.persistentLoginRepository = persistentLoginRepository;
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
|
||||
@@ -196,17 +194,41 @@ public class SecurityConfiguration {
|
||||
http.cors(cors -> cors.configurationSource(corsSource));
|
||||
} else {
|
||||
// Explicitly disable CORS when no origins are configured
|
||||
http.cors(cors -> cors.disable());
|
||||
http.cors(AbstractHttpConfigurer::disable);
|
||||
}
|
||||
|
||||
http.csrf(CsrfConfigurer::disable);
|
||||
|
||||
// Configure X-Frame-Options based on settings.yml configuration
|
||||
// When login is disabled, automatically disable X-Frame-Options to allow embedding
|
||||
if (!loginEnabledValue) {
|
||||
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()));
|
||||
} else {
|
||||
String xFrameOption = securityProperties.getXFrameOptions();
|
||||
if (xFrameOption != null) {
|
||||
http.headers(
|
||||
headers -> {
|
||||
if ("DISABLED".equalsIgnoreCase(xFrameOption)) {
|
||||
headers.frameOptions(frameOptions -> frameOptions.disable());
|
||||
} else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) {
|
||||
headers.frameOptions(frameOptions -> frameOptions.sameOrigin());
|
||||
} else {
|
||||
// Default to DENY
|
||||
headers.frameOptions(frameOptions -> frameOptions.deny());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If not configured, use default DENY
|
||||
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.deny()));
|
||||
}
|
||||
}
|
||||
|
||||
if (loginEnabledValue) {
|
||||
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
http.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class)
|
||||
.addFilterAfter(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
http.sessionManagement(
|
||||
sessionManagement ->
|
||||
@@ -226,17 +248,38 @@ public class SecurityConfiguration {
|
||||
return requestURI.startsWith(contextPath + "/api/");
|
||||
}));
|
||||
|
||||
// Create SAML logout handler if SAML SLO is enabled
|
||||
final LogoutSuccessHandler samlLogoutHandler;
|
||||
if (securityProperties.isSaml2Active()
|
||||
&& Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())
|
||||
&& saml2RelyingPartyRegistrations != null) {
|
||||
log.info("Creating SAML2 SLO handler for SP-initiated logout");
|
||||
OpenSaml4LogoutRequestResolver logoutRequestResolver =
|
||||
new OpenSaml4LogoutRequestResolver(saml2RelyingPartyRegistrations);
|
||||
samlLogoutHandler =
|
||||
new Saml2RelyingPartyInitiatedLogoutSuccessHandler(logoutRequestResolver);
|
||||
} else {
|
||||
samlLogoutHandler = null;
|
||||
}
|
||||
|
||||
http.logout(
|
||||
logout ->
|
||||
// Require POST to prevent logout CSRF attacks
|
||||
logout.logoutRequestMatcher(
|
||||
PathPatternRequestMatcher.withDefaults()
|
||||
.matcher("/logout"))
|
||||
.matcher(HttpMethod.POST, "/logout"))
|
||||
.logoutSuccessHandler(
|
||||
new CustomLogoutSuccessHandler(
|
||||
securityProperties, appConfig, jwtService))
|
||||
securityProperties,
|
||||
jwtService,
|
||||
samlLogoutHandler))
|
||||
.clearAuthentication(true)
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
|
||||
.deleteCookies(
|
||||
"JSESSIONID",
|
||||
"remember-me",
|
||||
"stirling_jwt",
|
||||
"stirling_logout_token"));
|
||||
http.rememberMe(
|
||||
rememberMeConfigurer -> // Use the configurator directly
|
||||
rememberMeConfigurer
|
||||
@@ -360,9 +403,18 @@ public class SecurityConfiguration {
|
||||
}
|
||||
})
|
||||
.saml2Metadata(metadata -> {});
|
||||
|
||||
// Configure SAML2 Single Logout if enabled
|
||||
// This sets up endpoints for:
|
||||
// - IdP-initiated logout: IdP sends LogoutRequest to /logout/saml2/slo
|
||||
// - SP-initiated logout response: IdP sends LogoutResponse to /logout/saml2/slo
|
||||
if (Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())) {
|
||||
log.debug("SAML2 Single Logout (SLO) is enabled");
|
||||
http.saml2Logout(logout -> logout.logoutUrl("/logout/saml2/slo"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.debug("Login is not enabled.");
|
||||
log.info("Login is not enabled.");
|
||||
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
|
||||
}
|
||||
return http.build();
|
||||
|
||||
+272
-27
@@ -11,7 +11,12 @@ 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.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
@@ -27,10 +32,13 @@ import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
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.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@@ -45,6 +53,8 @@ public class AuthController {
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final MfaService mfaService;
|
||||
private final TotpService totpService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
/**
|
||||
@@ -58,7 +68,7 @@ public class AuthController {
|
||||
@PostMapping("/login")
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request,
|
||||
@RequestBody UsernameAndPassMfa request,
|
||||
HttpServletRequest httpRequest,
|
||||
HttpServletResponse response) {
|
||||
try {
|
||||
@@ -116,6 +126,47 @@ public class AuthController {
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
String code = request.getMfaCode();
|
||||
if (code == null || code.isBlank()) {
|
||||
log.warn(
|
||||
"MFA required but no code provided for user: {} from IP: {}",
|
||||
username,
|
||||
ip);
|
||||
// loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "mfa_required",
|
||||
"message", "Two-factor code required"));
|
||||
}
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
log.error("MFA enabled but no secret stored for user: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "MFA configuration error"));
|
||||
}
|
||||
Long timeStep = totpService.getValidTimeStep(secret, code);
|
||||
if (timeStep == null) {
|
||||
log.warn("Invalid MFA code for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "invalid_mfa_code",
|
||||
"message", "Invalid two-factor code"));
|
||||
}
|
||||
if (!mfaService.markTotpStepUsed(user, timeStep)) {
|
||||
log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip);
|
||||
loginAttemptService.loginFailed(username);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(
|
||||
Map.of(
|
||||
"error", "invalid_mfa_code",
|
||||
"message", "Invalid two-factor code"));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
@@ -163,7 +214,7 @@ public class AuthController {
|
||||
|
||||
if (auth == null
|
||||
|| !auth.isAuthenticated()
|
||||
|| auth.getPrincipal().equals("anonymousUser")) {
|
||||
|| "anonymousUser".equals(auth.getPrincipal())) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
@@ -180,29 +231,6 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout endpoint
|
||||
*
|
||||
* @param response HTTP response
|
||||
* @return Success message
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<?> logout(HttpServletResponse response) {
|
||||
try {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
log.debug("User logged out successfully");
|
||||
|
||||
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Logout error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token
|
||||
*
|
||||
@@ -244,6 +272,210 @@ public class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/mfa/setup")
|
||||
public ResponseEntity<?> setupMfa(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "MFA already enabled"));
|
||||
}
|
||||
|
||||
try {
|
||||
String secret = totpService.generateSecret();
|
||||
mfaService.setSecret(user, secret);
|
||||
String otpAuthUri = totpService.buildOtpAuthUri(username, secret);
|
||||
|
||||
return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to setup MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to setup MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/enable")
|
||||
public ResponseEntity<?> enableMfa(
|
||||
@RequestBody MfaCodeRequest request, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA setup required"));
|
||||
}
|
||||
|
||||
if (request == null || request.getCode() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA code is required"));
|
||||
}
|
||||
|
||||
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
|
||||
if (timeStep == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mfaService.isTotpStepUsable(user, timeStep)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
mfaService.enableMfa(user);
|
||||
mfaService.markTotpStepUsed(user, timeStep);
|
||||
mfaService.setMfaRequired(user, false);
|
||||
return ResponseEntity.ok(Map.of("enabled", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to enable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to enable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/disable")
|
||||
public ResponseEntity<?> disableMfa(
|
||||
@RequestBody MfaCodeRequest request, Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
|
||||
if (authTypeResponse != null) {
|
||||
return authTypeResponse;
|
||||
}
|
||||
|
||||
if (!mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
}
|
||||
|
||||
String secret = mfaService.getSecret(user);
|
||||
if (secret == null || secret.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA configuration missing"));
|
||||
}
|
||||
|
||||
if (request == null || request.getCode() == null) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "MFA code is required"));
|
||||
}
|
||||
|
||||
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
|
||||
if (timeStep == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (!mfaService.isTotpStepUsable(user, timeStep)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid two-factor code"));
|
||||
}
|
||||
mfaService.disableMfa(user);
|
||||
mfaService.markTotpStepUsed(user, timeStep);
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to disable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to disable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/mfa/setup/cancel")
|
||||
public ResponseEntity<?> cancelMfaSetup(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
String username = authentication.getName();
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
if (mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "MFA already enabled"));
|
||||
}
|
||||
|
||||
try {
|
||||
mfaService.clearPendingSecret(user);
|
||||
return ResponseEntity.ok(Map.of("cleared", true));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to clear MFA setup for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to clear MFA setup"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin endpoint to disable MFA for a user
|
||||
*
|
||||
* @param username Username of the user to disable MFA for
|
||||
* @return Response indicating success or failure
|
||||
*/
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/mfa/disable/admin/{username}")
|
||||
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
|
||||
try {
|
||||
User user =
|
||||
userService
|
||||
.findByUsernameIgnoreCaseWithSettings(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
if (!mfaService.isMfaEnabled(user)) {
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
}
|
||||
|
||||
mfaService.disableMfa(user);
|
||||
return ResponseEntity.ok(Map.of("enabled", false));
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found for MFA disable: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found"));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to disable MFA for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Failed to disable MFA"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
@@ -266,6 +498,19 @@ public class AuthController {
|
||||
appMetadata.put("provider", user.getAuthenticationType());
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
// Add user metadata
|
||||
Map<String, Object> userMetadata = new HashMap<>();
|
||||
userMetadata.put("firstLogin", user.isFirstLogin());
|
||||
userMap.put("user_metadata", userMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "MFA settings are only available for web accounts"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -23,6 +23,7 @@ import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -90,7 +91,8 @@ public class InviteLinkController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"An active invite already exists for this email address"));
|
||||
"An active invite already exists for this email"
|
||||
+ " address"));
|
||||
}
|
||||
|
||||
// If sendEmail is requested but no email provided, reject
|
||||
@@ -123,7 +125,8 @@ public class InviteLinkController {
|
||||
+ (currentUserCount + activeInvites)
|
||||
+ "/"
|
||||
+ maxUsers
|
||||
+ " users). Contact your administrator to upgrade your license."));
|
||||
+ " users). Contact your administrator to"
|
||||
+ " upgrade your license."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,12 +460,13 @@ public class InviteLinkController {
|
||||
}
|
||||
|
||||
// Create the user account
|
||||
userService.saveUser(
|
||||
effectiveEmail,
|
||||
password,
|
||||
invite.getTeamId(),
|
||||
invite.getRole(),
|
||||
false); // Don't force password change
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(effectiveEmail)
|
||||
.password(password)
|
||||
.teamId(invite.getTeamId())
|
||||
.role(invite.getRole());
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Mark invite as used
|
||||
invite.setUsed(true);
|
||||
|
||||
+96
-29
@@ -5,6 +5,7 @@ import java.security.Principal;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
@@ -17,7 +18,10 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -42,6 +46,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@@ -65,26 +70,24 @@ public class UserController {
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
String username = usernameAndPass.getUsername();
|
||||
String password = usernameAndPass.getPassword();
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
|
||||
log.debug("Registration attempt for user: {}", username);
|
||||
|
||||
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: username already exists: {}",
|
||||
usernameAndPass.getUsername());
|
||||
if (userService.usernameExistsIgnoreCase(username)) {
|
||||
log.warn("Registration failed: username already exists: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "User already exists"));
|
||||
}
|
||||
|
||||
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: invalid username format: {}",
|
||||
usernameAndPass.getUsername());
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
log.warn("Registration failed: invalid username format: {}", username);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
|
||||
if (password == null || password.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
@@ -101,17 +104,16 @@ public class UserController {
|
||||
+ ", Available slots: "
|
||||
+ availableSlots));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
usernameAndPass.getUsername(),
|
||||
usernameAndPass.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.password(password)
|
||||
.team(team)
|
||||
.enabled(false);
|
||||
User user = userService.saveUserCore(builder.build());
|
||||
|
||||
log.info("User registered successfully: {}", usernameAndPass.getUsername());
|
||||
log.info("User registered successfully: {}", username);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
@@ -126,7 +128,7 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
|
||||
log.error("Registration error for user: {}", username, e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
@@ -221,6 +223,7 @@ public class UserController {
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
@RequestParam(name = "confirmPassword") String confirmPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
@@ -233,6 +236,43 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
|
||||
if (currentPassword == null
|
||||
|| currentPassword.isEmpty()
|
||||
|| newPassword == null
|
||||
|| newPassword.isEmpty()
|
||||
|| confirmPassword == null
|
||||
|| confirmPassword.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"missingParameters",
|
||||
"message",
|
||||
"Current password, new password, and confirmation are"
|
||||
+ " required"));
|
||||
}
|
||||
|
||||
if (!newPassword.equals(confirmPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"passwordMismatch",
|
||||
"message",
|
||||
"New password and confirmation do not match"));
|
||||
}
|
||||
|
||||
if (newPassword.equals(currentPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"passwordUnchanged",
|
||||
"message",
|
||||
"New password must be different from the current password"));
|
||||
}
|
||||
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -325,7 +365,9 @@ public class UserController {
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@RequestParam(name = "authType") String authType,
|
||||
@RequestParam(name = "forceChange", required = false, defaultValue = "false")
|
||||
boolean forceChange)
|
||||
boolean forceChange,
|
||||
@RequestParam(name = "forceMFA", required = false, defaultValue = "false")
|
||||
boolean forceMFA)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
@@ -347,8 +389,9 @@ public class UserController {
|
||||
+ availableSlots));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
User user = null;
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
user = userOpt.get();
|
||||
if (user.getUsername().equalsIgnoreCase(username)) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
@@ -390,9 +433,23 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder().username(username).teamId(effectiveTeamId).role(role);
|
||||
|
||||
AuthenticationType requestedAuthType;
|
||||
if ("SSO".equalsIgnoreCase(authType)) {
|
||||
requestedAuthType = AuthenticationType.OAUTH2;
|
||||
} else {
|
||||
try {
|
||||
requestedAuthType = AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid authentication type specified."));
|
||||
}
|
||||
}
|
||||
builder.authenticationType(requestedAuthType);
|
||||
|
||||
if (requestedAuthType == AuthenticationType.WEB) {
|
||||
if (password == null || password.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required."));
|
||||
@@ -401,8 +458,9 @@ public class UserController {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters."));
|
||||
}
|
||||
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
|
||||
builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
|
||||
}
|
||||
userService.saveUserCore(builder.build());
|
||||
return ResponseEntity.ok(Map.of("message", "User created successfully"));
|
||||
}
|
||||
|
||||
@@ -427,7 +485,8 @@ public class UserController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Email service is not configured. Please configure SMTP settings."));
|
||||
"Email service is not configured. Please configure SMTP"
|
||||
+ " settings."));
|
||||
}
|
||||
|
||||
// Parse comma-separated email addresses
|
||||
@@ -645,7 +704,8 @@ public class UserController {
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"User's email is not a valid email address. Notifications are disabled."));
|
||||
"User's email is not a valid email address. Notifications"
|
||||
+ " are disabled."));
|
||||
}
|
||||
|
||||
String loginUrl = buildLoginUrl(request);
|
||||
@@ -826,7 +886,14 @@ public class UserController {
|
||||
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
|
||||
|
||||
// Create user with forceChange=true
|
||||
userService.saveUser(email, temporaryPassword, teamId, role, true);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(email)
|
||||
.password(temporaryPassword)
|
||||
.teamId(teamId)
|
||||
.role(role)
|
||||
.firstLogin(true);
|
||||
userService.saveUserCore(builder.build());
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ public class H2SQLCondition implements Condition {
|
||||
boolean enableCustomDatabase =
|
||||
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
|
||||
|
||||
if (enableCustomDatabase) {
|
||||
return false;
|
||||
// If custom database is not enabled, H2 is used by default
|
||||
if (!enableCustomDatabase) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
|
||||
|
||||
+16
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -18,6 +19,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)")
|
||||
Optional<User> findByUsernameIgnoreCaseWithSettings(@Param("username") String username);
|
||||
|
||||
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
|
||||
Optional<User> findByIdWithSettings(@Param("id") Long id);
|
||||
|
||||
Optional<User> findByUsername(String username);
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
@@ -76,4 +80,16 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
|
||||
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
|
||||
long countSsoUsers();
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(u) FROM User u JOIN u.settings settings "
|
||||
+ "WHERE KEY(settings) = :key AND settings = :value")
|
||||
long countUsersBySetting(@Param("key") String key, @Param("value") String value);
|
||||
|
||||
@Modifying
|
||||
@Query(
|
||||
value = "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN (:keys)",
|
||||
nativeQuery = true)
|
||||
void deleteSettingsByUserIdAndKeys(
|
||||
@Param("userId") Long userId, @Param("keys") List<String> keys);
|
||||
}
|
||||
|
||||
+21
-2
@@ -26,8 +26,27 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
|
||||
throws ServletException, IOException {
|
||||
|
||||
if (!runningProOrHigher && isPrometheusEndpointRequest(request)) {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
// Allow only health checks to pass through for non-pro users
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// Strip the context path
|
||||
String contextPath = request.getContextPath();
|
||||
String trimmedUri =
|
||||
(contextPath != null && uri.startsWith(contextPath))
|
||||
? uri.substring(contextPath.length())
|
||||
: uri;
|
||||
|
||||
boolean isHealthCheck =
|
||||
trimmedUri.startsWith("/actuator/health")
|
||||
|| "/health".equals(trimmedUri)
|
||||
|| "/healthz".equals(trimmedUri)
|
||||
|| "/liveness".equals(trimmedUri)
|
||||
|| "/readiness".equals(trimmedUri);
|
||||
|
||||
if (!isHealthCheck) {
|
||||
response.setStatus(HttpStatus.NOT_FOUND.value());
|
||||
return;
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
+9
@@ -61,6 +61,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if (isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStaticResource(contextPath, requestURI)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
@@ -174,7 +179,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
|
||||
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
log.debug("Setting authentication for user: {}", username);
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
log.debug(
|
||||
"Authentication set successfully: {}",
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
} else {
|
||||
throw new UsernameNotFoundException("User not found: " + username);
|
||||
}
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
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.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Filter that redirects browser requests to /login to the frontend URL when configured. This is
|
||||
* needed for development mode where frontend runs on a different port, and for SAML logout which
|
||||
* redirects to /login?logout after SLO completes.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RequiredArgsConstructor
|
||||
public class LoginRedirectFilter extends OncePerRequestFilter {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String path = request.getRequestURI();
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
|
||||
// Only process /login requests when frontendUrl is configured
|
||||
if (path.equals("/login") && frontendUrl != null && !frontendUrl.isBlank()) {
|
||||
// Check if this is a browser request (Accept: text/html) vs API request
|
||||
// (Accept: application/json)
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
boolean isBrowserRequest =
|
||||
acceptHeader != null
|
||||
&& acceptHeader.contains("text/html")
|
||||
&& !acceptHeader.contains("application/json");
|
||||
|
||||
if (isBrowserRequest) {
|
||||
// Preserve query parameters (e.g., ?logout=true, ?error=xxx)
|
||||
String queryString = request.getQueryString();
|
||||
String redirectUrl =
|
||||
frontendUrl + "/login" + (queryString != null ? "?" + queryString : "");
|
||||
log.debug("Redirecting browser request to frontend: {}", redirectUrl);
|
||||
response.sendRedirect(redirectUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -4,6 +4,7 @@ import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -32,6 +33,7 @@ import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@@ -43,16 +45,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final UserService userService;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final boolean loginEnabledValue;
|
||||
private final JwtServiceInterface jwtService;
|
||||
|
||||
public UserAuthenticationFilter(
|
||||
@Lazy ApplicationProperties.Security securityProp,
|
||||
@Lazy UserService userService,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue) {
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue,
|
||||
@Lazy JwtServiceInterface jwtService) {
|
||||
this.securityProp = securityProp;
|
||||
this.userService = userService;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -135,7 +140,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
}
|
||||
|
||||
// Check if the authenticated user is disabled and invalidate their session if so
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
if (authentication.isAuthenticated()) {
|
||||
|
||||
UserLoginType loginMethod = UserLoginType.UNKNOWN;
|
||||
|
||||
@@ -146,7 +151,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String username = null;
|
||||
if (principal instanceof UserDetails detailsUser) {
|
||||
username = detailsUser.getUsername();
|
||||
loginMethod = UserLoginType.USERDETAILS;
|
||||
loginMethod = resolveLoginTypeFromJwt(request);
|
||||
if (loginMethod == UserLoginType.SAML2USER) {
|
||||
SAML2 saml2 = securityProp.getSaml2();
|
||||
blockRegistration = saml2 != null && saml2.getBlockRegistration();
|
||||
} else if (loginMethod == UserLoginType.OAUTH2USER) {
|
||||
OAUTH2 oAuth = securityProp.getOauth2();
|
||||
blockRegistration = oAuth != null && oAuth.getBlockRegistration();
|
||||
}
|
||||
} else if (principal instanceof OAuth2User oAuth2User) {
|
||||
username = oAuth2User.getName();
|
||||
loginMethod = UserLoginType.OAUTH2USER;
|
||||
@@ -241,6 +253,30 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private UserLoginType resolveLoginTypeFromJwt(HttpServletRequest request) {
|
||||
if (jwtService == null) {
|
||||
return UserLoginType.USERDETAILS;
|
||||
}
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
Map<String, Object> claims = jwtService.extractClaims(token);
|
||||
Object authType = claims.get("authType");
|
||||
if (authType != null) {
|
||||
String type = authType.toString().toUpperCase();
|
||||
if ("SAML2".equals(type)) {
|
||||
return UserLoginType.SAML2USER;
|
||||
} else if ("OAUTH2".equals(type)) {
|
||||
return UserLoginType.OAUTH2USER;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Unable to resolve login type from JWT claims", e);
|
||||
}
|
||||
return UserLoginType.USERDETAILS;
|
||||
}
|
||||
|
||||
private enum UserLoginType {
|
||||
USERDETAILS("UserDetails"),
|
||||
OAUTH2USER("OAuth2User"),
|
||||
|
||||
@@ -87,7 +87,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package stirling.software.proprietary.security.model.api.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class MfaCodeRequest {
|
||||
|
||||
@Schema(description = "6-digit authentication code from your authenticator app")
|
||||
private String code;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.security.model.api.user;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class UsernameAndPassMfa extends UsernameAndPass {
|
||||
|
||||
@Schema(description = "6-digit authentication code from authenticator app", example = "123456")
|
||||
private String mfaCode;
|
||||
}
|
||||
+15
-11
@@ -1,9 +1,9 @@
|
||||
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.net.URI;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -114,10 +114,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
return;
|
||||
}
|
||||
boolean isSsoUser = userService.isSsoAuthenticationTypeByUsername(username);
|
||||
if (userExists
|
||||
&& userService.hasPassword(username)
|
||||
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|
||||
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
|
||||
&& !isSsoUser
|
||||
&& oauth2Properties.getAutoCreateUser()) {
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
return;
|
||||
@@ -259,19 +259,23 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String refererHost = refererUrl.getHost().toLowerCase();
|
||||
URI refererUri = URI.create(referer);
|
||||
String host = refererUri.getHost();
|
||||
if (host == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String refererHost = host.toLowerCase();
|
||||
|
||||
if (!isOAuthProviderDomain(refererHost)) {
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
String origin = refererUri.getScheme() + "://" + host;
|
||||
int port = refererUri.getPort();
|
||||
if (port != -1 && port != 80 && port != 443) {
|
||||
origin += ":" + port;
|
||||
}
|
||||
return Optional.of(origin);
|
||||
}
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
} catch (IllegalArgumentException e) {
|
||||
// ignore and fall back
|
||||
}
|
||||
}
|
||||
|
||||
-5
@@ -53,9 +53,6 @@ public class OAuth2Configuration {
|
||||
ApplicationProperties applicationProperties, @Lazy UserService userService) {
|
||||
this.userService = userService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
log.info(
|
||||
"OAuth2Configuration initialized - OAuth2 enabled: {}",
|
||||
applicationProperties.getSecurity().getOauth2().getEnabled());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -222,8 +219,6 @@ public class OAuth2Configuration {
|
||||
name,
|
||||
oauth.getIssuer(),
|
||||
REDIRECT_URI_PATH + name);
|
||||
} else {
|
||||
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
|
||||
}
|
||||
|
||||
return isValid
|
||||
|
||||
+24
-1
@@ -12,9 +12,22 @@ public record CustomSaml2AuthenticatedPrincipal(
|
||||
String name,
|
||||
Map<String, List<Object>> attributes,
|
||||
String nameId,
|
||||
List<String> sessionIndexes)
|
||||
List<String> sessionIndexes,
|
||||
String relyingPartyRegistrationId)
|
||||
implements Saml2AuthenticatedPrincipal, Serializable {
|
||||
|
||||
/**
|
||||
* Constructor without relyingPartyRegistrationId for backwards compatibility. Sets
|
||||
* relyingPartyRegistrationId to null.
|
||||
*/
|
||||
public CustomSaml2AuthenticatedPrincipal(
|
||||
String name,
|
||||
Map<String, List<Object>> attributes,
|
||||
String nameId,
|
||||
List<String> sessionIndexes) {
|
||||
this(name, attributes, nameId, sessionIndexes, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return this.name;
|
||||
@@ -24,4 +37,14 @@ public record CustomSaml2AuthenticatedPrincipal(
|
||||
public Map<String, List<Object>> getAttributes() {
|
||||
return this.attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getSessionIndexes() {
|
||||
return this.sessionIndexes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRelyingPartyRegistrationId() {
|
||||
return this.relyingPartyRegistrationId;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-21
@@ -1,12 +1,14 @@
|
||||
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.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@@ -33,6 +35,7 @@ import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -70,8 +73,7 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
|
||||
if (userExists) {
|
||||
stirling.software.proprietary.security.model.User user =
|
||||
userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
User user = userService.findByUsernameIgnoreCase(username).orElse(null);
|
||||
|
||||
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
|
||||
// User is not grandfathered and no ENTERPRISE license - block SAML login
|
||||
@@ -126,8 +128,8 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
|
||||
boolean hasPassword = userExists && userService.hasPassword(username);
|
||||
boolean isSSOUser =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
|
||||
boolean isSsoUser =
|
||||
userExists && userService.isSsoAuthenticationTypeByUsername(username);
|
||||
boolean isSAML2User =
|
||||
userExists && userService.isAuthenticationTypeByUsername(username, SAML2);
|
||||
|
||||
@@ -135,15 +137,15 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
"User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}",
|
||||
userExists,
|
||||
hasPassword,
|
||||
isSSOUser,
|
||||
isSsoUser,
|
||||
isSAML2User);
|
||||
|
||||
if (userExists
|
||||
&& hasPassword
|
||||
&& (!isSSOUser || !isSAML2User)
|
||||
&& !isSsoUser
|
||||
&& saml2Properties.getAutoCreateUser()) {
|
||||
log.debug(
|
||||
"User {} exists with password but is not SSO user, redirecting to logout",
|
||||
"User {} exists with password but is not an SSO user, redirecting to logout",
|
||||
username);
|
||||
String origin = resolveOrigin(request);
|
||||
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
|
||||
@@ -172,7 +174,11 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
String ssoProvider = "saml2"; // fixme
|
||||
String ssoProvider =
|
||||
(saml2Properties.getIdpIssuer() != null
|
||||
&& !saml2Properties.getIdpIssuer().isBlank())
|
||||
? saml2Properties.getIdpIssuer()
|
||||
: saml2Properties.getRegistrationId();
|
||||
|
||||
log.debug(
|
||||
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
|
||||
@@ -188,12 +194,21 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
SAML2);
|
||||
log.debug("Successfully processed authentication for user: {}", username);
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.SAML2);
|
||||
claims.put("samlNameId", saml2Principal.nameId());
|
||||
List<String> sessionIndexes = saml2Principal.sessionIndexes();
|
||||
if (sessionIndexes != null && !sessionIndexes.isEmpty()) {
|
||||
claims.put("samlSessionIndexes", sessionIndexes);
|
||||
}
|
||||
if (ssoProvider != null) {
|
||||
claims.put("samlProvider", ssoProvider);
|
||||
}
|
||||
if (saml2Properties.getRegistrationId() != null) {
|
||||
claims.put("samlRegistrationId", saml2Properties.getRegistrationId());
|
||||
}
|
||||
String jwt = jwtService.generateToken(authentication, claims);
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
@@ -318,15 +333,18 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String referer = request.getHeader("Referer");
|
||||
if (referer != null && !referer.isEmpty()) {
|
||||
try {
|
||||
java.net.URL refererUrl = new java.net.URL(referer);
|
||||
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
|
||||
if (refererUrl.getPort() != -1
|
||||
&& refererUrl.getPort() != 80
|
||||
&& refererUrl.getPort() != 443) {
|
||||
origin += ":" + refererUrl.getPort();
|
||||
URI refererUri = URI.create(referer);
|
||||
String host = refererUri.getHost();
|
||||
if (host == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String origin = refererUri.getScheme() + "://" + host;
|
||||
int port = refererUri.getPort();
|
||||
if (port != -1 && port != 80 && port != 443) {
|
||||
origin += ":" + port;
|
||||
}
|
||||
return Optional.of(origin);
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ public class JwtSaml2AuthenticationRequestRepository
|
||||
|
||||
if (token != null) {
|
||||
tokenStore.remove(authnRequestId);
|
||||
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
|
||||
log.info("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
|
||||
return token;
|
||||
} else {
|
||||
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
|
||||
|
||||
+284
-36
@@ -1,9 +1,22 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.parsers.ParserConfigurationException;
|
||||
|
||||
import org.opensaml.saml.saml2.core.AuthnRequest;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
@@ -19,6 +32,10 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
|
||||
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
|
||||
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
|
||||
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
import org.xml.sax.SAXException;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@@ -27,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
|
||||
@Configuration
|
||||
@@ -35,45 +53,34 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
@RequiredArgsConstructor
|
||||
public class Saml2Configuration {
|
||||
|
||||
private static final String SAML_METADATA_NS = "urn:oasis:names:tc:SAML:2.0:metadata";
|
||||
private static final String XML_DSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
|
||||
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
|
||||
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
|
||||
Optional<IdpMetadataInfo> metadataInfo = loadIdpMetadata(samlConf);
|
||||
|
||||
log.info(
|
||||
"Initializing SAML2 configuration with registration ID: {}",
|
||||
samlConf.getRegistrationId());
|
||||
|
||||
// Load IdP certificate
|
||||
X509Certificate idpCert;
|
||||
try {
|
||||
Resource idpCertResource = samlConf.getIdpCert();
|
||||
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
|
||||
if (!idpCertResource.exists()) {
|
||||
log.error(
|
||||
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP certificate file does not exist: "
|
||||
+ idpCertResource.getDescription());
|
||||
}
|
||||
idpCert = CertificateUtils.readCertificate(idpCertResource);
|
||||
log.info(
|
||||
"Successfully loaded IdP certificate. Subject: {}",
|
||||
idpCert.getSubjectX500Principal().getName());
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
|
||||
}
|
||||
// Load IdP certificate either from metadata or fallback resource
|
||||
X509Certificate idpCert =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::signingCertificate)
|
||||
.orElseGet(() -> loadIdpCertificateFromResource(samlConf));
|
||||
|
||||
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
|
||||
|
||||
// Load SP private key and certificate
|
||||
Resource privateKeyResource = samlConf.getPrivateKey();
|
||||
Resource certificateResource = samlConf.getSpCert();
|
||||
Resource privateKeyResource = samlConf.getSp().getPrivateKeyResource();
|
||||
Resource certificateResource = samlConf.getSp().getCertResource();
|
||||
|
||||
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
|
||||
log.debug("Loading SP private key from: {}", privateKeyResource.getDescription());
|
||||
if (!privateKeyResource.exists()) {
|
||||
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
|
||||
throw new IllegalStateException(
|
||||
@@ -81,7 +88,7 @@ public class Saml2Configuration {
|
||||
+ privateKeyResource.getDescription());
|
||||
}
|
||||
|
||||
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
|
||||
log.debug("Loading SP certificate from: {}", certificateResource.getDescription());
|
||||
if (!certificateResource.exists()) {
|
||||
log.error(
|
||||
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
|
||||
@@ -102,6 +109,43 @@ public class Saml2Configuration {
|
||||
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
|
||||
}
|
||||
// Apply metadata overrides - metadata takes precedence over manual config
|
||||
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
|
||||
|
||||
// Get IdP configuration - prefer values from metadata, fall back to manual config
|
||||
String idpEntityId =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::entityId)
|
||||
.filter(id -> id != null && !id.isBlank())
|
||||
.orElseGet(() -> samlConf.getProvider().getEntityId());
|
||||
|
||||
String idpSingleLoginUrl =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::singleSignOnServiceUrl)
|
||||
.filter(url -> url != null && !url.isBlank())
|
||||
.orElseGet(() -> samlConf.getProvider().getSingleLoginUrl());
|
||||
|
||||
String idpSingleLogoutUrl =
|
||||
metadataInfo
|
||||
.map(IdpMetadataInfo::singleLogoutServiceUrl)
|
||||
.filter(url -> url != null && !url.isBlank())
|
||||
.orElseGet(() -> samlConf.getProvider().getSingleLogoutUrl());
|
||||
|
||||
// Validate required IdP configuration
|
||||
if (idpEntityId == null || idpEntityId.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP Entity ID is required. Set security.saml2.entityId or provide security.saml2.metadataUri");
|
||||
}
|
||||
if (idpSingleLoginUrl == null || idpSingleLoginUrl.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP Single Sign-On URL is required. Set security.saml2.provider.singleLoginUrl or provide security.saml2.metadataUri");
|
||||
}
|
||||
|
||||
log.info(
|
||||
"SAML2 IdP configuration: entityId={}, ssoUrl={}, sloUrl={}",
|
||||
idpEntityId,
|
||||
idpSingleLoginUrl,
|
||||
idpSingleLogoutUrl);
|
||||
|
||||
// Get backend URL from configuration (for SAML endpoints)
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
@@ -116,40 +160,38 @@ public class Saml2Configuration {
|
||||
String entityId =
|
||||
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
|
||||
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
|
||||
String sloResponseLocation = backendUrl + "/login";
|
||||
// SP's Single Logout Service endpoint (where SP receives logout requests/responses from
|
||||
// IdP)
|
||||
String spSloLocation = backendUrl + "/logout/saml2/slo";
|
||||
|
||||
RelyingPartyRegistration rp =
|
||||
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
|
||||
.signingX509Credentials(c -> c.add(signingCredential))
|
||||
.entityId(entityId)
|
||||
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation(sloResponseLocation)
|
||||
.singleLogoutServiceLocation(spSloLocation)
|
||||
.singleLogoutServiceResponseLocation(spSloLocation)
|
||||
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
||||
.assertionConsumerServiceLocation(acsLocation)
|
||||
.authnRequestsSigned(true)
|
||||
.assertingPartyMetadata(
|
||||
metadata ->
|
||||
metadata.entityId(samlConf.getIdpIssuer())
|
||||
metadata.entityId(idpEntityId)
|
||||
.verificationX509Credentials(
|
||||
c -> c.add(verificationCredential))
|
||||
.singleSignOnServiceBinding(
|
||||
Saml2MessageBinding.POST)
|
||||
.singleSignOnServiceLocation(
|
||||
samlConf.getIdpSingleLoginUrl())
|
||||
.singleSignOnServiceLocation(idpSingleLoginUrl)
|
||||
.singleLogoutServiceBinding(
|
||||
Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(
|
||||
samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation(
|
||||
sloResponseLocation)
|
||||
.singleLogoutServiceLocation(idpSingleLogoutUrl)
|
||||
.wantAuthnRequestsSigned(true))
|
||||
.build();
|
||||
|
||||
log.info(
|
||||
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
|
||||
samlConf.getRegistrationId(),
|
||||
samlConf.getIdpIssuer());
|
||||
idpEntityId);
|
||||
return new InMemoryRelyingPartyRegistrationRepository(rp);
|
||||
}
|
||||
|
||||
@@ -206,6 +248,212 @@ public class Saml2Configuration {
|
||||
return resolver;
|
||||
}
|
||||
|
||||
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
|
||||
try {
|
||||
Resource idpCertResource = samlConf.getProvider().getCertResource();
|
||||
if (idpCertResource == null) {
|
||||
throw new IllegalStateException("SAML2 IdP certificate resource is not defined");
|
||||
}
|
||||
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
|
||||
if (!idpCertResource.exists()) {
|
||||
throw new IllegalStateException(
|
||||
"SAML2 IdP certificate file does not exist: "
|
||||
+ idpCertResource.getDescription());
|
||||
}
|
||||
X509Certificate certificate = CertificateUtils.readCertificate(idpCertResource);
|
||||
log.info(
|
||||
"Successfully loaded IdP certificate. Subject: {}",
|
||||
certificate.getSubjectX500Principal().getName());
|
||||
return certificate;
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMetadataOverrides(SAML2 samlConf, IdpMetadataInfo metadataInfo) {
|
||||
log.info(
|
||||
"Applying IdP metadata overrides for registration: {}",
|
||||
samlConf.getRegistrationId());
|
||||
SAML2.Provider provider = samlConf.getProvider();
|
||||
overrideIfPresent(metadataInfo.entityId(), provider::setEntityId);
|
||||
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), provider::setSingleLoginUrl);
|
||||
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), provider::setSingleLogoutUrl);
|
||||
|
||||
// Persist discovered metadata values to settings.yml
|
||||
persistMetadataToSettings(metadataInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists IdP metadata discovered values to settings.yml. This ensures the discovered
|
||||
* configuration is saved for future reference and survives restarts even if the metadata
|
||||
* endpoint becomes unavailable.
|
||||
*/
|
||||
private void persistMetadataToSettings(IdpMetadataInfo metadataInfo) {
|
||||
log.info(
|
||||
"Migrating discovered IdP metadata to SAML configuration. Existing configuration will be overridden.");
|
||||
|
||||
try {
|
||||
boolean anyPersisted = false;
|
||||
|
||||
if (hasText(metadataInfo.entityId())) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.provider.entityId", metadataInfo.entityId());
|
||||
log.info(" -> Persisted provider.entityId: {}", metadataInfo.entityId());
|
||||
anyPersisted = true;
|
||||
}
|
||||
|
||||
if (hasText(metadataInfo.singleSignOnServiceUrl())) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.provider.singleLoginUrl",
|
||||
metadataInfo.singleSignOnServiceUrl());
|
||||
log.info(
|
||||
" -> Persisted provider.singleLoginUrl: {}",
|
||||
metadataInfo.singleSignOnServiceUrl());
|
||||
anyPersisted = true;
|
||||
}
|
||||
|
||||
if (hasText(metadataInfo.singleLogoutServiceUrl())) {
|
||||
GeneralUtils.saveKeyToSettings(
|
||||
"security.saml2.provider.singleLogoutUrl",
|
||||
metadataInfo.singleLogoutServiceUrl());
|
||||
log.info(
|
||||
" -> Persisted provider.singleLogoutUrl: {}",
|
||||
metadataInfo.singleLogoutServiceUrl());
|
||||
anyPersisted = true;
|
||||
}
|
||||
|
||||
if (anyPersisted) {
|
||||
log.info(
|
||||
"IdP metadata successfully persisted to settings.yml. These values will be used as fallback if metadataUri becomes unavailable.");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to persist IdP metadata to settings.yml: {}. SAML will still work but discovered values won't be saved.",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
|
||||
String metadataLocation = samlConf.getEffectiveMetadataUri();
|
||||
if (metadataLocation == null || metadataLocation.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try (InputStream metadataStream = samlConf.getMetadataUriAsStream()) {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
|
||||
// XXE prevention - disable all external entities and DTD processing
|
||||
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setFeature(
|
||||
"http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
|
||||
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(metadataStream);
|
||||
|
||||
Element entityDescriptor = doc.getDocumentElement();
|
||||
if (entityDescriptor == null) {
|
||||
log.warn("No EntityDescriptor found in SAML metadata: {}", metadataLocation);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String entityId = entityDescriptor.getAttribute("entityID");
|
||||
NodeList idpDescriptors =
|
||||
entityDescriptor.getElementsByTagNameNS(SAML_METADATA_NS, "IDPSSODescriptor");
|
||||
if (idpDescriptors.getLength() == 0) {
|
||||
log.warn("No IDPSSODescriptor found in SAML metadata: {}", metadataLocation);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Element idpDescriptor = (Element) idpDescriptors.item(0);
|
||||
String ssoUrl = extractServiceLocation(idpDescriptor, "SingleSignOnService");
|
||||
String sloUrl = extractServiceLocation(idpDescriptor, "SingleLogoutService");
|
||||
X509Certificate signingCert = extractSigningCertificate(idpDescriptor);
|
||||
|
||||
log.info("Loaded IdP metadata from: {}", metadataLocation);
|
||||
return Optional.of(new IdpMetadataInfo(entityId, ssoUrl, sloUrl, signingCert));
|
||||
} catch (IOException
|
||||
| ParserConfigurationException
|
||||
| SAXException
|
||||
| CertificateException e) {
|
||||
log.warn("Failed to parse SAML metadata from {}: {}", metadataLocation, e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String extractServiceLocation(Element descriptor, String tagName) {
|
||||
NodeList services = descriptor.getElementsByTagNameNS(SAML_METADATA_NS, tagName);
|
||||
String fallback = null;
|
||||
for (int i = 0; i < services.getLength(); i++) {
|
||||
Element service = (Element) services.item(i);
|
||||
String location = service.getAttribute("Location");
|
||||
String binding = service.getAttribute("Binding");
|
||||
if (!hasText(location)) {
|
||||
continue;
|
||||
}
|
||||
if (Saml2MessageBinding.POST.getUrn().equals(binding)) {
|
||||
return location;
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = location;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private X509Certificate extractSigningCertificate(Element descriptor)
|
||||
throws CertificateException {
|
||||
NodeList keyDescriptors =
|
||||
descriptor.getElementsByTagNameNS(SAML_METADATA_NS, "KeyDescriptor");
|
||||
for (int i = 0; i < keyDescriptors.getLength(); i++) {
|
||||
Element keyDescriptor = (Element) keyDescriptors.item(i);
|
||||
String use = keyDescriptor.getAttribute("use");
|
||||
if (hasText(use) && !"signing".equalsIgnoreCase(use)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NodeList certificateNodes =
|
||||
keyDescriptor.getElementsByTagNameNS(XML_DSIG_NS, "X509Certificate");
|
||||
if (certificateNodes.getLength() == 0) {
|
||||
continue;
|
||||
}
|
||||
String certificateValue = certificateNodes.item(0).getTextContent();
|
||||
if (!hasText(certificateValue)) {
|
||||
continue;
|
||||
}
|
||||
byte[] decoded =
|
||||
Base64.getMimeDecoder().decode(certificateValue.replaceAll("\\s+", ""));
|
||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||
return (X509Certificate)
|
||||
certificateFactory.generateCertificate(new ByteArrayInputStream(decoded));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private void overrideIfPresent(String value, Consumer<String> setter) {
|
||||
if (hasText(value)) {
|
||||
setter.accept(value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
private record IdpMetadataInfo(
|
||||
String entityId,
|
||||
String singleSignOnServiceUrl,
|
||||
String singleLogoutServiceUrl,
|
||||
X509Certificate signingCertificate) {}
|
||||
|
||||
private static void logAuthnRequestDetails(AuthnRequest authnRequest) {
|
||||
String message =
|
||||
"""
|
||||
|
||||
+6
-2
@@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
@@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface {
|
||||
String checksum = bytesToHex(digest.digest(content));
|
||||
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
|
||||
|
||||
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
|
||||
String verifyDbUrl = "jdbc:h2:mem:backupVerify_" + UUID.randomUUID();
|
||||
// Use a fresh in-memory database per verification to avoid leftover objects between
|
||||
// runs.
|
||||
try (Connection conn = DriverManager.getConnection(verifyDbUrl);
|
||||
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
|
||||
stmt.setString(1, backupPath.toString());
|
||||
stmt.execute();
|
||||
}
|
||||
return true;
|
||||
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
|
||||
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+18
@@ -258,6 +258,24 @@ public class JwtService implements JwtServiceInterface {
|
||||
return token;
|
||||
}
|
||||
|
||||
// Check for logout cookie (set by frontend before redirecting to /logout for SAML SLO)
|
||||
if (request.getCookies() != null) {
|
||||
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
|
||||
if ("stirling_logout_token".equals(cookie.getName())) {
|
||||
String value = cookie.getValue();
|
||||
if (value != null && !value.isBlank()) {
|
||||
try {
|
||||
return java.net.URLDecoder.decode(
|
||||
value, java.nio.charset.StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to decode logout token cookie", e);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/**
|
||||
* Service for managing multi-factor authentication (MFA) settings for users.
|
||||
*
|
||||
* <p>This service reads and writes MFA-related settings such as secrets, enablement flags, and
|
||||
* last-used TOTP steps.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class MfaService {
|
||||
|
||||
public static final String MFA_ENABLED_KEY = "mfaEnabled";
|
||||
public static final String MFA_SECRET_KEY = "mfaSecret";
|
||||
public static final String MFA_LAST_USED_STEP_KEY = "mfaLastUsedStep";
|
||||
public static final String MFA_REQUIRED_KEY = "mfaRequired";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final DatabaseServiceInterface databaseService;
|
||||
|
||||
/**
|
||||
* Determines whether MFA is enabled for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return {@code true} if MFA is enabled
|
||||
*/
|
||||
public boolean isMfaEnabled(User user) {
|
||||
String value = getSetting(user, MFA_ENABLED_KEY);
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the MFA secret for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return Base32-encoded secret, or {@code null} if not set
|
||||
*/
|
||||
public String getSecret(User user) {
|
||||
return getSetting(user, MFA_SECRET_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a new MFA secret and marks MFA as pending (disabled) for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @param secret Base32-encoded secret to store
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
@Transactional
|
||||
public void setSecret(User user, String secret)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
// Clear existing values and flush the removals before inserting a new secret. This keeps
|
||||
// the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
|
||||
// same keys within a single transaction.
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
if (managedUser != null && managedUser.getId() != null) {
|
||||
userRepository.deleteSettingsByUserIdAndKeys(
|
||||
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
|
||||
userRepository.flush();
|
||||
}
|
||||
settings.put(MFA_SECRET_KEY, secret);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables MFA for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "true");
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending MFA setup data for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables MFA and clears stored secrets for the user.
|
||||
*
|
||||
* @param user target user
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_ENABLED_KEY, "false");
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a TOTP time step has not been used before.
|
||||
*
|
||||
* @param user target user
|
||||
* @param timeStep candidate TOTP time step
|
||||
* @return {@code true} if the time step is usable
|
||||
*/
|
||||
public boolean isTotpStepUsable(User user, long timeStep) {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = managedUser.getSettings();
|
||||
if (settings == null) {
|
||||
return true;
|
||||
}
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed == null) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
long lastUsedStep = Long.parseLong(lastUsed);
|
||||
return timeStep > lastUsedStep;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a TOTP time step as used, preventing replay.
|
||||
*
|
||||
* @param user target user
|
||||
* @param timeStep time step to mark as used
|
||||
* @return {@code true} if the time step was marked, {@code false} if it was already used
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public boolean markTotpStepUsed(User user, long timeStep)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
|
||||
if (lastUsed != null) {
|
||||
try {
|
||||
long lastUsedStep = Long.parseLong(lastUsed);
|
||||
if (timeStep <= lastUsedStep) {
|
||||
return false;
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// treat malformed value as unused
|
||||
}
|
||||
}
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, Long.toString(timeStep));
|
||||
persist(managedUser);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether MFA is required for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @return {@code true} if MFA is required
|
||||
*/
|
||||
public boolean isMfaRequired(User user) {
|
||||
String value = getSetting(user, MFA_REQUIRED_KEY);
|
||||
if (value == null) {
|
||||
value = "false";
|
||||
}
|
||||
log.info("MFA required for user {}: {}", user.getUsername(), value);
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether MFA is required for the given user.
|
||||
*
|
||||
* @param user target user
|
||||
* @param required {@code true} to require MFA
|
||||
* @throws SQLException when database persistence fails
|
||||
* @throws UnsupportedProviderException when the database provider is unsupported
|
||||
*/
|
||||
public void setMfaRequired(User user, boolean required)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = ensureSettings(managedUser);
|
||||
settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
|
||||
log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
|
||||
persist(managedUser);
|
||||
}
|
||||
|
||||
private String getSetting(User user, String key) {
|
||||
User managedUser = getUserWithSettings(user);
|
||||
Map<String, String> settings = managedUser.getSettings();
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
return settings.get(key);
|
||||
}
|
||||
|
||||
private User getUserWithSettings(User user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
return user;
|
||||
}
|
||||
return userRepository.findByIdWithSettings(user.getId()).orElse(user);
|
||||
}
|
||||
|
||||
private Map<String, String> ensureSettings(User user) {
|
||||
Map<String, String> settings = user.getSettings();
|
||||
if (settings == null) {
|
||||
settings = new HashMap<>();
|
||||
user.setSettings(settings);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
private void persist(User user) throws SQLException, UnsupportedProviderException {
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
|
||||
/**
|
||||
* Carries all attributes required to create or update a user account, including credentials,
|
||||
* SSO/provider details, team association, role and MFA configuration. Used by the security service
|
||||
* layer to persist or update users.
|
||||
*
|
||||
* <p>Defaults:
|
||||
*
|
||||
* <ul>
|
||||
* <li>password: null
|
||||
* <li>ssoProviderId: null
|
||||
* <li>ssoProvider: null
|
||||
* <li>authenticationType: {@code AuthenticationType.WEB}
|
||||
* <li>teamId: null
|
||||
* <li>team: null
|
||||
* <li>role: {@code Role.USER.getRoleId()}
|
||||
* <li>firstLogin: false
|
||||
* <li>enabled: true
|
||||
* <li>requireMfa: false
|
||||
* <li>mfaEnabled: false
|
||||
* <li>mfaSecret: null
|
||||
* <li>mfaLastUsedStep: null
|
||||
* </ul>
|
||||
*/
|
||||
@Getter
|
||||
@Builder(builderClassName = "Builder")
|
||||
public class SaveUserRequest {
|
||||
private final String username;
|
||||
@Builder.Default private final String password = null;
|
||||
@Builder.Default private final String ssoProviderId = null;
|
||||
@Builder.Default private final String ssoProvider = null;
|
||||
@Builder.Default private final AuthenticationType authenticationType = AuthenticationType.WEB;
|
||||
@Builder.Default private final Long teamId = null;
|
||||
@Builder.Default private final Team team = null;
|
||||
@Builder.Default private final String role = Role.USER.getRoleId();
|
||||
@Builder.Default private final boolean firstLogin = false;
|
||||
@Builder.Default private final boolean enabled = true;
|
||||
@Builder.Default private final boolean requireMfa = false;
|
||||
@Builder.Default private final boolean mfaEnabled = false;
|
||||
@Builder.Default private final String mfaSecret = null;
|
||||
@Builder.Default private final Long mfaLastUsedStep = null;
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
/**
|
||||
* Service for generating and validating TOTP secrets and codes for MFA authentication.
|
||||
*
|
||||
* <p>This service handles secret generation, code validation across time steps, and building
|
||||
* otpauth:// URIs to provision authenticator apps.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TotpService {
|
||||
|
||||
private static final int SECRET_LENGTH_BYTES = 20;
|
||||
private static final int CODE_DIGITS = 6;
|
||||
private static final int PERIOD_SECONDS = 30;
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA1";
|
||||
private static final String DEFAULT_ISSUER = "Stirling PDF";
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Generates a new random TOTP secret encoded in Base32.
|
||||
*
|
||||
* @return Base32-encoded secret suitable for provisioning an authenticator app
|
||||
*/
|
||||
public String generateSecret() {
|
||||
byte[] secret = new byte[SECRET_LENGTH_BYTES];
|
||||
SECURE_RANDOM.nextBytes(secret);
|
||||
return Base32Codec.encode(secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a submitted TOTP code is valid for the current time window.
|
||||
*
|
||||
* @param secret Base32-encoded shared secret
|
||||
* @param code six-digit code supplied by the user
|
||||
* @return {@code true} if the code is valid for the current time window
|
||||
*/
|
||||
public boolean isValidCode(String secret, String code) {
|
||||
return getValidTimeStep(secret, code) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a TOTP code and returns the time step it matches.
|
||||
*
|
||||
* @param secret Base32-encoded shared secret
|
||||
* @param code six-digit code supplied by the user
|
||||
* @return matching time step, or {@code null} if the code is invalid
|
||||
*/
|
||||
public Long getValidTimeStep(String secret, String code) {
|
||||
if (secret == null || secret.isBlank() || code == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedCode = code.replace(" ", "");
|
||||
if (!normalizedCode.matches("\\d{6}")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] secretKey;
|
||||
try {
|
||||
secretKey = Base32Codec.decode(secret);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
long timeStep = Instant.now().getEpochSecond() / PERIOD_SECONDS;
|
||||
byte[] normalizedCodeBytes = normalizedCode.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
for (int offset = -1; offset <= 1; offset++) {
|
||||
long candidate = timeStep + offset;
|
||||
String generatedCode = generateCode(secretKey, candidate);
|
||||
if (MessageDigest.isEqual(
|
||||
generatedCode.getBytes(StandardCharsets.UTF_8), normalizedCodeBytes)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an otpauth:// URI for configuring TOTP in authenticator apps.
|
||||
*
|
||||
* @param username account identifier to embed in the label
|
||||
* @param secret Base32-encoded secret to embed in the URI
|
||||
* @return otpauth URI that can be encoded as a QR code
|
||||
*/
|
||||
public String buildOtpAuthUri(String username, String secret) {
|
||||
String issuer = resolveIssuer();
|
||||
String label = encodeForOtpAuth(issuer + ":" + username);
|
||||
String encodedIssuer = encodeForOtpAuth(issuer);
|
||||
|
||||
return "otpauth://totp/"
|
||||
+ label
|
||||
+ "?secret="
|
||||
+ secret
|
||||
+ "&issuer="
|
||||
+ encodedIssuer
|
||||
+ "&algorithm=SHA1&digits="
|
||||
+ CODE_DIGITS
|
||||
+ "&period="
|
||||
+ PERIOD_SECONDS;
|
||||
}
|
||||
|
||||
private String encodeForOtpAuth(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
}
|
||||
|
||||
private String resolveIssuer() {
|
||||
if (applicationProperties.getUi() != null) {
|
||||
String appName = applicationProperties.getUi().getAppNameNavbar();
|
||||
if (appName != null && !appName.isBlank()) {
|
||||
return appName.trim();
|
||||
}
|
||||
}
|
||||
return DEFAULT_ISSUER;
|
||||
}
|
||||
|
||||
private String generateCode(byte[] secret, long timeStep) {
|
||||
try {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(8);
|
||||
buffer.putLong(timeStep);
|
||||
|
||||
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
|
||||
mac.init(new SecretKeySpec(secret, HMAC_ALGORITHM));
|
||||
byte[] hash = mac.doFinal(buffer.array());
|
||||
|
||||
int offset = hash[hash.length - 1] & 0x0F;
|
||||
int binary =
|
||||
((hash[offset] & 0x7F) << 24)
|
||||
| ((hash[offset + 1] & 0xFF) << 16)
|
||||
| ((hash[offset + 2] & 0xFF) << 8)
|
||||
| (hash[offset + 3] & 0xFF);
|
||||
|
||||
int otp = binary % (int) Math.pow(10, CODE_DIGITS);
|
||||
return String.format("%0" + CODE_DIGITS + "d", otp);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate TOTP code", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
-200
@@ -1,5 +1,10 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_ENABLED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_LAST_USED_STEP_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY;
|
||||
import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
@@ -101,7 +106,13 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
if (autoCreateUser) {
|
||||
saveUser(username, ssoProviderId, ssoProvider, type);
|
||||
SaveUserRequest.Builder builder =
|
||||
SaveUserRequest.builder()
|
||||
.username(username)
|
||||
.ssoProviderId(ssoProviderId)
|
||||
.ssoProvider(ssoProvider)
|
||||
.authenticationType(type);
|
||||
saveUserCore(builder.build());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +152,14 @@ public class UserService implements UserServiceInterface {
|
||||
return user;
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User refreshApiKeyForUser(String username) {
|
||||
// reuse the add API key method for refreshing
|
||||
return addApiKeyToUser(username);
|
||||
@@ -179,162 +198,6 @@ public class UserService implements UserServiceInterface {
|
||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||
}
|
||||
|
||||
public void saveUser(String username, AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
authenticationType,
|
||||
(Long) null,
|
||||
Role.USER.getRoleId());
|
||||
}
|
||||
|
||||
private User saveUser(Optional<User> user, String apiKey) {
|
||||
if (user.isPresent()) {
|
||||
user.get().setApiKey(apiKey);
|
||||
return userRepository.save(user.get());
|
||||
}
|
||||
throw new UsernameNotFoundException("User not found");
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, AuthenticationType authenticationType, Long teamId, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
ssoProviderId, // ssoProviderId
|
||||
ssoProvider, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, AuthenticationType authenticationType, Team team, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
role, // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(String username, String password, Long teamId)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
Role.USER.getRoleId(), // role
|
||||
false, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, String password, Team team, String role, boolean firstLogin)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
role, // role
|
||||
firstLogin, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public User saveUser(
|
||||
String username, String password, Long teamId, String role, boolean firstLogin)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
role, // role
|
||||
firstLogin, // firstLogin
|
||||
true // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public void saveUser(String username, String password, Long teamId, String role)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUser(username, password, teamId, role, false);
|
||||
}
|
||||
|
||||
public void saveUser(
|
||||
String username, String password, Long teamId, boolean firstLogin, boolean enabled)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
Role.USER.getRoleId(), // role
|
||||
firstLogin, // firstLogin
|
||||
enabled // enabled
|
||||
);
|
||||
}
|
||||
|
||||
public void deleteUser(String username) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
@@ -485,75 +348,71 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
/**
|
||||
* Core implementation for saving a user with all possible parameters. This method centralizes
|
||||
* the common logic for all saveUser variants.
|
||||
* Core method to save a user based on the provided SaveUserRequest.
|
||||
*
|
||||
* @param username Username for the new user
|
||||
* @param password Password for the user (may be null for SSO/OAuth users)
|
||||
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
|
||||
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
|
||||
* @param authenticationType Type of authentication (WEB, SSO, etc.)
|
||||
* @param teamId ID of the team to assign (may be null to use default)
|
||||
* @param team Team object to assign (takes precedence over teamId if both provided)
|
||||
* @param role Role to assign to the user
|
||||
* @param firstLogin Whether this is the user's first login
|
||||
* @param enabled Whether the user account is enabled
|
||||
* @param request The SaveUserRequest containing user details
|
||||
* @return The saved User object
|
||||
* @throws IllegalArgumentException If username is invalid or team is invalid
|
||||
* @throws SQLException If database operation fails
|
||||
* @throws UnsupportedProviderException If provider is not supported
|
||||
* @throws IllegalArgumentException If the username is invalid
|
||||
* @throws SQLException If a database error occurs
|
||||
* @throws UnsupportedProviderException If an unsupported provider is specified
|
||||
*/
|
||||
private User saveUserCore(
|
||||
String username,
|
||||
String password,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
Team team,
|
||||
String role,
|
||||
boolean firstLogin,
|
||||
boolean enabled)
|
||||
public User saveUserCore(SaveUserRequest request)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
|
||||
if (!isUsernameValid(username)) {
|
||||
if (!isUsernameValid(request.getUsername())) {
|
||||
throw new IllegalArgumentException(getInvalidUsernameMessage());
|
||||
}
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setUsername(request.getUsername());
|
||||
|
||||
// Set password if provided
|
||||
if (password != null && !password.isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
if (request.getPassword() != null && !request.getPassword().isEmpty()) {
|
||||
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||
}
|
||||
|
||||
// Set SSO provider details if provided
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
if (request.getSsoProviderId() != null && request.getSsoProvider() != null) {
|
||||
user.setSsoProviderId(request.getSsoProviderId());
|
||||
user.setSsoProvider(request.getSsoProvider());
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
user.setAuthenticationType(authenticationType);
|
||||
user.setAuthenticationType(request.getAuthenticationType());
|
||||
|
||||
// Set enabled status
|
||||
user.setEnabled(enabled);
|
||||
user.setEnabled(request.isEnabled());
|
||||
|
||||
// Set first login flag
|
||||
user.setFirstLogin(firstLogin);
|
||||
user.setFirstLogin(request.isFirstLogin());
|
||||
|
||||
// Set MFA requirement
|
||||
Map<String, String> settings = user.getSettings();
|
||||
settings.put(MFA_REQUIRED_KEY, String.valueOf(request.isRequireMfa()));
|
||||
settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
|
||||
if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
|
||||
settings.put(MFA_SECRET_KEY, request.getMfaSecret());
|
||||
} else {
|
||||
settings.remove(MFA_SECRET_KEY);
|
||||
}
|
||||
if (request.getMfaLastUsedStep() != null) {
|
||||
settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
|
||||
} else {
|
||||
settings.remove(MFA_LAST_USED_STEP_KEY);
|
||||
}
|
||||
log.info(
|
||||
"MFA required set to true for user {} {}",
|
||||
request.getUsername(),
|
||||
user.getSettings().toString());
|
||||
|
||||
// Set role (authority)
|
||||
if (role == null) {
|
||||
role = Role.USER.getRoleId();
|
||||
}
|
||||
user.addAuthority(new Authority(role, user));
|
||||
user.addAuthority(new Authority(request.getRole(), user));
|
||||
|
||||
// Resolve and set team
|
||||
if (team != null) {
|
||||
user.setTeam(team);
|
||||
if (request.getTeam() != null) {
|
||||
user.setTeam(request.getTeam());
|
||||
} else {
|
||||
user.setTeam(resolveTeam(teamId, this::getDefaultTeam));
|
||||
user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam));
|
||||
}
|
||||
|
||||
// Save user
|
||||
@@ -600,6 +459,32 @@ public class UserService implements UserServiceInterface {
|
||||
return user.isPresent() && user.get().hasPassword();
|
||||
}
|
||||
|
||||
public boolean isSsoAuthenticationTypeByUsername(String username) {
|
||||
Optional<User> user = findByUsernameIgnoreCase(username);
|
||||
if (user.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String authType = user.get().getAuthenticationType();
|
||||
if (authType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
AuthenticationType authenticationType =
|
||||
AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT));
|
||||
if (authenticationType == AuthenticationType.OAUTH2
|
||||
|| authenticationType == AuthenticationType.SAML2) {
|
||||
return true;
|
||||
}
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Fall through to legacy string comparison below
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy "SSO" value without relying on the deprecated enum
|
||||
return "SSO".equalsIgnoreCase(authType);
|
||||
}
|
||||
|
||||
public boolean isAuthenticationTypeByUsername(
|
||||
String username, AuthenticationType authenticationType) {
|
||||
Optional<User> user = findByUsernameIgnoreCase(username);
|
||||
@@ -652,6 +537,7 @@ public class UserService implements UserServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrentUserAdmin() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
@@ -667,6 +553,7 @@ public class UserService implements UserServiceInterface {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrentUserFirstLogin() {
|
||||
try {
|
||||
String username = getCurrentUsername();
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* RFC 4648 Base32 encoder/decoder for handling TOTP secrets.
|
||||
*
|
||||
* <p>This implementation is used to encode binary secrets into Base32 strings and decode them back
|
||||
* into byte arrays for TOTP processing.
|
||||
*/
|
||||
public final class Base32Codec {
|
||||
|
||||
private static final char[] ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
|
||||
private static final int[] LOOKUP_TABLE = new int[128];
|
||||
|
||||
static {
|
||||
Arrays.fill(LOOKUP_TABLE, -1);
|
||||
for (int i = 0; i < ALPHABET.length; i++) {
|
||||
LOOKUP_TABLE[ALPHABET[i]] = i;
|
||||
}
|
||||
}
|
||||
|
||||
private Base32Codec() {}
|
||||
|
||||
/**
|
||||
* Encodes the provided bytes into a Base32 string.
|
||||
*
|
||||
* @param data the raw bytes to encode
|
||||
* @return Base32-encoded representation of the input, or an empty string for null/empty input
|
||||
*/
|
||||
public static String encode(byte[] data) {
|
||||
if (data == null || data.length == 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder((data.length * 8 + 4) / 5);
|
||||
int buffer = data[0] & 0xFF;
|
||||
int bitsLeft = 8;
|
||||
int index = 1;
|
||||
|
||||
while (bitsLeft > 0 || index < data.length) {
|
||||
if (bitsLeft < 5) {
|
||||
if (index < data.length) {
|
||||
buffer = (buffer << 8) | (data[index++] & 0xFF);
|
||||
bitsLeft += 8;
|
||||
} else {
|
||||
int padding = 5 - bitsLeft;
|
||||
buffer <<= padding;
|
||||
bitsLeft += padding;
|
||||
}
|
||||
}
|
||||
|
||||
int digit = (buffer >> (bitsLeft - 5)) & 0x1F;
|
||||
bitsLeft -= 5;
|
||||
result.append(ALPHABET[digit]);
|
||||
}
|
||||
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a Base32 string into raw bytes.
|
||||
*
|
||||
* @param value Base32-encoded string (padding and whitespace are ignored)
|
||||
* @return decoded byte array, or an empty array for null/blank input
|
||||
* @throws IllegalArgumentException when the input contains invalid Base32 characters
|
||||
*/
|
||||
public static byte[] decode(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
String normalized = value.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream(normalized.length());
|
||||
int buffer = 0;
|
||||
int bitsLeft = 0;
|
||||
|
||||
for (char character : normalized.toCharArray()) {
|
||||
if (character >= LOOKUP_TABLE.length || LOOKUP_TABLE[character] == -1) {
|
||||
throw new IllegalArgumentException("Invalid Base32 character: " + character);
|
||||
}
|
||||
|
||||
buffer = (buffer << 5) | LOOKUP_TABLE[character];
|
||||
bitsLeft += 5;
|
||||
|
||||
if (bitsLeft >= 8) {
|
||||
output.write((buffer >> (bitsLeft - 8)) & 0xFF);
|
||||
bitsLeft -= 8;
|
||||
}
|
||||
}
|
||||
|
||||
return output.toByteArray();
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
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.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.config.AuditConfigurationProperties;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData;
|
||||
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData;
|
||||
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
|
||||
import stirling.software.proprietary.security.database.repository.SessionRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.DatabaseService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ProprietaryUIDataControllerTest {
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private SessionRepository sessionRepository;
|
||||
@Mock private DatabaseService databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
@Mock private PersistentAuditEventRepository auditRepository;
|
||||
@Mock private MfaService mfaService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AuditConfigurationProperties auditConfig;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private ProprietaryUIDataController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getUi().setLanguages(List.of("en", "de"));
|
||||
applicationProperties.getSystem().setDefaultLocale("en");
|
||||
applicationProperties.getSecurity().setEnableLogin(true);
|
||||
applicationProperties.getSecurity().getOauth2().setEnabled(false);
|
||||
applicationProperties.getSecurity().getSaml2().setEnabled(false);
|
||||
|
||||
auditConfig = new AuditConfigurationProperties(applicationProperties);
|
||||
objectMapper = new ObjectMapper();
|
||||
|
||||
controller =
|
||||
new ProprietaryUIDataController(
|
||||
applicationProperties,
|
||||
auditConfig,
|
||||
sessionPersistentRegistry,
|
||||
userRepository,
|
||||
teamRepository,
|
||||
sessionRepository,
|
||||
databaseService,
|
||||
objectMapper,
|
||||
false,
|
||||
licenseSettingsService,
|
||||
auditRepository,
|
||||
mfaService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
|
||||
when(userRepository.findAll()).thenReturn(Collections.emptyList());
|
||||
|
||||
ResponseEntity<LoginData> response = controller.getLoginData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
LoginData body = response.getBody();
|
||||
assertThat(body.isFirstTimeSetup()).isTrue();
|
||||
assertThat(body.isShowDefaultCredentials()).isTrue();
|
||||
assertThat(body.getLanguages()).containsExactly("en", "de");
|
||||
assertThat(body.getDefaultLocale()).isEqualTo("en");
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountDataReturnsUserSettingsAndMfaFlags() {
|
||||
User user = new User();
|
||||
user.setUsername("user@example.com");
|
||||
Authority authority = new Authority();
|
||||
authority.setAuthority(Role.USER.getRoleId());
|
||||
user.addAuthority(authority);
|
||||
user.setSettings(Map.of("theme", "dark"));
|
||||
|
||||
when(userRepository.findByUsernameIgnoreCaseWithSettings("user@example.com"))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
when(mfaService.isMfaRequired(user)).thenReturn(false);
|
||||
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
|
||||
ResponseEntity<AccountData> response = controller.getAccountData(authentication);
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
AccountData data = response.getBody();
|
||||
assertThat(data.getUsername()).isEqualTo("user@example.com");
|
||||
assertThat(data.isMfaEnabled()).isTrue();
|
||||
assertThat(data.isMfaRequired()).isFalse();
|
||||
assertThat(data.getSettings()).contains("\"theme\":\"dark\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void databaseDataMarksUnknownVersion() {
|
||||
when(databaseService.getBackupList()).thenReturn(List.of());
|
||||
when(databaseService.getH2Version()).thenReturn("Unknown");
|
||||
|
||||
ResponseEntity<DatabaseData> response = controller.getDatabaseData();
|
||||
|
||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
||||
DatabaseData data = response.getBody();
|
||||
assertThat(data.getBackupFiles()).isEmpty();
|
||||
assertThat(data.isVersionUnknown()).isTrue();
|
||||
}
|
||||
}
|
||||
+37
-7
@@ -6,9 +6,9 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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 org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
@@ -26,17 +26,24 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
|
||||
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
@Mock private ApplicationProperties.Security.SAML2 saml2;
|
||||
|
||||
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSuccessfulLogout() throws IOException {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String token = "token";
|
||||
String logoutPath = "/login?logout=true";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -50,10 +57,10 @@ class CustomLogoutSuccessHandlerTest {
|
||||
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||
HttpServletResponse response = mock(HttpServletResponse.class);
|
||||
String logoutPath = "/login?logout=true";
|
||||
String token = "token";
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -71,6 +78,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
@@ -96,6 +106,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -128,6 +141,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
@@ -152,6 +168,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
@@ -177,10 +196,12 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter(error)).thenReturn("true");
|
||||
when(request.getContextPath()).thenReturn(url);
|
||||
when(request.getScheme()).thenReturn("http");
|
||||
when(request.getServerName()).thenReturn("localhost");
|
||||
when(request.getServerPort()).thenReturn(8080);
|
||||
@@ -204,6 +225,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -236,6 +260,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
@@ -269,6 +296,9 @@ class CustomLogoutSuccessHandlerTest {
|
||||
mock(ApplicationProperties.Security.OAUTH2.class);
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(request.getParameter("SAMLResponse")).thenReturn(null);
|
||||
when(securityProperties.getSaml2()).thenReturn(saml2);
|
||||
when(saml2.getEnableSingleLogout()).thenReturn(false);
|
||||
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
|
||||
when(request.getParameter("errorOAuth")).thenReturn(null);
|
||||
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
|
||||
import stirling.software.proprietary.security.service.SaveUserRequest;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InitialSecuritySetupTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private TeamService teamService;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private InitialSecuritySetup initialSecuritySetup;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getSecurity().getInitialLogin().setUsername("admin");
|
||||
applicationProperties.getSecurity().getInitialLogin().setPassword("password");
|
||||
Team internalTeam = new Team();
|
||||
internalTeam.setName(TeamService.INTERNAL_TEAM_NAME);
|
||||
User internalUser = new User();
|
||||
internalUser.setUsername(Role.INTERNAL_API_USER.getRoleId());
|
||||
internalUser.setTeam(internalTeam);
|
||||
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(Optional.of(internalUser));
|
||||
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
|
||||
initialSecuritySetup =
|
||||
new InitialSecuritySetup(
|
||||
userService,
|
||||
teamService,
|
||||
applicationProperties,
|
||||
databaseService,
|
||||
licenseSettingsService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void initImportsBackupWhenPresent() throws SQLException, UnsupportedProviderException {
|
||||
when(userService.hasUsers()).thenReturn(false);
|
||||
when(databaseService.hasBackup()).thenReturn(true);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
verify(databaseService).importDatabase();
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
verify(licenseSettingsService).initializeGrandfatheredCount();
|
||||
verify(licenseSettingsService).updateLicenseMaxUsers();
|
||||
}
|
||||
|
||||
@Test
|
||||
void initCreatesConfiguredAdminWhenNoBackup()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
when(userService.hasUsers()).thenReturn(false);
|
||||
when(databaseService.hasBackup()).thenReturn(false);
|
||||
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.empty());
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
when(teamService.getOrCreateDefaultTeam()).thenReturn(defaultTeam);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
|
||||
.thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
ArgumentCaptor<SaveUserRequest> captor = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(captor.capture());
|
||||
SaveUserRequest saved = captor.getValue();
|
||||
assertThat(saved.getUsername()).isEqualTo("admin");
|
||||
assertThat(saved.getPassword()).isEqualTo("password");
|
||||
}
|
||||
|
||||
@Test
|
||||
void configureJwtSettingsDisablesKeyCleanupWhenJwtDisabled() {
|
||||
ReflectionTestUtils.setField(initialSecuritySetup, "v2Enabled", true);
|
||||
applicationProperties.getSecurity().getJwt().setEnableKeystore(false);
|
||||
when(userService.hasUsers()).thenReturn(true);
|
||||
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
|
||||
when(userService.usernameExistsIgnoreCase(any())).thenReturn(true);
|
||||
|
||||
initialSecuritySetup.init();
|
||||
|
||||
assertThat(applicationProperties.getSecurity().getJwt().isEnableKeyCleanup()).isFalse();
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
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.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerLoginTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private CustomUserDetailsService userDetailsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
userService,
|
||||
jwtService,
|
||||
userDetailsService,
|
||||
loginAttemptService,
|
||||
mfaService,
|
||||
totpService,
|
||||
securityProperties);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRejectsWhenUserPassDisabled() throws Exception {
|
||||
securityProperties.setLoginMethod(
|
||||
ApplicationProperties.Security.LoginMethods.OAUTH2.toString());
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(
|
||||
jsonPath("$.error")
|
||||
.value(
|
||||
"Username/password authentication is not enabled. Please use the configured authentication method."));
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginBlockedAccountReturnsUnauthorized() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
when(loginAttemptService.isBlocked("user@example.com")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(
|
||||
jsonPath("$.error")
|
||||
.value("Account is locked due to too many failed attempts"));
|
||||
|
||||
verify(loginAttemptService, never()).loginSucceeded(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRequiresMfaCodeWhenEnabled() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("mfa_required"));
|
||||
|
||||
verify(loginAttemptService, never()).loginSucceeded(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginFailsWhenPasswordIncorrect() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Invalid credentials"));
|
||||
|
||||
verify(loginAttemptService).loginFailed("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginSucceedsAndGeneratesToken() throws Exception {
|
||||
UsernameAndPassMfa payload = buildPayload(null);
|
||||
User user = buildUser();
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("token-123");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.session.access_token").value("token-123"))
|
||||
.andExpect(jsonPath("$.user.username").value("user@example.com"));
|
||||
|
||||
verify(loginAttemptService).loginSucceeded("user@example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsUnauthorizedWhenTokenMissing() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn(null);
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("No token found"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.access_token").value("new-token"))
|
||||
.andExpect(jsonPath("$.expires_in").value(3600));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
|
||||
SecurityContextHolder.clearContext();
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Not authenticated"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCurrentUserReturnsUserDetails() throws Exception {
|
||||
User user = buildUser();
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/me"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user.username").value("user@example.com"))
|
||||
.andExpect(
|
||||
jsonPath("$.user.authenticationType")
|
||||
.value(AuthenticationType.WEB.name().toLowerCase()));
|
||||
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private User buildUser() {
|
||||
User user = new User();
|
||||
user.setUsername("user@example.com");
|
||||
user.setEnabled(true);
|
||||
user.setAuthenticationType(AuthenticationType.WEB);
|
||||
|
||||
Authority authority = new Authority();
|
||||
authority.setAuthority(Role.USER.getRoleId());
|
||||
user.addAuthorities(Set.of(authority));
|
||||
return user;
|
||||
}
|
||||
|
||||
private UsernameAndPassMfa buildPayload(String mfaCode) {
|
||||
UsernameAndPassMfa payload = new UsernameAndPassMfa();
|
||||
payload.setUsername("user@example.com");
|
||||
payload.setPassword("pw");
|
||||
payload.setMfaCode(mfaCode);
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.List;
|
||||
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.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
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.MfaService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerMfaTest {
|
||||
|
||||
private static final String USERNAME = "user@example.com";
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private Authentication authentication;
|
||||
private User user;
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private JwtServiceInterface jwtService;
|
||||
@Mock private CustomUserDetailsService userDetailsService;
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
|
||||
@InjectMocks private AuthController authController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
|
||||
authentication = new UsernamePasswordAuthenticationToken(USERNAME, "password", List.of());
|
||||
user = new User();
|
||||
user.setUsername(USERNAME);
|
||||
user.setAuthenticationType(AuthenticationType.WEB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaRequiresAuthentication() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(content().json("{\"error\":\"Not authenticated\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsSecretAndUri() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
when(totpService.generateSecret()).thenReturn("SECRET");
|
||||
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.secret").value("SECRET"))
|
||||
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
|
||||
|
||||
verify(mfaService).setSecret(user, "SECRET");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
|
||||
|
||||
verify(totpService, never()).generateSecret();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupMfaRejectsNonWebAuthenticationType() throws Exception {
|
||||
user.setAuthenticationType(AuthenticationType.OAUTH2);
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(
|
||||
content()
|
||||
.json(
|
||||
"{\"error\":\"MFA settings are only available for web accounts\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaRejectsMissingCode() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/enable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(content().json("{\"error\":\"MFA code is required\"}"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaCompletesWorkflow() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(42L);
|
||||
when(mfaService.isTotpStepUsable(user, 42L)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/enable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "123456"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(true));
|
||||
|
||||
verify(mfaService).enableMfa(user);
|
||||
verify(mfaService).markTotpStepUsed(user, 42L);
|
||||
verify(mfaService).setMfaRequired(user, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaCompletesWorkflow() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
when(mfaService.getSecret(user)).thenReturn("SECRET");
|
||||
when(totpService.getValidTimeStep("SECRET", "654321")).thenReturn(7L);
|
||||
when(mfaService.isTotpStepUsable(user, 7L)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/disable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false));
|
||||
|
||||
verify(mfaService).disableMfa(user);
|
||||
verify(mfaService).markTotpStepUsed(user, 7L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaReturnsDisabledWhenNotEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/auth/mfa/disable")
|
||||
.principal(authentication)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.enabled").value(false));
|
||||
|
||||
verify(mfaService, never()).getSecret(user);
|
||||
verifyNoInteractions(totpService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelMfaSetupClearsPendingSecret() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.cleared").value(true));
|
||||
|
||||
verify(mfaService).clearPendingSecret(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancelMfaSetupReturnsConflictWhenEnabled() throws Exception {
|
||||
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
|
||||
.thenReturn(Optional.of(user));
|
||||
when(mfaService.isMfaEnabled(user)).thenReturn(true);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
|
||||
|
||||
verify(mfaService, never()).clearPendingSecret(user);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.hamcrest.Matchers.startsWith;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.time.LocalDateTime;
|
||||
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.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.InviteToken;
|
||||
import stirling.software.proprietary.security.repository.InviteTokenRepository;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class InviteLinkControllerTest {
|
||||
|
||||
@Mock private InviteTokenRepository inviteTokenRepository;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private EmailService emailService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
private Principal adminPrincipal;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getMail().setEnableInvites(true);
|
||||
applicationProperties.getMail().setInviteLinkExpiryHours(24);
|
||||
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com");
|
||||
|
||||
adminPrincipal = () -> "admin";
|
||||
|
||||
InviteLinkController controller =
|
||||
new InviteLinkController(
|
||||
inviteTokenRepository,
|
||||
teamRepository,
|
||||
userService,
|
||||
applicationProperties,
|
||||
Optional.of(emailService));
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkRejectsWhenInvitesDisabled() throws Exception {
|
||||
applicationProperties.getMail().setEnableInvites(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/invite/generate").principal(adminPrincipal))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Email invites are not enabled"));
|
||||
|
||||
verify(inviteTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkRejectsInvalidEmail() throws Exception {
|
||||
applicationProperties.getMail().setEnableInvites(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "not-an-email"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Invalid email address"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkBlocksOnLicenseLimit() throws Exception {
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
applicationProperties.getPremium().setMaxUsers(1);
|
||||
when(userService.getTotalUsersCount()).thenReturn(1L);
|
||||
when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "new@ex.com"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value(startsWith("License limit reached")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateInviteLinkBuildsFrontendUrl() throws Exception {
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setId(5L);
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
|
||||
.thenReturn(Optional.of(defaultTeam));
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
when(inviteTokenRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/generate")
|
||||
.principal(adminPrincipal)
|
||||
.param("email", "new@example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(
|
||||
jsonPath("$.inviteUrl")
|
||||
.value(startsWith("https://frontend.example.com/invite?token=")))
|
||||
.andExpect(jsonPath("$.email").value("new@example.com"));
|
||||
|
||||
verify(inviteTokenRepository).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
|
||||
InviteToken expired = new InviteToken();
|
||||
expired.setToken("abc");
|
||||
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
|
||||
expired.setRole(Role.USER.getRoleId());
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
|
||||
|
||||
mockMvc.perform(get("/api/v1/invite/validate/abc"))
|
||||
.andExpect(status().isGone())
|
||||
.andExpect(jsonPath("$.error").value("This invite link has expired"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
|
||||
InviteToken invite = new InviteToken();
|
||||
invite.setToken("abc");
|
||||
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
|
||||
invite.setRole(Role.USER.getRoleId());
|
||||
invite.setUsed(false);
|
||||
invite.setEmail(null); // email required from request
|
||||
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/invite/accept/abc")
|
||||
.param("email", "new@example.com")
|
||||
.param("password", "password123"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.message").value("Account created successfully"))
|
||||
.andExpect(jsonPath("$.username").value("new@example.com"));
|
||||
|
||||
verify(userService).saveUserCore(any());
|
||||
verify(inviteTokenRepository).save(invite);
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
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.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.service.UserLicenseSettingsService;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class UserControllerTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private EmailService emailService;
|
||||
@Mock private UserLicenseSettingsService licenseSettingsService;
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getPremium().setMaxUsers(10);
|
||||
applicationProperties.getMail().setEnabled(true);
|
||||
|
||||
UserController controller =
|
||||
new UserController(
|
||||
userService,
|
||||
sessionRegistry,
|
||||
applicationProperties,
|
||||
teamRepository,
|
||||
userRepository,
|
||||
Optional.of(emailService),
|
||||
licenseSettingsService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerRejectsExistingUser() throws Exception {
|
||||
UsernameAndPass payload = new UsernameAndPass();
|
||||
payload.setUsername("existing@example.com");
|
||||
payload.setPassword("pw");
|
||||
when(userService.usernameExistsIgnoreCase("existing@example.com")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("User already exists"));
|
||||
|
||||
verify(userService, never()).saveUserCore(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerCreatesUserWhenValid() throws Exception {
|
||||
UsernameAndPass payload = new UsernameAndPass();
|
||||
payload.setUsername("new@example.com");
|
||||
payload.setPassword("pw");
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
|
||||
when(userService.isUsernameValid("new@example.com")).thenReturn(true);
|
||||
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
|
||||
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
|
||||
.thenReturn(Optional.of(defaultTeam));
|
||||
|
||||
User savedUser = new User();
|
||||
savedUser.setUsername("new@example.com");
|
||||
savedUser.setEnabled(false);
|
||||
when(userService.saveUserCore(any())).thenReturn(savedUser);
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(payload)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.user.username").value("new@example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changeUserEnabledPreventsSelfDisable() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("admin");
|
||||
when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true);
|
||||
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(user));
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "pw");
|
||||
|
||||
mockMvc.perform(
|
||||
post("/api/v1/user/admin/changeUserEnabled/admin")
|
||||
.param("enabled", "false")
|
||||
.principal(authentication))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.error").value("Cannot disable your own account."));
|
||||
}
|
||||
|
||||
@Test
|
||||
void changePasswordRejectsMissingUser() throws Exception {
|
||||
Authentication authentication = new UsernamePasswordAuthenticationToken("ghost", "pw");
|
||||
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/user/admin/deleteUser/ghost").principal(authentication))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.error").value("User not found."));
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -36,12 +36,12 @@ class H2SQLConditionTest {
|
||||
|
||||
@Test
|
||||
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
|
||||
// Flag=true, Typ=h2 -> false
|
||||
// Flag=true, Typ=h2 -> true
|
||||
MockEnvironment envTrueH2 =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "true")
|
||||
.withProperty("system.datasource.type", "h2");
|
||||
assertFalse(eval(envTrueH2));
|
||||
assertTrue(eval(envTrueH2));
|
||||
|
||||
// Flag=true, Typ=postgres -> false
|
||||
MockEnvironment envTrueOther =
|
||||
@@ -57,20 +57,6 @@ class H2SQLConditionTest {
|
||||
assertFalse(eval(envTrueMissingType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() {
|
||||
// Flag fehlt, Typ=postgres -> false
|
||||
MockEnvironment envNotH2 =
|
||||
new MockEnvironment().withProperty("system.datasource.type", "postgresql");
|
||||
assertFalse(eval(envNotH2));
|
||||
|
||||
// Flag=false, Typ fehlt -> false (Default: "")
|
||||
MockEnvironment envMissingType =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "false");
|
||||
assertFalse(eval(envMissingType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
|
||||
MockEnvironment envNotH2 =
|
||||
@@ -84,4 +70,18 @@ class H2SQLConditionTest {
|
||||
.withProperty("system.datasource.enableCustomDatabase", "true");
|
||||
assertFalse(eval(envMissingType));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsTrue_whenTypeNotH2_andCustomDatabaseDisabled() {
|
||||
MockEnvironment envNotH2 =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "false")
|
||||
.withProperty("system.datasource.type", "postgresql");
|
||||
assertTrue(eval(envNotH2));
|
||||
|
||||
MockEnvironment envMissingType =
|
||||
new MockEnvironment()
|
||||
.withProperty("system.datasource.enableCustomDatabase", "false");
|
||||
assertTrue(eval(envMissingType));
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -113,10 +113,10 @@ class UserTest {
|
||||
|
||||
// Use an existing value from your AuthenticationType enum (e.g. OAUTH2/SAML2/DATABASE)
|
||||
// If the name differs, simply adjust below.
|
||||
AuthenticationType at = AuthenticationType.SSO;
|
||||
AuthenticationType at = AuthenticationType.OAUTH2;
|
||||
u.setAuthenticationType(at);
|
||||
|
||||
assertEquals("sso", u.getAuthenticationType());
|
||||
assertEquals("oauth2", u.getAuthenticationType());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FileInfo;
|
||||
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
|
||||
|
||||
class DatabaseServiceTest {
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@Mock private DatabaseNotificationServiceInterface notificationService;
|
||||
|
||||
private DatabaseService databaseService;
|
||||
private ApplicationProperties.Datasource datasourceProps;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
datasourceProps = new ApplicationProperties.Datasource();
|
||||
datasourceProps.setType(ApplicationProperties.Driver.H2.name());
|
||||
datasourceProps.setCustomDatabaseUrl("jdbc:h2:mem:test");
|
||||
datasourceProps.setEnableCustomDatabase(false);
|
||||
|
||||
DataSource dataSource =
|
||||
new DriverManagerDataSource(
|
||||
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
|
||||
|
||||
databaseService = new DatabaseService(datasourceProps, dataSource, notificationService);
|
||||
ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasBackupReturnsFalseWhenEmpty() {
|
||||
assertThat(databaseService.hasBackup()).isFalse();
|
||||
assertThat(Files.exists(tempDir)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupListReturnsEntries() throws IOException {
|
||||
Path backup =
|
||||
tempDir.resolve(
|
||||
"backup_"
|
||||
+ LocalDateTime.now()
|
||||
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
|
||||
+ ".sql");
|
||||
Files.writeString(backup, "CREATE TABLE TEST(ID INT);");
|
||||
|
||||
List<stirling.software.common.model.FileInfo> backups = databaseService.getBackupList();
|
||||
|
||||
assertThat(backups).hasSize(1);
|
||||
assertThat(backups.get(0).getFileName()).isEqualTo(backup.getFileName().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void importDatabaseFromUICopiesBackupAndDeletesTemp() throws IOException {
|
||||
Path script = Files.createTempFile("script", ".sql");
|
||||
Files.writeString(script, "CREATE TABLE SAMPLE(ID INT PRIMARY KEY);\n");
|
||||
|
||||
boolean result = databaseService.importDatabaseFromUI(script);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
assertThat(Files.exists(script)).isFalse();
|
||||
try (var stream = Files.list(tempDir)) {
|
||||
assertThat(stream.toList()).isNotEmpty();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getBackupFilePathPreventsTraversal() {
|
||||
assertThatThrownBy(() -> databaseService.getBackupFilePath("../evil.sql"))
|
||||
.isInstanceOf(SecurityException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteBackupFileRejectsInvalidName() throws Exception {
|
||||
boolean deleted = databaseService.deleteBackupFile("..bad.sql");
|
||||
assertThat(deleted).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteAllBackupsRemovesFiles() throws Exception {
|
||||
Path first = tempDir.resolve("backup_first.sql");
|
||||
Path second = tempDir.resolve("backup_second.sql");
|
||||
Files.writeString(first, "SELECT 1;");
|
||||
Files.writeString(second, "SELECT 1;");
|
||||
|
||||
List<org.apache.commons.lang3.tuple.Pair<FileInfo, Boolean>> results =
|
||||
databaseService.deleteAllBackups();
|
||||
|
||||
assertThat(results).hasSize(2);
|
||||
assertThat(results.stream().allMatch(org.apache.commons.lang3.tuple.Pair::getRight))
|
||||
.isTrue();
|
||||
assertThat(Files.exists(first)).isFalse();
|
||||
assertThat(Files.exists(second)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportDatabaseCreatesScript() {
|
||||
databaseService.exportDatabase();
|
||||
|
||||
assertThat(tempDir.toFile().list()).isNotEmpty();
|
||||
verify(notificationService)
|
||||
.notifyBackupsSuccess(
|
||||
org.mockito.ArgumentMatchers.anyString(),
|
||||
org.mockito.ArgumentMatchers.anyString());
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
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 stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MfaServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
|
||||
@InjectMocks private MfaService mfaService;
|
||||
|
||||
@Test
|
||||
void setSecretStoresSecretAndDisablesMfa() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.setSecret(user, "NEWSECRET");
|
||||
|
||||
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void enableMfaSetsEnabledFlag() throws Exception {
|
||||
User user = new User();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.enableMfa(user);
|
||||
|
||||
assertEquals("true", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disableMfaClearsSecretAndUsage() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.disableMfa(user);
|
||||
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markTotpStepUsedTracksNewestStep() throws Exception {
|
||||
User user = new User();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
assertTrue(mfaService.markTotpStepUsed(user, 100L));
|
||||
assertEquals("100", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markTotpStepUsedRejectsReplays() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "200");
|
||||
|
||||
assertFalse(mfaService.markTotpStepUsed(user, 199L));
|
||||
assertFalse(mfaService.markTotpStepUsed(user, 200L));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markTotpStepUsedIgnoresMalformedStoredValue() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "not-a-number");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
assertTrue(mfaService.markTotpStepUsed(user, 5L));
|
||||
assertEquals("5", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService, times(1)).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isTotpStepUsableRespectsLastStep() {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "50");
|
||||
|
||||
assertFalse(mfaService.isTotpStepUsable(user, 50));
|
||||
assertFalse(mfaService.isTotpStepUsable(user, 40));
|
||||
assertTrue(mfaService.isTotpStepUsable(user, 51));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMfaRequiredDefaultsToFalse() {
|
||||
User user = new User();
|
||||
|
||||
assertFalse(mfaService.isMfaRequired(user));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setMfaRequiredStoresFlag() throws Exception {
|
||||
User user = new User();
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.setMfaRequired(user, true);
|
||||
|
||||
assertEquals("true", user.getSettings().get(MfaService.MFA_REQUIRED_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingSecretResetsValues() throws Exception {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
mfaService.clearPendingSecret(user);
|
||||
|
||||
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
|
||||
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isMfaEnabledAndGetSecretReadSettings() {
|
||||
User user = new User();
|
||||
user.getSettings().put(MfaService.MFA_ENABLED_KEY, "true");
|
||||
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
|
||||
|
||||
assertTrue(mfaService.isMfaEnabled(user));
|
||||
assertEquals("SECRET", mfaService.getSecret(user));
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
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 java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
class TotpServiceTest {
|
||||
|
||||
private TotpService buildService(String appName) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
ui.setAppNameNavbar(appName);
|
||||
properties.setUi(ui);
|
||||
return new TotpService(properties);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSecretReturnsBase32String() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
String secret = service.generateSecret();
|
||||
|
||||
assertNotNull(secret);
|
||||
assertEquals(32, secret.length());
|
||||
assertTrue(secret.matches("[A-Z2-7]+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildOtpAuthUriIncludesIssuerAndUsername() {
|
||||
TotpService service = buildService("Stirling Test");
|
||||
|
||||
String uri = service.buildOtpAuthUri("user@example.com", "SECRET");
|
||||
|
||||
assertTrue(uri.contains("issuer=Stirling%20Test"));
|
||||
assertTrue(uri.contains("Stirling%20Test%3Auser%40example.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeAcceptsCurrentAndAdjacentTimeSteps() throws Exception {
|
||||
TotpService service = buildService("Test App");
|
||||
byte[] secretBytes = "super-secret".getBytes(StandardCharsets.UTF_8);
|
||||
String secret = Base32Codec.encode(secretBytes);
|
||||
|
||||
long timeStep = Instant.now().getEpochSecond() / 30;
|
||||
String currentCode = generateCode(service, secretBytes, timeStep);
|
||||
String nextCode = generateCode(service, secretBytes, timeStep + 1);
|
||||
|
||||
assertTrue(service.isValidCode(secret, currentCode));
|
||||
assertEquals(timeStep, service.getValidTimeStep(secret, currentCode));
|
||||
assertTrue(service.isValidCode(secret, nextCode));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeRejectsInvalidFormats() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
assertFalse(service.isValidCode("SECRET", "ABCDEF"));
|
||||
assertFalse(service.isValidCode("SECRET", "12345"));
|
||||
assertFalse(service.isValidCode(null, "123456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void isValidCodeRejectsInvalidSecrets() {
|
||||
TotpService service = buildService("Test App");
|
||||
|
||||
assertFalse(service.isValidCode("INVALID*", "123456"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildOtpAuthUriUsesDefaultIssuerWhenMissing() {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
TotpService service = new TotpService(properties);
|
||||
|
||||
String uri = service.buildOtpAuthUri("user@example.com", "SECRET");
|
||||
|
||||
assertTrue(uri.contains("issuer=Stirling%20PDF"));
|
||||
}
|
||||
|
||||
private String generateCode(TotpService service, byte[] secretBytes, long timeStep)
|
||||
throws Exception {
|
||||
Method generateCode =
|
||||
TotpService.class.getDeclaredMethod("generateCode", byte[].class, long.class);
|
||||
generateCode.setAccessible(true);
|
||||
return (String) generateCode.invoke(service, secretBytes, timeStep);
|
||||
}
|
||||
}
|
||||
+118
-230
@@ -1,22 +1,26 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.sql.SQLException;
|
||||
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.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
class UserServiceTest {
|
||||
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
@Mock private AuthorityRepository authorityRepository;
|
||||
|
||||
@Mock private PasswordEncoder passwordEncoder;
|
||||
|
||||
@Mock private MessageSource messageSource;
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
|
||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||
@Mock private DatabaseServiceInterface databaseService;
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
private Team mockTeam;
|
||||
private User mockUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockTeam = new Team();
|
||||
mockTeam.setId(1L);
|
||||
mockTeam.setName("Test Team");
|
||||
|
||||
mockUser = new User();
|
||||
mockUser.setId(1L);
|
||||
mockUser.setUsername("testuser");
|
||||
mockUser.setEnabled(true);
|
||||
}
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
void saveUserCore_populatesFieldsAndPersists()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Long teamId = 42L;
|
||||
Team team = new Team();
|
||||
team.setId(teamId);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(team));
|
||||
when(passwordEncoder.encode("plain")).thenReturn("encoded");
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
SaveUserRequest request =
|
||||
SaveUserRequest.builder()
|
||||
.username("validUser")
|
||||
.password("plain")
|
||||
.ssoProviderId("sso-id")
|
||||
.ssoProvider("provider-x")
|
||||
.authenticationType(AuthenticationType.OAUTH2)
|
||||
.teamId(teamId)
|
||||
.role(Role.ADMIN.getRoleId())
|
||||
.firstLogin(true)
|
||||
.enabled(false)
|
||||
.requireMfa(true)
|
||||
.mfaEnabled(true)
|
||||
.mfaSecret("top-secret")
|
||||
.mfaLastUsedStep(5L)
|
||||
.build();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, authType);
|
||||
User saved = userService.saveUserCore(request);
|
||||
|
||||
// Then
|
||||
verify(userRepository).save(any(User.class));
|
||||
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
||||
verify(userRepository).save(userCaptor.capture());
|
||||
verify(databaseService).exportDatabase();
|
||||
|
||||
User persisted = userCaptor.getValue();
|
||||
assertEquals("validUser", persisted.getUsername());
|
||||
assertEquals("encoded", persisted.getPassword());
|
||||
assertEquals("provider-x", persisted.getSsoProvider());
|
||||
assertEquals("sso-id", persisted.getSsoProviderId());
|
||||
assertEquals(team, persisted.getTeam());
|
||||
assertEquals("oauth2", persisted.getAuthenticationType());
|
||||
assertFalse(persisted.isEnabled());
|
||||
assertTrue(persisted.isFirstLogin());
|
||||
assertTrue(
|
||||
persisted.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())));
|
||||
assertEquals(
|
||||
"true",
|
||||
persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY),
|
||||
"MFA requirement should be stored");
|
||||
assertEquals(
|
||||
"true",
|
||||
persisted.getSettings().get(MfaService.MFA_ENABLED_KEY),
|
||||
"MFA enabled flag should be stored");
|
||||
assertEquals(
|
||||
"top-secret",
|
||||
persisted.getSettings().get(MfaService.MFA_SECRET_KEY),
|
||||
"MFA secret should be stored");
|
||||
assertEquals(
|
||||
"5",
|
||||
persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY),
|
||||
"MFA last used step should be stored");
|
||||
assertSame(saved, persisted, "Returned user should be the persisted instance");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
void saveUserCore_withoutTeam_usesDefaultTeam()
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Team defaultTeam = new Team();
|
||||
defaultTeam.setName("Default");
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, password, teamId);
|
||||
User saved = userService.saveUserCore(request);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(teamRepository).findById(teamId);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(teamRepository).findByName("Default");
|
||||
verify(teamRepository, never()).findById(anyLong());
|
||||
verify(databaseService).exportDatabase();
|
||||
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithTeamAndRole_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
String role = Role.ADMIN.getRoleId();
|
||||
boolean firstLogin = true;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
void processSSOPostLogin_autoCreatesUserWhenMissing()
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
String username = "autoUser";
|
||||
doReturn(true).when(userService).isUsernameValid(username);
|
||||
when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
|
||||
.thenReturn(Optional.empty());
|
||||
when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
|
||||
User created = new User();
|
||||
doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class));
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
|
||||
ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
|
||||
verify(userService).saveUserCore(reqCaptor.capture());
|
||||
SaveUserRequest captured = reqCaptor.getValue();
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
assertEquals(username, captured.getUsername());
|
||||
assertEquals("id", captured.getSsoProviderId());
|
||||
assertEquals("prov", captured.getSsoProvider());
|
||||
assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String invalidUsername = "ab"; // Too short (less than 3 characters)
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
void addApiKeyToUserGeneratesAndPersists() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(invalidUsername, authType));
|
||||
User updated = userService.addApiKeyToUser("user");
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
assertNotNull(updated.getApiKey());
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithNullPassword_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
Long teamId = 1L;
|
||||
void getApiKeyForUserCreatesWhenMissing() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
String apiKey = userService.getApiKeyForUser("user");
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, null, teamId);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder, never()).encode(anyString());
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
assertNotNull(apiKey);
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithEmptyPassword_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String emptyPassword = "";
|
||||
Long teamId = 1L;
|
||||
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
User result = userService.saveUser(username, emptyPassword, teamId);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
verify(passwordEncoder, never()).encode(anyString());
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithValidEmail_Success() throws Exception {
|
||||
// Given
|
||||
String emailUsername = "test@example.com";
|
||||
AuthenticationType authType = AuthenticationType.OAUTH2;
|
||||
|
||||
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(emailUsername, authType);
|
||||
|
||||
// Then
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String reservedUsername = "all_users";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(reservedUsername, authType));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
|
||||
// Given
|
||||
String anonymousUsername = "anonymoususer";
|
||||
AuthenticationType authType = AuthenticationType.WEB;
|
||||
|
||||
// When & Then
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> userService.saveUser(anonymousUsername, authType));
|
||||
|
||||
verify(userRepository, never()).save(any(User.class));
|
||||
verify(databaseService, never()).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
|
||||
|
||||
// When & Then
|
||||
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
|
||||
|
||||
// Verify user was still saved before the exception
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
boolean firstLogin = true;
|
||||
boolean enabled = false;
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, password, teamId, firstLogin, enabled);
|
||||
|
||||
// Then
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSaveUser_WithCustomRole_Success() throws Exception {
|
||||
// Given
|
||||
String username = "testuser";
|
||||
String password = "password123";
|
||||
Long teamId = 1L;
|
||||
String customRole = Role.LIMITED_API_USER.getRoleId();
|
||||
String encodedPassword = "encodedPassword123";
|
||||
|
||||
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
|
||||
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
|
||||
when(userRepository.save(any(User.class))).thenReturn(mockUser);
|
||||
doNothing().when(databaseService).exportDatabase();
|
||||
|
||||
// When
|
||||
userService.saveUser(username, password, teamId, customRole);
|
||||
|
||||
// Then
|
||||
verify(passwordEncoder).encode(password);
|
||||
verify(userRepository).save(any(User.class));
|
||||
verify(databaseService).exportDatabase();
|
||||
void isUsernameValidRejectsReservedAndAcceptsEmail() {
|
||||
assertFalse(userService.isUsernameValid("ALL_USERS"));
|
||||
assertTrue(userService.isUsernameValid("valid@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Base32CodecTest {
|
||||
|
||||
@Test
|
||||
void encodeReturnsEmptyStringForEmptyInput() {
|
||||
assertEquals("", Base32Codec.encode(new byte[0]));
|
||||
assertEquals("", Base32Codec.encode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeReturnsEmptyArrayForBlankInput() {
|
||||
assertArrayEquals(new byte[0], Base32Codec.decode(null));
|
||||
assertArrayEquals(new byte[0], Base32Codec.decode(""));
|
||||
assertArrayEquals(new byte[0], Base32Codec.decode(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodeDecodeRoundTrip() {
|
||||
byte[] input = "hello world".getBytes(StandardCharsets.UTF_8);
|
||||
String encoded = Base32Codec.encode(input);
|
||||
byte[] decoded = Base32Codec.decode(encoded);
|
||||
|
||||
assertArrayEquals(input, decoded);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeAcceptsPaddingSpacesAndLowercase() {
|
||||
byte[] decoded = Base32Codec.decode("mzxw6y tboi====");
|
||||
assertEquals("foobar", new String(decoded, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeRejectsInvalidCharacters() {
|
||||
assertThrows(IllegalArgumentException.class, () -> Base32Codec.decode("MZXW6$"));
|
||||
}
|
||||
}
|
||||
+16
-12
@@ -2,7 +2,7 @@ plugins {
|
||||
id "java"
|
||||
id "jacoco"
|
||||
id "io.spring.dependency-management" version "1.1.7"
|
||||
id "org.springframework.boot" version "3.5.7"
|
||||
id "org.springframework.boot" version "3.5.9"
|
||||
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
|
||||
id "io.swagger.swaggerhub" version "1.3.2"
|
||||
id "com.diffplug.spotless" version "8.1.0"
|
||||
@@ -18,7 +18,7 @@ import groovy.xml.XmlSlurper
|
||||
import org.gradle.api.tasks.testing.Test
|
||||
|
||||
ext {
|
||||
springBootVersion = "3.5.7"
|
||||
springBootVersion = "3.5.9"
|
||||
pdfboxVersion = "3.0.6"
|
||||
imageioVersion = "3.13.0"
|
||||
lombokVersion = "1.18.42"
|
||||
@@ -27,7 +27,7 @@ ext {
|
||||
openSamlVersion = "4.3.2"
|
||||
commonmarkVersion = "0.27.0"
|
||||
googleJavaFormatVersion = "1.28.0"
|
||||
logback = "1.5.23"
|
||||
logback = "1.5.25"
|
||||
junitPlatformVersion = "1.12.2"
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.3.1'
|
||||
version = '2.4.2'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
@@ -102,12 +102,14 @@ def updateSimulationVersion(File fileToUpdate, String version) {
|
||||
writeIfChanged(fileToUpdate, updatedContent)
|
||||
}
|
||||
|
||||
def rootProjectRef = project
|
||||
|
||||
tasks.register('syncAppVersion') {
|
||||
group = 'versioning'
|
||||
description = 'Synchronizes app version across desktop and simulation configs.'
|
||||
|
||||
doLast {
|
||||
def appVersion = project.version.toString()
|
||||
def appVersion = rootProjectRef.version.toString()
|
||||
println "Synchronizing application version to ${appVersion}"
|
||||
updateTauriConfigVersion(appVersion)
|
||||
|
||||
@@ -149,18 +151,18 @@ subprojects {
|
||||
repositories {
|
||||
if (!rootProject.ext.mavenUrl.isEmpty()) {
|
||||
maven {
|
||||
url rootProject.ext.mavenUrl + '/releases'
|
||||
url = rootProject.ext.mavenUrl + '/releases'
|
||||
credentials(PasswordCredentials) {
|
||||
username rootProject.ext.username
|
||||
password rootProject.ext.password
|
||||
username = rootProject.ext.username
|
||||
password = rootProject.ext.password
|
||||
}
|
||||
authentication {
|
||||
basic(BasicAuthentication)
|
||||
}
|
||||
allowInsecureProtocol true
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
}
|
||||
maven { url "https://build.shibboleth.net/maven/releases" }
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
@@ -397,8 +399,10 @@ tasks.withType(JavaCompile).configureEach {
|
||||
|
||||
gradle.taskGraph.whenReady { graph ->
|
||||
if (project.hasProperty("noSpotless")) {
|
||||
tasks.matching { it.name.startsWith("spotless") }.configureEach {
|
||||
enabled = false
|
||||
allprojects { scopedProject ->
|
||||
scopedProject.tasks.matching { it.name.startsWith("spotless") }.configureEach {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ COPY . .
|
||||
|
||||
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 3: Final unified image
|
||||
|
||||
@@ -30,7 +30,6 @@ WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 3: Final unified ultra-lite image
|
||||
|
||||
@@ -25,7 +25,6 @@ COPY . .
|
||||
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# ========================================
|
||||
|
||||
@@ -25,7 +25,6 @@ COPY . .
|
||||
|
||||
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# ========================================
|
||||
|
||||
@@ -21,7 +21,6 @@ COPY . .
|
||||
|
||||
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# ========================================
|
||||
|
||||
@@ -32,12 +32,11 @@ COPY . .
|
||||
|
||||
# Build JAR with embedded frontend (includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image based on Debian stable-slim
|
||||
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
|
||||
FROM debian:stable-slim@sha256:f6681102cd18b4c0c4720a77b602498f4bdcf701c8fc02776dfb0d4c350c381f
|
||||
FROM debian:stable-slim@sha256:ed542b2d269ff08139fc5ab8c762efe8c8986b564a423d5241a5ce9fb09b6c08
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -32,12 +32,11 @@ COPY . .
|
||||
|
||||
# Build JAR with embedded frontend (includes security features controlled at runtime)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=false \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image based on Debian stable-slim
|
||||
# Contains Java runtime + LibreOffice + Calibre + all PDF tools + extra fonts for air-gapped environments
|
||||
FROM debian:stable-slim@sha256:f6681102cd18b4c0c4720a77b602498f4bdcf701c8fc02776dfb0d4c350c381f
|
||||
FROM debian:stable-slim@sha256:ed542b2d269ff08139fc5ab8c762efe8c8986b564a423d5241a5ce9fb09b6c08
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
@@ -32,7 +32,6 @@ COPY . .
|
||||
|
||||
# Build ultra-lite JAR with embedded frontend (minimal features)
|
||||
RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
STIRLING_PDF_DESKTOP_UI=false \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Frontend Dockerfile - React/Vite application
|
||||
FROM node:25-alpine@sha256:f4769ca6eeb6ebbd15eb9c8233afed856e437b75f486f7fccaa81d7c8ad56007 AS build
|
||||
FROM node:25-alpine@sha256:e80397b81fa93888b5f855e8bef37d9b18d3c5eb38b8731fc23d6d878647340f AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -16,7 +16,7 @@ COPY frontend .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine@sha256:c083c3799197cfff91fe5c3c558db3d2eea65ccbbfd419fa42a64d2c39a24027
|
||||
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
|
||||
|
||||
# Copy built files from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user