saml test

This commit is contained in:
Anthony Stirling 2024-09-04 15:20:44 +01:00
parent 2989d8d416
commit a875ae3034
19 changed files with 567 additions and 387 deletions

View File

@ -32,6 +32,12 @@ java {
repositories {
mavenCentral()
maven { url "https://jitpack.io" }
maven {
url "https://build.shibboleth.net/nexus/content/repositories/releases/"
}
maven {
url "https://build.shibboleth.net/maven/releases/"
}
}
licenseReport {
@ -114,6 +120,10 @@ configurations.all {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
}
dependencies {
//security updates
implementation "org.springframework:spring-webmvc:6.1.9"
@ -128,7 +138,7 @@ dependencies {
implementation "org.springframework.boot:spring-boot-starter-thymeleaf:$springBootVersion"
if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") {
if (System.getenv("DOCKER_ENABLE_SECURITY") != "false") {
implementation "org.springframework.boot:spring-boot-starter-security:$springBootVersion"
runtimeOnly "org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.2.RELEASE"
implementation "org.springframework.boot:spring-boot-starter-data-jpa:$springBootVersion"
@ -137,7 +147,8 @@ dependencies {
//2.2.x requires rebuild of DB file.. need migration path
runtimeOnly "com.h2database:h2:2.1.214"
// implementation "com.h2database:h2:2.2.224"
}
implementation 'org.springframework.security:spring-security-saml2-service-provider:6.3.3'
}
testImplementation "org.springframework.boot:spring-boot-starter-test:$springBootVersion"

View File

@ -6,37 +6,20 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ClientRegistrations;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutSuccessHandler;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutSuccessHandler;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestValidator;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestValidator;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseValidator;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseValidator;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
@ -47,19 +30,11 @@ import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationF
import stirling.software.SPDF.config.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.SPDF.config.security.oauth2.CustomOAuth2LogoutSuccessHandler;
import stirling.software.SPDF.config.security.oauth2.CustomOAuth2UserService;
import stirling.software.SPDF.config.security.saml.CustomSAMLAuthenticationSuccessHandler;
import stirling.software.SPDF.config.security.saml.CustomSAMLAuthenticationFailureHandler;
import stirling.software.SPDF.config.security.saml.SAMLUserDetailsService;
import stirling.software.SPDF.config.security.saml.SAMLConfig;
import stirling.software.SPDF.config.security.saml.CustomSAMLAuthenticationSuccessHandler;
import stirling.software.SPDF.config.security.saml.SAMLLogoutSuccessHandler;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.provider.GithubProvider;
import stirling.software.SPDF.model.provider.GoogleProvider;
import stirling.software.SPDF.model.provider.KeycloakProvider;
import stirling.software.SPDF.repository.JPATokenRepositoryImpl;
@Configuration
@ -69,6 +44,15 @@ public class SecurityConfiguration {
@Autowired private CustomUserDetailsService userDetailsService;
@Autowired(required = false)
private GrantedAuthoritiesMapper userAuthoritiesMapper;
@Autowired(required = false)
private OpenSaml4AuthenticationProvider samlAuthenticationProvider;
@Autowired(required = false)
private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
private static final Logger logger = LoggerFactory.getLogger(SecurityConfiguration.class);
@Bean
@ -153,6 +137,7 @@ public class SecurityConfiguration {
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith(
"/register")
@ -202,7 +187,7 @@ public class SecurityConfiguration {
userService,
loginAttemptService))
.userAuthoritiesMapper(
userAuthoritiesMapper())))
userAuthoritiesMapper)))
.logout(
logout ->
logout.logoutSuccessHandler(
@ -220,13 +205,19 @@ public class SecurityConfiguration {
http.saml2Login(
saml2 ->
saml2.loginPage("/saml2")
saml2.relyingPartyRegistrationRepository(
relyingPartyRegistrationRepository)
.loginProcessingUrl("/login/saml2/sso/stirling")
.loginPage("/saml2")
.authenticationManager(
new ProviderManager(
samlAuthenticationProvider))
.successHandler(
new CustomSAMLAuthenticationSuccessHandler(
loginAttemptService, userService))
.failureHandler(
new CustomSAMLAuthenticationFailureHandler())
.userDetailsService(new SAMLUserDetailsService()))
new CustomSAMLAuthenticationFailureHandler()))
.saml2Metadata(Customizer.withDefaults())
.logout(
logout ->
logout.logoutSuccessHandler(
@ -240,178 +231,6 @@ public class SecurityConfiguration {
return http.build();
}
// Client Registration Repository for OAUTH2 OIDC Login
@Bean
@ConditionalOnProperty(
value = "security.oauth2.enabled",
havingValue = "true",
matchIfMissing = false)
public ClientRegistrationRepository clientRegistrationRepository() {
List<ClientRegistration> registrations = new ArrayList<>();
githubClientRegistration().ifPresent(registrations::add);
oidcClientRegistration().ifPresent(registrations::add);
googleClientRegistration().ifPresent(registrations::add);
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
logger.error("At least one OAuth2 provider must be configured");
System.exit(1);
}
return new InMemoryClientRegistrationRepository(registrations);
}
private Optional<ClientRegistration> googleClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null || !oauth.getEnabled()) {
return Optional.empty();
}
Client client = oauth.getClient();
if (client == null) {
return Optional.empty();
}
GoogleProvider google = client.getGoogle();
return google != null && google.isSettingsValid()
? Optional.of(
ClientRegistration.withRegistrationId(google.getName())
.clientId(google.getClientId())
.clientSecret(google.getClientSecret())
.scope(google.getScopes())
.authorizationUri(google.getAuthorizationuri())
.tokenUri(google.getTokenuri())
.userInfoUri(google.getUserinfouri())
.userNameAttributeName(google.getUseAsUsername())
.clientName(google.getClientName())
.redirectUri("{baseUrl}/login/oauth2/code/" + google.getName())
.authorizationGrantType(
org.springframework.security.oauth2.core
.AuthorizationGrantType.AUTHORIZATION_CODE)
.build())
: Optional.empty();
}
private Optional<ClientRegistration> keycloakClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null || !oauth.getEnabled()) {
return Optional.empty();
}
Client client = oauth.getClient();
if (client == null) {
return Optional.empty();
}
KeycloakProvider keycloak = client.getKeycloak();
return keycloak != null && keycloak.isSettingsValid()
? Optional.of(
ClientRegistrations.fromIssuerLocation(keycloak.getIssuer())
.registrationId(keycloak.getName())
.clientId(keycloak.getClientId())
.clientSecret(keycloak.getClientSecret())
.scope(keycloak.getScopes())
.userNameAttributeName(keycloak.getUseAsUsername())
.clientName(keycloak.getClientName())
.build())
: Optional.empty();
}
private Optional<ClientRegistration> githubClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null || !oauth.getEnabled()) {
return Optional.empty();
}
Client client = oauth.getClient();
if (client == null) {
return Optional.empty();
}
GithubProvider github = client.getGithub();
return github != null && github.isSettingsValid()
? Optional.of(
ClientRegistration.withRegistrationId(github.getName())
.clientId(github.getClientId())
.clientSecret(github.getClientSecret())
.scope(github.getScopes())
.authorizationUri(github.getAuthorizationuri())
.tokenUri(github.getTokenuri())
.userInfoUri(github.getUserinfouri())
.userNameAttributeName(github.getUseAsUsername())
.clientName(github.getClientName())
.redirectUri("{baseUrl}/login/oauth2/code/" + github.getName())
.authorizationGrantType(
org.springframework.security.oauth2.core
.AuthorizationGrantType.AUTHORIZATION_CODE)
.build())
: Optional.empty();
}
private Optional<ClientRegistration> oidcClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null
|| oauth.getIssuer() == null
|| oauth.getIssuer().isEmpty()
|| oauth.getClientId() == null
|| oauth.getClientId().isEmpty()
|| oauth.getClientSecret() == null
|| oauth.getClientSecret().isEmpty()
|| oauth.getScopes() == null
|| oauth.getScopes().isEmpty()
|| oauth.getUseAsUsername() == null
|| oauth.getUseAsUsername().isEmpty()) {
return Optional.empty();
}
return Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId("oidc")
.clientId(oauth.getClientId())
.clientSecret(oauth.getClientSecret())
.scope(oauth.getScopes())
.userNameAttributeName(oauth.getUseAsUsername())
.clientName("OIDC")
.build());
}
/*
This following function is to grant Authorities to the OAUTH2 user from the values stored in the database.
This is required for the internal; 'hasRole()' function to give out the correct role.
*/
@Bean
@ConditionalOnProperty(
value = "security.oauth2.enabled",
havingValue = "true",
matchIfMissing = false)
GrantedAuthoritiesMapper userAuthoritiesMapper() {
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(
authority -> {
// Add existing OAUTH2 Authorities
mappedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority()));
// Add Authorities from database for existing user, if user is present.
if (authority instanceof OAuth2UserAuthority oauth2Auth) {
String useAsUsername =
applicationProperties
.getSecurity()
.getOAUTH2()
.getUseAsUsername();
Optional<User> userOpt =
userService.findByUsernameIgnoreCase(
(String) oauth2Auth.getAttributes().get(useAsUsername));
if (userOpt.isPresent()) {
User user = userOpt.get();
if (user != null) {
mappedAuthorities.add(
new SimpleGrantedAuthority(
userService.findRole(user).getAuthority()));
}
}
}
});
return mappedAuthorities;
};
}
@Bean
public IPRateLimitingFilter rateLimitingFilter() {
int maxRequestsPerIp = 1000000; // Example limit TODO add config level
@ -427,42 +246,4 @@ public class SecurityConfiguration {
public boolean activSecurity() {
return true;
}
// SAML Configuration
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
RelyingPartyRegistration registration = RelyingPartyRegistration
.withRegistrationId("saml")
.entityId(applicationProperties.getSecurity().getSAML().getEntityId())
.assertionConsumerServiceLocation(applicationProperties.getSecurity().getSAML().getSpBaseUrl() + "/saml2/acs")
.singleLogoutServiceLocation(applicationProperties.getSecurity().getSAML().getSpBaseUrl() + "/saml2/logout")
.idpWebSsoUrl(applicationProperties.getSecurity().getSAML().getIdpMetadataLocation())
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Bean
public Saml2LogoutRequestRepository logoutRequestRepository() {
return new OpenSaml4LogoutRequestRepository();
}
@Bean
public Saml2LogoutResponseRepository logoutResponseRepository() {
return new OpenSaml4LogoutResponseRepository();
}
@Bean
public Saml2LogoutSuccessHandler logoutSuccessHandler() {
return new OpenSaml4LogoutSuccessHandler();
}
@Bean
public Saml2LogoutRequestValidator logoutRequestValidator() {
return new OpenSaml4LogoutRequestValidator();
}
@Bean
public Saml2LogoutResponseValidator logoutResponseValidator() {
return new OpenSaml4LogoutResponseValidator();
}
}

View File

@ -1,5 +1,9 @@
package stirling.software.SPDF.config.security;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
@ -13,6 +17,7 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import stirling.software.SPDF.config.DatabaseBackupInterface;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.controller.api.pipeline.UserServiceInterface;
@ -23,10 +28,6 @@ import stirling.software.SPDF.model.User;
import stirling.software.SPDF.repository.AuthorityRepository;
import stirling.software.SPDF.repository.UserRepository;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class UserService implements UserServiceInterface {

View File

@ -0,0 +1,211 @@
package stirling.software.SPDF.config.security.oauth2;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.ClientRegistrations;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.model.ApplicationProperties;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2.Client;
import stirling.software.SPDF.model.User;
import stirling.software.SPDF.model.provider.GithubProvider;
import stirling.software.SPDF.model.provider.GoogleProvider;
import stirling.software.SPDF.model.provider.KeycloakProvider;
@Configuration
@Slf4j
public class OAuth2Config {
@Autowired ApplicationProperties applicationProperties;
@Autowired @Lazy private UserService userService;
// Client Registration Repository for OAUTH2 OIDC Login
@Bean
@ConditionalOnProperty(
value = "security.oauth2.enabled",
havingValue = "true",
matchIfMissing = false)
public ClientRegistrationRepository clientRegistrationRepository() {
List<ClientRegistration> registrations = new ArrayList<>();
githubClientRegistration().ifPresent(registrations::add);
oidcClientRegistration().ifPresent(registrations::add);
googleClientRegistration().ifPresent(registrations::add);
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
log.error("At least one OAuth2 provider must be configured");
System.exit(1);
}
return new InMemoryClientRegistrationRepository(registrations);
}
private Optional<ClientRegistration> googleClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null || !oauth.getEnabled()) {
return Optional.empty();
}
Client client = oauth.getClient();
if (client == null) {
return Optional.empty();
}
GoogleProvider google = client.getGoogle();
return google != null && google.isSettingsValid()
? Optional.of(
ClientRegistration.withRegistrationId(google.getName())
.clientId(google.getClientId())
.clientSecret(google.getClientSecret())
.scope(google.getScopes())
.authorizationUri(google.getAuthorizationuri())
.tokenUri(google.getTokenuri())
.userInfoUri(google.getUserinfouri())
.userNameAttributeName(google.getUseAsUsername())
.clientName(google.getClientName())
.redirectUri("{baseUrl}/login/oauth2/code/" + google.getName())
.authorizationGrantType(
org.springframework.security.oauth2.core
.AuthorizationGrantType.AUTHORIZATION_CODE)
.build())
: Optional.empty();
}
private Optional<ClientRegistration> keycloakClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null || !oauth.getEnabled()) {
return Optional.empty();
}
Client client = oauth.getClient();
if (client == null) {
return Optional.empty();
}
KeycloakProvider keycloak = client.getKeycloak();
return keycloak != null && keycloak.isSettingsValid()
? Optional.of(
ClientRegistrations.fromIssuerLocation(keycloak.getIssuer())
.registrationId(keycloak.getName())
.clientId(keycloak.getClientId())
.clientSecret(keycloak.getClientSecret())
.scope(keycloak.getScopes())
.userNameAttributeName(keycloak.getUseAsUsername())
.clientName(keycloak.getClientName())
.build())
: Optional.empty();
}
private Optional<ClientRegistration> githubClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null || !oauth.getEnabled()) {
return Optional.empty();
}
Client client = oauth.getClient();
if (client == null) {
return Optional.empty();
}
GithubProvider github = client.getGithub();
return github != null && github.isSettingsValid()
? Optional.of(
ClientRegistration.withRegistrationId(github.getName())
.clientId(github.getClientId())
.clientSecret(github.getClientSecret())
.scope(github.getScopes())
.authorizationUri(github.getAuthorizationuri())
.tokenUri(github.getTokenuri())
.userInfoUri(github.getUserinfouri())
.userNameAttributeName(github.getUseAsUsername())
.clientName(github.getClientName())
.redirectUri("{baseUrl}/login/oauth2/code/" + github.getName())
.authorizationGrantType(
org.springframework.security.oauth2.core
.AuthorizationGrantType.AUTHORIZATION_CODE)
.build())
: Optional.empty();
}
private Optional<ClientRegistration> oidcClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOAUTH2();
if (oauth == null
|| oauth.getIssuer() == null
|| oauth.getIssuer().isEmpty()
|| oauth.getClientId() == null
|| oauth.getClientId().isEmpty()
|| oauth.getClientSecret() == null
|| oauth.getClientSecret().isEmpty()
|| oauth.getScopes() == null
|| oauth.getScopes().isEmpty()
|| oauth.getUseAsUsername() == null
|| oauth.getUseAsUsername().isEmpty()) {
return Optional.empty();
}
return Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId("oidc")
.clientId(oauth.getClientId())
.clientSecret(oauth.getClientSecret())
.scope(oauth.getScopes())
.userNameAttributeName(oauth.getUseAsUsername())
.clientName("OIDC")
.build());
}
/*
This following function is to grant Authorities to the OAUTH2 user from the values stored in the database.
This is required for the internal; 'hasRole()' function to give out the correct role.
*/
@Bean
@ConditionalOnProperty(
value = "security.oauth2.enabled",
havingValue = "true",
matchIfMissing = false)
GrantedAuthoritiesMapper userAuthoritiesMapper() {
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(
authority -> {
// Add existing OAUTH2 Authorities
mappedAuthorities.add(new SimpleGrantedAuthority(authority.getAuthority()));
// Add Authorities from database for existing user, if user is present.
if (authority instanceof OAuth2UserAuthority oauth2Auth) {
String useAsUsername =
applicationProperties
.getSecurity()
.getOAUTH2()
.getUseAsUsername();
Optional<User> userOpt =
userService.findByUsernameIgnoreCase(
(String) oauth2Auth.getAttributes().get(useAsUsername));
if (userOpt.isPresent()) {
User user = userOpt.get();
if (user != null) {
mappedAuthorities.add(
new SimpleGrantedAuthority(
userService.findRole(user).getAuthority()));
}
}
}
});
return mappedAuthorities;
};
}
}

View File

@ -41,7 +41,8 @@ public class CustomSAMLAuthenticationFailureHandler extends SimpleUrlAuthenticat
}
if (exception instanceof Saml2AuthenticationException) {
log.error("SAML2 Authentication error: ", exception);
getRedirectStrategy().sendRedirect(request, response, "/logout?error=saml2AuthenticationError");
getRedirectStrategy()
.sendRedirect(request, response, "/logout?error=saml2AuthenticationError");
return;
}
log.error("Unhandled authentication exception", exception);

View File

@ -11,21 +11,26 @@ import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.LoginAttemptService;
import stirling.software.SPDF.config.security.UserService;
import stirling.software.SPDF.utils.RequestUriUtils;
@Slf4j
public class CustomSAMLAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
public class CustomSAMLAuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private LoginAttemptService loginAttemptService;
private UserService userService;
public CustomSAMLAuthenticationSuccessHandler(LoginAttemptService loginAttemptService, UserService userService) {
public CustomSAMLAuthenticationSuccessHandler(
LoginAttemptService loginAttemptService, UserService userService) {
this.loginAttemptService = loginAttemptService;
this.userService = userService;
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws ServletException, IOException {
String userName = request.getParameter("username");
@ -37,9 +42,14 @@ public class CustomSAMLAuthenticationSuccessHandler extends SavedRequestAwareAut
// Get the saved request
HttpSession session = request.getSession(false);
SavedRequest savedRequest = (session != null) ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") : null;
SavedRequest savedRequest =
(session != null)
? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST")
: null;
if (savedRequest != null && !RequestUriUtils.isStaticResource(request.getContextPath(), savedRequest.getRedirectUrl())) {
if (savedRequest != null
&& !RequestUriUtils.isStaticResource(
request.getContextPath(), savedRequest.getRedirectUrl())) {
// Redirect to the original destination
super.onAuthenticationSuccess(request, response, authentication);
} else {

View File

@ -1,66 +0,0 @@
package stirling.software.SPDF.config.security.saml;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseRepository;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutSuccessHandler;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutSuccessHandler;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutRequestValidator;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestValidator;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2LogoutResponseValidator;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutResponseValidator;
import stirling.software.SPDF.model.ApplicationProperties;
@Configuration
public class SAMLConfig {
private final ApplicationProperties applicationProperties;
public SAMLConfig(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Bean
public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
RelyingPartyRegistration registration = RelyingPartyRegistration
.withRegistrationId("saml")
.entityId(applicationProperties.getSecurity().getSAML().getEntityId())
.assertionConsumerServiceLocation(applicationProperties.getSecurity().getSAML().getSpBaseUrl() + "/saml2/acs")
.singleLogoutServiceLocation(applicationProperties.getSecurity().getSAML().getSpBaseUrl() + "/saml2/logout")
.idpWebSsoUrl(applicationProperties.getSecurity().getSAML().getIdpMetadataLocation())
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
@Bean
public Saml2LogoutRequestRepository logoutRequestRepository() {
return new OpenSaml4LogoutRequestRepository();
}
@Bean
public Saml2LogoutResponseRepository logoutResponseRepository() {
return new OpenSaml4LogoutResponseRepository();
}
@Bean
public Saml2LogoutSuccessHandler logoutSuccessHandler() {
return new OpenSaml4LogoutSuccessHandler();
}
@Bean
public Saml2LogoutRequestValidator logoutRequestValidator() {
return new OpenSaml4LogoutRequestValidator();
}
@Bean
public Saml2LogoutResponseValidator logoutResponseValidator() {
return new OpenSaml4LogoutResponseValidator();
}
}

View File

@ -29,7 +29,9 @@ public class SAMLLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
protected String determineTargetUrl(
HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
// Default to the root URL
return "/";
}

View File

@ -1,38 +0,0 @@
package stirling.software.SPDF.config.security.saml;
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;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticatedPrincipal;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class SAMLUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
throw new UnsupportedOperationException("This method is not supported for SAML authentication");
}
public UserDetails loadUserBySAML(Saml2Authentication authentication) {
Saml2AuthenticatedPrincipal principal = (Saml2AuthenticatedPrincipal) authentication.getPrincipal();
String username = principal.getName();
Collection<? extends GrantedAuthority> authorities = extractAuthorities(principal);
return new org.springframework.security.core.userdetails.User(username, "", authorities);
}
private Collection<? extends GrantedAuthority> extractAuthorities(Saml2AuthenticatedPrincipal principal) {
List<String> roles = principal.getAttribute("roles");
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,216 @@
package stirling.software.SPDF.config.security.saml;
import java.io.InputStream;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.security.saml2.core.Saml2X509Credential;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationToken;
import org.springframework.security.saml2.provider.service.metadata.OpenSamlMetadataResolver;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.DefaultRelyingPartyRegistrationResolver;
import org.springframework.security.saml2.provider.service.web.Saml2MetadataFilter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.ApplicationProperties;
@Configuration
@Slf4j
public class SamlConfig {
@Autowired ApplicationProperties applicationProperties;
@Bean
public OpenSaml4AuthenticationProvider openSaml4AuthenticationProvider() {
OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
provider.setResponseAuthenticationConverter(
responseToken -> {
Saml2AuthenticationToken token = responseToken.getToken();
log.info("Received SAML response: {}", token.getSaml2Response());
// Your custom conversion logic here
// For now, we'll just return the token as is
return token;
});
return provider;
}
@Bean
@ConditionalOnProperty(
value = "security.saml.enabled",
havingValue = "true",
matchIfMissing = false)
public RelyingPartyRegistrationRepository relyingPartyRegistrationRepository() {
RelyingPartyRegistration registration =
RelyingPartyRegistration.withRegistrationId(
applicationProperties.getSecurity().getSAML().getRegistrationId())
.entityId(applicationProperties.getSecurity().getSAML().getEntityId())
.assertionConsumerServiceLocation(
applicationProperties.getSecurity().getSAML().getSpBaseUrl()
+ "/login/saml2/sso/stirling")
.singleLogoutServiceLocation(
applicationProperties.getSecurity().getSAML().getSpBaseUrl()
+ "/logout/saml2/slo")
.singleLogoutServiceResponseLocation(
applicationProperties.getSecurity().getSAML().getSpBaseUrl()
+ "/logout/saml2/slo")
.signingX509Credentials(credentials -> credentials.add(signingCredential()))
.assertingPartyDetails(
party ->
party.entityId(
applicationProperties
.getSecurity()
.getSAML()
.getEntityId())
.singleSignOnServiceLocation(
applicationProperties
.getSecurity()
.getSAML()
.getIdpMetadataLocation())
.wantAuthnRequestsSigned(true)
.verificationX509Credentials(
c -> c.add(this.realmCertificate())))
.build();
return new InMemoryRelyingPartyRegistrationRepository(registration);
}
private Saml2X509Credential signingCredential() {
log.info("Starting to load signing credential");
try {
Resource storeResource =
applicationProperties
.getSecurity()
.getSAML()
.getKeystore()
.getKeystoreResource();
log.info("Keystore resource: {}", storeResource.getDescription());
KeyStore keyStore = KeyStore.getInstance("JKS");
try (InputStream is = storeResource.getInputStream()) {
keyStore.load(
is,
applicationProperties
.getSecurity()
.getSAML()
.getKeystore()
.getKeystorePassword()
.toCharArray());
log.info("Keystore loaded successfully");
}
String keyAlias =
applicationProperties.getSecurity().getSAML().getKeystore().getKeyAlias();
log.info("Attempting to retrieve private key with alias: {}", keyAlias);
PrivateKey privateKey =
(PrivateKey)
keyStore.getKey(
keyAlias,
applicationProperties
.getSecurity()
.getSAML()
.getKeystore()
.getKeyPassword()
.toCharArray());
if (privateKey == null) {
log.error("Private key not found for alias: {}", keyAlias);
throw new RuntimeException("Private key not found in keystore");
}
log.info("Private key retrieved successfully");
X509Certificate certificate = (X509Certificate) keyStore.getCertificate(keyAlias);
if (certificate == null) {
log.info("Certificate not found for alias: {}", keyAlias);
throw new RuntimeException("Certificate not found in keystore");
}
log.info(
"Certificate retrieved successfully. Subject: {}",
certificate.getSubjectX500Principal());
log.info("Signing credential created successfully");
return Saml2X509Credential.signing(privateKey, certificate);
} catch (Exception e) {
log.error("Error loading signing credential", e);
throw new RuntimeException("Error loading signing credential", e);
}
}
private Saml2X509Credential realmCertificate() {
log.info("Starting to load realm certificate");
try {
Resource storeResource =
applicationProperties
.getSecurity()
.getSAML()
.getKeystore()
.getKeystoreResource();
log.info("Keystore resource: {}", storeResource.getDescription());
KeyStore keyStore = KeyStore.getInstance("JKS");
try (InputStream is = storeResource.getInputStream()) {
keyStore.load(
is,
applicationProperties
.getSecurity()
.getSAML()
.getKeystore()
.getKeystorePassword()
.toCharArray());
log.info("Keystore loaded successfully");
}
String realmCertificateAlias =
applicationProperties
.getSecurity()
.getSAML()
.getKeystore()
.getRealmCertificateAlias();
log.info(
"Attempting to retrieve realm certificate with alias: {}",
realmCertificateAlias);
X509Certificate certificate =
(X509Certificate) keyStore.getCertificate(realmCertificateAlias);
if (certificate == null) {
log.error("Realm certificate not found for alias: {}", realmCertificateAlias);
throw new RuntimeException("Realm certificate not found in keystore");
}
log.info(
"Realm certificate retrieved successfully. Subject: {}",
certificate.getSubjectX500Principal());
log.info("Realm certificate credential created successfully");
return Saml2X509Credential.verification(certificate);
} catch (Exception e) {
log.error("Error loading realm certificate", e);
throw new RuntimeException("Error loading realm certificate", e);
}
}
@Bean
@ConditionalOnProperty(
value = "security.saml.enabled",
havingValue = "true",
matchIfMissing = false)
public Saml2MetadataFilter metadataFilter(RelyingPartyRegistrationRepository registrations) {
DefaultRelyingPartyRegistrationResolver registrationResolver =
new DefaultRelyingPartyRegistrationResolver(registrations);
OpenSamlMetadataResolver metadataResolver = new OpenSamlMetadataResolver();
return new Saml2MetadataFilter(registrationResolver, metadataResolver);
}
}

View File

@ -1,10 +1,10 @@
package stirling.software.SPDF.controller.web;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
@ -13,6 +13,14 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.security.session.SessionPersistentRegistry;
import stirling.software.SPDF.model.*;
import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2;
@ -22,11 +30,6 @@ import stirling.software.SPDF.model.provider.GoogleProvider;
import stirling.software.SPDF.model.provider.KeycloakProvider;
import stirling.software.SPDF.repository.UserRepository;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@Controller
@Slf4j
@Tag(name = "Account Security", description = "Account Security APIs")

View File

@ -1,5 +1,6 @@
package stirling.software.SPDF.model;
import java.security.KeyStore;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@ -11,7 +12,11 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import lombok.Data;
import stirling.software.SPDF.config.YamlPropertySourceFactory;
import stirling.software.SPDF.model.provider.GithubProvider;
import stirling.software.SPDF.model.provider.GoogleProvider;
@ -130,6 +135,7 @@ public class ApplicationProperties {
private Boolean csrfDisabled;
private InitialLogin initialLogin;
private OAUTH2 oauth2;
private SAML saml;
private int loginAttemptCount;
private long loginResetTimeMinutes;
private String loginMethod = "all";
@ -174,6 +180,14 @@ public class ApplicationProperties {
this.oauth2 = oauth2;
}
public SAML getSAML() {
return saml != null ? saml : new SAML();
}
public void setSAML(SAML saml) {
this.saml = saml;
}
public Boolean getEnableLogin() {
return enableLogin;
}
@ -235,6 +249,34 @@ public class ApplicationProperties {
}
}
@Data
public static class SAML {
private Boolean enabled = false;
private String entityId;
private String registrationId;
private String spBaseUrl;
private String idpMetadataLocation;
private KeyStore keystore;
@Data
public static class KeyStore {
private String keystoreLocation;
private String keystorePassword;
private String keyAlias;
private String keyPassword;
private String realmCertificateAlias;
public Resource getKeystoreResource() {
if (keystoreLocation.startsWith("classpath:")) {
return new ClassPathResource(
keystoreLocation.substring("classpath:".length()));
} else {
return new FileSystemResource(keystoreLocation);
}
}
}
}
public static class OAUTH2 {
private Boolean enabled = false;
private String issuer;

View File

@ -1,7 +1,5 @@
package stirling.software.SPDF.model;
import jakarta.persistence.*;
import java.io.Serializable;
import java.util.HashMap;
import java.util.HashSet;
@ -9,6 +7,8 @@ import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User implements Serializable {

View File

@ -1,11 +1,12 @@
package stirling.software.SPDF.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import stirling.software.SPDF.model.User;
import java.util.Optional;
import stirling.software.SPDF.model.User;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

View File

@ -262,5 +262,4 @@ public class GeneralUtils {
}
return true;
}
}

View File

@ -4,16 +4,7 @@ public class RequestUriUtils {
public static boolean isStaticResource(String requestURI) {
return requestURI.startsWith("/css/")
|| requestURI.startsWith("/fonts/")
|| requestURI.startsWith("/js/")
|| requestURI.startsWith("/images/")
|| requestURI.startsWith("/public/")
|| requestURI.startsWith("/pdfjs/")
|| requestURI.startsWith("/pdfjs-legacy/")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".webmanifest")
|| requestURI.startsWith("/api/v1/info/status");
return isStaticResource("", requestURI);
}
public static boolean isStaticResource(String contextPath, String requestURI) {
@ -24,6 +15,7 @@ public class RequestUriUtils {
|| requestURI.startsWith(contextPath + "/images/")
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/")
|| requestURI.startsWith(contextPath + "/saml2")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".webmanifest")
|| requestURI.startsWith(contextPath + "/api/v1/info/status");

View File

@ -1,5 +1,12 @@
multipart.enabled=true
otel.metrics.exporter=prometheus
otel.exporter.prometheus.port=9464
otel.service.name=stirling-pdf
logging.level.org.springframework.security.saml2=DEBUG
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN

View File

@ -49,9 +49,16 @@ security:
provider: google # Set this to your OAuth provider's name, e.g., 'google' or 'keycloak'
saml:
enabled: false # set to 'true' to enable SAML login (Note: enableLogin must also be 'true' for this to work)
registrationId: stirling
entityId: '' # Entity ID for the Service Provider (SP)
idpMetadataLocation: '' # URL or file path to the Identity Provider (IdP) metadata
spBaseUrl: '' # Base URL for the Service Provider (SP)
keystore:
keystoreLocation: /config/keystore.jks
keystorePassword: stirlingstore
keyAlias: stirling
keyPassword: stirlingkey
realmCertificateAlias: master
system:
defaultLocale: en-US # Set the default language (e.g. 'de-DE', 'fr-FR', etc)

View File

@ -114,7 +114,7 @@
<img class="my-4" th:src="@{'/favicon.svg'}" alt="favicon" width="144" height="144">
<h1 class="h1 mb-3 fw-normal" th:text="${@appName}">Stirling-PDF</h1>
<div th:if="${oAuth2Enabled} and (${loginMethod} == 'all' or ${loginMethod} == 'oauth2')">
<div th:if="(${oAuth2Enabled} or ${samlEnabled}) and (${loginMethod} == 'all' or ${loginMethod} == 'oauth2')">
<a href="#" class="w-100 btn btn-lg btn-primary" data-bs-toggle="modal" data-bs-target="#loginsModal" th:text="#{login.ssoSignIn}">Login Via SSO</a>
<br>
<br>
@ -184,7 +184,7 @@
<a th:href="@{|/oauth2/authorization/${provider.key}|}" th:text="${provider.value}" class="w-100 btn btn-lg btn-primary">OpenID Connect</a>
</div>
<div class="mb-3" th:if="${samlEnabled}">
<a th:href="@{'/saml2/authenticate/saml'}" class="w-100 btn btn-lg btn-primary" th:text="#{login.samlSignIn}">SAML</a>
<a th:href="@{'/saml2/authenticate/stirling'}" class="w-100 btn btn-lg btn-primary" th:text="#{login.ssoSignIn}">SAML</a>
</div>
</div>
<div class="modal-footer">