Saml2LoginConfigurer.java
/*
* Copyright 2004-present the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.config.annotation.web.configurers.saml2;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.opensaml.core.Version;
import org.springframework.context.ApplicationContext;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
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.RelyingPartyRegistrations;
import org.springframework.security.saml2.provider.service.web.HttpSessionSaml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.OpenSaml5AuthenticationTokenConverter;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationTokenConverter;
import org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml5AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2AuthenticationRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AuthenticationConverter;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.ParameterRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatchers;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* An {@link AbstractHttpConfigurer} for SAML 2.0 Login, which leverages the SAML 2.0 Web
* Browser Single Sign On (WebSSO) Flow.
*
* <p>
* SAML 2.0 Login provides an application with the capability to have users log in by
* using their existing account at an SAML 2.0 Identity Provider.
*
* <p>
* Defaults are provided for all configuration options with the only required
* configuration being
* {@link #relyingPartyRegistrationRepository(RelyingPartyRegistrationRepository)} .
* Alternatively, a {@link RelyingPartyRegistrationRepository} {@code @Bean} may be
* registered instead.
*
* <h2>Security Filters</h2>
*
* The following {@code Filter}'s are populated:
*
* <ul>
* <li>{@link Saml2WebSsoAuthenticationFilter}</li>
* <li>{@link Saml2WebSsoAuthenticationRequestFilter}</li>
* </ul>
*
* <h2>Shared Objects Created</h2>
*
* The following shared objects are populated:
*
* <ul>
* <li>{@link RelyingPartyRegistrationRepository} (required)</li>
* </ul>
*
* <h2>Shared Objects Used</h2>
*
* The following shared objects are used:
*
* <ul>
* <li>{@link RelyingPartyRegistrationRepository} (required)</li>
* <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not
* configured and {@code DefaultLoginPageGeneratingFilter} is available, than a default
* login page will be made available</li>
* </ul>
*
* @since 5.2
* @see HttpSecurity#saml2Login(Customizer)
* @see Saml2WebSsoAuthenticationFilter
* @see Saml2WebSsoAuthenticationRequestFilter
* @see RelyingPartyRegistrationRepository
* @see AbstractAuthenticationFilterConfigurer
*/
public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
extends AbstractAuthenticationFilterConfigurer<B, Saml2LoginConfigurer<B>, Saml2WebSsoAuthenticationFilter> {
private static final boolean USE_OPENSAML_5 = Version.getVersion().startsWith("5");
private String loginPage;
private String authenticationRequestUri = "/saml2/authenticate";
private String[] authenticationRequestParams = { "registrationId={registrationId}" };
private RequestMatcher authenticationRequestMatcher;
private Saml2AuthenticationRequestResolver authenticationRequestResolver;
private RequestMatcher loginProcessingUrl;
private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
private AuthenticationConverter authenticationConverter;
private AuthenticationManager authenticationManager;
private Saml2WebSsoAuthenticationFilter saml2WebSsoAuthenticationFilter;
/**
* Use this {@link AuthenticationConverter} when converting incoming requests to an
* {@link Authentication}. By default the {@link Saml2AuthenticationTokenConverter} is
* used.
* @param authenticationConverter the {@link AuthenticationConverter} to use
* @return the {@link Saml2LoginConfigurer} for further configuration
* @since 5.4
*/
public Saml2LoginConfigurer<B> authenticationConverter(AuthenticationConverter authenticationConverter) {
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
this.authenticationConverter = authenticationConverter;
return this;
}
/**
* Allows a configuration of a {@link AuthenticationManager} to be used during SAML 2
* authentication. If none is specified, the system will create one inject it into the
* {@link Saml2WebSsoAuthenticationFilter}
* @param authenticationManager the authentication manager to be used
* @return the {@link Saml2LoginConfigurer} for further configuration
* @throws IllegalArgumentException if authenticationManager is null configure the
* default manager
* @since 5.3
*/
public Saml2LoginConfigurer<B> authenticationManager(AuthenticationManager authenticationManager) {
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
this.authenticationManager = authenticationManager;
return this;
}
/**
* Sets the {@code RelyingPartyRegistrationRepository} of relying parties, each party
* representing a service provider, SP and this host, and identity provider, IDP pair
* that communicate with each other.
* @param repo the repository of relying parties
* @return the {@link Saml2LoginConfigurer} for further configuration
*/
public Saml2LoginConfigurer<B> relyingPartyRegistrationRepository(RelyingPartyRegistrationRepository repo) {
this.relyingPartyRegistrationRepository = repo;
return this;
}
@Override
public Saml2LoginConfigurer<B> loginPage(String loginPage) {
Assert.hasText(loginPage, "loginPage cannot be empty");
this.loginPage = loginPage;
return this;
}
/**
* Use this {@link Saml2AuthenticationRequestResolver} for generating SAML 2.0
* Authentication Requests.
* @param authenticationRequestResolver
* @return the {@link Saml2LoginConfigurer} for further configuration
* @since 5.7
*/
public Saml2LoginConfigurer<B> authenticationRequestResolver(
Saml2AuthenticationRequestResolver authenticationRequestResolver) {
Assert.notNull(authenticationRequestResolver, "authenticationRequestResolver cannot be null");
this.authenticationRequestResolver = authenticationRequestResolver;
return this;
}
/**
* Customize the URL that the SAML Authentication Request will be sent to.
* @param authenticationRequestUri the URI to use for the SAML 2.0 Authentication
* Request
* @return the {@link Saml2LoginConfigurer} for further configuration
* @since 6.0
* @deprecated Use {@link #authenticationRequestUriQuery} instead
*/
@Deprecated
public Saml2LoginConfigurer<B> authenticationRequestUri(String authenticationRequestUri) {
return authenticationRequestUriQuery(authenticationRequestUri);
}
/**
* Customize the URL that the SAML Authentication Request will be sent to. This method
* also supports query parameters like so: <pre>
* authenticationRequestUriQuery("/saml/authenticate?registrationId={registrationId}")
* </pre> {@link RelyingPartyRegistrations}
* @param authenticationRequestUriQuery the URI and query to use for the SAML 2.0
* Authentication Request
* @return the {@link Saml2LoginConfigurer} for further configuration
* @since 6.0
*/
public Saml2LoginConfigurer<B> authenticationRequestUriQuery(String authenticationRequestUriQuery) {
Assert.state(authenticationRequestUriQuery.contains("{registrationId}"),
"authenticationRequestUri must contain {registrationId} path variable or query value");
String[] parts = authenticationRequestUriQuery.split("[?&]");
this.authenticationRequestUri = parts[0];
this.authenticationRequestParams = new String[parts.length - 1];
System.arraycopy(parts, 1, this.authenticationRequestParams, 0, parts.length - 1);
this.authenticationRequestMatcher = new PathQueryRequestMatcher(
getRequestMatcherBuilder().matcher(this.authenticationRequestUri), this.authenticationRequestParams);
return this;
}
/**
* Specifies the URL to validate the credentials. If specified a custom URL, consider
* specifying a custom {@link AuthenticationConverter} via
* {@link #authenticationConverter(AuthenticationConverter)}, since the default
* {@link AuthenticationConverter} implementation relies on the
* <code>{registrationId}</code> path variable to be present in the URL
* @param loginProcessingUrl the URL to validate the credentials
* @return the {@link Saml2LoginConfigurer} for additional customization
* @see Saml2WebSsoAuthenticationFilter#DEFAULT_FILTER_PROCESSES_URI
*/
@Override
public Saml2LoginConfigurer<B> loginProcessingUrl(String loginProcessingUrl) {
Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be empty");
this.loginProcessingUrl = getRequestMatcherBuilder().matcher(loginProcessingUrl);
return this;
}
@Override
protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
return getRequestMatcherBuilder().matcher(loginProcessingUrl);
}
/**
* {@inheritDoc}
* <p>
* Initializes this filter chain for SAML 2 Login. The following actions are taken:
* <ul>
* <li>The WebSSO endpoint has CSRF disabled, typically {@code /login/saml2/sso}</li>
* <li>A {@link Saml2WebSsoAuthenticationFilter is configured}</li>
* <li>The {@code loginProcessingUrl} is set</li>
* <li>A custom login page is configured, <b>or</b></li>
* <li>A default login page with all SAML 2.0 Identity Providers is configured</li>
* <li>An {@link AuthenticationProvider} is configured</li>
* </ul>
*/
@Override
public void init(B http) {
registerDefaultCsrfOverride(http);
relyingPartyRegistrationRepository(http);
this.saml2WebSsoAuthenticationFilter = new Saml2WebSsoAuthenticationFilter(getAuthenticationConverter(http));
this.saml2WebSsoAuthenticationFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
this.saml2WebSsoAuthenticationFilter.setRequiresAuthenticationRequestMatcher(getLoginProcessingEndpoint());
setAuthenticationRequestRepository(http, this.saml2WebSsoAuthenticationFilter);
setAuthenticationFilter(this.saml2WebSsoAuthenticationFilter);
if (StringUtils.hasText(this.loginPage)) {
// Set custom login page
super.loginPage(this.loginPage);
super.init(http);
}
else {
Map<String, String> providerUrlMap = getIdentityProviderUrlMap(this.authenticationRequestUri,
this.authenticationRequestParams, this.relyingPartyRegistrationRepository);
boolean singleProvider = providerUrlMap.size() == 1;
if (singleProvider) {
// Setup auto-redirect to provider login page
// when only 1 IDP is configured
this.updateAuthenticationDefaults();
this.updateAccessDefaults(http);
String loginUrl = providerUrlMap.entrySet().iterator().next().getKey();
registerAuthenticationEntryPoint(http, getLoginEntryPoint(http, loginUrl));
}
else {
super.init(http);
}
}
this.initDefaultLoginFilter(http);
if (this.authenticationManager == null) {
registerDefaultAuthenticationProvider(http);
}
}
/**
* {@inheritDoc}
* <p>
* During the {@code configure} phase, a
* {@link Saml2WebSsoAuthenticationRequestFilter} is added to handle SAML 2.0
* AuthNRequest redirects
*/
@Override
public void configure(B http) {
Saml2WebSsoAuthenticationRequestFilter filter = getAuthenticationRequestFilter(http);
filter.setAuthenticationRequestRepository(getAuthenticationRequestRepository(http));
http.addFilter(postProcess(filter));
super.configure(http);
if (this.authenticationManager != null) {
this.saml2WebSsoAuthenticationFilter.setAuthenticationManager(this.authenticationManager);
}
}
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository(B http) {
if (this.relyingPartyRegistrationRepository == null) {
this.relyingPartyRegistrationRepository = getSharedOrBean(http, RelyingPartyRegistrationRepository.class);
}
return this.relyingPartyRegistrationRepository;
}
private AuthenticationEntryPoint getLoginEntryPoint(B http, String providerLoginPage) {
RequestMatcher loginPageMatcher = getRequestMatcherBuilder().matcher(this.getLoginPage());
RequestMatcher faviconMatcher = getRequestMatcherBuilder().matcher("/favicon.ico");
RequestMatcher defaultEntryPointMatcher = this.getAuthenticationEntryPointMatcher(http);
RequestMatcher defaultLoginPageMatcher = new AndRequestMatcher(
new OrRequestMatcher(loginPageMatcher, faviconMatcher), defaultEntryPointMatcher);
RequestMatcher notXRequestedWith = new NegatedRequestMatcher(
new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
LoginUrlAuthenticationEntryPoint loginUrlEntryPoint = new LoginUrlAuthenticationEntryPoint(providerLoginPage);
RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
new NegatedRequestMatcher(defaultLoginPageMatcher));
// @formatter:off
AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
.defaultEntryPoint(getAuthenticationEntryPoint())
.build();
// @formatter:on
ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
if (exceptions != null) {
RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
exceptions.defaultDeniedHandlerForMissingAuthority(
(ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher),
FactorGrantedAuthority.SAML_RESPONSE_AUTHORITY);
}
return loginEntryPoint;
}
private void setAuthenticationRequestRepository(B http,
Saml2WebSsoAuthenticationFilter saml2WebSsoAuthenticationFilter) {
saml2WebSsoAuthenticationFilter.setAuthenticationRequestRepository(getAuthenticationRequestRepository(http));
}
private Saml2WebSsoAuthenticationRequestFilter getAuthenticationRequestFilter(B http) {
Saml2AuthenticationRequestResolver authenticationRequestResolver = getAuthenticationRequestResolver(http);
return new Saml2WebSsoAuthenticationRequestFilter(authenticationRequestResolver);
}
private Saml2AuthenticationRequestResolver getAuthenticationRequestResolver(B http) {
if (this.authenticationRequestResolver != null) {
return this.authenticationRequestResolver;
}
Saml2AuthenticationRequestResolver bean = getBeanOrNull(http, Saml2AuthenticationRequestResolver.class);
if (bean != null) {
return bean;
}
if (USE_OPENSAML_5) {
OpenSaml5AuthenticationRequestResolver openSamlAuthenticationRequestResolver = new OpenSaml5AuthenticationRequestResolver(
relyingPartyRegistrationRepository(http));
openSamlAuthenticationRequestResolver.setRequestMatcher(getAuthenticationRequestMatcher());
return openSamlAuthenticationRequestResolver;
}
else {
throw new IllegalArgumentException(
"Spring Security does not support OpenSAML " + Version.getVersion() + ". Please use OpenSAML 5");
}
}
private RequestMatcher getAuthenticationRequestMatcher() {
if (this.authenticationRequestMatcher == null) {
this.authenticationRequestMatcher = RequestMatchers.anyOf(
getRequestMatcherBuilder()
.matcher(Saml2AuthenticationRequestResolver.DEFAULT_AUTHENTICATION_REQUEST_URI),
new PathQueryRequestMatcher(getRequestMatcherBuilder().matcher(this.authenticationRequestUri),
this.authenticationRequestParams));
}
return this.authenticationRequestMatcher;
}
private RequestMatcher getLoginProcessingEndpoint() {
if (this.loginProcessingUrl == null) {
this.loginProcessingUrl = RequestMatchers.anyOf(
getRequestMatcherBuilder().matcher(Saml2WebSsoAuthenticationFilter.DEFAULT_FILTER_PROCESSES_URI),
getRequestMatcherBuilder().matcher("/login/saml2/sso"));
}
return this.loginProcessingUrl;
}
private AuthenticationConverter getAuthenticationConverter(B http) {
if (this.authenticationConverter != null) {
return this.authenticationConverter;
}
AuthenticationConverter authenticationConverterBean = getBeanOrNull(http,
Saml2AuthenticationTokenConverter.class);
if (authenticationConverterBean != null) {
return authenticationConverterBean;
}
if (USE_OPENSAML_5) {
authenticationConverterBean = getBeanOrNull(http, OpenSaml5AuthenticationTokenConverter.class);
if (authenticationConverterBean != null) {
return authenticationConverterBean;
}
OpenSaml5AuthenticationTokenConverter converter = new OpenSaml5AuthenticationTokenConverter(
this.relyingPartyRegistrationRepository);
converter.setAuthenticationRequestRepository(getAuthenticationRequestRepository(http));
converter.setRequestMatcher(getLoginProcessingEndpoint());
return converter;
}
throw new IllegalArgumentException(
"Spring Security does not support OpenSAML " + Version.getVersion() + ". Please use OpenSAML 5");
}
private void registerDefaultAuthenticationProvider(B http) {
if (USE_OPENSAML_5) {
OpenSaml5AuthenticationProvider provider = getBeanOrNull(http, OpenSaml5AuthenticationProvider.class);
if (provider == null) {
http.authenticationProvider(postProcess(new OpenSaml5AuthenticationProvider()));
}
}
else {
throw new IllegalArgumentException(
"Spring Security does not support OpenSAML " + Version.getVersion() + ". Please use OpenSAML 5");
}
}
private void registerDefaultCsrfOverride(B http) {
CsrfConfigurer<B> csrf = http.getConfigurer(CsrfConfigurer.class);
if (csrf == null) {
return;
}
csrf.ignoringRequestMatchers(getLoginProcessingEndpoint());
}
private void initDefaultLoginFilter(B http) {
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
if (loginPageGeneratingFilter == null || this.isCustomLoginPage()) {
return;
}
loginPageGeneratingFilter.setSaml2LoginEnabled(true);
loginPageGeneratingFilter
.setSaml2AuthenticationUrlToProviderName(this.getIdentityProviderUrlMap(this.authenticationRequestUri,
this.authenticationRequestParams, this.relyingPartyRegistrationRepository));
loginPageGeneratingFilter.setLoginPageUrl(this.getLoginPage());
loginPageGeneratingFilter.setFailureUrl(this.getFailureUrl());
}
@SuppressWarnings("unchecked")
private Map<String, String> getIdentityProviderUrlMap(String authRequestPrefixUrl, String[] authRequestQueryParams,
RelyingPartyRegistrationRepository idpRepo) {
Map<String, String> idps = new LinkedHashMap<>();
if (idpRepo instanceof Iterable) {
Iterable<RelyingPartyRegistration> repo = (Iterable<RelyingPartyRegistration>) idpRepo;
StringBuilder authRequestQuery = new StringBuilder("?");
for (String authRequestQueryParam : authRequestQueryParams) {
authRequestQuery.append(authRequestQueryParam + "&");
}
authRequestQuery.deleteCharAt(authRequestQuery.length() - 1);
String authenticationRequestUriQuery = authRequestPrefixUrl + authRequestQuery;
repo.forEach(
(p) -> idps.put(authenticationRequestUriQuery.replace("{registrationId}", p.getRegistrationId()),
p.getRegistrationId()));
}
return idps;
}
private Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> getAuthenticationRequestRepository(
B http) {
Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> repository = getBeanOrNull(http,
Saml2AuthenticationRequestRepository.class);
if (repository == null) {
return new HttpSessionSaml2AuthenticationRequestRepository();
}
return repository;
}
private <C> C getSharedOrBean(B http, Class<C> clazz) {
C shared = http.getSharedObject(clazz);
if (shared != null) {
return shared;
}
return getBeanOrNull(http, clazz);
}
private <C> C getBeanOrNull(B http, Class<C> clazz) {
ApplicationContext context = http.getSharedObject(ApplicationContext.class);
if (context == null) {
return null;
}
return context.getBeanProvider(clazz).getIfUnique();
}
private <C> void setSharedObject(B http, Class<C> clazz, C object) {
if (http.getSharedObject(clazz) == null) {
http.setSharedObject(clazz, object);
}
}
static class PathQueryRequestMatcher implements RequestMatcher {
private final RequestMatcher matcher;
PathQueryRequestMatcher(RequestMatcher pathMatcher, String... params) {
List<RequestMatcher> matchers = new ArrayList<>();
matchers.add(pathMatcher);
for (String param : params) {
String[] parts = param.split("=");
if (parts.length == 1) {
matchers.add(new ParameterRequestMatcher(parts[0]));
}
else {
matchers.add(new ParameterRequestMatcher(parts[0], parts[1]));
}
}
this.matcher = new AndRequestMatcher(matchers);
}
@Override
public boolean matches(HttpServletRequest request) {
return matcher(request).isMatch();
}
@Override
public MatchResult matcher(HttpServletRequest request) {
return this.matcher.matcher(request);
}
}
}