diff --git a/build.gradle b/build.gradle index 13f187adf..5656ac888 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,12 @@ java { repositories { mavenCentral() maven { url "https://jitpack.io" } + maven { + url "https://build.shibboleth.net/nexus/content/repositories/releases/" + } + maven { + url "https://build.shibboleth.net/maven/releases/" + } } licenseReport { @@ -135,6 +141,8 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion" implementation "org.springframework.boot:spring-boot-starter-oauth2-client:$springBootVersion" + implementation 'org.springframework.security:spring-security-saml2-service-provider:6.3.3' + implementation 'com.unboundid.product.scim2:scim2-sdk-client:2.3.5' //2.2.x requires rebuild of DB file.. need migration path runtimeOnly "com.h2database:h2:2.1.214" // implementation "com.h2database:h2:2.2.224" diff --git a/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java b/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java index 4769d446b..5b1b40702 100644 --- a/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java +++ b/src/main/java/stirling/software/SPDF/EE/EEAppConfig.java @@ -17,9 +17,8 @@ public class EEAppConfig { @Autowired ApplicationProperties applicationProperties; - @Autowired - private LicenseKeyChecker licenseKeyChecker; - + @Autowired private LicenseKeyChecker licenseKeyChecker; + @Bean(name = "runningEE") public boolean runningEnterpriseEdition() { return licenseKeyChecker.getEnterpriseEnabledResult(); diff --git a/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java b/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java index 737dbd1cd..3d9acdb21 100644 --- a/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java +++ b/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java @@ -4,11 +4,6 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.security.KeyFactory; -import java.security.PublicKey; -import java.security.Signature; -import java.security.spec.X509EncodedKeySpec; -import java.util.Base64; import org.springframework.stereotype.Service; @@ -26,26 +21,40 @@ public class KeygenLicenseVerifier { private static final String BASE_URL = "https://api.keygen.sh/v1/accounts"; private static final ObjectMapper objectMapper = new ObjectMapper(); + // 23:26:20.344 [scheduling-1] INFO s.s.SPDF.EE.KeygenLicenseVerifier - + // validateLicenseResponse body: + // {"data":{"id":"808ed3c9-584b-46dd-8a80-c9217ef70915","type":"licenses","attributes":{"name":"userCounTest","key":"A7EW-KUPF-PRML-RRVL-HLMP-7THR-F7KE-XF4C","expiry":"2024-10-31T21:39:49.271Z","status":"ACTIVE","uses":0,"suspended":false,"scheme":null,"encrypted":false,"strict":true,"floating":true,"protected":true,"version":null,"maxMachines":1,"maxProcesses":null,"maxUsers":null,"maxCores":null,"maxUses":null,"requireHeartbeat":false,"requireCheckIn":false,"lastValidated":"2024-10-01T22:26:18.121Z","lastCheckIn":null,"nextCheckIn":null,"lastCheckOut":null,"metadata":{"users":10},"created":"2024-10-01T21:39:49.268Z","updated":"2024-10-01T21:39:49.268Z"},"relationships":{"account":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372"},"data":{"type":"accounts","id":"e5430f69-e834-4ae4-befd-b602aae5f372"}},"environment":{"links":{"related":null},"data":null},"product":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/product"},"data":{"type":"products","id":"f9bb2423-62c9-4d39-8def-4fdc5aca751e"}},"policy":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/policy"},"data":{"type":"policies","id":"04caef06-9ac2-4084-bf3c-bca4a0d29143"}},"group":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/group"},"data":null},"owner":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/owner"},"data":null},"users":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/users"},"meta":{"count":0}},"machines":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/machines"},"meta":{"cores":0,"count":0}},"tokens":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/tokens"}},"entitlements":{"links":{"related":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915/entitlements"}}},"links":{"self":"/v1/accounts/e5430f69-e834-4ae4-befd-b602aae5f372/licenses/808ed3c9-584b-46dd-8a80-c9217ef70915"}},"meta":{"ts":"2024-10-01T22:26:18.124Z","valid":false,"detail":"fingerprint is not activated (has no associated machines)","code":"NO_MACHINES","scope":{"fingerprint":"example-fingerprint"}}} + public boolean verifyLicense(String licenseKey) { try { + log.info("Checking license key"); String machineFingerprint = generateMachineFingerprint(); // First, try to validate the license JsonNode validationResponse = validateLicense(licenseKey, machineFingerprint); - log.debug(validationResponse.asText()); + log.info(validationResponse.asText()); if (validationResponse != null) { boolean isValid = validationResponse.path("meta").path("valid").asBoolean(); String licenseId = validationResponse.path("data").path("id").asText(); if (!isValid) { String code = validationResponse.path("meta").path("code").asText(); log.debug(code); - if ("NO_MACHINE".equals(code) || "NO_MACHINES".equals(code) || "FINGERPRINT_SCOPE_MISMATCH".equals(code)) { - log.info("License not activated for this machine. Attempting to activate..."); - boolean activated = activateMachine(licenseKey, licenseId, machineFingerprint); + if ("NO_MACHINE".equals(code) + || "NO_MACHINES".equals(code) + || "FINGERPRINT_SCOPE_MISMATCH".equals(code)) { + log.info( + "License not activated for this machine. Attempting to activate..."); + boolean activated = + activateMachine(licenseKey, licenseId, machineFingerprint); if (activated) { // Revalidate after activation validationResponse = validateLicense(licenseKey, machineFingerprint); - isValid = validationResponse != null && validationResponse.path("meta").path("valid").asBoolean(); + isValid = + validationResponse != null + && validationResponse + .path("meta") + .path("valid") + .asBoolean(); } } } @@ -54,7 +63,7 @@ public class KeygenLicenseVerifier { return false; } catch (Exception e) { - log.error("Error verifying license: " + e.getMessage()); + log.error("Error verifying license: " + e.getMessage()); return false; } } @@ -65,7 +74,7 @@ public class KeygenLicenseVerifier { String requestBody = String.format( "{\"meta\":{\"key\":\"%s\",\"scope\":{\"fingerprint\":\"%s\"}}}", - licenseKey, machineFingerprint ); + licenseKey, machineFingerprint); HttpRequest request = HttpRequest.newBuilder() .uri( @@ -76,18 +85,18 @@ public class KeygenLicenseVerifier { + "/licenses/actions/validate-key")) .header("Content-Type", "application/vnd.api+json") .header("Accept", "application/vnd.api+json") - //.header("Authorization", "License " + licenseKey) + // .header("Authorization", "License " + licenseKey) .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); - log.debug(" validateLicenseResponse body: " + response.body()); + log.info(" validateLicenseResponse body: " + response.body()); JsonNode jsonResponse = objectMapper.readTree(response.body()); if (response.statusCode() == 200) { - + JsonNode metaNode = jsonResponse.path("meta"); boolean isValid = metaNode.path("valid").asBoolean(); - + String detail = metaNode.path("detail").asText(); String code = metaNode.path("code").asText(); @@ -95,15 +104,14 @@ public class KeygenLicenseVerifier { log.debug("Validation detail: " + detail); log.debug("Validation code: " + code); - } else { log.error("Error validating license. Status code: " + response.statusCode()); } return jsonResponse; } - private static boolean activateMachine(String licenseKey, String licenseId, String machineFingerprint) - throws Exception { + private static boolean activateMachine( + String licenseKey, String licenseId, String machineFingerprint) throws Exception { HttpClient client = HttpClient.newHttpClient(); String hostname; @@ -112,27 +120,54 @@ public class KeygenLicenseVerifier { } catch (Exception e) { hostname = "Unknown"; } - - JSONObject body = new JSONObject() - .put("data", new JSONObject() - .put("type", "machines") - .put("attributes", new JSONObject() - .put("fingerprint", machineFingerprint) - .put("platform", System.getProperty("os.name")) // Added platform parameter - .put("name", hostname)) // Added name parameter - .put("relationships", new JSONObject() - .put("license", new JSONObject() - .put("data", new JSONObject() - .put("type", "licenses") - .put("id", licenseId))))); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines")) - .header("Content-Type", "application/vnd.api+json") - .header("Accept", "application/vnd.api+json") - .header("Authorization", "License " + licenseKey) // Keep the license key authentication - .POST(HttpRequest.BodyPublishers.ofString(body.toString())) // Send the JSON body - .build(); + JSONObject body = + new JSONObject() + .put( + "data", + new JSONObject() + .put("type", "machines") + .put( + "attributes", + new JSONObject() + .put("fingerprint", machineFingerprint) + .put( + "platform", + System.getProperty( + "os.name")) // Added + // platform + // parameter + .put( + "name", + hostname)) // Added name parameter + .put( + "relationships", + new JSONObject() + .put( + "license", + new JSONObject() + .put( + "data", + new JSONObject() + .put( + "type", + "licenses") + .put( + "id", + licenseId))))); + + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines")) + .header("Content-Type", "application/vnd.api+json") + .header("Accept", "application/vnd.api+json") + .header( + "Authorization", + "License " + licenseKey) // Keep the license key authentication + .POST( + HttpRequest.BodyPublishers.ofString( + body.toString())) // Send the JSON body + .build(); HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); log.debug("activateMachine Response body: " + response.body()); @@ -141,16 +176,14 @@ public class KeygenLicenseVerifier { return true; } else { log.error("Error activating machine. Status code: " + response.statusCode()); - + return false; } } - private static String generateMachineFingerprint() { // This is a simplified example. In a real-world scenario, you'd want to generate // a more robust and unique fingerprint based on hardware characteristics. return "example-fingerprint"; } - } diff --git a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java index 3ce11c95d..a3c23ce9c 100644 --- a/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java +++ b/src/main/java/stirling/software/SPDF/EE/LicenseKeyChecker.java @@ -2,41 +2,49 @@ package stirling.software.SPDF.EE; import java.io.IOException; -import org.springframework.boot.CommandLineRunner; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.utils.GeneralUtils; @Component -public class LicenseKeyChecker { +@Slf4j +public class LicenseKeyChecker { private final KeygenLicenseVerifier licenseService; private final ApplicationProperties applicationProperties; - private static boolean enterpriseEnbaledResult = false; + private boolean enterpriseEnbaledResult = false; + // Inject your license service or configuration + @Autowired public LicenseKeyChecker( KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) { this.licenseService = licenseService; - this.applicationProperties = new ApplicationProperties(); + this.applicationProperties = applicationProperties; } - - @Scheduled(fixedRate = 604800000) // 7 days in milliseconds + @Scheduled(fixedRate = 604800000, initialDelay = 1000) // 7 days in milliseconds public void checkLicensePeriodically() { checkLicense(); } private void checkLicense() { - if(!applicationProperties.getEnterpriseEdition().isEnabled()) { - enterpriseEnbaledResult = false; - } else { - enterpriseEnbaledResult = licenseService.verifyLicense(applicationProperties.getEnterpriseEdition().getKey()); - } - + log.info(applicationProperties.toString()); + log.info(applicationProperties.getEnterpriseEdition().toString()); + if (!applicationProperties.getEnterpriseEdition().isEnabled()) { + System.out.println("gggggg"); + enterpriseEnbaledResult = false; + } else { + System.out.println("ssssssssssss"); + enterpriseEnbaledResult = + licenseService.verifyLicense( + applicationProperties.getEnterpriseEdition().getKey()); + } } public void updateLicenseKey(String newKey) throws IOException { @@ -44,7 +52,7 @@ public class LicenseKeyChecker { GeneralUtils.saveKeyToConfig("EnterpriseEdition.key", newKey, false); checkLicense(); } - + public boolean getEnterpriseEnabledResult() { return enterpriseEnbaledResult; } diff --git a/src/main/java/stirling/software/SPDF/LibreOfficeListener.java b/src/main/java/stirling/software/SPDF/LibreOfficeListener.java index 5e66b8a8c..549a00926 100644 --- a/src/main/java/stirling/software/SPDF/LibreOfficeListener.java +++ b/src/main/java/stirling/software/SPDF/LibreOfficeListener.java @@ -10,6 +10,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.github.pixee.security.SystemCommand; + import lombok.extern.slf4j.Slf4j; @Slf4j diff --git a/src/main/java/stirling/software/SPDF/config/AppConfig.java b/src/main/java/stirling/software/SPDF/config/AppConfig.java index 88ed34ea7..3391a50f0 100644 --- a/src/main/java/stirling/software/SPDF/config/AppConfig.java +++ b/src/main/java/stirling/software/SPDF/config/AppConfig.java @@ -20,7 +20,6 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.thymeleaf.spring6.SpringTemplateEngine; -import stirling.software.SPDF.EE.LicenseKeyChecker; import stirling.software.SPDF.model.ApplicationProperties; @Configuration @@ -31,7 +30,6 @@ public class AppConfig { @Autowired ApplicationProperties applicationProperties; - @Bean @ConditionalOnProperty( name = "system.customHTMLFiles", @@ -185,6 +183,4 @@ public class AppConfig { public String uuid() { return applicationProperties.getAutomaticallyGenerated().getUUID(); } - - } diff --git a/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionFilter.java b/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionFilter.java index 6106e977a..530a89388 100644 --- a/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionFilter.java +++ b/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionFilter.java @@ -56,11 +56,13 @@ public class FingerprintBasedSessionFilter extends OncePerRequestFilter { } session.setAttribute("userFingerprint", fingerprint); - session.setAttribute(FingerprintBasedSessionManager.STARTUP_TIMESTAMP, FingerprintBasedSessionManager.APP_STARTUP_TIME); + session.setAttribute( + FingerprintBasedSessionManager.STARTUP_TIMESTAMP, + FingerprintBasedSessionManager.APP_STARTUP_TIME); sessionManager.registerFingerprint(fingerprint, sessionId); log.debug("Proceeding with request: {}", request.getRequestURI()); filterChain.doFilter(request, response); } -} \ No newline at end of file +} diff --git a/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionManager.java b/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionManager.java index 9f85defc9..315791c31 100644 --- a/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionManager.java +++ b/src/main/java/stirling/software/SPDF/config/fingerprint/FingerprintBasedSessionManager.java @@ -5,7 +5,6 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -16,17 +15,20 @@ import jakarta.servlet.http.HttpSessionListener; import lombok.AllArgsConstructor; import lombok.Data; import lombok.extern.slf4j.Slf4j; + @Slf4j @Component -public class FingerprintBasedSessionManager implements HttpSessionListener, HttpSessionAttributeListener { - private static final ConcurrentHashMap activeFingerprints = new ConcurrentHashMap<>(); - +public class FingerprintBasedSessionManager + implements HttpSessionListener, HttpSessionAttributeListener { + private static final ConcurrentHashMap activeFingerprints = + new ConcurrentHashMap<>(); + // To be reduced in later version to 8~ private static final int MAX_ACTIVE_FINGERPRINTS = 30; - + static final String STARTUP_TIMESTAMP = "appStartupTimestamp"; - static final long APP_STARTUP_TIME = System.currentTimeMillis(); - private static final long FINGERPRINT_EXPIRATION = TimeUnit.MINUTES.toMillis(30); + static final long APP_STARTUP_TIME = System.currentTimeMillis(); + private static final long FINGERPRINT_EXPIRATION = TimeUnit.MINUTES.toMillis(30); @Override public void sessionCreated(HttpSessionEvent se) { @@ -40,16 +42,17 @@ public class FingerprintBasedSessionManager implements HttpSessionListener, Http } synchronized (activeFingerprints) { - if (activeFingerprints.size() >= MAX_ACTIVE_FINGERPRINTS && !activeFingerprints.containsKey(fingerprint)) { + if (activeFingerprints.size() >= MAX_ACTIVE_FINGERPRINTS + && !activeFingerprints.containsKey(fingerprint)) { log.info("Max fingerprints reached. Marking session as blocked: {}", sessionId); session.setAttribute("blocked", true); } else { - activeFingerprints.put(fingerprint, new FingerprintInfo(sessionId, System.currentTimeMillis())); + activeFingerprints.put( + fingerprint, new FingerprintInfo(sessionId, System.currentTimeMillis())); log.info( - "New fingerprint registered: {}. Total active fingerprints: {}", - fingerprint, - activeFingerprints.size() - ); + "New fingerprint registered: {}. Total active fingerprints: {}", + fingerprint, + activeFingerprints.size()); } session.setAttribute(STARTUP_TIMESTAMP, APP_STARTUP_TIME); } @@ -64,23 +67,24 @@ public class FingerprintBasedSessionManager implements HttpSessionListener, Http synchronized (activeFingerprints) { activeFingerprints.remove(fingerprint); log.info( - "Fingerprint removed: {}. Total active fingerprints: {}", - fingerprint, - activeFingerprints.size() - ); + "Fingerprint removed: {}. Total active fingerprints: {}", + fingerprint, + activeFingerprints.size()); } } } public boolean isFingerPrintAllowed(String fingerprint) { synchronized (activeFingerprints) { - return activeFingerprints.size() < MAX_ACTIVE_FINGERPRINTS || activeFingerprints.containsKey(fingerprint); + return activeFingerprints.size() < MAX_ACTIVE_FINGERPRINTS + || activeFingerprints.containsKey(fingerprint); } } public void registerFingerprint(String fingerprint, String sessionId) { synchronized (activeFingerprints) { - activeFingerprints.put(fingerprint, new FingerprintInfo(sessionId, System.currentTimeMillis())); + activeFingerprints.put( + fingerprint, new FingerprintInfo(sessionId, System.currentTimeMillis())); } } @@ -97,7 +101,8 @@ public class FingerprintBasedSessionManager implements HttpSessionListener, Http int removedCount = 0; synchronized (activeFingerprints) { - Iterator> iterator = activeFingerprints.entrySet().iterator(); + Iterator> iterator = + activeFingerprints.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry entry = iterator.next(); FingerprintInfo info = entry.getValue(); @@ -120,11 +125,10 @@ public class FingerprintBasedSessionManager implements HttpSessionListener, Http } } - @Data @AllArgsConstructor + @Data + @AllArgsConstructor private static class FingerprintInfo { private String sessionId; private long lastAccessTime; - - } -} \ No newline at end of file +} diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index 352b6184a..e6360904f 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -1,57 +1,56 @@ package stirling.software.SPDF.config.security; -import java.util.*; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.registration.ClientRegistrations; -import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; -import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.savedrequest.NullRequestCache; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationFailureHandler; import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationSuccessHandler; import stirling.software.SPDF.config.security.oauth2.CustomOAuth2LogoutSuccessHandler; import stirling.software.SPDF.config.security.oauth2.CustomOAuth2UserService; +import stirling.software.SPDF.config.security.saml.ConvertResponseToAuthentication; +import stirling.software.SPDF.config.security.saml.CustomSAMLAuthenticationFailureHandler; +import stirling.software.SPDF.config.security.saml.CustomSAMLAuthenticationSuccessHandler; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.ApplicationProperties; -import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; -import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client; -import stirling.software.SPDF.model.User; -import stirling.software.SPDF.model.provider.GithubProvider; -import stirling.software.SPDF.model.provider.GoogleProvider; -import stirling.software.SPDF.model.provider.KeycloakProvider; import stirling.software.SPDF.repository.JPATokenRepositoryImpl; @Configuration @EnableWebSecurity @EnableMethodSecurity +@Slf4j public class SecurityConfiguration { @Autowired private CustomUserDetailsService userDetailsService; - private static final Logger logger = LoggerFactory.getLogger(SecurityConfiguration.class); + @Autowired(required = false) + private GrantedAuthoritiesMapper userAuthoritiesMapper; + + @Autowired(required = false) + private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository; @Bean public PasswordEncoder passwordEncoder() { @@ -73,10 +72,14 @@ public class SecurityConfiguration { @Autowired private FirstLoginFilter firstLoginFilter; @Autowired private SessionPersistentRegistry sessionRegistry; + @Autowired private ConvertResponseToAuthentication convertResponseToAuthentication; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.authenticationManager(authenticationManager(http)); + if (loginEnabledValue) { - http.addFilterBefore( + http.addFilterBefore( userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); http.csrf(csrf -> csrf.disable()); http.addFilterBefore(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); @@ -85,7 +88,7 @@ public class SecurityConfiguration { sessionManagement -> sessionManagement .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .maximumSessions(4) + .maximumSessions(10) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry) .expiredUrl("/login?logout=true")); @@ -134,6 +137,7 @@ public class SecurityConfiguration { return trimmedUri.startsWith("/login") || trimmedUri.startsWith("/oauth") + || trimmedUri.startsWith("/saml2") || trimmedUri.endsWith(".svg") || trimmedUri.startsWith( "/register") @@ -183,13 +187,37 @@ public class SecurityConfiguration { userService, loginAttemptService)) .userAuthoritiesMapper( - userAuthoritiesMapper()))) + userAuthoritiesMapper))) .logout( logout -> logout.logoutSuccessHandler( new CustomOAuth2LogoutSuccessHandler( applicationProperties))); } + + // Handle SAML + if (applicationProperties.getSecurity().getSaml() != null + && applicationProperties.getSecurity().getSaml().getEnabled() + && !applicationProperties + .getSecurity() + .getLoginMethod() + .equalsIgnoreCase("normal")) { + http.saml2Login( + saml2 -> { + saml2.loginPage("/saml2") + .relyingPartyRegistrationRepository( + relyingPartyRegistrationRepository) + .successHandler( + new CustomSAMLAuthenticationSuccessHandler( + loginAttemptService, + userService, + applicationProperties)) + .failureHandler( + new CustomSAMLAuthenticationFailureHandler()); + }) + .addFilterBefore( + userAuthenticationFilter, Saml2WebSsoAuthenticationFilter.class); + } } else { http.csrf(csrf -> csrf.disable()) .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); @@ -198,176 +226,32 @@ public class SecurityConfiguration { return http.build(); } - // Client Registration Repository for OAUTH2 OIDC Login @Bean - @ConditionalOnProperty( - value = "security.oauth2.enabled", - havingValue = "true", - matchIfMissing = false) - public ClientRegistrationRepository clientRegistrationRepository() { - List registrations = new ArrayList<>(); - - githubClientRegistration().ifPresent(registrations::add); - oidcClientRegistration().ifPresent(registrations::add); - googleClientRegistration().ifPresent(registrations::add); - keycloakClientRegistration().ifPresent(registrations::add); - - if (registrations.isEmpty()) { - logger.error("At least one OAuth2 provider must be configured"); - System.exit(1); - } - - return new InMemoryClientRegistrationRepository(registrations); + public AuthenticationProvider samlAuthenticationProvider() { + OpenSaml4AuthenticationProvider authenticationProvider = + new OpenSaml4AuthenticationProvider(); + authenticationProvider.setResponseAuthenticationConverter(convertResponseToAuthentication); + return authenticationProvider; } - private Optional googleClientRegistration() { - OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (oauth == null || !oauth.getEnabled()) { - return Optional.empty(); - } - Client client = oauth.getClient(); - if (client == null) { - return Optional.empty(); - } - GoogleProvider google = client.getGoogle(); - return google != null && google.isSettingsValid() - ? Optional.of( - ClientRegistration.withRegistrationId(google.getName()) - .clientId(google.getClientId()) - .clientSecret(google.getClientSecret()) - .scope(google.getScopes()) - .authorizationUri(google.getAuthorizationuri()) - .tokenUri(google.getTokenuri()) - .userInfoUri(google.getUserinfouri()) - .userNameAttributeName(google.getUseAsUsername()) - .clientName(google.getClientName()) - .redirectUri("{baseUrl}/login/oauth2/code/" + google.getName()) - .authorizationGrantType( - org.springframework.security.oauth2.core - .AuthorizationGrantType.AUTHORIZATION_CODE) - .build()) - : Optional.empty(); - } - - private Optional keycloakClientRegistration() { - OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (oauth == null || !oauth.getEnabled()) { - return Optional.empty(); - } - Client client = oauth.getClient(); - if (client == null) { - return Optional.empty(); - } - KeycloakProvider keycloak = client.getKeycloak(); - - return keycloak != null && keycloak.isSettingsValid() - ? Optional.of( - ClientRegistrations.fromIssuerLocation(keycloak.getIssuer()) - .registrationId(keycloak.getName()) - .clientId(keycloak.getClientId()) - .clientSecret(keycloak.getClientSecret()) - .scope(keycloak.getScopes()) - .userNameAttributeName(keycloak.getUseAsUsername()) - .clientName(keycloak.getClientName()) - .build()) - : Optional.empty(); - } - - private Optional githubClientRegistration() { - OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (oauth == null || !oauth.getEnabled()) { - return Optional.empty(); - } - Client client = oauth.getClient(); - if (client == null) { - return Optional.empty(); - } - GithubProvider github = client.getGithub(); - return github != null && github.isSettingsValid() - ? Optional.of( - ClientRegistration.withRegistrationId(github.getName()) - .clientId(github.getClientId()) - .clientSecret(github.getClientSecret()) - .scope(github.getScopes()) - .authorizationUri(github.getAuthorizationuri()) - .tokenUri(github.getTokenuri()) - .userInfoUri(github.getUserinfouri()) - .userNameAttributeName(github.getUseAsUsername()) - .clientName(github.getClientName()) - .redirectUri("{baseUrl}/login/oauth2/code/" + github.getName()) - .authorizationGrantType( - org.springframework.security.oauth2.core - .AuthorizationGrantType.AUTHORIZATION_CODE) - .build()) - : Optional.empty(); - } - - private Optional oidcClientRegistration() { - OAUTH2 oauth = applicationProperties.getSecurity().getOauth2(); - if (oauth == null - || oauth.getIssuer() == null - || oauth.getIssuer().isEmpty() - || oauth.getClientId() == null - || oauth.getClientId().isEmpty() - || oauth.getClientSecret() == null - || oauth.getClientSecret().isEmpty() - || oauth.getScopes() == null - || oauth.getScopes().isEmpty() - || oauth.getUseAsUsername() == null - || oauth.getUseAsUsername().isEmpty()) { - return Optional.empty(); - } - return Optional.of( - ClientRegistrations.fromIssuerLocation(oauth.getIssuer()) - .registrationId("oidc") - .clientId(oauth.getClientId()) - .clientSecret(oauth.getClientSecret()) - .scope(oauth.getScopes()) - .userNameAttributeName(oauth.getUseAsUsername()) - .clientName("OIDC") - .build()); - } - - /* - This following function is to grant Authorities to the OAUTH2 user from the values stored in the database. - This is required for the internal; 'hasRole()' function to give out the correct role. - */ @Bean - @ConditionalOnProperty( - value = "security.oauth2.enabled", - havingValue = "true", - matchIfMissing = false) - GrantedAuthoritiesMapper userAuthoritiesMapper() { - return (authorities) -> { - Set mappedAuthorities = new HashSet<>(); + public AuthenticationProvider daoAuthenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); // UserDetailsService + provider.setPasswordEncoder(passwordEncoder()); // PasswordEncoder + return provider; + } - authorities.forEach( - authority -> { - // Add existing OAUTH2 Authorities - mappedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority())); + @Bean + public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception { + AuthenticationManagerBuilder authenticationManagerBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); - // Add Authorities from database for existing user, if user is present. - if (authority instanceof OAuth2UserAuthority oauth2Auth) { - String useAsUsername = - applicationProperties - .getSecurity() - .getOauth2() - .getUseAsUsername(); - Optional userOpt = - userService.findByUsernameIgnoreCase( - (String) oauth2Auth.getAttributes().get(useAsUsername)); - if (userOpt.isPresent()) { - User user = userOpt.get(); - if (user != null) { - mappedAuthorities.add( - new SimpleGrantedAuthority( - userService.findRole(user).getAuthority())); - } - } - } - }); - return mappedAuthorities; - }; + authenticationManagerBuilder + .authenticationProvider(daoAuthenticationProvider()) // Benutzername/Passwort + .authenticationProvider(samlAuthenticationProvider()); // SAML + + return authenticationManagerBuilder.build(); } @Bean @@ -385,4 +269,13 @@ public class SecurityConfiguration { public boolean activSecurity() { return true; } + + // Only Dev test + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> + web.ignoring() + .requestMatchers( + "/css/**", "/images/**", "/js/**", "/**.svg", "/pdfjs-legacy/**"); + } } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/ConvertResponseToAuthentication.java b/src/main/java/stirling/software/SPDF/config/security/saml/ConvertResponseToAuthentication.java new file mode 100644 index 000000000..02bf05930 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/ConvertResponseToAuthentication.java @@ -0,0 +1,68 @@ +package stirling.software.SPDF.config.security.saml; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.opensaml.saml.saml2.core.Assertion; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.ResponseToken; +import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +public class ConvertResponseToAuthentication + implements Converter { + + private final Saml2AuthorityAttributeLookup saml2AuthorityAttributeLookup; + + public ConvertResponseToAuthentication( + Saml2AuthorityAttributeLookup saml2AuthorityAttributeLookup) { + this.saml2AuthorityAttributeLookup = saml2AuthorityAttributeLookup; + } + + @Override + public Saml2Authentication convert(ResponseToken responseToken) { + final Assertion assertion = + CollectionUtils.firstElement(responseToken.getResponse().getAssertions()); + final Map> attributes = + SamlAssertionUtils.getAssertionAttributes(assertion); + final String registrationId = + responseToken.getToken().getRelyingPartyRegistration().getRegistrationId(); + final ScimSaml2AuthenticatedPrincipal principal = + new ScimSaml2AuthenticatedPrincipal( + assertion, + attributes, + saml2AuthorityAttributeLookup.getIdentityMappings(registrationId)); + final Collection assertionAuthorities = + getAssertionAuthorities( + attributes, + saml2AuthorityAttributeLookup.getAuthorityAttribute(registrationId)); + return new Saml2Authentication( + principal, responseToken.getToken().getSaml2Response(), assertionAuthorities); + } + + private static Collection getAssertionAuthorities( + final Map> attributes, final String authoritiesAttributeName) { + if (attributes == null || attributes.isEmpty()) { + return Collections.emptySet(); + } + + final List groups = new ArrayList<>(attributes.get(authoritiesAttributeName)); + return groups.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .map(String::toLowerCase) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java new file mode 100644 index 000000000..2a61e771f --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java @@ -0,0 +1,51 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.IOException; + +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomSAMLAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) + throws IOException, ServletException { + + if (exception instanceof BadCredentialsException) { + log.error("BadCredentialsException", exception); + getRedirectStrategy().sendRedirect(request, response, "/login?error=badcredentials"); + return; + } + if (exception instanceof DisabledException) { + log.error("User is deactivated: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + if (exception instanceof LockedException) { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + if (exception instanceof Saml2AuthenticationException) { + log.error("SAML2 Authentication error: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?error=saml2AuthenticationError"); + return; + } + log.error("Unhandled authentication exception", exception); + super.onAuthenticationFailure(request, response, exception); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java new file mode 100644 index 000000000..46d20ac02 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java @@ -0,0 +1,108 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.IOException; + +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.SavedRequest; + +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.SPDF.config.security.LoginAttemptService; +import stirling.software.SPDF.config.security.UserService; +import stirling.software.SPDF.model.ApplicationProperties; +import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; +import stirling.software.SPDF.model.AuthenticationType; +import stirling.software.SPDF.utils.RequestUriUtils; + +@Slf4j +public class CustomSAMLAuthenticationSuccessHandler + extends SavedRequestAwareAuthenticationSuccessHandler { + + private LoginAttemptService loginAttemptService; + private UserService userService; + private ApplicationProperties applicationProperties; + + public CustomSAMLAuthenticationSuccessHandler( + LoginAttemptService loginAttemptService, + UserService userService, + ApplicationProperties applicationProperties) { + this.loginAttemptService = loginAttemptService; + this.userService = userService; + this.applicationProperties = applicationProperties; + } + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws ServletException, IOException { + + Object principal = authentication.getPrincipal(); + String username = ""; + + if (principal instanceof OAuth2User) { + OAuth2User oauthUser = (OAuth2User) principal; + username = oauthUser.getName(); + } else if (principal instanceof UserDetails) { + UserDetails oauthUser = (UserDetails) principal; + username = oauthUser.getUsername(); + } else if (principal instanceof ScimSaml2AuthenticatedPrincipal) { + ScimSaml2AuthenticatedPrincipal samlPrincipal = + (ScimSaml2AuthenticatedPrincipal) principal; + username = samlPrincipal.getName(); + } + + // Get the saved request + HttpSession session = request.getSession(false); + String contextPath = request.getContextPath(); + SavedRequest savedRequest = + (session != null) + ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") + : null; + + if (savedRequest != null + && !RequestUriUtils.isStaticResource(contextPath, savedRequest.getRedirectUrl())) { + // Redirect to the original destination + super.onAuthenticationSuccess(request, response, authentication); + } else { + OAUTH2 oAuth = applicationProperties.getSecurity().getOauth2(); + + if (loginAttemptService.isBlocked(username)) { + if (session != null) { + session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); + } + throw new LockedException( + "Your account has been locked due to too many failed login attempts."); + } + if (userService.usernameExistsIgnoreCase(username) + && userService.hasPassword(username) + && !userService.isAuthenticationTypeByUsername( + username, AuthenticationType.OAUTH2) + && oAuth.getAutoCreateUser()) { + response.sendRedirect(contextPath + "/logout?oauth2AuthenticationErrorWeb=true"); + return; + } + try { + if (oAuth.getBlockRegistration() + && !userService.usernameExistsIgnoreCase(username)) { + response.sendRedirect(contextPath + "/logout?oauth2_admin_blocked_user=true"); + return; + } + if (principal instanceof OAuth2User) { + userService.processOAuth2PostLogin(username, oAuth.getAutoCreateUser()); + } + response.sendRedirect(contextPath + "/"); + return; + } catch (IllegalArgumentException e) { + response.sendRedirect(contextPath + "/logout?invalidUsername=true"); + return; + } + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java new file mode 100644 index 000000000..24e81889f --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java @@ -0,0 +1,38 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SAMLLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { + + @Override + public void onLogoutSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + + String redirectUrl = determineTargetUrl(request, response, authentication); + + if (response.isCommitted()) { + log.debug("Response has already been committed. Unable to redirect to " + redirectUrl); + return; + } + + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } + + protected String determineTargetUrl( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) { + // Default to the root URL + return "/"; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/Saml2AuthorityAttributeLookup.java b/src/main/java/stirling/software/SPDF/config/security/saml/Saml2AuthorityAttributeLookup.java new file mode 100644 index 000000000..cceb5bf32 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/Saml2AuthorityAttributeLookup.java @@ -0,0 +1,7 @@ +package stirling.software.SPDF.config.security.saml; + +public interface Saml2AuthorityAttributeLookup { + String getAuthorityAttribute(String registrationId); + + SimpleScimMappings getIdentityMappings(String registrationId); +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/Saml2AuthorityAttributeLookupImpl.java b/src/main/java/stirling/software/SPDF/config/security/saml/Saml2AuthorityAttributeLookupImpl.java new file mode 100644 index 000000000..c3b038e58 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/Saml2AuthorityAttributeLookupImpl.java @@ -0,0 +1,17 @@ +package stirling.software.SPDF.config.security.saml; + +import org.springframework.stereotype.Component; + +@Component +public class Saml2AuthorityAttributeLookupImpl implements Saml2AuthorityAttributeLookup { + + @Override + public String getAuthorityAttribute(String registrationId) { + return "authorityAttributeName"; + } + + @Override + public SimpleScimMappings getIdentityMappings(String registrationId) { + return new SimpleScimMappings(); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SamlAssertionUtils.java b/src/main/java/stirling/software/SPDF/config/security/saml/SamlAssertionUtils.java new file mode 100644 index 000000000..c4a560bf7 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SamlAssertionUtils.java @@ -0,0 +1,63 @@ +package stirling.software.SPDF.config.security.saml; + +import java.time.Instant; +import java.util.*; + +import org.opensaml.core.xml.XMLObject; +import org.opensaml.core.xml.schema.*; +import org.opensaml.saml.saml2.core.Assertion; + +public class SamlAssertionUtils { + + public static Map> getAssertionAttributes(Assertion assertion) { + Map> attributeMap = new LinkedHashMap<>(); + + assertion + .getAttributeStatements() + .forEach( + attributeStatement -> { + attributeStatement + .getAttributes() + .forEach( + attribute -> { + List attributeValues = new ArrayList<>(); + + attribute + .getAttributeValues() + .forEach( + xmlObject -> { + Object attributeValue = + getXmlObjectValue( + xmlObject); + if (attributeValue != null) { + attributeValues.add( + attributeValue); + } + }); + + attributeMap.put( + attribute.getName(), attributeValues); + }); + }); + + return attributeMap; + } + + public static Object getXmlObjectValue(XMLObject xmlObject) { + if (xmlObject instanceof XSAny) { + return ((XSAny) xmlObject).getTextContent(); + } else if (xmlObject instanceof XSString) { + return ((XSString) xmlObject).getValue(); + } else if (xmlObject instanceof XSInteger) { + return ((XSInteger) xmlObject).getValue(); + } else if (xmlObject instanceof XSURI) { + return ((XSURI) xmlObject).getURI(); + } else if (xmlObject instanceof XSBoolean) { + return ((XSBoolean) xmlObject).getValue().getValue(); + } else if (xmlObject instanceof XSDateTime) { + Instant dateTime = ((XSDateTime) xmlObject).getValue(); + return (dateTime != null) ? Instant.ofEpochMilli(dateTime.toEpochMilli()) : null; + } + return null; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SamlConfig.java b/src/main/java/stirling/software/SPDF/config/security/saml/SamlConfig.java new file mode 100644 index 000000000..358c44886 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SamlConfig.java @@ -0,0 +1,42 @@ +package stirling.software.SPDF.config.security.saml; + +import java.security.cert.CertificateException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository; +import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrations; + +import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.model.ApplicationProperties; + +@Configuration +@Slf4j +public class SamlConfig { + + @Autowired ApplicationProperties applicationProperties; + + @Bean + @ConditionalOnProperty( + value = "security.saml.enabled", + havingValue = "true", + matchIfMissing = false) + public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() + throws CertificateException { + RelyingPartyRegistration registration = + RelyingPartyRegistrations.fromMetadataLocation( + applicationProperties + .getSecurity() + .getSaml() + .getIdpMetadataLocation()) + .entityId(applicationProperties.getSecurity().getSaml().getEntityId()) + .registrationId( + applicationProperties.getSecurity().getSaml().getRegistrationId()) + .build(); + return new InMemoryRelyingPartyRegistrationRepository(registration); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/ScimSaml2AuthenticatedPrincipal.java b/src/main/java/stirling/software/SPDF/config/security/saml/ScimSaml2AuthenticatedPrincipal.java new file mode 100644 index 000000000..9be94f389 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/ScimSaml2AuthenticatedPrincipal.java @@ -0,0 +1,89 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.opensaml.saml.saml2.core.Assertion; +import org.springframework.security.core.AuthenticatedPrincipal; +import org.springframework.util.Assert; + +import com.unboundid.scim2.common.types.Email; +import com.unboundid.scim2.common.types.Name; +import com.unboundid.scim2.common.types.UserResource; + +public class ScimSaml2AuthenticatedPrincipal implements AuthenticatedPrincipal, Serializable { + + private static final long serialVersionUID = 1L; + + private final transient UserResource userResource; + + public ScimSaml2AuthenticatedPrincipal( + final Assertion assertion, + final Map> attributes, + final SimpleScimMappings attributeMappings) { + Assert.notNull(assertion, "assertion cannot be null"); + Assert.notNull(assertion.getSubject(), "assertion subject cannot be null"); + Assert.notNull( + assertion.getSubject().getNameID(), "assertion subject NameID cannot be null"); + Assert.notNull(attributes, "attributes cannot be null"); + Assert.notNull(attributeMappings, "attributeMappings cannot be null"); + + final Name name = + new Name() + .setFamilyName( + getAttribute( + attributes, + attributeMappings, + SimpleScimMappings::getFamilyName)) + .setGivenName( + getAttribute( + attributes, + attributeMappings, + SimpleScimMappings::getGivenName)); + + final List emails = new ArrayList<>(1); + emails.add( + new Email() + .setValue( + getAttribute( + attributes, + attributeMappings, + SimpleScimMappings::getEmail)) + .setPrimary(true)); + + userResource = + new UserResource() + .setUserName(assertion.getSubject().getNameID().getValue()) + .setName(name) + .setEmails(emails); + } + + private static String getAttribute( + final Map> attributes, + final SimpleScimMappings simpleScimMappings, + final Function attributeMapper) { + + final String key = attributeMapper.apply(simpleScimMappings); + + final List values = attributes.getOrDefault(key, Collections.emptyList()); + + return values.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .findFirst() + .orElse(null); + } + + @Override + public String getName() { + return this.userResource.getUserName(); + } + + public UserResource getUserResource() { + return this.userResource; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SimpleScimMappings.java b/src/main/java/stirling/software/SPDF/config/security/saml/SimpleScimMappings.java new file mode 100644 index 000000000..97f61d315 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SimpleScimMappings.java @@ -0,0 +1,10 @@ +package stirling.software.SPDF.config.security.saml; + +import lombok.Data; + +@Data +public class SimpleScimMappings { + String givenName; + String familyName; + String email; +} diff --git a/src/main/java/stirling/software/SPDF/controller/api/UserController.java b/src/main/java/stirling/software/SPDF/controller/api/UserController.java index e9dfb0662..cc9287496 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/UserController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/UserController.java @@ -193,7 +193,6 @@ public class UserController { Map paramMap = request.getParameterMap(); Map updates = new HashMap<>(); - for (Map.Entry entry : paramMap.entrySet()) { updates.put(entry.getKey(), entry.getValue()[0]); } diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java index 4ca068d5a..8f993abd0 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/PrintFileController.java @@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import io.swagger.v3.oas.annotations.tags.Tag; + import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.misc.PrintFileRequest; diff --git a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java index 60fd1287b..758e6e8ce 100644 --- a/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java +++ b/src/main/java/stirling/software/SPDF/model/ApplicationProperties.java @@ -13,6 +13,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.PropertySource; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import lombok.Data; import lombok.ToString; @@ -60,6 +63,7 @@ public class ApplicationProperties { private Boolean csrfDisabled; private InitialLogin initialLogin = new InitialLogin(); private OAUTH2 oauth2 = new OAUTH2(); + private SAML saml = new SAML(); private int loginAttemptCount; private long loginResetTimeMinutes; private String loginMethod = "all"; @@ -70,6 +74,34 @@ public class ApplicationProperties { @ToString.Exclude private String password; } + @Data + public static class SAML { + private Boolean enabled = false; + private String entityId; + private String registrationId; + private String spBaseUrl; + private String idpMetadataLocation; + private KeyStore keystore; + + @Data + public static class KeyStore { + private String keystoreLocation; + private String keystorePassword; + private String keyAlias; + private String keyPassword; + private String realmCertificateAlias; + + public Resource getKeystoreResource() { + if (keystoreLocation.startsWith("classpath:")) { + return new ClassPathResource( + keystoreLocation.substring("classpath:".length())); + } else { + return new FileSystemResource(keystoreLocation); + } + } + } + } + @Data public static class OAUTH2 { private Boolean enabled = false; diff --git a/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java b/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java index 100264590..eb3a83b01 100644 --- a/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java +++ b/src/main/java/stirling/software/SPDF/service/MetricsAggregatorService.java @@ -17,7 +17,7 @@ public class MetricsAggregatorService { private final MeterRegistry meterRegistry; private final PostHogService postHogService; private final Map lastSentMetrics = new ConcurrentHashMap<>(); - + @Autowired public MetricsAggregatorService(MeterRegistry meterRegistry, PostHogService postHogService) { this.meterRegistry = meterRegistry; @@ -28,25 +28,26 @@ public class MetricsAggregatorService { public void aggregateAndSendMetrics() { Map metrics = new HashMap<>(); Search.in(meterRegistry) - .name("http.requests") - .counters() - .forEach(counter -> { - String key = String.format( - "http_requests_%s_%s", - counter.getId().getTag("method"), - counter.getId().getTag("uri").replace("/", "_")); - - double currentCount = counter.count(); - double lastCount = lastSentMetrics.getOrDefault(key, 0.0); - double difference = currentCount - lastCount; - - if (difference > 0) { - metrics.put(key, difference); - lastSentMetrics.put(key, currentCount); - } - }); - - + .name("http.requests") + .counters() + .forEach( + counter -> { + String key = + String.format( + "http_requests_%s_%s", + counter.getId().getTag("method"), + counter.getId().getTag("uri").replace("/", "_")); + + double currentCount = counter.count(); + double lastCount = lastSentMetrics.getOrDefault(key, 0.0); + double difference = currentCount - lastCount; + + if (difference > 0) { + metrics.put(key, difference); + lastSentMetrics.put(key, currentCount); + } + }); + // Send aggregated metrics to PostHog if (!metrics.isEmpty()) { postHogService.captureEvent("aggregated_metrics", metrics); diff --git a/src/main/java/stirling/software/SPDF/service/PostHogService.java b/src/main/java/stirling/software/SPDF/service/PostHogService.java index b7e8dd41e..2ff679a99 100644 --- a/src/main/java/stirling/software/SPDF/service/PostHogService.java +++ b/src/main/java/stirling/software/SPDF/service/PostHogService.java @@ -39,15 +39,20 @@ public class PostHogService { } private void captureSystemInfo() { + if(!Boolean.getBoolean(applicationProperties.getSystem().getEnableAnalytics())) { + return; + } try { postHog.capture(uniqueId, "system_info_captured", captureServerMetrics()); - } catch (Exception e) { // Handle exceptions } } public void captureEvent(String eventName, Map properties) { + if(!Boolean.getBoolean(applicationProperties.getSystem().getEnableAnalytics())) { + return; + } postHog.capture(uniqueId, eventName, properties); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c57449dd4..0f957c0a9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -49,5 +49,5 @@ springdoc.api-docs.path=/v1/api-docs springdoc.swagger-ui.url=/v1/api-docs -posthog.api.key=phc_Bh95TRT3qZveAxJpBmJcPpSpW2dJeiKlgin8c7xXGna +posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq posthog.host=https://eu.i.posthog.com \ No newline at end of file diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 216221f61..cd2481128 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -49,7 +49,7 @@ security: provider: google # Set this to your OAuth provider's name, e.g., 'google' or 'keycloak' # Enterprise edition settings unused for now please ignore! -EnterpriseEdition: +enterpriseEdition: enabled: false # set to 'true' to enable enterprise edition key: 00000000-0000-0000-0000-000000000000 CustomMetadata: diff --git a/src/main/resources/templates/account.html b/src/main/resources/templates/account.html index a0e543978..c9d087236 100644 --- a/src/main/resources/templates/account.html +++ b/src/main/resources/templates/account.html @@ -278,7 +278,7 @@ let allKeys = new Set([...Object.keys(accountSettings), ...Object.keys(localStorage)]); allKeys.forEach(key => { - if(key === 'debug' || key === '0' || key === '1') return; // Ignoring specific keys + if(key === 'debug' || key === '0' || key === '1' || key.includes('pdfjs') || key.includes('posthog') || key.includes('pageViews')) return; // Ignoring specific keys const accountValue = accountSettings[key] || '-'; const browserValue = localStorage.getItem(key) || '-'; @@ -299,7 +299,7 @@ // Then, set the account settings to local storage for (let key in accountSettings) { - if(key !== 'debug' && key !== '0' && key !== '1') { // Only sync non-ignored keys + if(key !== 'debug' && key !== '0' && key !== '1' && !key.includes('pdfjs') && !key.includes('posthog') && !key.includes('pageViews')) { // Only sync non-ignored keys localStorage.setItem(key, accountSettings[key]); } } @@ -316,7 +316,7 @@ for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if(key !== 'debug' && key !== '0' && key !== '1') { // Only send non-ignored keys + if(key !== 'debug' && key !== '0' && key !== '1' && !key.includes('pdfjs') && !key.includes('posthog') && !key.includes('pageViews')) { // Only send non-ignored keys let hiddenField = document.createElement("input"); hiddenField.type = "hidden"; hiddenField.name = key; diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index 4ad17a207..293a8549b 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -97,6 +97,7 @@ }(document, window.posthog || []); posthog.init('phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq', { api_host: 'https://eu.i.posthog.com', + persistence: 'localStorage', person_profiles: 'always', mask_all_text: true, mask_all_element_attributes: true @@ -107,8 +108,6 @@ 'UUID': /*[[${@UUID}]]*/ '' }) } - -