Skip to content

Commit 7112ee3

Browse files
Allow SAML 2.0 loginProcessingURL without registrationId
Closes spring-projectsgh-10176
1 parent 8c74d6c commit 7112ee3

File tree

4 files changed

+134
-9
lines changed

4 files changed

+134
-9
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,19 @@ public Saml2LoginConfigurer<B> loginPage(String loginPage) {
176176
return this;
177177
}
178178

179+
/**
180+
* Specifies the URL to validate the credentials. If specified a custom URL, consider
181+
* specifying a custom {@link AuthenticationConverter} via
182+
* {@link #authenticationConverter(AuthenticationConverter)}, since the default
183+
* {@link AuthenticationConverter} implementation relies on the
184+
* <code>{registrationId}</code> path variable to be present in the URL
185+
* @param loginProcessingUrl the URL to validate the credentials
186+
* @return the {@link Saml2LoginConfigurer} for additional customization
187+
* @see Saml2WebSsoAuthenticationFilter#DEFAULT_FILTER_PROCESSES_URI
188+
*/
179189
@Override
180190
public Saml2LoginConfigurer<B> loginProcessingUrl(String loginProcessingUrl) {
181191
Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be empty");
182-
Assert.state(loginProcessingUrl.contains("{registrationId}"), "{registrationId} path variable is required");
183192
this.loginProcessingUrl = loginProcessingUrl;
184193
return this;
185194
}
@@ -274,6 +283,8 @@ private AuthenticationConverter getAuthenticationConverter(B http) {
274283
AuthenticationConverter authenticationConverterBean = getBeanOrNull(http,
275284
Saml2AuthenticationTokenConverter.class);
276285
if (authenticationConverterBean == null) {
286+
Assert.state(this.loginProcessingUrl.contains("{registrationId}"),
287+
"loginProcessingUrl must contain {registrationId} path variable");
277288
return new Saml2AuthenticationTokenConverter(
278289
(RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver(
279290
this.relyingPartyRegistrationRepository));

config/src/test/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurerTests.java

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.opensaml.saml.saml2.core.Assertion;
3838
import org.opensaml.saml.saml2.core.AuthnRequest;
3939

40+
import org.springframework.beans.factory.BeanCreationException;
4041
import org.springframework.beans.factory.annotation.Autowired;
4142
import org.springframework.context.ConfigurableApplicationContext;
4243
import org.springframework.context.annotation.Bean;
@@ -97,6 +98,7 @@
9798
import org.springframework.web.util.UriComponentsBuilder;
9899

99100
import static org.assertj.core.api.Assertions.assertThat;
101+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
100102
import static org.mockito.ArgumentMatchers.any;
101103
import static org.mockito.ArgumentMatchers.anyString;
102104
import static org.mockito.BDDMockito.given;
@@ -124,6 +126,8 @@ public class Saml2LoginConfigurerTests {
124126

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

129+
private static final AuthenticationConverter AUTHENTICATION_CONVERTER = mock(AuthenticationConverter.class);
130+
127131
@Autowired
128132
private ConfigurableApplicationContext context;
129133

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

293+
@Test
294+
public void saml2LoginWhenLoginProcessingUrlWithoutRegistrationIdAndDefaultAuthenticationConverterThenValidates() {
295+
assertThatExceptionOfType(BeanCreationException.class)
296+
.isThrownBy(() -> this.spring.register(CustomLoginProcessingUrlDefaultAuthenticationConverter.class)
297+
.autowire())
298+
.havingRootCause().isInstanceOf(IllegalStateException.class)
299+
.withMessage("loginProcessingUrl must contain {registrationId} path variable");
300+
}
301+
302+
@Test
303+
public void authenticateWhenCustomLoginProcessingUrlAndCustomAuthenticationConverterThenAuthenticate()
304+
throws Exception {
305+
this.spring.register(CustomLoginProcessingUrlCustomAuthenticationConverter.class).autowire();
306+
RelyingPartyRegistration relyingPartyRegistration = TestRelyingPartyRegistrations.noCredentials()
307+
.assertingPartyDetails((party) -> party.verificationX509Credentials(
308+
(c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential())))
309+
.build();
310+
String response = new String(Saml2Utils.samlDecode(SIGNED_RESPONSE));
311+
given(AUTHENTICATION_CONVERTER.convert(any(HttpServletRequest.class)))
312+
.willReturn(new Saml2AuthenticationToken(relyingPartyRegistration, response));
313+
// @formatter:off
314+
MockHttpServletRequestBuilder request = post("/my/custom/url").param("SAMLResponse", SIGNED_RESPONSE);
315+
// @formatter:on
316+
this.mvc.perform(request).andExpect(redirectedUrl("/"));
317+
verify(AUTHENTICATION_CONVERTER).convert(any(HttpServletRequest.class));
318+
}
319+
320+
@Test
321+
public void authenticateWhenCustomLoginProcessingUrlAndSaml2AuthenticationTokenConverterBeanThenAuthenticate()
322+
throws Exception {
323+
this.spring.register(CustomLoginProcessingUrlSaml2AuthenticationTokenConverterBean.class).autowire();
324+
Saml2AuthenticationTokenConverter authenticationConverter = this.spring.getContext()
325+
.getBean(Saml2AuthenticationTokenConverter.class);
326+
RelyingPartyRegistration relyingPartyRegistration = TestRelyingPartyRegistrations.noCredentials()
327+
.assertingPartyDetails((party) -> party.verificationX509Credentials(
328+
(c) -> c.add(TestSaml2X509Credentials.relyingPartyVerifyingCredential())))
329+
.build();
330+
String response = new String(Saml2Utils.samlDecode(SIGNED_RESPONSE));
331+
given(authenticationConverter.convert(any(HttpServletRequest.class)))
332+
.willReturn(new Saml2AuthenticationToken(relyingPartyRegistration, response));
333+
// @formatter:off
334+
MockHttpServletRequestBuilder request = post("/my/custom/url").param("SAMLResponse", SIGNED_RESPONSE);
335+
// @formatter:on
336+
this.mvc.perform(request).andExpect(redirectedUrl("/"));
337+
verify(authenticationConverter).convert(any(HttpServletRequest.class));
338+
}
339+
289340
private void validateSaml2WebSsoAuthenticationFilterConfiguration() {
290341
// get the OpenSamlAuthenticationProvider
291342
Saml2WebSsoAuthenticationFilter filter = getSaml2SsoFilter(this.springSecurityFilterChain);
@@ -511,6 +562,65 @@ Saml2AuthenticationRequestRepository<AbstractSaml2AuthenticationRequest> authent
511562

512563
}
513564

565+
@EnableWebSecurity
566+
@Import(Saml2LoginConfigBeans.class)
567+
static class CustomLoginProcessingUrlDefaultAuthenticationConverter {
568+
569+
@Bean
570+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
571+
// @formatter:off
572+
http
573+
.authorizeRequests((authz) -> authz.anyRequest().authenticated())
574+
.saml2Login((saml2) -> saml2.loginProcessingUrl("/my/custom/url"));
575+
// @formatter:on
576+
return http.build();
577+
}
578+
579+
}
580+
581+
@EnableWebSecurity
582+
@Import(Saml2LoginConfigBeans.class)
583+
static class CustomLoginProcessingUrlCustomAuthenticationConverter {
584+
585+
@Bean
586+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
587+
// @formatter:off
588+
http
589+
.authorizeRequests((authz) -> authz.anyRequest().authenticated())
590+
.saml2Login((saml2) -> saml2
591+
.loginProcessingUrl("/my/custom/url")
592+
.authenticationConverter(AUTHENTICATION_CONVERTER)
593+
);
594+
// @formatter:on
595+
return http.build();
596+
}
597+
598+
}
599+
600+
@EnableWebSecurity
601+
@Import(Saml2LoginConfigBeans.class)
602+
static class CustomLoginProcessingUrlSaml2AuthenticationTokenConverterBean {
603+
604+
private final Saml2AuthenticationTokenConverter authenticationConverter = mock(
605+
Saml2AuthenticationTokenConverter.class);
606+
607+
@Bean
608+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
609+
// @formatter:off
610+
http
611+
.authorizeRequests((authz) -> authz.anyRequest().authenticated())
612+
.saml2Login((saml2) -> saml2.loginProcessingUrl("/my/custom/url"));
613+
// @formatter:on
614+
return http.build();
615+
}
616+
617+
@Bean
618+
Saml2AuthenticationTokenConverter authenticationTokenConverter() {
619+
return this.authenticationConverter;
620+
}
621+
622+
}
623+
514624
static class Saml2LoginConfigBeans {
515625

516626
@Bean

saml2/saml2-service-provider/src/main/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilter.java

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,23 +73,21 @@ public Saml2WebSsoAuthenticationFilter(RelyingPartyRegistrationRepository relyin
7373
(RelyingPartyRegistrationResolver) new DefaultRelyingPartyRegistrationResolver(
7474
relyingPartyRegistrationRepository)),
7575
filterProcessesUrl);
76+
Assert.isTrue(filterProcessesUrl.contains("{registrationId}"),
77+
"filterProcessesUrl must contain a {registrationId} match variable");
7678
}
7779

7880
/**
7981
* Creates a {@link Saml2WebSsoAuthenticationFilter} given the provided parameters
8082
* @param authenticationConverter the strategy for converting an
8183
* {@link HttpServletRequest} into an {@link Authentication}
82-
* @param filterProcessingUrl the processing URL, must contain a {registrationId}
83-
* variable
84+
* @param filterProcessesUrl the processing URL
8485
* @since 5.4
8586
*/
86-
public Saml2WebSsoAuthenticationFilter(AuthenticationConverter authenticationConverter,
87-
String filterProcessingUrl) {
88-
super(filterProcessingUrl);
87+
public Saml2WebSsoAuthenticationFilter(AuthenticationConverter authenticationConverter, String filterProcessesUrl) {
88+
super(filterProcessesUrl);
8989
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
90-
Assert.hasText(filterProcessingUrl, "filterProcessesUrl must contain a URL pattern");
91-
Assert.isTrue(filterProcessingUrl.contains("{registrationId}"),
92-
"filterProcessesUrl must contain a {registrationId} match variable");
90+
Assert.hasText(filterProcessesUrl, "filterProcessesUrl must contain a URL pattern");
9391
this.authenticationConverter = authenticationConverter;
9492
setAllowSessionCreation(true);
9593
setSessionAuthenticationStrategy(new ChangeSessionIdAuthenticationStrategy());

saml2/saml2-service-provider/src/test/java/org/springframework/security/saml2/provider/service/servlet/filter/Saml2WebSsoAuthenticationFilterTests.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ public void constructingFilterWithValidRegistrationIdVariableThenSucceeds() {
8585
this.filter = new Saml2WebSsoAuthenticationFilter(this.repository, "/url/variable/is/present/{registrationId}");
8686
}
8787

88+
@Test
89+
public void constructingFilterWithMissingRegistrationIdVariableAndCustomAuthenticationConverterThenSucceeds() {
90+
AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
91+
this.filter = new Saml2WebSsoAuthenticationFilter(authenticationConverter, "/url/missing/variable");
92+
}
93+
8894
@Test
8995
public void requiresAuthenticationWhenHappyPathThenReturnsTrue() {
9096
Assertions.assertTrue(this.filter.requiresAuthentication(this.request, this.response));

0 commit comments

Comments
 (0)