mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 02:25:21 +00:00
Removed file
This commit is contained in:
parent
006c186694
commit
b53ac89541
120
CLAUDE.md
120
CLAUDE.md
@ -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
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
mappedAuthorities.add(
|
||||
new SimpleGrantedAuthority(
|
||||
userService.findRole(user).getAuthority()));
|
||||
}
|
||||
userOpt.ifPresent(
|
||||
user ->
|
||||
mappedAuthorities.add(
|
||||
new Authority(
|
||||
userService
|
||||
.findRole(user)
|
||||
.getAuthority(),
|
||||
user)));
|
||||
}
|
||||
});
|
||||
return mappedAuthorities;
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user