This commit is contained in:
Anthony Stirling 2024-09-03 14:10:14 +02:00
parent c1f78d0f9b
commit 2989d8d416
9 changed files with 331 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 "/";
}
}

View File

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

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

View File

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

View File

@ -168,7 +168,7 @@
</main>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
<div th:if="${oAuth2Enabled}" class="modal fade" id="loginsModal" tabindex="-1" role="dialog" aria-labelledby="loginsModalLabel" aria-hidden="true">
<div th:if="${oAuth2Enabled or samlEnabled}" class="modal fade" id="loginsModal" tabindex="-1" role="dialog" aria-labelledby="loginsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
@ -183,6 +183,9 @@
<div class="mb-3" th:each="provider : ${providerlist}">
<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>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" th:text="#{close}"></button>
@ -191,4 +194,4 @@
</div>
</div>
</body>
</html>
</html>