diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 880e2bf40..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,120 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Essential Development Commands - -### Build and Run -```bash -# Build the project -./gradlew clean build - -# Run locally (includes JWT authentication work-in-progress) -./gradlew bootRun - -# Run specific module -./gradlew :stirling-pdf:bootRun - -# Build with security features enabled/disabled -DISABLE_ADDITIONAL_FEATURES=false ./gradlew clean build # enable security -DISABLE_ADDITIONAL_FEATURES=true ./gradlew clean build # disable security -``` - -### Testing -```bash -# Run unit tests -./gradlew test - -# Run comprehensive integration tests (builds all Docker versions and runs Cucumber tests) -./testing/test.sh - -# Run Cucumber/BDD tests specifically -cd testing/cucumber && python -m behave - -# Test web pages -cd testing && ./test_webpages.sh -f webpage_urls.txt -b http://localhost:8080 -``` - -### Code Quality and Formatting -```bash -# Apply Java code formatting (required before commits) -./gradlew spotlessApply - -# Check formatting compliance -./gradlew spotlessCheck - -# Generate license report -./gradlew generateLicenseReport -``` - -### Docker Development -```bash -# Build different Docker variants -docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile . -docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite . -DISABLE_ADDITIONAL_FEATURES=false docker build --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . - -# Use example Docker Compose configs -docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up -d -``` - -## Architecture Overview - -Stirling-PDF is a Spring Boot web application for PDF manipulation with the following key architectural components: - -### Multi-Module Structure -- **stirling-pdf/**: Main application module with web UI and REST APIs -- **common/**: Shared utilities and common functionality -- **proprietary/**: Enterprise/security features (JWT authentication, audit, teams) - -### Technology Stack -- **Backend**: Spring Boot 3.5, Spring Security, Spring Data JPA -- **Frontend**: Thymeleaf templates, Bootstrap, vanilla JavaScript -- **PDF Processing**: Apache PDFBox 3.0, qpdf, LibreOffice -- **Authentication**: JWT-based stateless sessions (in development) -- **Database**: H2 (default), supports PostgreSQL/MySQL -- **Build**: Gradle with multi-project setup - -### Current Development Context -The repository is on the `jwt-authentication` branch with work-in-progress changes to: -- JWT-based authentication system (`JWTService`, `JWTServiceInterface`) -- Stateless session management -- User model updates for JWT support - -### Key Directories -- `stirling-pdf/src/main/java/stirling/software/SPDF/`: Main application code - - `controller/`: REST API endpoints and UI controllers - - `service/`: Business logic layer - - `config/`: Spring configuration classes - - `security/`: Authentication and authorization -- `stirling-pdf/src/main/resources/templates/`: Thymeleaf HTML templates -- `stirling-pdf/src/main/resources/static/`: CSS, JavaScript, and assets -- `proprietary/src/main/java/stirling/software/proprietary/`: Enterprise features -- `testing/`: Integration tests and Cucumber features - -### Configuration Management -- Environment variables or `settings.yml` for runtime configuration -- Conditional feature compilation based on `DISABLE_ADDITIONAL_FEATURES` -- Multi-environment Docker configurations in `exampleYmlFiles/` - -### API Design Patterns -- RESTful endpoints under `/api/v1/` -- OpenAPI/Swagger documentation available at `/swagger-ui/index.html` -- File upload/download handling with multipart form data -- Consistent error handling and response formats - -## Development Workflow - -1. **Environment Setup**: Set `DISABLE_ADDITIONAL_FEATURES=false` for full feature development -2. **Code Formatting**: Always run `./gradlew spotlessApply` before committing -3. **Testing Strategy**: Use `./testing/test.sh` for comprehensive testing before PRs -4. **Feature Development**: Follow the controller -> service -> template pattern -5. **Security**: JWT authentication is currently in development on this branch - -## Important Notes - -- The application supports conditional compilation of security features -- Translation files are in `messages_*.properties` format -- PDF processing operations are primarily stateless -- Docker is the recommended deployment method -- All text should be internationalized using translation keys \ No newline at end of file diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index f611f42ca..e24a92d6a 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -8,6 +8,7 @@ import java.util.List; import java.util.Locale; import java.util.Properties; import java.util.function.Predicate; +import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -51,6 +52,14 @@ public class AppConfig { @Value("${server.port:8080}") private String serverPort; + @Value("${v2}") + public boolean v2Enabled; + + @Bean + public boolean v2Enabled() { + return v2Enabled; + } + @Bean @ConditionalOnProperty(name = "system.customHTMLFiles", havingValue = "true") public SpringTemplateEngine templateEngine(ResourceLoader resourceLoader) { @@ -120,7 +129,7 @@ public class AppConfig { public boolean rateLimit() { String rateLimit = System.getProperty("rateLimit"); if (rateLimit == null) rateLimit = System.getenv("rateLimit"); - return (rateLimit != null) ? Boolean.valueOf(rateLimit) : false; + return Boolean.parseBoolean(rateLimit); } @Bean(name = "RunningInDocker") @@ -140,8 +149,8 @@ public class AppConfig { if (!Files.exists(mountInfo)) { return true; } - try { - return Files.lines(mountInfo).anyMatch(line -> line.contains(" /configs ")); + try (Stream lines = Files.lines(mountInfo)) { + return lines.anyMatch(line -> line.contains(" /configs ")); } catch (IOException e) { return false; } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index c3abe0c26..802a55831 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -115,14 +115,13 @@ public class ApplicationProperties { private InitialLogin initialLogin = new InitialLogin(); private OAUTH2 oauth2 = new OAUTH2(); private SAML2 saml2 = new SAML2(); - private JWT jwt = new JWT(); private int loginAttemptCount; private long loginResetTimeMinutes; private String loginMethod = "all"; private String customGlobalAPIKey; public Boolean isAltLogin() { - return saml2.getEnabled() || oauth2.getEnabled() || jwt.getEnabled(); + return saml2.getEnabled() || oauth2.getEnabled(); } public enum LoginMethods { @@ -160,10 +159,6 @@ public class ApplicationProperties { && !loginMethod.equalsIgnoreCase(LoginMethods.NORMAL.toString())); } - public boolean isJwtActive() { - return (jwt != null && jwt.getEnabled()); - } - @Data public static class InitialLogin { private String username; @@ -302,20 +297,6 @@ public class ApplicationProperties { } } } - - @Data - public static class JWT { - private Boolean enabled = false; - private Long expiration = 3600000L; // Default 1 hour in milliseconds - private String algorithm = "HS256"; // Default HMAC algorithm - private String issuer = "Stirling-PDF"; // Default issuer - private Boolean enableRefreshToken = false; - private Long refreshTokenExpiration = 86400000L; // Default 24 hours - - public boolean isSettingsValid() { - return enabled != null && enabled && expiration != null && expiration > 0; - } - } } @Data diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index fecfd1c21..ec3a0a390 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -48,3 +48,6 @@ spring.main.allow-bean-definition-overriding=true # Set up a consistent temporary directory location java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf} + +# V2 features +v2=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 1aae02f2d..07e0fb7db 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -12,13 +12,13 @@ security: enableLogin: true # set to 'true' to enable login - csrfDisabled: true # set to 'true' to disable CSRF protection (not recommended for production) + csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production) loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1 loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2) initialLogin: - username: 'admin' # initial username for the first login - password: 'stirling' # initial password for the first login + username: '' # initial username for the first login + password: '' # initial password for the first login oauth2: enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work) client: @@ -47,29 +47,24 @@ security: scopes: openid, profile, email # specify the scopes for which the application will request permissions provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak' saml2: - enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) - provider: '' # The name of your Provider + enabled: true # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true) + provider: authentik # The name of your Provider autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider - idpIssuer: '' # The ID of your Provider - idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider - privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair - spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair - jwt: - enabled: true # set to 'true' to enable JWT authentication - expiration: 3600000 # Expiration time in milliseconds. Default is 1 hour (3600000 ms) - algorithm: HS256 # JWT signing algorithm. Default is HS256 - issuer: Stirling-PDF # Issuer of the JWT token. Default is 'Stirling-PDF' + idpIssuer: authentik # The ID of your Provider + idpCert: classpath:authentik-Self_Signed_Certificate.pem # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider + privateKey: classpath:private-key.key # Your private key. Generated from your keypair + spCert: classpath:cert.crt # Your signing certificate. Generated from your keypair premium: key: 00000000-0000-0000-0000-000000000000 - enabled: false # Enable license key checks for pro/enterprise features + enabled: true # Enable license key checks for pro/enterprise features proFeatures: - database: true # Enable database features + database: false # Enable database features SSOAutoLogin: false CustomMetadata: autoUpdateMetadata: false @@ -105,7 +100,7 @@ legal: system: defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc) googlevisibility: false # 'true' to allow Google visibility (via robots.txt), 'false' to disallow - enableAlphaFunctionality: false # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) + enableAlphaFunctionality: true # set to enable functionality which might need more testing before it fully goes live (this feature might make no changes) showUpdate: false # see when a new update is available 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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java index 63bb87613..8bcddf7d3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomAuthenticationSuccessHandler.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.security; import java.io.IOException; +import java.util.Map; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; @@ -17,6 +18,7 @@ import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; +import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.service.JWTServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -51,20 +53,17 @@ public class CustomAuthenticationSuccessHandler } loginAttemptService.loginSucceeded(userName); - // Generate JWT token if JWT authentication is enabled - boolean jwtEnabled = jwtService.isJwtEnabled(); - if (jwtEnabled) { + if (jwtService.isJwtEnabled()) { try { - String jwt = jwtService.generateToken(authentication); + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.WEB)); jwtService.addTokenToResponse(response, jwt); log.debug("JWT generated for user: {}", userName); } catch (Exception e) { log.error("Failed to generate JWT token for user: {}", userName, e); } - } - if (jwtEnabled) { - // JWT mode: stateless authentication, redirect after setting token getRedirectStrategy().sendRedirect(request, response, "/"); } else { // Get the saved request diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 8249d31f5..ceb474a7c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -71,10 +71,8 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { authentication.getClass().getSimpleName()); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } - } else if (jwtService.isJwtEnabled()) { - // Clear JWT token if JWT authentication is enabled + } else if (!jwtService.extractTokenFromRequest(request).isBlank()) { jwtService.clearTokenFromResponse(response); - log.debug("Cleared JWT from response"); getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); } else { // Redirect to login page after logout diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 6d3caa690..2abeb5682 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -127,15 +127,14 @@ public class SecurityConfiguration { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - boolean jwtEnabled = securityProperties.isJwtActive(); - - // Disable CSRF if explicitly disabled, login is disabled, or JWT is enabled (stateless) - if (securityProperties.getCsrfDisabled() || !loginEnabledValue || jwtEnabled) { + if (securityProperties.getCsrfDisabled() || !loginEnabledValue) { http.csrf(CsrfConfigurer::disable); } if (loginEnabledValue) { - if (jwtEnabled) { + boolean v2Enabled = appConfig.v2Enabled(); + + if (v2Enabled) { http.addFilterBefore( jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) @@ -143,13 +142,10 @@ public class SecurityConfiguration { exceptionHandling -> exceptionHandling.authenticationEntryPoint( jwtAuthenticationEntryPoint)); - } else { - http.addFilterBefore( - userAuthenticationFilter, - UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(userAuthenticationFilter, firstLoginFilter.getClass()); } - http.addFilterAfter(rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class); + http.addFilterAt(userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(rateLimitingFilter(), userAuthenticationFilter.getClass()) + .addFilterAfter(firstLoginFilter, rateLimitingFilter().getClass()); if (!securityProperties.getCsrfDisabled()) { CookieCsrfTokenRepository cookieRepo = @@ -189,7 +185,7 @@ public class SecurityConfiguration { // Configure session management based on JWT setting http.sessionManagement( sessionManagement -> { - if (jwtEnabled) { + if (v2Enabled) { sessionManagement.sessionCreationPolicy( SessionCreationPolicy.STATELESS); } else { @@ -290,8 +286,9 @@ public class SecurityConfiguration { .successHandler( new CustomOAuth2AuthenticationSuccessHandler( loginAttemptService, - securityProperties, - userService)) + securityProperties.getOauth2(), + userService, + jwtService)) .failureHandler( new CustomOAuth2AuthenticationFailureHandler()) // Add existing Authorities from the database @@ -326,8 +323,9 @@ public class SecurityConfiguration { .successHandler( new CustomSaml2AuthenticationSuccessHandler( loginAttemptService, - securityProperties, - userService)) + securityProperties.getSaml2(), + userService, + jwtService)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( @@ -372,6 +370,10 @@ public class SecurityConfiguration { @Bean public JWTAuthenticationFilter jwtAuthenticationFilter() { return new JWTAuthenticationFilter( - jwtService, userDetailsService, jwtAuthenticationEntryPoint); + jwtService, + userService, + userDetailsService, + jwtAuthenticationEntryPoint, + securityProperties); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 971cd4859..8a148e931 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -9,7 +9,6 @@ import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; @@ -92,14 +91,9 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { response.getWriter().write("Invalid API Key."); return; } - List authorities = - user.get().getAuthorities().stream() - .map( - authority -> - new SimpleGrantedAuthority( - authority.getAuthority())) - .toList(); - authentication = new ApiKeyAuthenticationToken(user.get(), apiKey, authorities); + authentication = + new ApiKeyAuthenticationToken( + user.get(), apiKey, user.get().getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (AuthenticationException e) { // If API key authentication fails, deny the request diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java index ca8140bca..b3042dd25 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/AuthenticationType.java @@ -2,5 +2,8 @@ package stirling.software.proprietary.security.model; public enum AuthenticationType { WEB, - SSO + SSO, + // TODO: Worth making a distinction between OAuth2 and SAML2? + OAUTH2, + SAML2 } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 382d3a71e..a32e7d7ca 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -2,6 +2,8 @@ package stirling.software.proprietary.security.model; import java.io.Serializable; +import org.springframework.security.core.GrantedAuthority; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -18,7 +20,7 @@ import lombok.Setter; @Table(name = "authorities") @Getter @Setter -public class Authority implements Serializable { +public class Authority implements GrantedAuthority, Serializable { private static final long serialVersionUID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index d3e232f61..7d1b235cd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -7,6 +7,8 @@ import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.security.core.userdetails.UserDetails; + import jakarta.persistence.*; import lombok.EqualsAndHashCode; @@ -25,7 +27,7 @@ import stirling.software.proprietary.model.Team; @Setter @EqualsAndHashCode(onlyExplicitlyIncluded = true) @ToString(onlyExplicitlyIncluded = true) -public class User implements Serializable { +public class User implements UserDetails, Serializable { private static final long serialVersionUID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java index 9b64bd68b..0a3dd937e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationSuccessHandler.java @@ -1,7 +1,11 @@ package stirling.software.proprietary.security.oauth2; +import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.io.IOException; import java.sql.SQLException; +import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -18,10 +22,10 @@ import jakarta.servlet.http.HttpSession; import lombok.RequiredArgsConstructor; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.service.JWTServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -30,8 +34,9 @@ public class CustomOAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private final LoginAttemptService loginAttemptService; - private final ApplicationProperties.Security securityProperties; + private final ApplicationProperties.Security.OAUTH2 oauth2Properties; private final UserService userService; + private final JWTServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -60,8 +65,6 @@ public class CustomOAuth2AuthenticationSuccessHandler // Redirect to the original destination super.onAuthenticationSuccess(request, response, authentication); } else { - OAUTH2 oAuth = securityProperties.getOauth2(); - if (loginAttemptService.isBlocked(username)) { if (session != null) { session.removeAttribute("SPRING_SECURITY_SAVED_REQUEST"); @@ -69,7 +72,12 @@ 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.addTokenToResponse(response, jwt); + } if (userService.isUserDisabled(username)) { getRedirectStrategy() .sendRedirect(request, response, "/logout?userIsDisabled=true"); @@ -77,20 +85,21 @@ public class CustomOAuth2AuthenticationSuccessHandler } if (userService.usernameExistsIgnoreCase(username) && userService.hasPassword(username) - && !userService.isAuthenticationTypeByUsername(username, AuthenticationType.SSO) - && oAuth.getAutoCreateUser()) { + && !userService.isAuthenticationTypeByUsername(username, SSO) + && oauth2Properties.getAutoCreateUser()) { response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (oAuth.getBlockRegistration() + if (oauth2Properties.getBlockRegistration() && !userService.usernameExistsIgnoreCase(username)) { response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true"); return; } if (principal instanceof OAuth2User) { - userService.processSSOPostLogin(username, oAuth.getAutoCreateUser()); + userService.processSSOPostLogin( + username, oauth2Properties.getAutoCreateUser(), OAUTH2); } response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java index 6516cc7d7..913dc458a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/OAuth2Configuration.java @@ -34,6 +34,7 @@ import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; import stirling.software.common.model.oauth2.Provider; +import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.exception.NoProviderFoundException; import stirling.software.proprietary.security.service.UserService; @@ -239,12 +240,14 @@ public class OAuth2Configuration { Optional userOpt = userService.findByUsernameIgnoreCase( (String) oAuth2Auth.getAttributes().get(useAsUsername)); - if (userOpt.isPresent()) { - User user = userOpt.get(); - mappedAuthorities.add( - new SimpleGrantedAuthority( - userService.findRole(user).getAuthority())); - } + userOpt.ifPresent( + user -> + mappedAuthorities.add( + new Authority( + userService + .findRole(user) + .getAuthority(), + user))); } }); return mappedAuthorities; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index eeb73ef7e..bc5ab5ecd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -1,7 +1,11 @@ package stirling.software.proprietary.security.saml2; +import static stirling.software.proprietary.security.model.AuthenticationType.SAML2; +import static stirling.software.proprietary.security.model.AuthenticationType.SSO; + import java.io.IOException; import java.sql.SQLException; +import java.util.Map; import org.springframework.security.authentication.LockedException; import org.springframework.security.core.Authentication; @@ -17,10 +21,10 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; -import stirling.software.common.model.ApplicationProperties.Security.SAML2; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.util.RequestUriUtils; import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.service.JWTServiceInterface; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.UserService; @@ -30,8 +34,9 @@ public class CustomSaml2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { private LoginAttemptService loginAttemptService; - private ApplicationProperties.Security securityProperties; + private ApplicationProperties.Security.SAML2 saml2Properties; private UserService userService; + private final JWTServiceInterface jwtService; @Override public void onAuthenticationSuccess( @@ -65,10 +70,20 @@ public class CustomSaml2AuthenticationSuccessHandler savedRequest.getRedirectUrl()); super.onAuthenticationSuccess(request, response, authentication); } else { - SAML2 saml2 = securityProperties.getSaml2(); + if (jwtService.isJwtEnabled()) { + String jwt = + jwtService.generateToken( + authentication, Map.of("authType", AuthenticationType.SAML2)); + jwtService.addTokenToResponse(response, jwt); + + super.onAuthenticationSuccess(request, response, authentication); + // getRedirectStrategy().sendRedirect(request, response, + // "/"); + // return; + } log.debug( "Processing SAML2 authentication with autoCreateUser: {}", - saml2.getAutoCreateUser()); + saml2Properties.getAutoCreateUser()); if (loginAttemptService.isBlocked(username)) { log.debug("User {} is blocked due to too many login attempts", username); @@ -82,17 +97,21 @@ public class CustomSaml2AuthenticationSuccessHandler boolean userExists = userService.usernameExistsIgnoreCase(username); boolean hasPassword = userExists && userService.hasPassword(username); boolean isSSOUser = - userExists - && userService.isAuthenticationTypeByUsername( - username, AuthenticationType.SSO); + userExists && userService.isAuthenticationTypeByUsername(username, SSO); + boolean isSAML2User = + userExists && userService.isAuthenticationTypeByUsername(username, SAML2); log.debug( - "User status - Exists: {}, Has password: {}, Is SSO user: {}", + "User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}", userExists, hasPassword, - isSSOUser); + isSSOUser, + isSAML2User); - if (userExists && hasPassword && !isSSOUser && saml2.getAutoCreateUser()) { + if (userExists + && hasPassword + && (!isSSOUser || !isSAML2User) + && saml2Properties.getAutoCreateUser()) { log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); @@ -102,14 +121,15 @@ public class CustomSaml2AuthenticationSuccessHandler } try { - if (saml2.getBlockRegistration() && !userExists) { + if (saml2Properties.getBlockRegistration() && !userExists) { log.debug("Registration blocked for new user: {}", username); response.sendRedirect( contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } log.debug("Processing SSO post-login for user: {}", username); - userService.processSSOPostLogin(username, saml2.getAutoCreateUser()); + userService.processSSOPostLogin( + username, saml2Properties.getAutoCreateUser(), SAML2); log.debug("Successfully processed authentication for user: {}", username); response.sendRedirect(contextPath + "/"); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java index 7fd4768b3..70fc9a154 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/SAML2Configuration.java @@ -54,7 +54,7 @@ public class SAML2Configuration { .entityId(samlConf.getIdpIssuer()) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .singleLogoutServiceResponseLocation("{baseUrl}:{basePort}/login") .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") @@ -76,10 +76,17 @@ public class SAML2Configuration { return new InMemoryRelyingPartyRegistrationRepository(rp); } + @Bean + @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") + public HttpSessionSaml2AuthenticationRequestRepository saml2AuthenticationRequestRepository() { + return new HttpSessionSaml2AuthenticationRequestRepository(); + } + @Bean @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver( - RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) { + RelyingPartyRegistrationRepository relyingPartyRegistrationRepository, + HttpSessionSaml2AuthenticationRequestRepository saml2AuthenticationRequestRepository) { OpenSaml4AuthenticationRequestResolver resolver = new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository); @@ -87,10 +94,8 @@ public class SAML2Configuration { customizer -> { HttpServletRequest request = customizer.getRequest(); AuthnRequest authnRequest = customizer.getAuthnRequest(); - HttpSessionSaml2AuthenticationRequestRepository requestRepository = - new HttpSessionSaml2AuthenticationRequestRepository(); AbstractSaml2AuthenticationRequest saml2AuthenticationRequest = - requestRepository.loadAuthenticationRequest(request); + saml2AuthenticationRequestRepository.loadAuthenticationRequest(request); if (saml2AuthenticationRequest != null) { String sessionId = request.getSession(false).getId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java index 6ece48a4e..1704e5972 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomUserDetailsService.java @@ -1,11 +1,6 @@ package stirling.software.proprietary.security.service; -import java.util.Collection; -import java.util.Set; - import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; @@ -14,7 +9,7 @@ import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; import stirling.software.proprietary.security.database.repository.UserRepository; -import stirling.software.proprietary.security.model.Authority; +import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.User; @Service @@ -34,26 +29,18 @@ public class CustomUserDetailsService implements UserDetailsService { () -> new UsernameNotFoundException( "No user found with username: " + username)); + if (loginAttemptService.isBlocked(username)) { throw new LockedException( "Your account has been locked due to too many failed login attempts."); } - if (!user.hasPassword()) { + + AuthenticationType userAuthenticationType = + AuthenticationType.valueOf(user.getAuthenticationType().toUpperCase()); + if (!user.hasPassword() && userAuthenticationType == AuthenticationType.WEB) { throw new IllegalArgumentException("Password must not be null"); } - return new org.springframework.security.core.userdetails.User( - user.getUsername(), - user.getPassword(), - user.isEnabled(), - true, - true, - true, - getAuthorities(user.getAuthorities())); - } - private Collection getAuthorities(Set authorities) { - return authorities.stream() - .map(authority -> new SimpleGrantedAuthority(authority.getAuthority())) - .toList(); + return user; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 50c8027f6..8cddebcbf 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -15,7 +15,6 @@ import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; @@ -73,7 +72,8 @@ public class UserService implements UserServiceInterface { } // Handle OAUTH2 login and user auto creation. - public void processSSOPostLogin(String username, boolean autoCreateUser) + public void processSSOPostLogin( + String username, boolean autoCreateUser, AuthenticationType type) throws IllegalArgumentException, SQLException, UnsupportedProviderException { if (!isUsernameValid(username)) { return; @@ -83,7 +83,7 @@ public class UserService implements UserServiceInterface { return; } if (autoCreateUser) { - saveUser(username, AuthenticationType.SSO); + saveUser(username, type); } } @@ -100,10 +100,7 @@ public class UserService implements UserServiceInterface { } private Collection getAuthorities(User user) { - // Convert each Authority object into a SimpleGrantedAuthority object. - return user.getAuthorities().stream() - .map((Authority authority) -> new SimpleGrantedAuthority(authority.getAuthority())) - .toList(); + return user.getAuthorities(); } private String generateApiKey() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java index 4e4bf8552..89851ce91 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/CustomLogoutSuccessHandlerTest.java @@ -29,10 +29,12 @@ class CustomLogoutSuccessHandlerTest { void testSuccessfulLogout() throws IOException { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); - String logoutPath = "logout=true"; + String token = "token"; + String logoutPath = "/login?logout=true"; when(response.isCommitted()).thenReturn(false); - when(jwtService.isJwtEnabled()).thenReturn(false); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doNothing().when(jwtService).clearTokenFromResponse(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath); @@ -46,9 +48,11 @@ class CustomLogoutSuccessHandlerTest { HttpServletRequest request = mock(HttpServletRequest.class); HttpServletResponse response = mock(HttpServletResponse.class); String logoutPath = "/login?logout=true"; + String token = "token"; when(response.isCommitted()).thenReturn(false); - when(jwtService.isJwtEnabled()).thenReturn(true); + when(jwtService.extractTokenFromRequest(request)).thenReturn(token); + doNothing().when(jwtService).clearTokenFromResponse(response); when(request.getContextPath()).thenReturn(""); when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);