mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Merge remote-tracking branch 'origin/V2' into mainToV2
This commit is contained in:
@@ -74,8 +74,7 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "appName")
|
||||
public String appName() {
|
||||
String homeTitle = applicationProperties.getUi().getAppName();
|
||||
return (homeTitle != null) ? homeTitle : "Stirling PDF";
|
||||
return "Stirling PDF";
|
||||
}
|
||||
|
||||
@Bean(name = "appVersion")
|
||||
@@ -93,9 +92,7 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "homeText")
|
||||
public String homeText() {
|
||||
return (applicationProperties.getUi().getHomeDescription() != null)
|
||||
? applicationProperties.getUi().getHomeDescription()
|
||||
: "null";
|
||||
return "null";
|
||||
}
|
||||
|
||||
@Bean(name = "languages")
|
||||
@@ -110,11 +107,8 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "navBarText")
|
||||
public String navBarText() {
|
||||
String defaultNavBar =
|
||||
applicationProperties.getUi().getAppNameNavbar() != null
|
||||
? applicationProperties.getUi().getAppNameNavbar()
|
||||
: applicationProperties.getUi().getAppName();
|
||||
return (defaultNavBar != null) ? defaultNavBar : "Stirling PDF";
|
||||
String navBar = applicationProperties.getUi().getAppNameNavbar();
|
||||
return (navBar != null) ? navBar : "Stirling PDF";
|
||||
}
|
||||
|
||||
@Bean(name = "enableAlphaFunctionality")
|
||||
|
||||
+52
-13
@@ -121,6 +121,7 @@ public class ApplicationProperties {
|
||||
private String loginMethod = "all";
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -307,7 +308,41 @@ public class ApplicationProperties {
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
private boolean secureCookie;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Validation {
|
||||
private Trust trust = new Trust();
|
||||
private boolean allowAIA = false;
|
||||
private Aatl aatl = new Aatl();
|
||||
private Eutl eutl = new Eutl();
|
||||
private Revocation revocation = new Revocation();
|
||||
|
||||
@Data
|
||||
public static class Trust {
|
||||
private boolean serverAsAnchor = true;
|
||||
private boolean useSystemTrust = false;
|
||||
private boolean useMozillaBundle = false;
|
||||
private boolean useAATL = false;
|
||||
private boolean useEUTL = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Aatl {
|
||||
private String url = "https://trustlist.adobe.com/tl.pdf";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Eutl {
|
||||
private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
|
||||
private boolean acceptTransitional = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Revocation {
|
||||
private String mode = "none";
|
||||
private boolean hardFail = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +356,8 @@ public class ApplicationProperties {
|
||||
private String tessdataDir;
|
||||
private Boolean enableAlphaFunctionality;
|
||||
private Boolean enableAnalytics;
|
||||
private Boolean enablePosthog;
|
||||
private Boolean enableScarf;
|
||||
private Datasource datasource;
|
||||
private Boolean disableSanitize;
|
||||
private int maxDPI;
|
||||
@@ -330,10 +367,23 @@ public class ApplicationProperties {
|
||||
private String fileUploadLimit;
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private DatabaseBackup databaseBackup = new DatabaseBackup();
|
||||
private List<String> corsAllowedOrigins = new ArrayList<>();
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
}
|
||||
|
||||
public boolean isPosthogEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnablePosthog() == null || this.getEnablePosthog());
|
||||
}
|
||||
|
||||
public boolean isScarfEnabled() {
|
||||
// Treat null as enabled when analytics is enabled
|
||||
return this.isAnalyticsEnabled()
|
||||
&& (this.getEnableScarf() == null || this.getEnableScarf());
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -449,21 +499,9 @@ public class ApplicationProperties {
|
||||
|
||||
@Data
|
||||
public static class Ui {
|
||||
private String appName;
|
||||
private String homeDescription;
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
|
||||
public String getAppName() {
|
||||
return appName != null && !appName.trim().isEmpty() ? appName : null;
|
||||
}
|
||||
|
||||
public String getHomeDescription() {
|
||||
return homeDescription != null && !homeDescription.trim().isEmpty()
|
||||
? homeDescription
|
||||
: null;
|
||||
}
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
}
|
||||
@@ -517,6 +555,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class Mail {
|
||||
private boolean enabled;
|
||||
private boolean enableInvites = false;
|
||||
private String host;
|
||||
private int port;
|
||||
private String username;
|
||||
|
||||
@@ -56,7 +56,7 @@ public class PostHogService {
|
||||
}
|
||||
|
||||
private void captureSystemInfo() {
|
||||
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
|
||||
if (!applicationProperties.getSystem().isPosthogEnabled()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -67,7 +67,7 @@ public class PostHogService {
|
||||
}
|
||||
|
||||
public void captureEvent(String eventName, Map<String, Object> properties) {
|
||||
if (!applicationProperties.getSystem().isAnalyticsEnabled()) {
|
||||
if (!applicationProperties.getSystem().isPosthogEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -325,13 +325,16 @@ public class PostHogService {
|
||||
properties,
|
||||
"system_enableAnalytics",
|
||||
applicationProperties.getSystem().isAnalyticsEnabled());
|
||||
|
||||
// Capture UI properties
|
||||
addIfNotEmpty(properties, "ui_appName", applicationProperties.getUi().getAppName());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"ui_homeDescription",
|
||||
applicationProperties.getUi().getHomeDescription());
|
||||
"system_enablePosthog",
|
||||
applicationProperties.getSystem().isPosthogEnabled());
|
||||
addIfNotEmpty(
|
||||
properties,
|
||||
"system_enableScarf",
|
||||
applicationProperties.getSystem().isScarfEnabled());
|
||||
|
||||
// Capture UI properties
|
||||
addIfNotEmpty(
|
||||
properties, "ui_appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
|
||||
|
||||
@@ -6,4 +6,6 @@ public interface UserServiceInterface {
|
||||
String getCurrentUsername();
|
||||
|
||||
long getTotalUsersCount();
|
||||
|
||||
boolean isCurrentUserAdmin();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Captures application command-line arguments at startup so they can be reused for restart
|
||||
* operations. This allows the application to restart with the same configuration.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AppArgsCapture implements ApplicationRunner {
|
||||
|
||||
public static final AtomicReference<List<String>> APP_ARGS = new AtomicReference<>(List.of());
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
APP_ARGS.set(List.of(args.getSourceArgs()));
|
||||
log.debug(
|
||||
"Captured {} application arguments for restart capability",
|
||||
args.getSourceArgs().length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Utility class to locate JAR files at runtime for restart operations */
|
||||
@Slf4j
|
||||
public class JarPathUtil {
|
||||
|
||||
/**
|
||||
* Gets the path to the currently running JAR file
|
||||
*
|
||||
* @return Path to the current JAR, or null if not running from a JAR
|
||||
*/
|
||||
public static Path currentJar() {
|
||||
try {
|
||||
Path jar =
|
||||
Paths.get(
|
||||
JarPathUtil.class
|
||||
.getProtectionDomain()
|
||||
.getCodeSource()
|
||||
.getLocation()
|
||||
.toURI())
|
||||
.toAbsolutePath();
|
||||
|
||||
// Check if we're actually running from a JAR (not from IDE/classes directory)
|
||||
if (jar.toString().endsWith(".jar")) {
|
||||
log.debug("Current JAR located at: {}", jar);
|
||||
return jar;
|
||||
} else {
|
||||
log.warn("Not running from JAR, current location: {}", jar);
|
||||
return null;
|
||||
}
|
||||
} catch (URISyntaxException e) {
|
||||
log.error("Failed to determine current JAR location", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the restart-helper.jar file Expected to be in the same directory as the main
|
||||
* JAR
|
||||
*
|
||||
* @return Path to restart-helper.jar, or null if not found
|
||||
*/
|
||||
public static Path restartHelperJar() {
|
||||
Path appJar = currentJar();
|
||||
if (appJar == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Path helperJar = appJar.getParent().resolve("restart-helper.jar");
|
||||
|
||||
if (Files.isRegularFile(helperJar)) {
|
||||
log.debug("Restart helper JAR located at: {}", helperJar);
|
||||
return helperJar;
|
||||
} else {
|
||||
log.warn("Restart helper JAR not found at: {}", helperJar);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the java binary path for the current JVM
|
||||
*
|
||||
* @return Path to java executable
|
||||
*/
|
||||
public static String javaExecutable() {
|
||||
String javaHome = System.getProperty("java.home");
|
||||
String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
|
||||
|
||||
// On Windows, add .exe extension
|
||||
if (System.getProperty("os.name").toLowerCase().contains("win")) {
|
||||
javaBin += ".exe";
|
||||
}
|
||||
|
||||
return javaBin;
|
||||
}
|
||||
}
|
||||
-8
@@ -115,19 +115,11 @@ class ApplicationPropertiesLogicTest {
|
||||
@Test
|
||||
void ui_getters_return_null_for_blank() {
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
ui.setAppName(" ");
|
||||
ui.setHomeDescription("");
|
||||
ui.setAppNameNavbar(null);
|
||||
|
||||
assertNull(ui.getAppName());
|
||||
assertNull(ui.getHomeDescription());
|
||||
assertNull(ui.getAppNameNavbar());
|
||||
|
||||
ui.setAppName("Stirling-PDF");
|
||||
ui.setHomeDescription("Home");
|
||||
ui.setAppNameNavbar("Nav");
|
||||
assertEquals("Stirling-PDF", ui.getAppName());
|
||||
assertEquals("Home", ui.getHomeDescription());
|
||||
assertEquals("Nav", ui.getAppNameNavbar());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,49 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final EndpointInterceptor endpointInterceptor;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
// Only configure CORS if allowed origins are specified
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
|
||||
String[] allowedOrigins =
|
||||
applicationProperties
|
||||
.getSystem()
|
||||
.getCorsAllowedOrigins()
|
||||
.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured, CORS is not enabled (secure by default)
|
||||
}
|
||||
|
||||
// @Override
|
||||
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// // Handler for external static resources - DISABLED in backend-only mode
|
||||
|
||||
+392
-1
@@ -1,12 +1,15 @@
|
||||
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;
|
||||
|
||||
@@ -29,7 +32,7 @@ public class SettingsController {
|
||||
|
||||
@AutoJobPostMapping("/update-enable-analytics")
|
||||
@Hidden
|
||||
public ResponseEntity<String> updateApiKey(@RequestBody Boolean enabled) throws IOException {
|
||||
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
|
||||
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
|
||||
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
|
||||
.body(
|
||||
@@ -46,4 +49,392 @@ 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().getShowUpdateOnlyAdmin(),
|
||||
"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.getEnableLogin());
|
||||
settings.put("csrfDisabled", security.getCsrfDisabled());
|
||||
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("csrfDisabled")) {
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
|
||||
}
|
||||
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().getGooglevisibility());
|
||||
settings.put("metricsEnabled", applicationProperties.getMetrics().getEnabled());
|
||||
|
||||
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().getEnableAlphaFunctionality());
|
||||
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
|
||||
settings.put("enableUrlToPDF", applicationProperties.getSystem().getEnableUrlToPDF());
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -15,6 +15,7 @@ import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@@ -24,17 +25,21 @@ public class ConfigController {
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
ServerCertificateServiceInterface serverCertificateService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
UserServiceInterface userService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
@@ -51,20 +56,34 @@ public class ConfigController {
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appName", applicationProperties.getUi().getAppName());
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("homeDescription", applicationProperties.getUi().getHomeDescription());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
|
||||
// Mail settings
|
||||
configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites());
|
||||
|
||||
// Check if user is admin using UserServiceInterface
|
||||
boolean isAdmin = false;
|
||||
if (userService != null) {
|
||||
try {
|
||||
isAdmin = userService.isCurrentUserAdmin();
|
||||
} catch (Exception e) {
|
||||
// If there's an error, isAdmin remains false
|
||||
}
|
||||
}
|
||||
configData.put("isAdmin", isAdmin);
|
||||
|
||||
// System settings
|
||||
configData.put(
|
||||
"enableAlphaFunctionality",
|
||||
applicationProperties.getSystem().getEnableAlphaFunctionality());
|
||||
configData.put(
|
||||
"enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
|
||||
configData.put("enablePosthog", applicationProperties.getSystem().getEnablePosthog());
|
||||
configData.put("enableScarf", applicationProperties.getSystem().getEnableScarf());
|
||||
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
+130
-45
@@ -5,10 +5,12 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.PKIXCertPathBuilderResult;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -32,6 +34,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
|
||||
@@ -42,6 +45,7 @@ import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
@SecurityApi
|
||||
@RequiredArgsConstructor
|
||||
public class ValidateSignatureController {
|
||||
@@ -65,8 +69,9 @@ public class ValidateSignatureController {
|
||||
@Operation(
|
||||
summary = "Validate PDF Digital Signature",
|
||||
description =
|
||||
"Validates the digital signatures in a PDF file against default or custom"
|
||||
+ " certificates. Input:PDF Output:JSON Type:SISO")
|
||||
"Validates the digital signatures in a PDF file using PKIX path building"
|
||||
+ " and time-of-signing semantics. Supports custom trust anchors."
|
||||
+ " Input:PDF Output:JSON Type:SISO")
|
||||
@AutoJobPostMapping(
|
||||
value = "/validate-signature",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -74,12 +79,12 @@ public class ValidateSignatureController {
|
||||
@ModelAttribute SignatureValidationRequest request) throws IOException {
|
||||
List<SignatureValidationResult> results = new ArrayList<>();
|
||||
MultipartFile file = request.getFileInput();
|
||||
MultipartFile certFile = request.getCertFile();
|
||||
|
||||
// Load custom certificate if provided
|
||||
X509Certificate customCert = null;
|
||||
if (certFile != null && !certFile.isEmpty()) {
|
||||
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
|
||||
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
|
||||
try (ByteArrayInputStream certStream =
|
||||
new ByteArrayInputStream(request.getCertFile().getBytes())) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
customCert = (X509Certificate) cf.generateCertificate(certStream);
|
||||
} catch (CertificateException e) {
|
||||
@@ -108,68 +113,150 @@ public class ValidateSignatureController {
|
||||
Store<X509CertificateHolder> certStore = signedData.getCertificates();
|
||||
SignerInformationStore signerStore = signedData.getSignerInfos();
|
||||
|
||||
for (SignerInformation signer : signerStore.getSigners()) {
|
||||
for (SignerInformation signerInfo : signerStore.getSigners()) {
|
||||
X509CertificateHolder certHolder =
|
||||
(X509CertificateHolder)
|
||||
certStore.getMatches(signer.getSID()).iterator().next();
|
||||
X509Certificate cert =
|
||||
certStore.getMatches(signerInfo.getSID()).iterator().next();
|
||||
X509Certificate signerCert =
|
||||
new JcaX509CertificateConverter().getCertificate(certHolder);
|
||||
|
||||
boolean isValid =
|
||||
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
|
||||
result.setValid(isValid);
|
||||
// Extract intermediate certificates from CMS
|
||||
Collection<X509Certificate> intermediates =
|
||||
certValidationService.extractIntermediateCertificates(
|
||||
certStore, signerCert);
|
||||
|
||||
// Additional validations
|
||||
result.setChainValid(
|
||||
customCert != null
|
||||
? certValidationService
|
||||
.validateCertificateChainWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateCertificateChain(cert));
|
||||
// Log what we found
|
||||
log.debug(
|
||||
"Found {} intermediate certificates in CMS signature",
|
||||
intermediates.size());
|
||||
for (X509Certificate inter : intermediates) {
|
||||
log.debug(
|
||||
" → Intermediate: {}",
|
||||
inter.getSubjectX500Principal().getName());
|
||||
log.debug(
|
||||
" Issuer DN: {}", inter.getIssuerX500Principal().getName());
|
||||
}
|
||||
|
||||
result.setTrustValid(
|
||||
customCert != null
|
||||
? certValidationService.validateTrustWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateTrustStore(cert));
|
||||
// Determine validation time (TSA timestamp or signingTime, or current)
|
||||
CertificateValidationService.ValidationTime validationTimeResult =
|
||||
certValidationService.extractValidationTime(signerInfo);
|
||||
Date validationTime;
|
||||
if (validationTimeResult == null) {
|
||||
validationTime = new Date();
|
||||
result.setValidationTimeSource("current");
|
||||
} else {
|
||||
validationTime = validationTimeResult.date;
|
||||
result.setValidationTimeSource(validationTimeResult.source);
|
||||
}
|
||||
|
||||
result.setNotRevoked(!certValidationService.isRevoked(cert));
|
||||
result.setNotExpired(
|
||||
Instant.now().isBefore(cert.getNotAfter().toInstant()));
|
||||
// Verify cryptographic signature
|
||||
boolean cmsValid =
|
||||
signerInfo.verify(
|
||||
new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
|
||||
result.setValid(cmsValid);
|
||||
|
||||
// Build and validate certificate path
|
||||
boolean chainValid = false;
|
||||
boolean trustValid = false;
|
||||
try {
|
||||
PKIXCertPathBuilderResult pathResult =
|
||||
certValidationService.buildAndValidatePath(
|
||||
signerCert, intermediates, customCert, validationTime);
|
||||
chainValid = true;
|
||||
trustValid = true; // Path ends at trust anchor
|
||||
result.setCertPathLength(
|
||||
pathResult.getCertPath().getCertificates().size());
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
result.setChainValidationError(errorMsg);
|
||||
chainValid = false;
|
||||
trustValid = false;
|
||||
// Log the full error for debugging
|
||||
log.warn(
|
||||
"Certificate path validation failed for {}: {}",
|
||||
signerCert.getSubjectX500Principal().getName(),
|
||||
errorMsg);
|
||||
log.debug("Full stack trace:", e);
|
||||
}
|
||||
result.setChainValid(chainValid);
|
||||
result.setTrustValid(trustValid);
|
||||
|
||||
// Check validity at validation time
|
||||
boolean outside =
|
||||
certValidationService.isOutsideValidityPeriod(
|
||||
signerCert, validationTime);
|
||||
result.setNotExpired(!outside);
|
||||
|
||||
// Revocation status determination
|
||||
boolean revocationEnabled = certValidationService.isRevocationEnabled();
|
||||
result.setRevocationChecked(revocationEnabled);
|
||||
|
||||
if (!revocationEnabled) {
|
||||
result.setRevocationStatus("not-checked");
|
||||
} else if (chainValid && trustValid) {
|
||||
// Path building succeeded with revocation enabled = no revocation found
|
||||
result.setRevocationStatus("good");
|
||||
} else if (result.getChainValidationError() != null
|
||||
&& result.getChainValidationError()
|
||||
.toLowerCase()
|
||||
.contains("revocation")) {
|
||||
// Check if failure was revocation-related
|
||||
if (result.getChainValidationError()
|
||||
.toLowerCase()
|
||||
.contains("unable to check")) {
|
||||
result.setRevocationStatus("soft-fail");
|
||||
} else {
|
||||
result.setRevocationStatus("revoked");
|
||||
}
|
||||
} else {
|
||||
result.setRevocationStatus("unknown");
|
||||
}
|
||||
|
||||
// Set basic signature info
|
||||
result.setSignerName(sig.getName());
|
||||
result.setSignatureDate(sig.getSignDate().toInstant().toString());
|
||||
result.setSignatureDate(
|
||||
sig.getSignDate() != null
|
||||
? sig.getSignDate().getTime().toString()
|
||||
: null);
|
||||
result.setReason(sig.getReason());
|
||||
result.setLocation(sig.getLocation());
|
||||
|
||||
// Set new certificate details
|
||||
result.setIssuerDN(cert.getIssuerX500Principal().getName());
|
||||
result.setSubjectDN(cert.getSubjectX500Principal().getName());
|
||||
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
|
||||
result.setValidFrom(cert.getNotBefore().toString());
|
||||
result.setValidUntil(cert.getNotAfter().toString());
|
||||
result.setSignatureAlgorithm(cert.getSigAlgName());
|
||||
// Set certificate details (from signer cert)
|
||||
result.setIssuerDN(signerCert.getIssuerX500Principal().getName());
|
||||
result.setSubjectDN(signerCert.getSubjectX500Principal().getName());
|
||||
result.setSerialNumber(
|
||||
signerCert.getSerialNumber().toString(16)); // Hex format
|
||||
result.setValidFrom(signerCert.getNotBefore().toString());
|
||||
result.setValidUntil(signerCert.getNotAfter().toString());
|
||||
result.setSignatureAlgorithm(signerCert.getSigAlgName());
|
||||
|
||||
// Get key size (if possible)
|
||||
try {
|
||||
result.setKeySize(
|
||||
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
|
||||
((RSAPublicKey) signerCert.getPublicKey())
|
||||
.getModulus()
|
||||
.bitLength());
|
||||
} catch (Exception e) {
|
||||
// If not RSA or error, set to 0
|
||||
result.setKeySize(0);
|
||||
}
|
||||
|
||||
result.setVersion(String.valueOf(cert.getVersion()));
|
||||
result.setVersion(String.valueOf(signerCert.getVersion()));
|
||||
|
||||
// Set key usage
|
||||
List<String> keyUsages = new ArrayList<>();
|
||||
boolean[] keyUsageFlags = cert.getKeyUsage();
|
||||
boolean[] keyUsageFlags = signerCert.getKeyUsage();
|
||||
if (keyUsageFlags != null) {
|
||||
String[] keyUsageLabels = {
|
||||
"Digital Signature", "Non-Repudiation", "Key Encipherment",
|
||||
"Data Encipherment", "Key Agreement", "Certificate Signing",
|
||||
"CRL Signing", "Encipher Only", "Decipher Only"
|
||||
"Digital Signature",
|
||||
"Non-Repudiation",
|
||||
"Key Encipherment",
|
||||
"Data Encipherment",
|
||||
"Key Agreement",
|
||||
"Certificate Signing",
|
||||
"CRL Signing",
|
||||
"Encipher Only",
|
||||
"Decipher Only"
|
||||
};
|
||||
for (int i = 0; i < keyUsageFlags.length; i++) {
|
||||
if (keyUsageFlags[i]) {
|
||||
@@ -179,10 +266,8 @@ public class ValidateSignatureController {
|
||||
}
|
||||
result.setKeyUsages(keyUsages);
|
||||
|
||||
// Check if self-signed
|
||||
result.setSelfSigned(
|
||||
cert.getSubjectX500Principal()
|
||||
.equals(cert.getIssuerX500Principal()));
|
||||
// Check if self-signed (properly)
|
||||
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.setValid(false);
|
||||
|
||||
+19
-4
@@ -6,17 +6,32 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SignatureValidationResult {
|
||||
// Cryptographic signature validation
|
||||
private boolean valid;
|
||||
|
||||
// Certificate chain validation
|
||||
private boolean chainValid;
|
||||
private boolean trustValid;
|
||||
private String chainValidationError;
|
||||
private int certPathLength;
|
||||
|
||||
// Time validation
|
||||
private boolean notExpired;
|
||||
|
||||
// Revocation validation
|
||||
private boolean revocationChecked; // true if PKIX revocation was enabled
|
||||
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
|
||||
private String validationTimeSource; // "current", "signing-time", or "timestamp"
|
||||
|
||||
// Signature metadata
|
||||
private String signerName;
|
||||
private String signatureDate;
|
||||
private String reason;
|
||||
private String location;
|
||||
private String errorMessage;
|
||||
private boolean chainValid;
|
||||
private boolean trustValid;
|
||||
private boolean notExpired;
|
||||
private boolean notRevoked;
|
||||
|
||||
// Certificate details
|
||||
private String issuerDN; // Certificate issuer's Distinguished Name
|
||||
private String subjectDN; // Certificate subject's Distinguished Name
|
||||
private String serialNumber; // Certificate serial number
|
||||
|
||||
+814
-94
@@ -1,143 +1,863 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.cert.*;
|
||||
import java.util.*;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.bouncycastle.asn1.ASN1Encodable;
|
||||
import org.bouncycastle.asn1.ASN1GeneralizedTime;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.ASN1UTCTime;
|
||||
import org.bouncycastle.asn1.cms.CMSAttributes;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cms.CMSSignedData;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.bouncycastle.util.Store;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CertificateValidationService {
|
||||
private KeyStore trustStore;
|
||||
/**
|
||||
* Result container for validation time extraction Contains both the date and the source of the
|
||||
* time
|
||||
*/
|
||||
public static class ValidationTime {
|
||||
public final Date date;
|
||||
public final String source; // "timestamp" | "signing-time" | "current"
|
||||
|
||||
public ValidationTime(Date date, String source) {
|
||||
this.date = date;
|
||||
this.source = source;
|
||||
}
|
||||
}
|
||||
|
||||
// Separate trust stores: signing vs TLS
|
||||
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
// EUTL (EU Trusted List) constants
|
||||
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
|
||||
|
||||
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
|
||||
private static final Set<String> EUTL_SERVICE_TYPES =
|
||||
new HashSet<>(
|
||||
Arrays.asList(
|
||||
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",
|
||||
"http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC"));
|
||||
|
||||
// Active statuses to accept (per ETSI TS 119 612)
|
||||
private static final String STATUS_UNDER_SUPERVISION =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision";
|
||||
private static final String STATUS_ACCREDITED =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited";
|
||||
private static final String STATUS_SUPERVISION_IN_CESSATION =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation";
|
||||
|
||||
static {
|
||||
if (java.security.Security.getProvider("BC") == null) {
|
||||
java.security.Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
}
|
||||
|
||||
public CertificateValidationService(
|
||||
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
private void initializeTrustStore() throws Exception {
|
||||
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
trustStore.load(null, null);
|
||||
loadMozillaCertificates();
|
||||
signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
signingTrustAnchors.load(null, null);
|
||||
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
applicationProperties.getSecurity().getValidation();
|
||||
|
||||
// Enable JDK fetching of OCSP/CRLDP if allowed
|
||||
if (validation.isAllowAIA()) {
|
||||
java.security.Security.setProperty("ocsp.enable", "true");
|
||||
System.setProperty("com.sun.security.enableCRLDP", "true");
|
||||
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
|
||||
log.info("Enabled AIA certificate fetching and revocation checking");
|
||||
}
|
||||
|
||||
// Trust only what we explicitly opt into:
|
||||
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
|
||||
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
|
||||
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
|
||||
if (validation.getTrust().isUseAATL()) loadAATLCertificates();
|
||||
if (validation.getTrust().isUseEUTL()) loadEUTLCertificates();
|
||||
}
|
||||
|
||||
private void loadMozillaCertificates() throws Exception {
|
||||
try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
||||
String line;
|
||||
StringBuilder certData = new StringBuilder();
|
||||
boolean inCert = false;
|
||||
int certCount = 0;
|
||||
/**
|
||||
* Core entry-point: build a valid PKIX path from signerCert using provided intermediates
|
||||
*
|
||||
* @param signerCert The signer certificate
|
||||
* @param intermediates Collection of intermediate certificates from CMS
|
||||
* @param customTrustAnchor Optional custom root/intermediate certificate
|
||||
* @param validationTime Time to validate at (signing time or current)
|
||||
* @return PKIXCertPathBuilderResult containing validated path
|
||||
* @throws GeneralSecurityException if path building/validation fails
|
||||
*/
|
||||
public PKIXCertPathBuilderResult buildAndValidatePath(
|
||||
X509Certificate signerCert,
|
||||
Collection<X509Certificate> intermediates,
|
||||
X509Certificate customTrustAnchor,
|
||||
Date validationTime)
|
||||
throws GeneralSecurityException {
|
||||
|
||||
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
|
||||
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
|
||||
inCert = true;
|
||||
certData = new StringBuilder();
|
||||
continue;
|
||||
// Build trust anchors
|
||||
Set<TrustAnchor> anchors = new HashSet<>();
|
||||
if (customTrustAnchor != null) {
|
||||
anchors.add(new TrustAnchor(customTrustAnchor, null));
|
||||
} else {
|
||||
Enumeration<String> aliases = signingTrustAnchors.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement());
|
||||
if (c instanceof X509Certificate x) {
|
||||
anchors.add(new TrustAnchor(x, null));
|
||||
}
|
||||
if (inCert) {
|
||||
if ("END".equals(line)) {
|
||||
inCert = false;
|
||||
byte[] certBytes = parseOctalData(certData.toString());
|
||||
if (certBytes != null) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(
|
||||
new ByteArrayInputStream(certBytes));
|
||||
trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert);
|
||||
}
|
||||
} else {
|
||||
certData.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
if (anchors.isEmpty()) {
|
||||
throw new CertPathBuilderException("No trust anchors available");
|
||||
}
|
||||
|
||||
// Target certificate selector
|
||||
X509CertSelector target = new X509CertSelector();
|
||||
target.setCertificate(signerCert);
|
||||
|
||||
// Intermediate certificate store
|
||||
List<Certificate> allCerts = new ArrayList<>(intermediates);
|
||||
CertStore intermediateStore =
|
||||
CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts));
|
||||
|
||||
// PKIX parameters
|
||||
PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target);
|
||||
params.addCertStore(intermediateStore);
|
||||
String revocationMode =
|
||||
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
|
||||
params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode));
|
||||
if (validationTime != null) {
|
||||
params.setDate(validationTime);
|
||||
}
|
||||
|
||||
// Revocation checking
|
||||
if (!"none".equalsIgnoreCase(revocationMode)) {
|
||||
try {
|
||||
PKIXRevocationChecker rc =
|
||||
(PKIXRevocationChecker)
|
||||
CertPathValidator.getInstance("PKIX").getRevocationChecker();
|
||||
|
||||
Set<PKIXRevocationChecker.Option> options =
|
||||
EnumSet.noneOf(PKIXRevocationChecker.Option.class);
|
||||
|
||||
// Soft-fail: allow validation to succeed if revocation status unavailable
|
||||
boolean revocationHardFail =
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getValidation()
|
||||
.getRevocation()
|
||||
.isHardFail();
|
||||
if (!revocationHardFail) {
|
||||
options.add(PKIXRevocationChecker.Option.SOFT_FAIL);
|
||||
}
|
||||
|
||||
// Revocation mode configuration
|
||||
if ("ocsp".equalsIgnoreCase(revocationMode)) {
|
||||
// OCSP-only: prefer OCSP (default), disable fallback to CRL
|
||||
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
|
||||
} else if ("crl".equalsIgnoreCase(revocationMode)) {
|
||||
// CRL-only: prefer CRLs, disable fallback to OCSP
|
||||
options.add(PKIXRevocationChecker.Option.PREFER_CRLS);
|
||||
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
|
||||
}
|
||||
// "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL)
|
||||
|
||||
rc.setOptions(options);
|
||||
params.addCertPathChecker(rc);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to configure revocation checker: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Build path
|
||||
CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
|
||||
return (PKIXCertPathBuilderResult) builder.build(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract validation time from signature (TSA timestamp or signingTime)
|
||||
*
|
||||
* @param signerInfo The CMS signer information
|
||||
* @return ValidationTime containing date and source, or null if not found
|
||||
*/
|
||||
public ValidationTime extractValidationTime(SignerInformation signerInfo) {
|
||||
try {
|
||||
// 1) Check for timestamp token (RFC 3161) - highest priority
|
||||
var unsignedAttrs = signerInfo.getUnsignedAttributes();
|
||||
if (unsignedAttrs != null) {
|
||||
var attr =
|
||||
unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14"));
|
||||
if (attr != null) {
|
||||
try {
|
||||
TimeStampToken tst =
|
||||
new TimeStampToken(
|
||||
new CMSSignedData(
|
||||
attr.getAttributeValues()[0]
|
||||
.toASN1Primitive()
|
||||
.getEncoded()));
|
||||
Date tstTime = tst.getTimeStampInfo().getGenTime();
|
||||
log.debug("Using timestamp token time: {}", tstTime);
|
||||
return new ValidationTime(tstTime, "timestamp");
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse timestamp token: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] parseOctalData(String data) {
|
||||
try {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
String[] tokens = data.split("\\\\");
|
||||
for (String token : tokens) {
|
||||
token = token.trim();
|
||||
if (!token.isEmpty()) {
|
||||
baos.write(Integer.parseInt(token, 8));
|
||||
// 2) Check for signingTime attribute - fallback
|
||||
var signedAttrs = signerInfo.getSignedAttributes();
|
||||
if (signedAttrs != null) {
|
||||
var st = signedAttrs.get(CMSAttributes.signingTime);
|
||||
if (st != null) {
|
||||
ASN1Encodable val = st.getAttributeValues()[0];
|
||||
Date signingTime = null;
|
||||
if (val instanceof ASN1UTCTime ut) {
|
||||
signingTime = ut.getDate();
|
||||
} else if (val instanceof ASN1GeneralizedTime gt) {
|
||||
signingTime = gt.getDate();
|
||||
}
|
||||
if (signingTime != null) {
|
||||
log.debug("Using signingTime attribute: {}", signingTime);
|
||||
return new ValidationTime(signingTime, "signing-time");
|
||||
}
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
log.debug("Error extracting validation time: {}", e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean validateCertificateChain(X509Certificate cert) {
|
||||
/**
|
||||
* Check if certificate is outside validity period at given time
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @param at Time to check validity
|
||||
* @return true if certificate is expired or not yet valid
|
||||
*/
|
||||
public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) {
|
||||
try {
|
||||
CertPathValidator validator = CertPathValidator.getInstance("PKIX");
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
List<X509Certificate> certList = Collections.singletonList(cert);
|
||||
CertPath certPath = cf.generateCertPath(certList);
|
||||
|
||||
Set<TrustAnchor> anchors = new HashSet<>();
|
||||
Enumeration<String> aliases = trustStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Object trustCert = trustStore.getCertificate(aliases.nextElement());
|
||||
if (trustCert instanceof X509Certificate x509Cert) {
|
||||
anchors.add(new TrustAnchor(x509Cert, null));
|
||||
}
|
||||
}
|
||||
|
||||
PKIXParameters params = new PKIXParameters(anchors);
|
||||
params.setRevocationEnabled(false);
|
||||
validator.validate(certPath, params);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateTrustStore(X509Certificate cert) {
|
||||
try {
|
||||
Enumeration<String> aliases = trustStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Object trustCert = trustStore.getCertificate(aliases.nextElement());
|
||||
if (trustCert instanceof X509Certificate && cert.equals(trustCert)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (KeyStoreException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRevoked(X509Certificate cert) {
|
||||
try {
|
||||
cert.checkValidity();
|
||||
cert.checkValidity(at);
|
||||
return false;
|
||||
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateCertificateChainWithCustomCert(
|
||||
X509Certificate cert, X509Certificate customCert) {
|
||||
/**
|
||||
* Check if revocation checking is enabled
|
||||
*
|
||||
* @return true if revocation mode is not "none"
|
||||
*/
|
||||
public boolean isRevocationEnabled() {
|
||||
String revocationMode =
|
||||
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
|
||||
return !"none".equalsIgnoreCase(revocationMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is a CA certificate
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @return true if certificate has basicConstraints with CA=true
|
||||
*/
|
||||
public boolean isCA(X509Certificate cert) {
|
||||
return cert.getBasicConstraints() >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if certificate is self-signed by checking signature
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @return true if certificate is self-signed and signature is valid
|
||||
*/
|
||||
public boolean isSelfSigned(X509Certificate cert) {
|
||||
try {
|
||||
cert.verify(customCert.getPublicKey());
|
||||
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
|
||||
return false;
|
||||
}
|
||||
cert.verify(cert.getPublicKey());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
|
||||
/**
|
||||
* Calculate SHA-256 fingerprint of certificate
|
||||
*
|
||||
* @param cert Certificate
|
||||
* @return Hex string of SHA-256 hash
|
||||
*/
|
||||
public String sha256Fingerprint(X509Certificate cert) {
|
||||
try {
|
||||
// Compare the issuer of the signature certificate with the custom certificate
|
||||
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(cert.getEncoded());
|
||||
return bytesToHex(hash);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all certificates from CMS signature store
|
||||
*
|
||||
* @param certStore BouncyCastle certificate store
|
||||
* @param signerCert The signer certificate
|
||||
* @return Collection of all certificates except signer
|
||||
*/
|
||||
public Collection<X509Certificate> extractIntermediateCertificates(
|
||||
Store<X509CertificateHolder> certStore, X509Certificate signerCert) {
|
||||
List<X509Certificate> intermediates = new ArrayList<>();
|
||||
try {
|
||||
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
|
||||
Collection<X509CertificateHolder> holders = certStore.getMatches(null);
|
||||
|
||||
for (X509CertificateHolder holder : holders) {
|
||||
X509Certificate cert = converter.getCertificate(holder);
|
||||
if (!cert.equals(signerCert)) {
|
||||
intermediates.add(cert);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error extracting intermediate certificates: {}", e.getMessage());
|
||||
}
|
||||
return intermediates;
|
||||
}
|
||||
|
||||
// ==================== Trust Store Loading ====================
|
||||
|
||||
/**
|
||||
* Load certificates from Java's system trust store (cacerts). On Windows, this includes
|
||||
* certificates from the Windows trust store. This provides maximum compatibility with what
|
||||
* browsers and OS trust.
|
||||
*/
|
||||
private void loadJavaSystemTrustStore() {
|
||||
try {
|
||||
log.info("Loading certificates from Java system trust store");
|
||||
|
||||
// Get default trust manager factory
|
||||
TrustManagerFactory tmf =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init((KeyStore) null); // null = use system default
|
||||
|
||||
// Extract certificates from trust managers
|
||||
int loadedCount = 0;
|
||||
for (TrustManager tm : tmf.getTrustManagers()) {
|
||||
if (tm instanceof X509TrustManager x509tm) {
|
||||
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
|
||||
if (isCA(cert)) {
|
||||
String fingerprint = sha256Fingerprint(cert);
|
||||
String alias = "system-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
loadedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} CA certificates from Java system trust store", loadedCount);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load Java system trust store: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted
|
||||
* root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF
|
||||
* signatures.
|
||||
*/
|
||||
private void loadBundledMozillaCACerts() {
|
||||
try {
|
||||
log.info("Loading bundled Mozilla CA certificates from resources");
|
||||
InputStream certStream =
|
||||
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
|
||||
if (certStream == null) {
|
||||
log.warn("Bundled Mozilla CA certificate file not found in resources");
|
||||
return;
|
||||
}
|
||||
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
|
||||
certStream.close();
|
||||
|
||||
int loadedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (Certificate cert : certs) {
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
// Only add CA certificates to trust anchors
|
||||
if (isCA(x509)) {
|
||||
String fingerprint = sha256Fingerprint(x509);
|
||||
String alias = "mozilla-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, x509);
|
||||
loadedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
|
||||
loadedCount,
|
||||
skippedCount);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadServerCertAsAnchor() {
|
||||
try {
|
||||
if (serverCertificateService != null
|
||||
&& serverCertificateService.isEnabled()
|
||||
&& serverCertificateService.hasServerCertificate()) {
|
||||
X509Certificate serverCert = serverCertificateService.getServerCertificate();
|
||||
|
||||
// Self-signed certificates can be trust anchors regardless of CA flag
|
||||
// Non-self-signed certificates should only be trust anchors if they're CAs
|
||||
boolean selfSigned = isSelfSigned(serverCert);
|
||||
boolean ca = isCA(serverCert);
|
||||
|
||||
if (selfSigned || ca) {
|
||||
signingTrustAnchors.setCertificateEntry("server-anchor", serverCert);
|
||||
log.info(
|
||||
"Loaded server certificate as trust anchor (self-signed: {}, CA: {})",
|
||||
selfSigned,
|
||||
ca);
|
||||
} else {
|
||||
log.warn(
|
||||
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed loading server certificate as anchor: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */
|
||||
private void loadAATLCertificates() {
|
||||
try {
|
||||
String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl();
|
||||
log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl);
|
||||
byte[] pdfBytes = downloadTrustList(aatlUrl);
|
||||
if (pdfBytes == null) {
|
||||
log.warn("AATL download returned no data");
|
||||
return;
|
||||
}
|
||||
int added = parseAATLPdf(pdfBytes);
|
||||
log.info("Loaded {} AATL CA certificates into signing trust", added);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load AATL: {}", e.getMessage());
|
||||
log.debug("AATL loading error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple HTTP(S) fetch with sane timeouts. */
|
||||
private byte[] downloadTrustList(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_OK) {
|
||||
try (InputStream in = conn.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int r;
|
||||
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} else {
|
||||
log.warn("AATL download failed: HTTP {}", code);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AATL download error: {}", e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
|
||||
* number of newly-added CA certificates.
|
||||
*/
|
||||
private int parseAATLPdf(byte[] pdfBytes) throws Exception {
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames();
|
||||
if (names == null) {
|
||||
log.warn("AATL PDF has no name dictionary");
|
||||
return 0;
|
||||
}
|
||||
|
||||
PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles();
|
||||
if (efRoot == null) {
|
||||
log.warn("AATL PDF has no embedded files");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 1) Try names at root level
|
||||
Map<String, PDComplexFileSpecification> top = efRoot.getNames();
|
||||
if (top != null) {
|
||||
Integer count = tryParseSecuritySettingsXML(top);
|
||||
if (count != null) return count;
|
||||
}
|
||||
|
||||
// 2) Traverse kids (name-tree)
|
||||
@SuppressWarnings("unchecked")
|
||||
List<?> kids = efRoot.getKids();
|
||||
if (kids != null) {
|
||||
for (Object kidObj : kids) {
|
||||
if (kidObj instanceof PDEmbeddedFilesNameTreeNode) {
|
||||
PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj;
|
||||
Map<String, PDComplexFileSpecification> map = kid.getNames();
|
||||
if (map != null) {
|
||||
Integer count = tryParseSecuritySettingsXML(map);
|
||||
if (count != null) return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("AATL PDF did not contain SecuritySettings.xml");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the
|
||||
* number of certs added; otherwise returns null.
|
||||
*/
|
||||
private Integer tryParseSecuritySettingsXML(Map<String, PDComplexFileSpecification> nameMap) {
|
||||
PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml");
|
||||
if (fileSpec == null) return null;
|
||||
|
||||
PDEmbeddedFile ef = fileSpec.getEmbeddedFile();
|
||||
if (ef == null) return null;
|
||||
|
||||
try (InputStream xmlStream = ef.createInputStream()) {
|
||||
return parseSecuritySettingsXML(xmlStream);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage());
|
||||
log.debug("SecuritySettings.xml parse error", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns
|
||||
* the number of newly-added CA certificates.
|
||||
*/
|
||||
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
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.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(xmlStream);
|
||||
|
||||
NodeList certNodes = doc.getElementsByTagName("Certificate");
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
|
||||
int added = 0;
|
||||
for (int i = 0; i < certNodes.getLength(); i++) {
|
||||
String base64 = certNodes.item(i).getTextContent().trim();
|
||||
if (base64.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
|
||||
// Only add CA certs as anchors
|
||||
if (isCA(cert)) {
|
||||
String fingerprint = sha256Fingerprint(cert);
|
||||
String alias = "aatl-" + fingerprint;
|
||||
|
||||
// avoid duplicates
|
||||
if (signingTrustAnchors.getCertificate(alias) == null) {
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
added++;
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
"Skipping non-CA certificate from AATL: {}",
|
||||
cert.getSubjectX500Principal().getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse an AATL certificate node: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA
|
||||
* certificates.
|
||||
*/
|
||||
private void loadEUTLCertificates() {
|
||||
try {
|
||||
String lotlUrl =
|
||||
applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl();
|
||||
log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl);
|
||||
byte[] lotlBytes = downloadXml(lotlUrl);
|
||||
if (lotlBytes == null) {
|
||||
log.warn("LOTL download returned no data");
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> tslUrls = parseLotlForTslLocations(lotlBytes);
|
||||
log.info("Found {} national TSL locations in LOTL", tslUrls.size());
|
||||
|
||||
int totalAdded = 0;
|
||||
for (String tslUrl : tslUrls) {
|
||||
try {
|
||||
byte[] tslBytes = downloadXml(tslUrl);
|
||||
if (tslBytes == null) {
|
||||
log.warn("TSL download failed: {}", tslUrl);
|
||||
continue;
|
||||
}
|
||||
int added = parseTslAndAddCas(tslBytes, tslUrl);
|
||||
totalAdded += added;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage());
|
||||
log.debug("TSL parse error", e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Imported {} qualified CA certificates from EUTL", totalAdded);
|
||||
} catch (Exception e) {
|
||||
log.warn("EUTL load failed: {}", e.getMessage());
|
||||
log.debug("EUTL load error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP(S) GET for XML with sane timeouts. */
|
||||
private byte[] downloadXml(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_OK) {
|
||||
try (InputStream in = conn.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int r;
|
||||
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} else {
|
||||
log.warn("XML download failed: HTTP {} for {}", code, urlStr);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("XML download error for {}: {}", urlStr, e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */
|
||||
private List<String> parseLotlForTslLocations(byte[] lotlBytes) throws Exception {
|
||||
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(new ByteArrayInputStream(lotlBytes));
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL");
|
||||
if (ptrs.getLength() == 0) return out;
|
||||
|
||||
org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0);
|
||||
NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation");
|
||||
for (int i = 0; i < locations.getLength(); i++) {
|
||||
String url = locations.item(i).getTextContent().trim();
|
||||
if (!url.isEmpty()) out.add(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single national TSL, import CA certificates for qualified services in an active
|
||||
* status. Returns count of newly added CA certs.
|
||||
*/
|
||||
private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception {
|
||||
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(new ByteArrayInputStream(tslBytes));
|
||||
|
||||
int added = 0;
|
||||
|
||||
NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService");
|
||||
for (int i = 0; i < services.getLength(); i++) {
|
||||
org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i);
|
||||
org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation");
|
||||
if (info == null) continue;
|
||||
|
||||
String type = textOf(info, "ServiceTypeIdentifier");
|
||||
if (!EUTL_SERVICE_TYPES.contains(type)) continue;
|
||||
|
||||
String status = textOf(info, "ServiceStatus");
|
||||
if (!isActiveStatus(status)) continue;
|
||||
|
||||
org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity");
|
||||
if (sdi == null) continue;
|
||||
|
||||
NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId");
|
||||
for (int d = 0; d < digitalIds.getLength(); d++) {
|
||||
org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d);
|
||||
NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate");
|
||||
for (int c = 0; c < certNodes.getLength(); c++) {
|
||||
String base64 = certNodes.item(c).getTextContent().trim();
|
||||
if (base64.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
|
||||
if (!isCA(cert)) {
|
||||
log.debug(
|
||||
"Skipping non-CA in TSL {}: {}",
|
||||
sourceUrl,
|
||||
cert.getSubjectX500Principal().getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
String fp = sha256Fingerprint(cert);
|
||||
String alias = "eutl-" + fp;
|
||||
|
||||
if (signingTrustAnchors.getCertificate(alias) == null) {
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
added++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to import a certificate from {}: {}",
|
||||
sourceUrl,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("TSL {} → imported {} CA certificates", sourceUrl, added);
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Check if service status is active (per ETSI TS 119 612). */
|
||||
private boolean isActiveStatus(String statusUri) {
|
||||
if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true;
|
||||
if (STATUS_ACCREDITED.equals(statusUri)) return true;
|
||||
boolean acceptTransitional =
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getValidation()
|
||||
.getEutl()
|
||||
.isAcceptTransitional();
|
||||
if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Create secure DocumentBuilderFactory with namespace awareness. */
|
||||
private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
// Secure processing hardening
|
||||
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.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** Get first child element with given local name in TSL namespace. */
|
||||
private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) {
|
||||
NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName);
|
||||
return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0);
|
||||
}
|
||||
|
||||
/** Get text content of first child with given local name. */
|
||||
private String textOf(org.w3c.dom.Element parent, String localName) {
|
||||
org.w3c.dom.Element e = firstChildNS(parent, localName);
|
||||
return (e == null) ? "" : e.getTextContent().trim();
|
||||
}
|
||||
|
||||
/** Get signing trust store */
|
||||
public KeyStore getSigningTrustStore() {
|
||||
return signingTrustAnchors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ multipart.enabled=true
|
||||
logging.level.org.springframework=WARN
|
||||
logging.level.org.hibernate=WARN
|
||||
logging.level.org.eclipse.jetty=WARN
|
||||
#logging.level.org.springframework.security.saml2=TRACE
|
||||
#logging.level.org.springframework.security.oauth2=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
#logging.level.org.opensaml=DEBUG
|
||||
#logging.level.stirling.software.proprietary.security=DEBUG
|
||||
@@ -35,12 +35,12 @@ spring.datasource.username=sa
|
||||
spring.datasource.password=
|
||||
spring.h2.console.enabled=false
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
# Defer datasource initialization to ensure that the database is fully set up
|
||||
# before Hibernate attempts to access it. This is particularly useful when
|
||||
# Defer datasource initialization to ensure that the database is fully set up
|
||||
# before Hibernate attempts to access it. This is particularly useful when
|
||||
# using database initialization scripts or tools.
|
||||
spring.jpa.defer-datasource-initialization=true
|
||||
|
||||
# Disable SQL logging to avoid cluttering the logs in production. Enable this
|
||||
# Disable SQL logging to avoid cluttering the logs in production. Enable this
|
||||
# property during development if you need to debug SQL queries.
|
||||
spring.jpa.show-sql=false
|
||||
server.servlet.session.timeout:30m
|
||||
@@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true
|
||||
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
|
||||
|
||||
# V2 features
|
||||
v2=false
|
||||
v2=true
|
||||
|
||||
@@ -64,7 +64,22 @@ security:
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
secureCookie: false # Set to 'true' to use secure cookies for JWTs
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation
|
||||
useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation
|
||||
useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled
|
||||
useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled
|
||||
allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA)
|
||||
aatl:
|
||||
url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL
|
||||
eutl:
|
||||
lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL
|
||||
acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state)
|
||||
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)
|
||||
|
||||
premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
@@ -84,6 +99,7 @@ premium:
|
||||
|
||||
mail:
|
||||
enabled: false # set to 'true' to enable sending emails
|
||||
enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin)
|
||||
host: smtp.example.com # SMTP server hostname
|
||||
port: 587 # SMTP server port
|
||||
username: '' # SMTP server username
|
||||
@@ -91,8 +107,8 @@ mail:
|
||||
from: '' # sender email address
|
||||
|
||||
legal:
|
||||
termsAndConditions: https://www.stirlingpdf.com/terms # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
|
||||
privacyPolicy: https://www.stirlingpdf.com/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
|
||||
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
|
||||
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
|
||||
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
|
||||
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
|
||||
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
|
||||
@@ -105,10 +121,13 @@ system:
|
||||
showUpdateOnlyAdmin: false # only admins can see when a new update is available, depending on showUpdate it must be set to 'true'
|
||||
customHTMLFiles: false # enable to have files placed in /customFiles/templates override the existing template HTML files
|
||||
tessdataDir: /usr/share/tessdata # path to the directory containing the Tessdata files. This setting is relevant for Windows systems. For Windows users, this path should be adjusted to point to the appropriate directory where the Tessdata files are stored.
|
||||
enableAnalytics: null # set to 'true' to enable analytics, set to 'false' to disable analytics; for enterprise users, this is set to true
|
||||
enableAnalytics: null # Master toggle for analytics: set to 'true' to enable all analytics, 'false' to disable all analytics, or leave as 'null' to prompt admin on first launch
|
||||
enablePosthog: null # Enable PostHog analytics (open-source product analytics): set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
enableScarf: null # Enable Scarf tracking pixel: set to 'true' to enable, 'false' to disable, or 'null' to enable by default when analytics is enabled
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
@@ -155,8 +174,6 @@ system:
|
||||
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
|
||||
|
||||
ui:
|
||||
appName: '' # application's visible name
|
||||
homeDescription: '' # short description or tagline shown on the homepage
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
|
||||
|
||||
+46
-100
@@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateExpiredException;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
import java.util.Date;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/** Tests for the CertificateValidationService using mocked certificates. */
|
||||
class CertificateValidationServiceTest {
|
||||
@@ -26,121 +26,67 @@ class CertificateValidationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
validationService = new CertificateValidationService();
|
||||
// Create mock ApplicationProperties with default validation settings
|
||||
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
|
||||
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
mock(ApplicationProperties.Security.Validation.class);
|
||||
ApplicationProperties.Security.Validation.Trust trust =
|
||||
mock(ApplicationProperties.Security.Validation.Trust.class);
|
||||
ApplicationProperties.Security.Validation.Revocation revocation =
|
||||
mock(ApplicationProperties.Security.Validation.Revocation.class);
|
||||
|
||||
when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
when(security.getValidation()).thenReturn(validation);
|
||||
when(validation.getTrust()).thenReturn(trust);
|
||||
when(validation.getRevocation()).thenReturn(revocation);
|
||||
when(validation.isAllowAIA()).thenReturn(false);
|
||||
when(trust.isServerAsAnchor()).thenReturn(false);
|
||||
when(trust.isUseSystemTrust()).thenReturn(false);
|
||||
when(trust.isUseMozillaBundle()).thenReturn(false);
|
||||
when(trust.isUseAATL()).thenReturn(false);
|
||||
when(trust.isUseEUTL()).thenReturn(false);
|
||||
when(revocation.getMode()).thenReturn("none");
|
||||
when(revocation.isHardFail()).thenReturn(false);
|
||||
|
||||
validationService = new CertificateValidationService(null, applicationProperties);
|
||||
|
||||
// Create mock certificates
|
||||
validCertificate = mock(X509Certificate.class);
|
||||
expiredCertificate = mock(X509Certificate.class);
|
||||
|
||||
// Set up behaviors for valid certificate
|
||||
doNothing().when(validCertificate).checkValidity(); // No exception means valid
|
||||
// Set up behaviors for valid certificate (both overloads)
|
||||
doNothing().when(validCertificate).checkValidity();
|
||||
doNothing().when(validCertificate).checkValidity(any(Date.class));
|
||||
|
||||
// Set up behaviors for expired certificate
|
||||
// Set up behaviors for expired certificate (both overloads)
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity();
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity(any(Date.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsRevoked_ValidCertificate() {
|
||||
void testIsOutsideValidityPeriod_ValidCertificate() {
|
||||
// When certificate is valid (not expired)
|
||||
boolean result = validationService.isRevoked(validCertificate);
|
||||
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
|
||||
|
||||
// Then it should not be considered revoked
|
||||
assertFalse(result, "Valid certificate should not be considered revoked");
|
||||
// Then it should not be outside validity period
|
||||
assertFalse(result, "Valid certificate should not be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsRevoked_ExpiredCertificate() {
|
||||
void testIsOutsideValidityPeriod_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isRevoked(expiredCertificate);
|
||||
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
|
||||
|
||||
// Then it should be considered revoked
|
||||
assertTrue(result, "Expired certificate should be considered revoked");
|
||||
// Then it should be outside validity period
|
||||
assertTrue(result, "Expired certificate should be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_Match() {
|
||||
// Create certificates with matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
|
||||
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate with matching issuer and subject should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_NoMatch() {
|
||||
// Create certificates with non-matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
|
||||
|
||||
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
|
||||
// certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, don't throw exception
|
||||
doNothing().when(signedCert).verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate chain with proper signing should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, throw exception
|
||||
// Need to use a specific exception that verify() can throw
|
||||
doThrow(new java.security.SignatureException("Verification failed"))
|
||||
.when(signedCert)
|
||||
.verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate chain with failed signing should not validate");
|
||||
}
|
||||
// Note: Full integration tests for buildAndValidatePath() would require
|
||||
// real certificate chains and trust anchors. These would be better as
|
||||
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
|
||||
}
|
||||
|
||||
+1
-1
@@ -53,7 +53,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@Slf4j
|
||||
@ProprietaryUiDataApi
|
||||
@EnterpriseEndpoint
|
||||
public class ProprietaryUIDataController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@@ -89,6 +88,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@GetMapping("/audit-dashboard")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@EnterpriseEndpoint
|
||||
@Operation(summary = "Get audit dashboard data")
|
||||
public ResponseEntity<AuditDashboardData> getAuditDashboardData() {
|
||||
AuditDashboardData data = new AuditDashboardData();
|
||||
|
||||
-1
@@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.WEB));
|
||||
jwtService.addToken(response, jwt);
|
||||
log.debug("JWT generated for user: {}", userName);
|
||||
|
||||
getRedirectStrategy().sendRedirect(request, response, "/");
|
||||
|
||||
+13
-8
@@ -72,13 +72,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
authentication.getClass().getSimpleName());
|
||||
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
|
||||
}
|
||||
} else if (jwtService != null) {
|
||||
String token = jwtService.extractToken(request);
|
||||
if (token != null && !token.isBlank()) {
|
||||
jwtService.clearToken(response);
|
||||
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);
|
||||
@@ -119,8 +120,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
// Set service provider keys for the SamlClient
|
||||
samlClient.setSPKeys(certificate, privateKey);
|
||||
|
||||
// Redirect to identity provider for logout. todo: add relay state
|
||||
samlClient.redirectToIdentityProvider(response, null, nameIdValue);
|
||||
// Build relay state to return user to login page after IdP logout
|
||||
String relayState =
|
||||
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
|
||||
|
||||
// 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 {}",
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ public class RateLimitResetScheduler {
|
||||
|
||||
private final IPRateLimitingFilter rateLimitingFilter;
|
||||
|
||||
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
|
||||
@Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}")
|
||||
public void resetRateLimit() {
|
||||
rateLimitingFilter.resetRequestCounts();
|
||||
}
|
||||
|
||||
+60
-42
@@ -39,7 +39,6 @@ import stirling.software.proprietary.security.CustomLogoutSuccessHandler;
|
||||
import stirling.software.proprietary.security.JwtAuthenticationEntryPoint;
|
||||
import stirling.software.proprietary.security.database.repository.JPATokenRepositoryImpl;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.filter.FirstLoginFilter;
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
@@ -74,7 +73,6 @@ public class SecurityConfiguration {
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final FirstLoginFilter firstLoginFilter;
|
||||
private final SessionPersistentRegistry sessionRegistry;
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
|
||||
@@ -93,7 +91,6 @@ public class SecurityConfiguration {
|
||||
JwtServiceInterface jwtService,
|
||||
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
|
||||
LoginAttemptService loginAttemptService,
|
||||
FirstLoginFilter firstLoginFilter,
|
||||
SessionPersistentRegistry sessionRegistry,
|
||||
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
|
||||
@Autowired(required = false)
|
||||
@@ -110,7 +107,6 @@ public class SecurityConfiguration {
|
||||
this.jwtService = jwtService;
|
||||
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
this.firstLoginFilter = firstLoginFilter;
|
||||
this.sessionRegistry = sessionRegistry;
|
||||
this.persistentLoginRepository = persistentLoginRepository;
|
||||
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
|
||||
@@ -132,19 +128,14 @@ public class SecurityConfiguration {
|
||||
if (loginEnabledValue) {
|
||||
boolean v2Enabled = appConfig.v2Enabled();
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(
|
||||
jwtAuthenticationFilter(),
|
||||
UsernamePasswordAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
exceptionHandling ->
|
||||
exceptionHandling.authenticationEntryPoint(
|
||||
jwtAuthenticationEntryPoint));
|
||||
}
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
|
||||
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(
|
||||
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
if (v2Enabled) {
|
||||
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
|
||||
}
|
||||
|
||||
if (!securityProperties.getCsrfDisabled()) {
|
||||
CookieCsrfTokenRepository cookieRepo =
|
||||
@@ -156,6 +147,13 @@ public class SecurityConfiguration {
|
||||
csrf ->
|
||||
csrf.ignoringRequestMatchers(
|
||||
request -> {
|
||||
String uri = request.getRequestURI();
|
||||
|
||||
// Ignore CSRF for auth endpoints
|
||||
if (uri.startsWith("/api/v1/auth/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
// If there's no API key, don't ignore CSRF
|
||||
// (return false)
|
||||
@@ -238,9 +236,12 @@ public class SecurityConfiguration {
|
||||
: uri;
|
||||
return trimmedUri.startsWith("/login")
|
||||
|| trimmedUri.startsWith("/oauth")
|
||||
|| trimmedUri.startsWith("/oauth2")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.endsWith(".svg")
|
||||
|| trimmedUri.startsWith("/register")
|
||||
|| trimmedUri.startsWith("/signup")
|
||||
|| trimmedUri.startsWith("/auth/callback")
|
||||
|| trimmedUri.startsWith("/error")
|
||||
|| trimmedUri.startsWith("/images/")
|
||||
|| trimmedUri.startsWith("/public/")
|
||||
@@ -252,6 +253,16 @@ public class SecurityConfiguration {
|
||||
|| trimmedUri.startsWith("/favicon")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/info/status")
|
||||
|| trimmedUri.startsWith("/api/v1/config")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/register")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/user/register")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/me")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| uri.contains("/v1/api-docs");
|
||||
})
|
||||
@@ -277,33 +288,40 @@ public class SecurityConfiguration {
|
||||
// Handle OAUTH2 Logins
|
||||
if (securityProperties.isOauth2Active()) {
|
||||
http.oauth2Login(
|
||||
oauth2 ->
|
||||
oauth2.loginPage("/oauth2")
|
||||
/*
|
||||
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
|
||||
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
|
||||
is set as true, else login fails with an error message advising the same.
|
||||
*/
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
.failureHandler(
|
||||
new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties,
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll());
|
||||
oauth2 -> {
|
||||
// v1: Use /oauth2 as login page for Thymeleaf templates
|
||||
if (!v2Enabled) {
|
||||
oauth2.loginPage("/oauth2");
|
||||
}
|
||||
|
||||
// v2: Don't set loginPage, let default OAuth2 flow handle it
|
||||
oauth2
|
||||
/*
|
||||
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
|
||||
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
|
||||
is set as true, else login fails with an error message advising the same.
|
||||
*/
|
||||
.successHandler(
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
userInfoEndpoint ->
|
||||
userInfoEndpoint
|
||||
.oidcUserService(
|
||||
new CustomOAuth2UserService(
|
||||
securityProperties
|
||||
.getOauth2(),
|
||||
userService,
|
||||
loginAttemptService))
|
||||
.userAuthoritiesMapper(
|
||||
oAuth2userAuthoritiesMapper))
|
||||
.permitAll();
|
||||
});
|
||||
}
|
||||
// Handle SAML
|
||||
if (securityProperties.isSaml2Active() && runningProOrHigher) {
|
||||
|
||||
+187
-5
@@ -1,19 +1,27 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -33,7 +41,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.AdminApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.AppArgsCapture;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.JarPathUtil;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||
@@ -47,6 +57,7 @@ public class AdminSettingsController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
// Track settings that have been modified but not yet applied (require restart)
|
||||
private static final ConcurrentHashMap<String, Object> pendingChanges =
|
||||
@@ -203,8 +214,8 @@ public class AdminSettingsController {
|
||||
@Operation(
|
||||
summary = "Get specific settings section",
|
||||
description =
|
||||
"Retrieve settings for a specific section (e.g., security, system, ui). Admin"
|
||||
+ " access required.")
|
||||
"Retrieve settings for a specific section (e.g., security, system, ui). "
|
||||
+ "By default includes pending changes with awaitingRestart flags. Admin access required.")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(
|
||||
@@ -215,7 +226,9 @@ public class AdminSettingsController {
|
||||
responseCode = "403",
|
||||
description = "Access denied - Admin role required")
|
||||
})
|
||||
public ResponseEntity<?> getSettingsSection(@PathVariable String sectionName) {
|
||||
public ResponseEntity<?> getSettingsSection(
|
||||
@PathVariable String sectionName,
|
||||
@RequestParam(defaultValue = "true") boolean includePending) {
|
||||
try {
|
||||
Object sectionData = getSectionData(sectionName);
|
||||
if (sectionData == null) {
|
||||
@@ -226,8 +239,24 @@ public class AdminSettingsController {
|
||||
+ ". Valid sections: "
|
||||
+ String.join(", ", VALID_SECTION_NAMES));
|
||||
}
|
||||
log.debug("Admin requested settings section: {}", sectionName);
|
||||
return ResponseEntity.ok(sectionData);
|
||||
|
||||
// Convert to Map for manipulation
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> sectionMap = objectMapper.convertValue(sectionData, Map.class);
|
||||
|
||||
if (includePending && !pendingChanges.isEmpty()) {
|
||||
// Add pending changes block for this section
|
||||
Map<String, Object> sectionPending = extractPendingForSection(sectionName);
|
||||
if (!sectionPending.isEmpty()) {
|
||||
sectionMap.put("_pending", sectionPending);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Admin requested settings section: {} (includePending={})",
|
||||
sectionName,
|
||||
includePending);
|
||||
return ResponseEntity.ok(sectionMap);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.error("Invalid section name {}: {}", sectionName, e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
@@ -401,6 +430,101 @@ public class AdminSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/restart")
|
||||
@Operation(
|
||||
summary = "Restart the application",
|
||||
description =
|
||||
"Triggers a graceful restart of the Spring Boot application to apply pending settings changes. Uses a restart helper to ensure proper restart. Admin access required.")
|
||||
@ApiResponses(
|
||||
value = {
|
||||
@ApiResponse(responseCode = "200", description = "Restart initiated successfully"),
|
||||
@ApiResponse(
|
||||
responseCode = "403",
|
||||
description = "Access denied - Admin role required"),
|
||||
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
|
||||
})
|
||||
public ResponseEntity<String> restartApplication() {
|
||||
try {
|
||||
log.warn("Admin initiated application restart");
|
||||
|
||||
// Get paths to current JAR and restart helper
|
||||
Path appJar = JarPathUtil.currentJar();
|
||||
Path helperJar = JarPathUtil.restartHelperJar();
|
||||
|
||||
if (appJar == null) {
|
||||
log.error("Cannot restart: not running from JAR (likely development mode)");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
"Restart not available in development mode. Please restart the application manually.");
|
||||
}
|
||||
|
||||
if (helperJar == null || !Files.isRegularFile(helperJar)) {
|
||||
log.error("Cannot restart: restart-helper.jar not found at expected location");
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body("Restart helper not found. Please restart the application manually.");
|
||||
}
|
||||
|
||||
// Get current application arguments
|
||||
List<String> appArgs = AppArgsCapture.APP_ARGS.get();
|
||||
|
||||
// Write args to temp file to avoid command-line quoting issues
|
||||
Path argsFile = Files.createTempFile("stirling-app-args-", ".txt");
|
||||
Files.write(argsFile, appArgs, StandardCharsets.UTF_8);
|
||||
|
||||
// Get current process PID and java executable
|
||||
long pid = ProcessHandle.current().pid();
|
||||
String javaBin = JarPathUtil.javaExecutable();
|
||||
|
||||
// Build command to launch restart helper
|
||||
List<String> cmd = new ArrayList<>();
|
||||
cmd.add(javaBin);
|
||||
cmd.add("-jar");
|
||||
cmd.add(helperJar.toString());
|
||||
cmd.add("--pid");
|
||||
cmd.add(Long.toString(pid));
|
||||
cmd.add("--app");
|
||||
cmd.add(appJar.toString());
|
||||
cmd.add("--argsFile");
|
||||
cmd.add(argsFile.toString());
|
||||
cmd.add("--backoffMs");
|
||||
cmd.add("1000");
|
||||
|
||||
log.info("Launching restart helper: {}", String.join(" ", cmd));
|
||||
|
||||
// Launch restart helper process
|
||||
new ProcessBuilder(cmd)
|
||||
.directory(appJar.getParent().toFile())
|
||||
.inheritIO() // Forward logs
|
||||
.start();
|
||||
|
||||
// Clear pending changes since we're restarting
|
||||
pendingChanges.clear();
|
||||
|
||||
// Give the HTTP response time to complete, then exit
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
log.info("Shutting down for restart...");
|
||||
SpringApplication.exit(applicationContext, () -> 0);
|
||||
System.exit(0);
|
||||
} catch (InterruptedException e) {
|
||||
log.error("Restart interrupted: {}", e.getMessage(), e);
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
})
|
||||
.start();
|
||||
|
||||
return ResponseEntity.ok(
|
||||
"Application restart initiated. The server will be back online shortly.");
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initiate restart: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("Failed to initiate application restart: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Object getSectionData(String sectionName) {
|
||||
if (sectionName == null || sectionName.trim().isEmpty()) {
|
||||
return null;
|
||||
@@ -639,4 +763,62 @@ public class AdminSettingsController {
|
||||
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract pending changes for a specific section
|
||||
*
|
||||
* @param sectionName The section name (e.g., "security", "system")
|
||||
* @return Map of pending changes with nested structure for this section
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> extractPendingForSection(String sectionName) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
String sectionPrefix = sectionName.toLowerCase() + ".";
|
||||
|
||||
// Find all pending changes for this section
|
||||
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
|
||||
String pendingKey = entry.getKey();
|
||||
|
||||
if (pendingKey.toLowerCase().startsWith(sectionPrefix)) {
|
||||
// Extract the path within the section (e.g., "security.enableLogin" ->
|
||||
// "enableLogin")
|
||||
String pathInSection = pendingKey.substring(sectionPrefix.length());
|
||||
Object pendingValue = entry.getValue();
|
||||
|
||||
// Build nested structure from dot notation
|
||||
setNestedValue(result, pathInSection, pendingValue);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in a nested map using dot notation
|
||||
*
|
||||
* @param map The root map
|
||||
* @param dotPath The dot notation path (e.g., "oauth2.clientSecret")
|
||||
* @param value The value to set
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private void setNestedValue(Map<String, Object> map, String dotPath, Object value) {
|
||||
String[] parts = dotPath.split("\\.");
|
||||
Map<String, Object> current = map;
|
||||
|
||||
// Navigate/create nested maps for all parts except the last
|
||||
for (int i = 0; i < parts.length - 1; i++) {
|
||||
String part = parts[i];
|
||||
Object nested = current.get(part);
|
||||
|
||||
if (!(nested instanceof Map)) {
|
||||
nested = new HashMap<String, Object>();
|
||||
current.put(part, nested);
|
||||
}
|
||||
|
||||
current = (Map<String, Object>) nested;
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
current.put(parts[parts.length - 1], value);
|
||||
}
|
||||
}
|
||||
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
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.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
|
||||
public class AuthController {
|
||||
|
||||
private final UserService userService;
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
*
|
||||
* @param request Login credentials (email/username and password)
|
||||
* @param response HTTP response to set JWT cookie
|
||||
* @return User and session information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(
|
||||
@RequestBody UsernameAndPass request, HttpServletResponse response) {
|
||||
try {
|
||||
// Validate input parameters
|
||||
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
|
||||
log.warn("Login attempt with null or empty username");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Username is required"));
|
||||
}
|
||||
|
||||
if (request.getPassword() == null || request.getPassword().isEmpty()) {
|
||||
log.warn(
|
||||
"Login attempt with null or empty password for user: {}",
|
||||
request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required"));
|
||||
}
|
||||
|
||||
log.debug("Login attempt for user: {}", request.getUsername());
|
||||
|
||||
UserDetails userDetails =
|
||||
userDetailsService.loadUserByUsername(request.getUsername().trim());
|
||||
User user = (User) userDetails;
|
||||
|
||||
if (!userService.isPasswordCorrect(user, request.getPassword())) {
|
||||
log.warn("Invalid password for user: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
|
||||
if (!user.isEnabled()) {
|
||||
log.warn("Disabled user attempted login: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "User account is disabled"));
|
||||
}
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
|
||||
log.info("Login successful for user: {}", request.getUsername());
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
log.warn("User not found: {}", request.getUsername());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid username or password"));
|
||||
} catch (AuthenticationException e) {
|
||||
log.error("Authentication failed for user: {}", request.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Invalid credentials"));
|
||||
} catch (Exception e) {
|
||||
log.error("Login error for user: {}", request.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user
|
||||
*
|
||||
* @return Current authenticated user information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<?> getCurrentUser() {
|
||||
try {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
if (auth == null
|
||||
|| !auth.isAuthenticated()
|
||||
|| auth.getPrincipal().equals("anonymousUser")) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Not authenticated"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = (UserDetails) auth.getPrincipal();
|
||||
User user = (User) userDetails;
|
||||
|
||||
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Get current user error", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* @param request HTTP request containing current JWT cookie
|
||||
* @param response HTTP response to set new JWT cookie
|
||||
* @return New token information
|
||||
*/
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
|
||||
try {
|
||||
String token = jwtService.extractToken(request);
|
||||
|
||||
if (token == null) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Request/Response DTOs
|
||||
// ===========================
|
||||
|
||||
/** Login request DTO */
|
||||
public record LoginRequest(String email, String password) {}
|
||||
}
|
||||
+95
-29
@@ -2,7 +2,6 @@ package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
@@ -16,7 +15,6 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -42,15 +40,19 @@ public class DatabaseController {
|
||||
summary = "Import a database backup file",
|
||||
description = "Uploads and imports a database backup SQL file.")
|
||||
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "import-database")
|
||||
public String importDatabase(
|
||||
public ResponseEntity<?> importDatabase(
|
||||
@Parameter(description = "SQL file to import", required = true)
|
||||
@RequestParam("fileInput")
|
||||
MultipartFile file,
|
||||
RedirectAttributes redirectAttributes)
|
||||
MultipartFile file)
|
||||
throws IOException {
|
||||
if (file == null || file.isEmpty()) {
|
||||
redirectAttributes.addAttribute("error", "fileNullOrEmpty");
|
||||
return "redirect:/database";
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"fileNullOrEmpty",
|
||||
"message",
|
||||
"File is null or empty"));
|
||||
}
|
||||
log.info("Received file: {}", file.getOriginalFilename());
|
||||
Path tempTemplatePath = Files.createTempFile("backup_", ".sql");
|
||||
@@ -58,15 +60,31 @@ public class DatabaseController {
|
||||
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
boolean importSuccess = databaseService.importDatabaseFromUI(tempTemplatePath);
|
||||
if (importSuccess) {
|
||||
redirectAttributes.addAttribute("infoMessage", "importIntoDatabaseSuccessed");
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"importIntoDatabaseSuccessed",
|
||||
"description",
|
||||
"Database imported successfully"));
|
||||
} else {
|
||||
redirectAttributes.addAttribute("error", "failedImportFile");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedImportFile",
|
||||
"message",
|
||||
"Failed to import database file"));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error importing database: {}", e.getMessage());
|
||||
redirectAttributes.addAttribute("error", "failedImportFile");
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedImportFile",
|
||||
"message",
|
||||
"Failed to import database: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/database";
|
||||
}
|
||||
|
||||
@Hidden
|
||||
@@ -74,11 +92,17 @@ public class DatabaseController {
|
||||
summary = "Import database backup by filename",
|
||||
description = "Imports a database backup file from the server using its file name.")
|
||||
@GetMapping("/import-database-file/{fileName}")
|
||||
public String importDatabaseFromBackupUI(
|
||||
public ResponseEntity<?> importDatabaseFromBackupUI(
|
||||
@Parameter(description = "Name of the file to import", required = true) @PathVariable
|
||||
String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "redirect:/database?error=fileNullOrEmpty";
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"fileNullOrEmpty",
|
||||
"message",
|
||||
"File name is null or empty"));
|
||||
}
|
||||
// Check if the file exists in the backup list
|
||||
boolean fileExists =
|
||||
@@ -86,14 +110,31 @@ public class DatabaseController {
|
||||
.anyMatch(backup -> backup.getFileName().equals(fileName));
|
||||
if (!fileExists) {
|
||||
log.error("File {} not found in backup list", fileName);
|
||||
return "redirect:/database?error=fileNotFound";
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"fileNotFound",
|
||||
"message",
|
||||
"File not found in backup list"));
|
||||
}
|
||||
log.info("Received file: {}", fileName);
|
||||
if (databaseService.importDatabaseFromUI(fileName)) {
|
||||
log.info("File {} imported to database", fileName);
|
||||
return "redirect:/database?infoMessage=importIntoDatabaseSuccessed";
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"importIntoDatabaseSuccessed",
|
||||
"description",
|
||||
"Database backup imported successfully"));
|
||||
}
|
||||
return "redirect:/database?error=failedImportFile";
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedImportFile",
|
||||
"message",
|
||||
"Failed to import database file"));
|
||||
}
|
||||
|
||||
@Hidden
|
||||
@@ -101,24 +142,42 @@ public class DatabaseController {
|
||||
summary = "Delete a database backup file",
|
||||
description = "Deletes a specified database backup file from the server.")
|
||||
@GetMapping("/delete/{fileName}")
|
||||
public String deleteFile(
|
||||
public ResponseEntity<?> deleteFile(
|
||||
@Parameter(description = "Name of the file to delete", required = true) @PathVariable
|
||||
String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
throw new IllegalArgumentException("File must not be null or empty");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"invalidFileName",
|
||||
"message",
|
||||
"File must not be null or empty"));
|
||||
}
|
||||
try {
|
||||
if (databaseService.deleteBackupFile(fileName)) {
|
||||
log.info("Deleted file: {}", fileName);
|
||||
return ResponseEntity.ok(java.util.Map.of("message", "File deleted successfully"));
|
||||
} else {
|
||||
log.error("Failed to delete file: {}", fileName);
|
||||
return "redirect:/database?error=failedToDeleteFile";
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"failedToDeleteFile",
|
||||
"message",
|
||||
"Failed to delete backup file"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error deleting file: {}", e.getMessage());
|
||||
return "redirect:/database?error=" + e.getMessage();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"deleteError",
|
||||
"message",
|
||||
"Error deleting file: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/database";
|
||||
}
|
||||
|
||||
@Hidden
|
||||
@@ -142,22 +201,29 @@ public class DatabaseController {
|
||||
.body(resource);
|
||||
} catch (IOException e) {
|
||||
log.error("Error downloading file: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.SEE_OTHER)
|
||||
.location(URI.create("/database?error=downloadFailed"))
|
||||
.build();
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(
|
||||
java.util.Map.of(
|
||||
"error",
|
||||
"downloadFailed",
|
||||
"message",
|
||||
"Failed to download file: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "Create a database backup",
|
||||
description =
|
||||
"This endpoint triggers the creation of a database backup and redirects to the"
|
||||
+ " database management page.")
|
||||
description = "This endpoint triggers the creation of a database backup.")
|
||||
@GetMapping("/createDatabaseBackup")
|
||||
public String createDatabaseBackup() {
|
||||
public ResponseEntity<?> createDatabaseBackup() {
|
||||
log.info("Starting database backup creation...");
|
||||
databaseService.exportDatabase();
|
||||
log.info("Database backup successfully created.");
|
||||
return "redirect:/database?infoMessage=backupCreated";
|
||||
return ResponseEntity.ok(
|
||||
java.util.Map.of(
|
||||
"message",
|
||||
"backupCreated",
|
||||
"description",
|
||||
"Database backup created successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
+44
-27
@@ -1,10 +1,12 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import jakarta.transaction.Transactional;
|
||||
|
||||
@@ -30,98 +32,113 @@ public class TeamController {
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/create")
|
||||
public RedirectView createTeam(@RequestParam("name") String name) {
|
||||
public ResponseEntity<?> createTeam(@RequestParam("name") String name) {
|
||||
if (teamRepository.existsByNameIgnoreCase(name)) {
|
||||
return new RedirectView("/teams?messageType=teamExists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Team name already exists."));
|
||||
}
|
||||
Team team = new Team();
|
||||
team.setName(name);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/teams?messageType=teamCreated");
|
||||
return ResponseEntity.ok(Map.of("message", "Team created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/rename")
|
||||
public RedirectView renameTeam(
|
||||
public ResponseEntity<?> renameTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("newName") String newName) {
|
||||
Optional<Team> existing = teamRepository.findById(teamId);
|
||||
if (existing.isEmpty()) {
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
if (teamRepository.existsByNameIgnoreCase(newName)) {
|
||||
return new RedirectView("/teams?messageType=teamNameExists");
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Team name already exists."));
|
||||
}
|
||||
Team team = existing.get();
|
||||
|
||||
// Prevent renaming the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot rename Internal team."));
|
||||
}
|
||||
|
||||
team.setName(newName);
|
||||
teamRepository.save(team);
|
||||
return new RedirectView("/teams?messageType=teamRenamed");
|
||||
return ResponseEntity.ok(Map.of("message", "Team renamed successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/delete")
|
||||
@Transactional
|
||||
public RedirectView deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
public ResponseEntity<?> deleteTeam(@RequestParam("teamId") Long teamId) {
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return new RedirectView("/teams?messageType=teamNotFound");
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
|
||||
Team team = teamOpt.get();
|
||||
|
||||
// Prevent deleting the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?messageType=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot delete Internal team."));
|
||||
}
|
||||
|
||||
long memberCount = userRepository.countByTeam(team);
|
||||
if (memberCount > 0) {
|
||||
return new RedirectView("/teams?messageType=teamHasUsers");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Team must be empty before deletion. Please remove all members first."));
|
||||
}
|
||||
|
||||
teamRepository.delete(team);
|
||||
return new RedirectView("/teams?messageType=teamDeleted");
|
||||
return ResponseEntity.ok(Map.of("message", "Team deleted successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/addUser")
|
||||
@Transactional
|
||||
public RedirectView addUserToTeam(
|
||||
public ResponseEntity<?> addUserToTeam(
|
||||
@RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) {
|
||||
|
||||
// Find the team
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> new RuntimeException("Team not found"));
|
||||
Optional<Team> teamOpt = teamRepository.findById(teamId);
|
||||
if (teamOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Team not found."));
|
||||
}
|
||||
Team team = teamOpt.get();
|
||||
|
||||
// Prevent adding users to the Internal team
|
||||
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams?error=internalTeamNotAccessible");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot add users to Internal team."));
|
||||
}
|
||||
|
||||
// Find the user
|
||||
User user =
|
||||
userRepository
|
||||
.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
Optional<User> userOpt = userRepository.findById(userId);
|
||||
if (userOpt.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
|
||||
// Check if user is in the Internal team - prevent moving them
|
||||
if (user.getTeam() != null
|
||||
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
|
||||
return new RedirectView("/teams/" + teamId + "?error=cannotMoveInternalUsers");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot move users from Internal team."));
|
||||
}
|
||||
|
||||
// Assign user to team
|
||||
user.setTeam(team);
|
||||
userRepository.save(user);
|
||||
|
||||
// Redirect back to team details page
|
||||
return new RedirectView("/teams/" + teamId + "?messageType=userAdded");
|
||||
return ResponseEntity.ok(Map.of("message", "User added to team successfully"));
|
||||
}
|
||||
}
|
||||
|
||||
+375
-85
@@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api;
|
||||
import java.io.IOException;
|
||||
import java.security.Principal;
|
||||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -15,10 +16,7 @@ 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.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
@@ -41,6 +39,7 @@ 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.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
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;
|
||||
@@ -56,128 +55,218 @@ public class UserController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final TeamRepository teamRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final Optional<EmailService> emailService;
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/register")
|
||||
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
|
||||
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
|
||||
model.addAttribute("error", "Username already exists");
|
||||
return "register";
|
||||
}
|
||||
try {
|
||||
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
|
||||
|
||||
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
|
||||
log.warn(
|
||||
"Registration failed: username already exists: {}",
|
||||
usernameAndPass.getUsername());
|
||||
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());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid username format"));
|
||||
}
|
||||
|
||||
if (usernameAndPass.getPassword() == null
|
||||
|| usernameAndPass.getPassword().length() < 6) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters"));
|
||||
}
|
||||
|
||||
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
userService.saveUser(
|
||||
requestModel.getUsername(),
|
||||
requestModel.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
User user =
|
||||
userService.saveUser(
|
||||
usernameAndPass.getUsername(),
|
||||
usernameAndPass.getPassword(),
|
||||
team,
|
||||
Role.USER.getRoleId(),
|
||||
false);
|
||||
|
||||
log.info("User registered successfully: {}", usernameAndPass.getUsername());
|
||||
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(
|
||||
Map.of(
|
||||
"user",
|
||||
buildUserResponse(user),
|
||||
"message",
|
||||
"Account created successfully. Please log in."));
|
||||
|
||||
} catch (IllegalArgumentException e) {
|
||||
return "redirect:/login?messageType=invalidUsername";
|
||||
log.error("Registration validation error: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
} catch (Exception e) {
|
||||
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Registration failed: " + e.getMessage()));
|
||||
}
|
||||
return "redirect:/login?registered=true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to build user response object
|
||||
*
|
||||
* @param user User entity
|
||||
* @return Map containing user information
|
||||
*/
|
||||
private Map<String, Object> buildUserResponse(User user) {
|
||||
Map<String, Object> userMap = new HashMap<>();
|
||||
userMap.put("id", user.getId());
|
||||
userMap.put("email", user.getUsername()); // Use username as email
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
|
||||
// Add metadata for OAuth compatibility
|
||||
Map<String, Object> appMetadata = new HashMap<>();
|
||||
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
|
||||
userMap.put("app_metadata", appMetadata);
|
||||
|
||||
return userMap;
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-username")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
public RedirectView changeUsername(
|
||||
public ResponseEntity<?> changeUsername(
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPasswordChangeUsername") String currentPassword,
|
||||
@RequestParam(name = "newUsername") String newUsername,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes)
|
||||
HttpServletResponse response)
|
||||
throws IOException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(newUsername)) {
|
||||
return new RedirectView("/account?messageType=invalidUsername", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "invalidUsername", "message", "Invalid username format"));
|
||||
}
|
||||
if (principal == null) {
|
||||
return new RedirectView("/account?messageType=notAuthenticated", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
|
||||
}
|
||||
// The username MUST be unique when renaming
|
||||
Optional<User> userOpt = userService.findByUsername(principal.getName());
|
||||
if (userOpt == null || userOpt.isEmpty()) {
|
||||
return new RedirectView("/account?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (user.getUsername().equals(newUsername)) {
|
||||
return new RedirectView("/account?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "usernameExists", "message", "Username already in use"));
|
||||
}
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/account?messageType=incorrectPassword", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
|
||||
}
|
||||
if (!user.getUsername().equals(newUsername) && userService.usernameExists(newUsername)) {
|
||||
return new RedirectView("/account?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "usernameExists", "message", "Username already exists"));
|
||||
}
|
||||
if (newUsername != null && newUsername.length() > 0) {
|
||||
try {
|
||||
userService.changeUsername(user, newUsername);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return new RedirectView("/account?messageType=invalidUsername", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"invalidUsername",
|
||||
"message",
|
||||
"Invalid username format"));
|
||||
}
|
||||
}
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"credsUpdated",
|
||||
"description",
|
||||
"Username changed successfully. Please log in again."));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-password-on-login")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
public RedirectView changePasswordOnLogin(
|
||||
public ResponseEntity<?> changePasswordOnLogin(
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes)
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (principal == null) {
|
||||
return new RedirectView("/change-creds?messageType=notAuthenticated", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
if (userOpt.isEmpty()) {
|
||||
return new RedirectView("/change-creds?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/change-creds?messageType=incorrectPassword", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
|
||||
}
|
||||
userService.changePassword(user, newPassword);
|
||||
userService.changeFirstUse(user, false);
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"credsUpdated",
|
||||
"description",
|
||||
"Password changed successfully. Please log in again."));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@PostMapping("/change-password")
|
||||
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||
public RedirectView changePassword(
|
||||
public ResponseEntity<?> changePassword(
|
||||
Principal principal,
|
||||
@RequestParam(name = "currentPassword") String currentPassword,
|
||||
@RequestParam(name = "newPassword") String newPassword,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
RedirectAttributes redirectAttributes)
|
||||
HttpServletResponse response)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
if (principal == null) {
|
||||
return new RedirectView("/account?messageType=notAuthenticated", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "notAuthenticated", "message", "User not authenticated"));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(principal.getName());
|
||||
if (userOpt.isEmpty()) {
|
||||
return new RedirectView("/account?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "userNotFound", "message", "User not found"));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
if (!userService.isPasswordCorrect(user, currentPassword)) {
|
||||
return new RedirectView("/account?messageType=incorrectPassword", true);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
|
||||
}
|
||||
userService.changePassword(user, newPassword);
|
||||
// Logout using Spring's utility
|
||||
new SecurityContextLogoutHandler().logout(request, response, null);
|
||||
return new RedirectView(LOGIN_MESSAGETYPE_CREDSUPDATED, true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
"credsUpdated",
|
||||
"description",
|
||||
"Password changed successfully. Please log in again."));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@@ -195,23 +284,23 @@ public class UserController {
|
||||
* </ul>
|
||||
* Keys not listed above will be ignored.
|
||||
* @param principal The currently authenticated user.
|
||||
* @return A redirect string to the account page after updating the settings.
|
||||
* @return A ResponseEntity with success or error information.
|
||||
* @throws SQLException If a database error occurs.
|
||||
* @throws UnsupportedProviderException If the operation is not supported for the user's
|
||||
* provider.
|
||||
*/
|
||||
public String updateUserSettings(@RequestBody Map<String, String> updates, Principal principal)
|
||||
public ResponseEntity<?> updateUserSettings(
|
||||
@RequestBody Map<String, String> updates, Principal principal)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
log.debug("Processed updates: {}", updates);
|
||||
// Assuming you have a method in userService to update the settings for a user
|
||||
userService.updateUserSettings(principal.getName(), updates);
|
||||
// Redirect to a page of your choice after updating
|
||||
return "redirect:/account";
|
||||
return ResponseEntity.ok(Map.of("message", "Settings updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/saveUser")
|
||||
public RedirectView saveUser(
|
||||
public ResponseEntity<?> saveUser(
|
||||
@RequestParam(name = "username", required = true) String username,
|
||||
@RequestParam(name = "password", required = false) String password,
|
||||
@RequestParam(name = "role") String role,
|
||||
@@ -221,33 +310,42 @@ public class UserController {
|
||||
boolean forceChange)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!userService.isUsernameValid(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=invalidUsername", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Invalid username format. Username must be 3-50 characters."));
|
||||
}
|
||||
if (applicationProperties.getPremium().isEnabled()
|
||||
&& applicationProperties.getPremium().getMaxUsers()
|
||||
<= userService.getTotalUsersCount()) {
|
||||
return new RedirectView("/adminSettings?messageType=maxUsersReached", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Maximum number of users reached for your license."));
|
||||
}
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
User user = userOpt.get();
|
||||
if (user.getUsername().equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
}
|
||||
}
|
||||
if (userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=usernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(Map.of("error", "Username already exists."));
|
||||
}
|
||||
try {
|
||||
// Validate the role
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
// If the role is INTERNAL_API_USER, reject the request
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// If the role ID is not valid, redirect with an error message
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
// If the role ID is not valid, return error
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified."));
|
||||
}
|
||||
|
||||
// Use teamId if provided, otherwise use default team
|
||||
@@ -263,28 +361,144 @@ public class UserController {
|
||||
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
|
||||
if (selectedTeam != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team."));
|
||||
}
|
||||
}
|
||||
|
||||
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
|
||||
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
|
||||
} else {
|
||||
if (password.isBlank()) {
|
||||
return new RedirectView("/adminSettings?messageType=invalidPassword", true);
|
||||
if (password == null || password.isBlank()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password is required."));
|
||||
}
|
||||
if (password.length() < 6) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Password must be at least 6 characters."));
|
||||
}
|
||||
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
|
||||
}
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
true);
|
||||
return ResponseEntity.ok(Map.of("message", "User created successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/inviteUsers")
|
||||
public ResponseEntity<?> inviteUsers(
|
||||
@RequestParam(name = "emails", required = true) String emails,
|
||||
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
|
||||
// Check if email invites are enabled
|
||||
if (!applicationProperties.getMail().isEnableInvites()) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Email invites are not enabled"));
|
||||
}
|
||||
|
||||
// Check if email service is available
|
||||
if (!emailService.isPresent()) {
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Email service is not configured. Please configure SMTP settings."));
|
||||
}
|
||||
|
||||
// Parse comma-separated email addresses
|
||||
String[] emailArray = emails.split(",");
|
||||
if (emailArray.length == 0) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "At least one email address is required"));
|
||||
}
|
||||
|
||||
// Check license limits
|
||||
if (applicationProperties.getPremium().isEnabled()) {
|
||||
long currentUserCount = userService.getTotalUsersCount();
|
||||
int maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
long availableSlots = maxUsers - currentUserCount;
|
||||
if (availableSlots < emailArray.length) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Not enough user slots available. Available: "
|
||||
+ availableSlots
|
||||
+ ", Requested: "
|
||||
+ emailArray.length));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate role
|
||||
try {
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role"));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified"));
|
||||
}
|
||||
|
||||
// Determine team
|
||||
Long effectiveTeamId = teamId;
|
||||
if (effectiveTeamId == null) {
|
||||
Team defaultTeam =
|
||||
teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
|
||||
if (defaultTeam != null) {
|
||||
effectiveTeamId = defaultTeam.getId();
|
||||
}
|
||||
} else {
|
||||
Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null);
|
||||
if (selectedTeam != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team"));
|
||||
}
|
||||
}
|
||||
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
StringBuilder errors = new StringBuilder();
|
||||
|
||||
// Process each email
|
||||
for (String email : emailArray) {
|
||||
email = email.trim();
|
||||
if (email.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
|
||||
if (result.isSuccess()) {
|
||||
successCount++;
|
||||
} else {
|
||||
failureCount++;
|
||||
errors.append(result.getErrorMessage()).append("; ");
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("successCount", successCount);
|
||||
response.put("failureCount", failureCount);
|
||||
|
||||
if (failureCount > 0) {
|
||||
response.put("errors", errors.toString());
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
response.put("message", successCount + " user(s) invited successfully");
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
response.put("error", "Failed to invite any users");
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
|
||||
}
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/changeRole")
|
||||
@Transactional
|
||||
public RedirectView changeRole(
|
||||
public ResponseEntity<?> changeRole(
|
||||
@RequestParam(name = "username") String username,
|
||||
@RequestParam(name = "role") String role,
|
||||
@RequestParam(name = "teamId", required = false) Long teamId,
|
||||
@@ -292,27 +506,32 @@ public class UserController {
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (!userOpt.isPresent()) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
// Get the currently authenticated username
|
||||
String currentUsername = authentication.getName();
|
||||
// Check if the provided username matches the current session's username
|
||||
if (currentUsername.equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=downgradeCurrentUser", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot change your own role."));
|
||||
}
|
||||
try {
|
||||
// Validate the role
|
||||
Role roleEnum = Role.fromString(role);
|
||||
if (roleEnum == Role.INTERNAL_API_USER) {
|
||||
// If the role is INTERNAL_API_USER, reject the request
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign INTERNAL_API_USER role."));
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
// If the role ID is not valid, redirect with an error message
|
||||
return new RedirectView("/adminSettings?messageType=invalidRole", true);
|
||||
// If the role ID is not valid, return error
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Invalid role specified."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
|
||||
@@ -322,15 +541,15 @@ public class UserController {
|
||||
if (team != null) {
|
||||
// Prevent assigning to Internal team
|
||||
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=internalTeamNotAccessible", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot assign users to Internal team."));
|
||||
}
|
||||
|
||||
// Prevent moving users from Internal team
|
||||
if (user.getTeam() != null
|
||||
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
|
||||
return new RedirectView(
|
||||
"/adminSettings?messageType=cannotMoveInternalUsers", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot move users from Internal team."));
|
||||
}
|
||||
|
||||
user.setTeam(team);
|
||||
@@ -339,30 +558,31 @@ public class UserController {
|
||||
}
|
||||
|
||||
userService.changeRole(user, role);
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
true);
|
||||
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/changeUserEnabled/{username}")
|
||||
public RedirectView changeUserEnabled(
|
||||
public ResponseEntity<?> changeUserEnabled(
|
||||
@PathVariable("username") String username,
|
||||
@RequestParam("enabled") boolean enabled,
|
||||
Authentication authentication)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isEmpty()) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=userNotFound", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
// Get the currently authenticated username
|
||||
String currentUsername = authentication.getName();
|
||||
// Check if the provided username matches the current session's username
|
||||
if (currentUsername.equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=disabledCurrentUser", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot disable your own account."));
|
||||
}
|
||||
User user = userOpt.get();
|
||||
userService.changeUserEnabled(user, enabled);
|
||||
@@ -389,23 +609,24 @@ public class UserController {
|
||||
}
|
||||
}
|
||||
}
|
||||
return new RedirectView(
|
||||
"/adminSettings", // Redirect to account page after adding the user
|
||||
true);
|
||||
return ResponseEntity.ok(
|
||||
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@PostMapping("/admin/deleteUser/{username}")
|
||||
public RedirectView deleteUser(
|
||||
public ResponseEntity<?> deleteUser(
|
||||
@PathVariable("username") String username, Authentication authentication) {
|
||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=deleteUsernameExists", true);
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "User not found."));
|
||||
}
|
||||
// Get the currently authenticated username
|
||||
String currentUsername = authentication.getName();
|
||||
// Check if the provided username matches the current session's username
|
||||
if (currentUsername.equalsIgnoreCase(username)) {
|
||||
return new RedirectView("/adminSettings?messageType=deleteCurrentUser", true);
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("error", "Cannot delete your own account."));
|
||||
}
|
||||
// Invalidate all sessions before deleting the user
|
||||
List<SessionInformation> sessionsInformations =
|
||||
@@ -415,7 +636,7 @@ public class UserController {
|
||||
sessionRegistry.removeSessionInformation(sessionsInformation.getSessionId());
|
||||
}
|
||||
userService.deleteUser(username);
|
||||
return new RedirectView("/adminSettings", true);
|
||||
return ResponseEntity.ok(Map.of("message", "User deleted successfully"));
|
||||
}
|
||||
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
@@ -446,4 +667,73 @@ public class UserController {
|
||||
}
|
||||
return ResponseEntity.ok(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to process a single email invitation.
|
||||
*
|
||||
* @param email The email address to invite
|
||||
* @param teamId The team ID to assign the user to
|
||||
* @param role The role to assign to the user
|
||||
* @return InviteResult containing success status and optional error message
|
||||
*/
|
||||
private InviteResult processEmailInvite(String email, Long teamId, String role) {
|
||||
try {
|
||||
// Validate email format (basic check)
|
||||
if (!email.contains("@") || !email.contains(".")) {
|
||||
return InviteResult.failure(email + ": Invalid email format");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
if (userService.usernameExistsIgnoreCase(email)) {
|
||||
return InviteResult.failure(email + ": User already exists");
|
||||
}
|
||||
|
||||
// Generate random password
|
||||
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
|
||||
|
||||
// Create user with forceChange=true
|
||||
userService.saveUser(email, temporaryPassword, teamId, role, true);
|
||||
|
||||
// Send invite email
|
||||
try {
|
||||
emailService.get().sendInviteEmail(email, email, temporaryPassword);
|
||||
log.info("Sent invite email to: {}", email);
|
||||
return InviteResult.success();
|
||||
} catch (Exception emailEx) {
|
||||
log.error("Failed to send invite email to {}: {}", email, emailEx.getMessage());
|
||||
return InviteResult.failure(email + ": User created but email failed to send");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to invite user {}: {}", email, e.getMessage());
|
||||
return InviteResult.failure(email + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Result object for individual email invite processing. */
|
||||
private static class InviteResult {
|
||||
private final boolean success;
|
||||
private final String errorMessage;
|
||||
|
||||
private InviteResult(boolean success, String errorMessage) {
|
||||
this.success = success;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
static InviteResult success() {
|
||||
return new InviteResult(true, null);
|
||||
}
|
||||
|
||||
static InviteResult failure(String errorMessage) {
|
||||
return new InviteResult(false, errorMessage);
|
||||
}
|
||||
|
||||
boolean isSuccess() {
|
||||
return success;
|
||||
}
|
||||
|
||||
String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
|
||||
|
||||
Optional<User> findByApiKey(String apiKey);
|
||||
|
||||
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
|
||||
|
||||
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
|
||||
|
||||
@Query("SELECT u FROM User u WHERE u.team IS NULL")
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
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 jakarta.servlet.http.HttpSession;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class FirstLoginFilter extends OncePerRequestFilter {
|
||||
|
||||
@Lazy private final UserService userService;
|
||||
|
||||
public FirstLoginFilter(@Lazy UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
String method = request.getMethod();
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
// Check if the request is for static resources
|
||||
boolean isStaticResource = RequestUriUtils.isStaticResource(contextPath, requestURI);
|
||||
// If it's a static resource, just continue the filter chain and skip the logic below
|
||||
if (isStaticResource) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
Optional<User> user = userService.findByUsernameIgnoreCase(authentication.getName());
|
||||
if ("GET".equalsIgnoreCase(method)
|
||||
&& user.isPresent()
|
||||
&& user.get().isFirstLogin()
|
||||
&& !(contextPath + "/change-creds").equals(requestURI)) {
|
||||
response.sendRedirect(contextPath + "/change-creds");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (log.isDebugEnabled()) {
|
||||
HttpSession session = request.getSession(true);
|
||||
DateTimeFormatter timeFormat = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
String creationTime =
|
||||
timeFormat.format(
|
||||
Instant.ofEpochMilli(session.getCreationTime())
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalTime());
|
||||
log.debug(
|
||||
"Request Info - New: {}, creationTimeSession {}, ID: {}, IP: {}, User-Agent: {}, Referer: {}, Request URL: {}",
|
||||
session.isNew(),
|
||||
creationTime,
|
||||
session.getId(),
|
||||
request.getRemoteAddr(),
|
||||
request.getHeader("User-Agent"),
|
||||
request.getHeader("Referer"),
|
||||
request.getRequestURL().toString());
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
+45
-8
@@ -1,8 +1,9 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.*;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
|
||||
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.SQLException;
|
||||
@@ -75,29 +76,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
String jwtToken = jwtService.extractToken(request);
|
||||
|
||||
if (jwtToken == null) {
|
||||
// Any unauthenticated requests should redirect to /login
|
||||
// Allow specific auth endpoints to pass through without JWT
|
||||
String requestURI = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
|
||||
if (!requestURI.startsWith(contextPath + "/login")) {
|
||||
response.sendRedirect("/login");
|
||||
// Public auth endpoints that don't require JWT
|
||||
boolean isPublicAuthEndpoint =
|
||||
requestURI.startsWith(contextPath + "/login")
|
||||
|| requestURI.startsWith(contextPath + "/signup")
|
||||
|| requestURI.startsWith(contextPath + "/auth/")
|
||||
|| requestURI.startsWith(contextPath + "/oauth2")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|
||||
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh");
|
||||
|
||||
if (!isPublicAuthEndpoint) {
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null
|
||||
&& acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// For public auth endpoints without JWT, continue to the endpoint
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Validating JWT token");
|
||||
jwtService.validateToken(jwtToken);
|
||||
log.debug("JWT token validated successfully");
|
||||
} catch (AuthenticationFailureException e) {
|
||||
jwtService.clearToken(response);
|
||||
log.warn("JWT validation failed: {}", e.getMessage());
|
||||
handleAuthenticationFailure(request, response, e);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
|
||||
String tokenUsername = claims.get("sub").toString();
|
||||
log.debug("JWT token username: {}", tokenUsername);
|
||||
|
||||
try {
|
||||
authenticate(request, claims);
|
||||
log.debug("Authentication successful for user: {}", tokenUsername);
|
||||
} catch (SQLException | UnsupportedProviderException e) {
|
||||
log.error("Error processing user authentication for user: {}", tokenUsername, e);
|
||||
handleAuthenticationFailure(
|
||||
@@ -175,21 +207,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private void processUserAuthenticationType(Map<String, Object> claims, String username)
|
||||
throws SQLException, UnsupportedProviderException {
|
||||
AuthenticationType authenticationType =
|
||||
AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString());
|
||||
AuthenticationType.valueOf(
|
||||
claims.getOrDefault("authType", WEB).toString().toUpperCase());
|
||||
log.debug("Processing {} login for {} user", authenticationType, username);
|
||||
|
||||
switch (authenticationType) {
|
||||
case OAUTH2 -> {
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Properties =
|
||||
securityProperties.getOauth2();
|
||||
// Provider IDs should already be set during initial authentication
|
||||
// Pass null here since this is validating an existing JWT token
|
||||
userService.processSSOPostLogin(
|
||||
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
}
|
||||
case SAML2 -> {
|
||||
ApplicationProperties.Security.SAML2 saml2Properties =
|
||||
securityProperties.getSaml2();
|
||||
// Provider IDs should already be set during initial authentication
|
||||
// Pass null here since this is validating an existing JWT token
|
||||
userService.processSSOPostLogin(
|
||||
username, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
username, null, null, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -236,6 +236,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
contextPath + "/pdfjs/",
|
||||
contextPath + "/pdfjs-legacy/",
|
||||
contextPath + "/api/v1/info/status",
|
||||
contextPath + "/api/v1/auth/login",
|
||||
contextPath + "/api/v1/auth/register",
|
||||
contextPath + "/api/v1/auth/refresh",
|
||||
contextPath + "/api/v1/auth/me",
|
||||
contextPath + "/site.webmanifest"
|
||||
};
|
||||
|
||||
|
||||
+18
-1
@@ -1,12 +1,15 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
@@ -59,12 +62,17 @@ public class User implements UserDetails, Serializable {
|
||||
@Column(name = "authenticationtype")
|
||||
private String authenticationType;
|
||||
|
||||
@Column(name = "sso_provider_id")
|
||||
private String ssoProviderId;
|
||||
|
||||
@Column(name = "sso_provider")
|
||||
private String ssoProvider;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
private Set<Authority> authorities = new HashSet<>();
|
||||
|
||||
@ManyToOne(fetch = FetchType.EAGER)
|
||||
@JoinColumn(name = "team_id")
|
||||
@JsonIgnore
|
||||
private Team team;
|
||||
|
||||
@ElementCollection
|
||||
@@ -72,8 +80,17 @@ public class User implements UserDetails, Serializable {
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public String getRoleName() {
|
||||
return Role.getRoleNameByRoleId(getRolesAsString());
|
||||
}
|
||||
|
||||
+85
-9
@@ -10,6 +10,7 @@ import java.util.Map;
|
||||
import org.springframework.security.authentication.LockedException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
|
||||
import org.springframework.security.web.savedrequest.SavedRequest;
|
||||
@@ -76,12 +77,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
throw new LockedException(
|
||||
"Your account has been locked due to too many failed login attempts.");
|
||||
}
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
jwtService.addToken(response, jwt);
|
||||
}
|
||||
if (userService.isUserDisabled(username)) {
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/logout?userIsDisabled=true");
|
||||
@@ -102,14 +97,95 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
|
||||
return;
|
||||
}
|
||||
if (principal instanceof OAuth2User) {
|
||||
if (principal instanceof OAuth2User oAuth2User) {
|
||||
// Extract SSO provider information from OAuth2User
|
||||
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
|
||||
// Extract provider from authentication - need to get it from the token/request
|
||||
// For now, we'll extract it in a more generic way
|
||||
String ssoProvider = extractProviderFromAuthentication(authentication);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
oauth2Properties.getAutoCreateUser(),
|
||||
OAUTH2);
|
||||
}
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
response.sendRedirect(contextPath + "/");
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the OAuth2 provider registration ID from the authentication object.
|
||||
*
|
||||
* @param authentication The authentication object
|
||||
* @return The provider registration ID (e.g., "google", "github"), or null if not available
|
||||
*/
|
||||
private String extractProviderFromAuthentication(Authentication authentication) {
|
||||
if (authentication instanceof OAuth2AuthenticationToken oauth2Token) {
|
||||
return oauth2Token.getAuthorizedClientRegistrationId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
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();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
// Fall back to other methods if referer is malformed
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin.toString() + "/auth/callback#access_token=" + jwt;
|
||||
}
|
||||
}
|
||||
|
||||
+29
-8
@@ -10,7 +10,6 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnBooleanProperty("security.oauth2.enabled")
|
||||
@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true")
|
||||
public class OAuth2Configuration {
|
||||
|
||||
public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/";
|
||||
@@ -53,6 +52,9 @@ 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
|
||||
@@ -75,7 +77,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> keycloakClientRegistration() {
|
||||
OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) {
|
||||
if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> googleClientRegistration() {
|
||||
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) {
|
||||
if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -138,12 +140,23 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> githubClientRegistration() {
|
||||
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oAuth2)) {
|
||||
if (isOAuth2Disabled(oAuth2)) {
|
||||
log.debug("OAuth2 is disabled, skipping GitHub client registration");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Client client = oAuth2.getClient();
|
||||
if (client == null) {
|
||||
log.debug("OAuth2 client configuration is null, skipping GitHub");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
GitHubProvider githubClient = client.getGithub();
|
||||
if (githubClient == null) {
|
||||
log.debug("GitHub client configuration is null");
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Provider github =
|
||||
new GitHubProvider(
|
||||
githubClient.getClientId(),
|
||||
@@ -151,7 +164,15 @@ public class OAuth2Configuration {
|
||||
githubClient.getScopes(),
|
||||
githubClient.getUseAsUsername());
|
||||
|
||||
return validateProvider(github)
|
||||
boolean isValid = validateProvider(github);
|
||||
log.info(
|
||||
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
|
||||
isValid,
|
||||
githubClient.getClientId(),
|
||||
githubClient.getClientSecret() != null ? "***" : "null",
|
||||
githubClient.getScopes());
|
||||
|
||||
return isValid
|
||||
? Optional.of(
|
||||
ClientRegistration.withRegistrationId(github.getName())
|
||||
.clientId(github.getClientId())
|
||||
@@ -171,7 +192,7 @@ public class OAuth2Configuration {
|
||||
private Optional<ClientRegistration> oidcClientRegistration() {
|
||||
OAUTH2 oauth = applicationProperties.getSecurity().getOauth2();
|
||||
|
||||
if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) {
|
||||
if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -207,7 +228,7 @@ public class OAuth2Configuration {
|
||||
: Optional.empty();
|
||||
}
|
||||
|
||||
private boolean isOAuth2Enabled(OAUTH2 oAuth2) {
|
||||
private boolean isOAuth2Disabled(OAUTH2 oAuth2) {
|
||||
return oAuth2 == null || !oAuth2.getEnabled();
|
||||
}
|
||||
|
||||
|
||||
+74
-10
@@ -120,13 +120,41 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
|
||||
return;
|
||||
}
|
||||
log.debug("Processing SSO post-login for user: {}", username);
|
||||
|
||||
// Extract SSO provider information from SAML2 assertion
|
||||
String ssoProviderId = saml2Principal.nameId();
|
||||
String ssoProvider = "saml2"; // fixme
|
||||
|
||||
log.debug(
|
||||
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
|
||||
username,
|
||||
ssoProvider,
|
||||
ssoProviderId);
|
||||
|
||||
userService.processSSOPostLogin(
|
||||
username, saml2Properties.getAutoCreateUser(), SAML2);
|
||||
username,
|
||||
ssoProviderId,
|
||||
ssoProvider,
|
||||
saml2Properties.getAutoCreateUser(),
|
||||
SAML2);
|
||||
log.debug("Successfully processed authentication for user: {}", username);
|
||||
|
||||
generateJwt(response, authentication);
|
||||
response.sendRedirect(contextPath + "/");
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
buildContextAwareRedirectUrl(request, contextPath, jwt);
|
||||
|
||||
response.sendRedirect(redirectUrl);
|
||||
} else {
|
||||
// v1: redirect directly to home
|
||||
response.sendRedirect(contextPath + "/");
|
||||
}
|
||||
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
|
||||
log.debug(
|
||||
"Invalid username detected for user: {}, redirecting to logout",
|
||||
@@ -140,12 +168,48 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
}
|
||||
|
||||
private void generateJwt(HttpServletResponse response, Authentication authentication) {
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.SAML2));
|
||||
jwtService.addToken(response, jwt);
|
||||
/**
|
||||
* Builds a context-aware redirect URL based on the request's origin
|
||||
*
|
||||
* @param request The HTTP request
|
||||
* @param contextPath The application context path
|
||||
* @param jwt The JWT token to include
|
||||
* @return The appropriate redirect URL
|
||||
*/
|
||||
private String buildContextAwareRedirectUrl(
|
||||
HttpServletRequest request, String contextPath, String jwt) {
|
||||
// Try to get the origin from the Referer header first
|
||||
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();
|
||||
}
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
} catch (java.net.MalformedURLException e) {
|
||||
log.debug(
|
||||
"Malformed referer URL: {}, falling back to request-based origin", referer);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to building from request host/port
|
||||
String scheme = request.getScheme();
|
||||
String serverName = request.getServerName();
|
||||
int serverPort = request.getServerPort();
|
||||
|
||||
StringBuilder origin = new StringBuilder();
|
||||
origin.append(scheme).append("://").append(serverName);
|
||||
|
||||
// Only add port if it's not the default port for the scheme
|
||||
if ((!"http".equals(scheme) || serverPort != 80)
|
||||
&& (!"https".equals(scheme) || serverPort != 443)) {
|
||||
origin.append(":").append(serverPort);
|
||||
}
|
||||
|
||||
return origin + "/auth/callback#access_token=" + jwt;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-11
@@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
|
||||
import stirling.software.common.model.enumeration.UsernameAttribute;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@@ -27,13 +26,13 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
|
||||
|
||||
public CustomOAuth2UserService(
|
||||
ApplicationProperties.Security securityProperties,
|
||||
ApplicationProperties.Security.OAUTH2 oauth2Properties,
|
||||
UserService userService,
|
||||
LoginAttemptService loginAttemptService) {
|
||||
this.securityProperties = securityProperties;
|
||||
this.oauth2Properties = oauth2Properties;
|
||||
this.userService = userService;
|
||||
this.loginAttemptService = loginAttemptService;
|
||||
}
|
||||
@@ -42,14 +41,22 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
|
||||
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
|
||||
try {
|
||||
OidcUser user = delegate.loadUser(userRequest);
|
||||
OAUTH2 oauth2 = securityProperties.getOauth2();
|
||||
UsernameAttribute usernameAttribute =
|
||||
UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
|
||||
String usernameAttributeKey = usernameAttribute.getName();
|
||||
String usernameAttributeKey =
|
||||
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
|
||||
.getName();
|
||||
|
||||
// todo: save user by OIDC ID instead of username
|
||||
Optional<User> internalUser =
|
||||
userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey));
|
||||
// Extract SSO provider information
|
||||
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
|
||||
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
|
||||
String username = user.getAttribute(usernameAttributeKey);
|
||||
|
||||
log.debug(
|
||||
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
|
||||
ssoProvider,
|
||||
ssoProviderId,
|
||||
username);
|
||||
|
||||
Optional<User> internalUser = userService.findByUsernameIgnoreCase(username);
|
||||
|
||||
if (internalUser.isPresent()) {
|
||||
String internalUsername = internalUser.get().getUsername();
|
||||
|
||||
+86
@@ -73,4 +73,90 @@ public class EmailService {
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a plain text/HTML email without attachments asynchronously.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param subject The email subject
|
||||
* @param body The email body (can contain HTML)
|
||||
* @param isHtml Whether the body contains HTML content
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendPlainEmail(String to, String subject, String body, boolean isHtml)
|
||||
throws MessagingException {
|
||||
// Validate recipient email address
|
||||
if (to == null || to.trim().isEmpty()) {
|
||||
throw new MessagingException("Invalid recipient email address");
|
||||
}
|
||||
|
||||
ApplicationProperties.Mail mailProperties = applicationProperties.getMail();
|
||||
|
||||
// Creates a MimeMessage to represent the email
|
||||
MimeMessage message = mailSender.createMimeMessage();
|
||||
|
||||
// Helper class to set up the message content
|
||||
MimeMessageHelper helper = new MimeMessageHelper(message, false);
|
||||
|
||||
// Sets the recipient, subject, body, and sender email
|
||||
helper.addTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(body, isHtml);
|
||||
helper.setFrom(mailProperties.getFrom());
|
||||
|
||||
// Sends the email via the configured mail sender
|
||||
mailSender.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an invitation email to a new user with their credentials.
|
||||
*
|
||||
* @param to The recipient email address
|
||||
* @param username The username for the new account
|
||||
* @param temporaryPassword The temporary password
|
||||
* @throws MessagingException If there is an issue with creating or sending the email.
|
||||
*/
|
||||
@Async
|
||||
public void sendInviteEmail(String to, String username, String temporaryPassword)
|
||||
throws MessagingException {
|
||||
String subject = "Welcome to Stirling PDF";
|
||||
|
||||
String body =
|
||||
"""
|
||||
<html><body style="margin: 0; padding: 0;">
|
||||
<div style="font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;">
|
||||
<div style="max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;">
|
||||
<!-- Logo -->
|
||||
<div style="text-align: center; padding: 20px; background-color: #222;">
|
||||
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg" alt="Stirling PDF" style="max-height: 60px;">
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div style="padding: 30px; color: #333;">
|
||||
<h2 style="color: #222; margin-top: 0;">Welcome to Stirling PDF!</h2>
|
||||
<p>Hi there,</p>
|
||||
<p>You have been invited to join the workspace. Below are your login credentials:</p>
|
||||
<!-- Credentials Box -->
|
||||
<div style="background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0 0 10px 0;"><strong>Username:</strong> %s</p>
|
||||
<p style="margin: 0;"><strong>Temporary Password:</strong> %s</p>
|
||||
</div>
|
||||
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
|
||||
</div>
|
||||
<p>Please keep these credentials secure and do not share them with anyone.</p>
|
||||
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div style="text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;">
|
||||
© 2025 Stirling PDF. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
.formatted(username, temporaryPassword);
|
||||
|
||||
sendPlainEmail(to, subject, body, true);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-46
@@ -14,14 +14,11 @@ import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import io.github.pixee.security.Newlines;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -29,9 +26,7 @@ import io.jsonwebtoken.MalformedJwtException;
|
||||
import io.jsonwebtoken.UnsupportedJwtException;
|
||||
import io.jsonwebtoken.security.SignatureException;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
@Service
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String JWT_COOKIE_NAME = "stirling_jwt";
|
||||
private static final String ISSUER = "Stirling PDF";
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 3600000;
|
||||
|
||||
@Value("${stirling.security.jwt.secureCookie:true}")
|
||||
private boolean secureCookie;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
@@ -59,6 +50,7 @@ public class JwtService implements JwtServiceInterface {
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
log.info("JwtService initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -260,47 +252,18 @@ public class JwtService implements JwtServiceInterface {
|
||||
|
||||
@Override
|
||||
public String extractToken(HttpServletRequest request) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
|
||||
if (cookies != null) {
|
||||
for (Cookie cookie : cookies) {
|
||||
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
|
||||
return cookie.getValue();
|
||||
}
|
||||
}
|
||||
// Extract from Authorization header Bearer token
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
log.debug("JWT token extracted from Authorization header");
|
||||
return token;
|
||||
}
|
||||
|
||||
log.debug("No JWT token found in Authorization header");
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addToken(HttpServletResponse response, String token) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite("Strict")
|
||||
.maxAge(EXPIRATION / 1000)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearToken(HttpServletResponse response) {
|
||||
ResponseCookie cookie =
|
||||
ResponseCookie.from(JWT_COOKIE_NAME, "")
|
||||
.httpOnly(true)
|
||||
.secure(secureCookie)
|
||||
.sameSite("None")
|
||||
.maxAge(0)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
response.addHeader("Set-Cookie", cookie.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isJwtEnabled() {
|
||||
return v2Enabled;
|
||||
|
||||
-16
@@ -5,7 +5,6 @@ import java.util.Map;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
public interface JwtServiceInterface {
|
||||
|
||||
@@ -66,21 +65,6 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractToken(HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* Add JWT token to HTTP response (header and cookie)
|
||||
*
|
||||
* @param response HTTP servlet response
|
||||
* @param token JWT token to add
|
||||
*/
|
||||
void addToken(HttpServletResponse response, String token);
|
||||
|
||||
/**
|
||||
* Clear JWT token from HTTP response (remove cookie)
|
||||
*
|
||||
* @param response HTTP servlet response
|
||||
*/
|
||||
void clearToken(HttpServletResponse response);
|
||||
|
||||
/**
|
||||
* Check if JWT authentication is enabled
|
||||
*
|
||||
|
||||
+105
-4
@@ -61,19 +61,46 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
// Handle OAUTH2 login and user auto creation.
|
||||
public void processSSOPostLogin(
|
||||
String username, boolean autoCreateUser, AuthenticationType type)
|
||||
String username,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
boolean autoCreateUser,
|
||||
AuthenticationType type)
|
||||
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
|
||||
if (!isUsernameValid(username)) {
|
||||
return;
|
||||
}
|
||||
Optional<User> existingUser = findByUsernameIgnoreCase(username);
|
||||
|
||||
// Find user by SSO provider ID first
|
||||
Optional<User> existingUser;
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
existingUser =
|
||||
userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId);
|
||||
|
||||
if (existingUser.isPresent()) {
|
||||
log.debug("User found by SSO provider ID: {}", ssoProviderId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
existingUser = findByUsernameIgnoreCase(username);
|
||||
if (existingUser.isPresent()) {
|
||||
User user = existingUser.get();
|
||||
|
||||
// Migrate existing user to use provider ID if not already set
|
||||
if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) {
|
||||
log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId);
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
userRepository.save(user);
|
||||
databaseService.exportDatabase();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoCreateUser) {
|
||||
saveUser(username, type);
|
||||
saveUser(username, ssoProviderId, ssoProvider, type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +182,21 @@ public class UserService implements UserServiceInterface {
|
||||
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);
|
||||
@@ -169,6 +211,30 @@ public class UserService implements UserServiceInterface {
|
||||
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
|
||||
@@ -184,6 +250,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
null, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
authenticationType, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
@@ -198,6 +266,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -213,6 +283,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
null, // teamId
|
||||
team, // team
|
||||
@@ -228,6 +300,8 @@ public class UserService implements UserServiceInterface {
|
||||
return saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -248,6 +322,8 @@ public class UserService implements UserServiceInterface {
|
||||
saveUserCore(
|
||||
username, // username
|
||||
password, // password
|
||||
null, // ssoProviderId
|
||||
null, // ssoProvider
|
||||
AuthenticationType.WEB, // authenticationType
|
||||
teamId, // teamId
|
||||
null, // team
|
||||
@@ -412,6 +488,8 @@ public class UserService implements UserServiceInterface {
|
||||
*
|
||||
* @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)
|
||||
@@ -426,6 +504,8 @@ public class UserService implements UserServiceInterface {
|
||||
private User saveUserCore(
|
||||
String username,
|
||||
String password,
|
||||
String ssoProviderId,
|
||||
String ssoProvider,
|
||||
AuthenticationType authenticationType,
|
||||
Long teamId,
|
||||
Team team,
|
||||
@@ -446,6 +526,12 @@ public class UserService implements UserServiceInterface {
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
}
|
||||
|
||||
// Set SSO provider details if provided
|
||||
if (ssoProviderId != null && ssoProvider != null) {
|
||||
user.setSsoProviderId(ssoProviderId);
|
||||
user.setSsoProvider(ssoProvider);
|
||||
}
|
||||
|
||||
// Set authentication type
|
||||
user.setAuthenticationType(authenticationType);
|
||||
|
||||
@@ -562,6 +648,21 @@ public class UserService implements UserServiceInterface {
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean isCurrentUserAdmin() {
|
||||
try {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getPrincipal())) {
|
||||
return authentication.getAuthorities().stream()
|
||||
.anyMatch(auth -> Role.ADMIN.getRoleId().equals(auth.getAuthority()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error checking admin status", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void syncCustomApiUser(String customApiKey) {
|
||||
if (customApiKey == null || customApiKey.trim().isBlank()) {
|
||||
|
||||
+34
-1
@@ -30,6 +30,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -51,6 +53,12 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
public ServerCertificateService(LicenseKeyChecker licenseKeyChecker) {
|
||||
this.licenseKeyChecker = licenseKeyChecker;
|
||||
}
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
@@ -59,8 +67,13 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
private boolean hasProOrEnterpriseAccess() {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
return license == License.PRO || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
return enabled && hasProOrEnterpriseAccess();
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
@@ -73,6 +86,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
log.info("Server certificate feature requires Pro or Enterprise license");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
@@ -88,6 +106,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
@@ -114,6 +137,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
@@ -174,6 +202,11 @@ public class ServerCertificateService implements ServerCertificateServiceInterfa
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
if (!hasProOrEnterpriseAccess()) {
|
||||
throw new IllegalStateException(
|
||||
"Server certificate feature requires Pro or Enterprise license");
|
||||
}
|
||||
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
|
||||
+3
-4
@@ -1,6 +1,8 @@
|
||||
package stirling.software.proprietary.security;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
doNothing().when(jwtService).clearToken(response);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
@@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest {
|
||||
|
||||
when(response.isCommitted()).thenReturn(false);
|
||||
when(jwtService.extractToken(request)).thenReturn(token);
|
||||
doNothing().when(jwtService).clearToken(response);
|
||||
when(request.getContextPath()).thenReturn("");
|
||||
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
|
||||
|
||||
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
|
||||
|
||||
verify(response).sendRedirect(logoutPath);
|
||||
verify(jwtService).clearToken(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-1
@@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest {
|
||||
.setAuthentication(any(UsernamePasswordAuthenticationToken.class));
|
||||
verify(jwtService)
|
||||
.generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims));
|
||||
verify(jwtService).addToken(response, newToken);
|
||||
verify(filterChain).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-62
@@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.atLeast;
|
||||
import static org.mockito.Mockito.contains;
|
||||
import static org.mockito.Mockito.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -17,7 +15,6 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
@@ -27,13 +24,10 @@ import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@@ -59,7 +53,7 @@ class JwtServiceTest {
|
||||
private JwtVerificationKey testVerificationKey;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchAlgorithmException {
|
||||
void setUp() throws Exception {
|
||||
// Generate a test keypair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
|
||||
keyPairGenerator.initialize(2048);
|
||||
@@ -224,7 +218,8 @@ class JwtServiceTest {
|
||||
assertEquals("admin", extractedClaims.get("role"));
|
||||
assertEquals("IT", extractedClaims.get("department"));
|
||||
assertEquals(username, extractedClaims.get("sub"));
|
||||
assertEquals("Stirling PDF", extractedClaims.get("iss"));
|
||||
// Verify the constant issuer is set correctly
|
||||
assertEquals("https://stirling.com", extractedClaims.get("iss"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -239,62 +234,27 @@ class JwtServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithCookie() {
|
||||
void testExtractTokenWithAuthorizationHeader() {
|
||||
String token = "test-token";
|
||||
Cookie[] cookies = {new Cookie("stirling_jwt", token)};
|
||||
when(request.getCookies()).thenReturn(cookies);
|
||||
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
|
||||
|
||||
assertEquals(token, jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithNoCookies() {
|
||||
when(request.getCookies()).thenReturn(null);
|
||||
void testExtractTokenWithNoAuthorizationHeader() {
|
||||
when(request.getHeader("Authorization")).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithWrongCookie() {
|
||||
Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")};
|
||||
when(request.getCookies()).thenReturn(cookies);
|
||||
void testExtractTokenWithInvalidAuthorizationHeaderFormat() {
|
||||
when(request.getHeader("Authorization")).thenReturn("InvalidFormat token");
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTokenWithInvalidAuthorizationHeader() {
|
||||
when(request.getCookies()).thenReturn(null);
|
||||
|
||||
assertNull(jwtService.extractToken(request));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(booleans = {true, false})
|
||||
void testAddToken(boolean secureCookie) throws Exception {
|
||||
String token = "test-token";
|
||||
|
||||
// Create new JwtService instance with the secureCookie parameter
|
||||
JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie);
|
||||
|
||||
testJwtService.addToken(response, token);
|
||||
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token));
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly"));
|
||||
|
||||
if (secureCookie) {
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testClearToken() {
|
||||
jwtService.clearToken(response);
|
||||
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt="));
|
||||
verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateTokenWithKeyId() throws Exception {
|
||||
String username = "testuser";
|
||||
@@ -373,17 +333,4 @@ class JwtServiceTest {
|
||||
// Verify fallback logic was used
|
||||
verify(keystoreService, atLeast(1)).getActiveKey();
|
||||
}
|
||||
|
||||
private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception {
|
||||
// Use reflection to create JwtService with custom secureCookie value
|
||||
JwtService testService = new JwtService(true, keystoreService);
|
||||
|
||||
// Set the secureCookie field using reflection
|
||||
java.lang.reflect.Field secureCookieField =
|
||||
JwtService.class.getDeclaredField("secureCookie");
|
||||
secureCookieField.setAccessible(true);
|
||||
secureCookieField.set(testService, secureCookie);
|
||||
|
||||
return testService;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user