From 2989d8d41617a39e62926fc74fb975e06c377082 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:10:14 +0200 Subject: [PATCH] --- .../security/SecurityConfiguration.java | 79 +++++++++++++++++++ ...ustomSAMLAuthenticationFailureHandler.java | 50 ++++++++++++ ...ustomSAMLAuthenticationSuccessHandler.java | 50 ++++++++++++ .../SPDF/config/security/saml/SAMLConfig.java | 66 ++++++++++++++++ .../saml/SAMLLogoutSuccessHandler.java | 36 +++++++++ .../security/saml/SAMLUserDetailsService.java | 38 +++++++++ .../controller/web/AccountWebController.java | 2 + src/main/resources/settings.yml.template | 5 ++ src/main/resources/templates/login.html | 7 +- 9 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java create mode 100644 src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java create mode 100644 src/main/java/stirling/software/SPDF/config/security/saml/SAMLConfig.java create mode 100644 src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java create mode 100644 src/main/java/stirling/software/SPDF/config/security/saml/SAMLUserDetailsService.java diff --git a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java index ed7b7921..3d6411bc 100644 --- a/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java +++ b/src/main/java/stirling/software/SPDF/config/security/SecurityConfiguration.java @@ -24,6 +24,19 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio 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.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; @@ -34,6 +47,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.SAMLLogoutSuccessHandler; import stirling.software.SPDF.config.security.session.SessionPersistentRegistry; import stirling.software.SPDF.model.ApplicationProperties; import stirling.software.SPDF.model.ApplicationProperties.Security.OAUTH2; @@ -191,6 +209,29 @@ public class SecurityConfiguration { new CustomOAuth2LogoutSuccessHandler( applicationProperties))); } + + // Handle SAML Logins + if (applicationProperties.getSecurity().getSAML() != null + && applicationProperties.getSecurity().getSAML().getEnabled() + && !applicationProperties + .getSecurity() + .getLoginMethod() + .equalsIgnoreCase("normal")) { + + http.saml2Login( + saml2 -> + saml2.loginPage("/saml2") + .successHandler( + new CustomSAMLAuthenticationSuccessHandler( + loginAttemptService, userService)) + .failureHandler( + new CustomSAMLAuthenticationFailureHandler()) + .userDetailsService(new SAMLUserDetailsService())) + .logout( + logout -> + logout.logoutSuccessHandler( + new SAMLLogoutSuccessHandler())); + } } else { http.csrf(csrf -> csrf.disable()) .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); @@ -386,4 +427,42 @@ 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(); + } } diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java new file mode 100644 index 00000000..13bfa200 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationFailureHandler.java @@ -0,0 +1,50 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.IOException; + +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.saml2.provider.service.authentication.Saml2AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class CustomSAMLAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + @Override + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) + throws IOException, ServletException { + + if (exception instanceof BadCredentialsException) { + log.error("BadCredentialsException", exception); + getRedirectStrategy().sendRedirect(request, response, "/login?error=badcredentials"); + return; + } + if (exception instanceof DisabledException) { + log.error("User is deactivated: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + if (exception instanceof LockedException) { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + if (exception instanceof Saml2AuthenticationException) { + log.error("SAML2 Authentication error: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=saml2AuthenticationError"); + return; + } + log.error("Unhandled authentication exception", exception); + super.onAuthenticationFailure(request, response, exception); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java new file mode 100644 index 00000000..b211a100 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/CustomSAMLAuthenticationSuccessHandler.java @@ -0,0 +1,50 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.security.web.savedrequest.SavedRequest; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import lombok.extern.slf4j.Slf4j; +import stirling.software.SPDF.utils.RequestUriUtils; + +@Slf4j +public class CustomSAMLAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + private LoginAttemptService loginAttemptService; + private UserService userService; + + public CustomSAMLAuthenticationSuccessHandler(LoginAttemptService loginAttemptService, UserService userService) { + this.loginAttemptService = loginAttemptService; + this.userService = userService; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws ServletException, IOException { + + String userName = request.getParameter("username"); + if (userService.isUserDisabled(userName)) { + getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + loginAttemptService.loginSucceeded(userName); + + // Get the saved request + HttpSession session = request.getSession(false); + SavedRequest savedRequest = (session != null) ? (SavedRequest) session.getAttribute("SPRING_SECURITY_SAVED_REQUEST") : null; + + if (savedRequest != null && !RequestUriUtils.isStaticResource(request.getContextPath(), savedRequest.getRedirectUrl())) { + // Redirect to the original destination + super.onAuthenticationSuccess(request, response, authentication); + } else { + // Redirect to the root URL (considering context path) + getRedirectStrategy().sendRedirect(request, response, "/"); + } + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SAMLConfig.java b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLConfig.java new file mode 100644 index 00000000..d3e8f080 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLConfig.java @@ -0,0 +1,66 @@ +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(); + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java new file mode 100644 index 00000000..c236fc4e --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLLogoutSuccessHandler.java @@ -0,0 +1,36 @@ +package stirling.software.SPDF.config.security.saml; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SAMLLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { + + @Override + public void onLogoutSuccess( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) + throws IOException, ServletException { + + String redirectUrl = determineTargetUrl(request, response, authentication); + + if (response.isCommitted()) { + log.debug("Response has already been committed. Unable to redirect to " + redirectUrl); + return; + } + + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } + + protected String determineTargetUrl( + HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + // Default to the root URL + return "/"; + } +} diff --git a/src/main/java/stirling/software/SPDF/config/security/saml/SAMLUserDetailsService.java b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLUserDetailsService.java new file mode 100644 index 00000000..efa80637 --- /dev/null +++ b/src/main/java/stirling/software/SPDF/config/security/saml/SAMLUserDetailsService.java @@ -0,0 +1,38 @@ +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 authorities = extractAuthorities(principal); + + return new org.springframework.security.core.userdetails.User(username, "", authorities); + } + + private Collection extractAuthorities(Saml2AuthenticatedPrincipal principal) { + List roles = principal.getAttribute("roles"); + return roles.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java index 739f2600..2f2ed8f4 100644 --- a/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java +++ b/src/main/java/stirling/software/SPDF/controller/web/AccountWebController.java @@ -80,6 +80,8 @@ public class AccountWebController { model.addAttribute("loginMethod", applicationProperties.getSecurity().getLoginMethod()); model.addAttribute( "oAuth2Enabled", applicationProperties.getSecurity().getOAUTH2().getEnabled()); + model.addAttribute( + "samlEnabled", applicationProperties.getSecurity().getSAML().getEnabled()); model.addAttribute("currentPage", "login"); diff --git a/src/main/resources/settings.yml.template b/src/main/resources/settings.yml.template index 2e06ea9f..d6476fff 100644 --- a/src/main/resources/settings.yml.template +++ b/src/main/resources/settings.yml.template @@ -47,6 +47,11 @@ security: useAsUsername: email # Default is 'email'; custom fields can be used as the username 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' + saml: + enabled: false # set to 'true' to enable SAML login (Note: enableLogin must also be 'true' for this to work) + 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) system: defaultLocale: en-US # Set the default language (e.g. 'de-DE', 'fr-FR', etc) diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index 6d312bff..7105fb94 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -168,7 +168,7 @@ -