Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow SAML 2.0 loginProcessingUrl without registrationId path variable #10307

Merged
merged 1 commit into from
Oct 4, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,19 @@ public Saml2LoginConfigurer<B> loginPage(String loginPage) {
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");
Assert.state(loginProcessingUrl.contains("{registrationId}"), "{registrationId} path variable is required");
this.loginProcessingUrl = loginProcessingUrl;
return this;
}
Expand Down Expand Up @@ -274,6 +283,8 @@ private AuthenticationConverter getAuthenticationConverter(B http) {
AuthenticationConverter authenticationConverterBean = getBeanOrNull(http,
Saml2AuthenticationTokenConverter.class);
if (authenticationConverterBean == null) {
Assert.state(this.loginProcessingUrl.contains("{registrationId}"),
"loginProcessingUrl must contain {registrationId} path variable");
return new Saml2AuthenticationTokenConverter(
(RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver(
this.relyingPartyRegistrationRepository));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.opensaml.saml.saml2.core.Assertion;
import org.opensaml.saml.saml2.core.AuthnRequest;

import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
Expand Down Expand Up @@ -97,6 +98,7 @@
import org.springframework.web.util.UriComponentsBuilder;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
Expand Down Expand Up @@ -124,6 +126,8 @@ public class Saml2LoginConfigurerTests {

private static final String SIGNED_RESPONSE = "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBEZXN0aW5hdGlvbj0iaHR0cHM6Ly9ycC5leGFtcGxlLm9yZy9hY3MiIElEPSJfYzE3MzM2YTAtNTM1My00MTQ5LWI3MmMtMDNkOWY5YWYzMDdlIiBJc3N1ZUluc3RhbnQ9IjIwMjAtMDgtMDRUMjI6MDQ6NDUuMDE2WiIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIj5hcC1lbnRpdHktaWQ8L3NhbWwyOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj4KPGRzOlNpZ25lZEluZm8+CjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CjxkczpTaWduYXR1cmVNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGRzaWctbW9yZSNyc2Etc2hhMjU2Ii8+CjxkczpSZWZlcmVuY2UgVVJJPSIjX2MxNzMzNmEwLTUzNTMtNDE0OS1iNzJjLTAzZDlmOWFmMzA3ZSI+CjxkczpUcmFuc2Zvcm1zPgo8ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz4KPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPgo8L2RzOlRyYW5zZm9ybXM+CjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNzaGEyNTYiLz4KPGRzOkRpZ2VzdFZhbHVlPjYzTmlyenFzaDVVa0h1a3NuRWUrM0hWWU5aYWFsQW1OQXFMc1lGMlRuRDA9PC9kczpEaWdlc3RWYWx1ZT4KPC9kczpSZWZlcmVuY2U+CjwvZHM6U2lnbmVkSW5mbz4KPGRzOlNpZ25hdHVyZVZhbHVlPgpLMVlvWWJVUjBTclY4RTdVMkhxTTIvZUNTOTNoV25mOExnNnozeGZWMUlyalgzSXhWYkNvMVlYcnRBSGRwRVdvYTJKKzVOMmFNbFBHJiMxMzsKN2VpbDBZRC9xdUVRamRYbTNwQTBjZmEvY25pa2RuKzVhbnM0ZWQwanU1amo2dkpvZ2w2Smt4Q25LWUpwTU9HNzhtampmb0phengrWCYjMTM7CkM2NktQVStBYUdxeGVwUEQ1ZlhRdTFKSy9Jb3lBaitaa3k4Z2Jwc3VyZHFCSEJLRWxjdnVOWS92UGY0OGtBeFZBKzdtRGhNNUMvL1AmIzEzOwp0L084Y3NZYXB2UjZjdjZrdk45QXZ1N3FRdm9qVk1McHVxZWNJZDJwTUVYb0NSSnE2Nkd4MStNTUVPeHVpMWZZQlRoMEhhYjRmK3JyJiMxMzsKOEY2V1NFRC8xZllVeHliRkJqZ1Q4d2lEWHFBRU8wSVY4ZWRQeEE9PQo8L2RzOlNpZ25hdHVyZVZhbHVlPgo8L2RzOlNpZ25hdHVyZT48c2FtbDI6QXNzZXJ0aW9uIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0iQWUzZjQ5OGI4LTliMTctNDA3OC05ZDM1LTg2YTA4NDA4NDk5NSIgSXNzdWVJbnN0YW50PSIyMDIwLTA4LTA0VDIyOjA0OjQ1LjA3N1oiIFZlcnNpb249IjIuMCI+PHNhbWwyOklzc3Vlcj5hcC1lbnRpdHktaWQ8L3NhbWwyOklzc3Vlcj48c2FtbDI6U3ViamVjdD48c2FtbDI6TmFtZUlEPnRlc3RAc2FtbC51c2VyPC9zYW1sMjpOYW1lSUQ+PHNhbWwyOlN1YmplY3RDb25maXJtYXRpb24gTWV0aG9kPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6Y206YmVhcmVyIj48c2FtbDI6U3ViamVjdENvbmZpcm1hdGlvbkRhdGEgTm90QmVmb3JlPSIyMDIwLTA4LTA0VDIxOjU5OjQ1LjA5MFoiIE5vdE9uT3JBZnRlcj0iMjA0MC0wNy0zMFQyMjowNTowNi4wODhaIiBSZWNpcGllbnQ9Imh0dHBzOi8vcnAuZXhhbXBsZS5vcmcvYWNzIi8+PC9zYW1sMjpTdWJqZWN0Q29uZmlybWF0aW9uPjwvc2FtbDI6U3ViamVjdD48c2FtbDI6Q29uZGl0aW9ucyBOb3RCZWZvcmU9IjIwMjAtMDgtMDRUMjE6NTk6NDUuMDgwWiIgTm90T25PckFmdGVyPSIyMDQwLTA3LTMwVDIyOjA1OjA2LjA4N1oiLz48L3NhbWwyOkFzc2VydGlvbj48L3NhbWwycDpSZXNwb25zZT4=";

private static final AuthenticationConverter AUTHENTICATION_CONVERTER = mock(AuthenticationConverter.class);

@Autowired
private ConfigurableApplicationContext context;

Expand Down Expand Up @@ -286,6 +290,53 @@ public void authenticateWhenCustomAuthnRequestRepositoryThenUses() throws Except
verify(repository).removeAuthenticationRequest(any(HttpServletRequest.class), any(HttpServletResponse.class));
}

@Test
public void saml2LoginWhenLoginProcessingUrlWithoutRegistrationIdAndDefaultAuthenticationConverterThenValidates() {
assertThatExceptionOfType(BeanCreationException.class)
.isThrownBy(() -> this.spring.register(CustomLoginProcessingUrlDefaultAuthenticationConverter.class)
.autowire())
.havingRootCause().isInstanceOf(IllegalStateException.class)
.withMessage("loginProcessingUrl must contain {registrationId} path variable");
}

@Test
public void authenticateWhenCustomLoginProcessingUrlAndCustomAuthenticationConverterThenAuthenticate()
throws Exception {
this.spring.register(CustomLoginProcessingUrlCustomAuthenticationConverter.class).autowire();
RelyingPartyRegistration relyingPartyRegistration = TestRelyingPartyRegistrations.noCredentials()
.assertingPartyDetails((party) -> party.verificationX509Credentials(
(c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential())))
.build();
String response = new String(Saml2Utils.samlDecode(SIGNED_RESPONSE));
given(AUTHENTICATION_CONVERTER.convert(any(HttpServletRequest.class)))
.willReturn(new Saml2AuthenticationToken(relyingPartyRegistration, response));
// @formatter:off
MockHttpServletRequestBuilder request = post("/my/custom/url").param("SAMLResponse", SIGNED_RESPONSE);
// @formatter:on
this.mvc.perform(request).andExpect(redirectedUrl("/"));
verify(AUTHENTICATION_CONVERTER).convert(any(HttpServletRequest.class));
}

@Test
public void authenticateWhenCustomLoginProcessingUrlAndSaml2AuthenticationTokenConverterBeanThenAuthenticate()
throws Exception {
this.spring.register(CustomLoginProcessingUrlSaml2AuthenticationTokenConverterBean.class).autowire();
Saml2AuthenticationTokenConverter authenticationConverter = this.spring.getContext()
.getBean(Saml2AuthenticationTokenConverter.class);
RelyingPartyRegistration relyingPartyRegistration = TestRelyingPartyRegistrations.noCredentials()
.assertingPartyDetails((party) -> party.verificationX509Credentials(
(c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential())))
.build();
String response = new String(Saml2Utils.samlDecode(SIGNED_RESPONSE));
given(authenticationConverter.convert(any(HttpServletRequest.class)))
.willReturn(new Saml2AuthenticationToken(relyingPartyRegistration, response));
// @formatter:off
MockHttpServletRequestBuilder request = post("/my/custom/url").param("SAMLResponse", SIGNED_RESPONSE);
// @formatter:on
this.mvc.perform(request).andExpect(redirectedUrl("/"));
verify(authenticationConverter).convert(any(HttpServletRequest.class));
}

private void validateSaml2WebSsoAuthenticationFilterConfiguration() {
// get the OpenSamlAuthenticationProvider
Saml2WebSsoAuthenticationFilter filter = getSaml2SsoFilter(this.springSecurityFilterChain);
Expand Down Expand Up @@ -511,6 +562,65 @@ Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> authent

}

@EnableWebSecurity
@Import(Saml2LoginConfigBeans.class)
static class CustomLoginProcessingUrlDefaultAuthenticationConverter {

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests((authz) -> authz.anyRequest().authenticated())
.saml2Login((saml2) -> saml2.loginProcessingUrl("/my/custom/url"));
// @formatter:on
return http.build();
}

}

@EnableWebSecurity
@Import(Saml2LoginConfigBeans.class)
static class CustomLoginProcessingUrlCustomAuthenticationConverter {

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests((authz) -> authz.anyRequest().authenticated())
.saml2Login((saml2) -> saml2
.loginProcessingUrl("/my/custom/url")
.authenticationConverter(AUTHENTICATION_CONVERTER)
);
// @formatter:on
return http.build();
}

}

@EnableWebSecurity
@Import(Saml2LoginConfigBeans.class)
static class CustomLoginProcessingUrlSaml2AuthenticationTokenConverterBean {

private final Saml2AuthenticationTokenConverter authenticationConverter = mock(
Saml2AuthenticationTokenConverter.class);

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// @formatter:off
http
.authorizeRequests((authz) -> authz.anyRequest().authenticated())
.saml2Login((saml2) -> saml2.loginProcessingUrl("/my/custom/url"));
// @formatter:on
return http.build();
}

@Bean
Saml2AuthenticationTokenConverter authenticationTokenConverter() {
return this.authenticationConverter;
}

}

static class Saml2LoginConfigBeans {

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,23 +72,21 @@ public Saml2WebSsoAuthenticationFilter(RelyingPartyRegistrationRepository relyin
(RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver(
relyingPartyRegistrationRepository)),
filterProcessesUrl);
Assert.isTrue(filterProcessesUrl.contains("{registrationId}"),
"filterProcessesUrl must contain a {registrationId} match variable");
}

/**
* Creates a {@link Saml2WebSsoAuthenticationFilter} given the provided parameters
* @param authenticationConverter the strategy for converting an
* {@link HttpServletRequest} into an {@link Authentication}
* @param filterProcessingUrl the processing URL, must contain a {registrationId}
* variable
* @param filterProcessesUrl the processing URL
* @since 5.4
*/
public Saml2WebSsoAuthenticationFilter(AuthenticationConverter authenticationConverter,
String filterProcessingUrl) {
super(filterProcessingUrl);
public Saml2WebSsoAuthenticationFilter(AuthenticationConverter authenticationConverter, String filterProcessesUrl) {
super(filterProcessesUrl);
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
Assert.hasText(filterProcessingUrl, "filterProcessesUrl must contain a URL pattern");
Assert.isTrue(filterProcessingUrl.contains("{registrationId}"),
"filterProcessesUrl must contain a {registrationId} match variable");
Assert.hasText(filterProcessesUrl, "filterProcessesUrl must contain a URL pattern");
this.authenticationConverter = authenticationConverter;
setAllowSessionCreation(true);
setSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ public void constructingFilterWithValidRegistrationIdVariableThenSucceeds() {
this.filter = new Saml2WebSsoAuthenticationFilter(this.repository, "/url/variable/is/present/{registrationId}");
}

@Test
public void constructingFilterWithMissingRegistrationIdVariableAndCustomAuthenticationConverterThenSucceeds() {
AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
this.filter = new Saml2WebSsoAuthenticationFilter(authenticationConverter, "/url/missing/variable");
}

@Test
public void requiresAuthenticationWhenHappyPathThenReturnsTrue() {
Assertions.assertTrue(this.filter.requiresAuthentication(this.request, this.response));
Expand Down