Removed file

This commit is contained in:
Dario Ghunney Ware 2025-07-10 13:41:12 +01:00
parent 006c186694
commit b53ac89541
19 changed files with 154 additions and 261 deletions

120
CLAUDE.md
View File

@ -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

View File

@ -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<String> lines = Files.lines(mountInfo)) {
return lines.anyMatch(line -> line.contains(" /configs "));
} catch (IOException e) {
return false;
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);
}
}

View File

@ -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<SimpleGrantedAuthority> 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

View File

@ -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
}

View File

@ -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;

View File

@ -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;

View File

@ -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) {

View File

@ -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<User> userOpt =
userService.findByUsernameIgnoreCase(
(String) oAuth2Auth.getAttributes().get(useAsUsername));
if (userOpt.isPresent()) {
User user = userOpt.get();
userOpt.ifPresent(
user ->
mappedAuthorities.add(
new SimpleGrantedAuthority(
userService.findRole(user).getAuthority()));
}
new Authority(
userService
.findRole(user)
.getAuthority(),
user)));
}
});
return mappedAuthorities;

View File

@ -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) {

View File

@ -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();

View File

@ -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<? extends GrantedAuthority> getAuthorities(Set<Authority> authorities) {
return authorities.stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthority()))
.toList();
return user;
}
}

View File

@ -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<? extends GrantedAuthority> 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() {

View File

@ -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);