Skip to content

Commit 3869b13

Browse files
committed
Add ResponseAuthenticationConverter
Aside from simplifying configuration, this commit also makes it possible to provide a response authentication converter that doesn't need the NameID element to be present. Closes gh-12136
1 parent 3e686ab commit 3869b13

File tree

7 files changed

+470
-15
lines changed

7 files changed

+470
-15
lines changed

Diff for: docs/modules/ROOT/pages/migration-7/saml2.adoc

+105
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,108 @@ Xml::
5858
<b:bean id="saml2PostProcessor" class="org.example.MySaml2WebSsoAuthenticationFilterBeanPostProcessor"/>
5959
----
6060
======
61+
62+
== Validate Response After Validating Assertions
63+
64+
In Spring Security 6, the order of authenticating a `<saml2:Response>` is as follows:
65+
66+
1. Verify the Response Signature, if any
67+
2. Decrypt the Response
68+
3. Validate Response attributes, like Destination and Issuer
69+
4. For each assertion, verify the signature, decrypt, and then validate its fields
70+
5. Check to ensure that the response has at least one assertion with a name field
71+
72+
This ordering sometimes poses challenges since some response validation is being done in Step 3 and some in Step 5.
73+
Specifically, this poses a chellenge when an application doesn't have a name field and doesn't need it to be validated.
74+
75+
In Spring Security 7, this is simplified by moving response validation to after assertion validation and combining the two separate validation steps 3 and 5.
76+
When this is complete, response validation will no longer check for the existence of the `NameID` attribute and rely on ``ResponseAuthenticationConverter``s to do this.
77+
78+
This will add support ``ResponseAuthenticationConverter``s that don't use the `NameID` element in their `Authentication` instance and so don't need it validated.
79+
80+
To opt-in to this behavior in advance, use `OpenSaml5AuthenticationProvider#setValidateResponseAfterAssertions` to `true` like so:
81+
82+
[tabs]
83+
======
84+
Java::
85+
+
86+
[source,java,role="primary"]
87+
----
88+
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
89+
provider.setValidateResponseAfterAssertions(true);
90+
----
91+
92+
Kotlin::
93+
+
94+
[source,kotlin,role="secondary"]
95+
----
96+
val provider = OpenSaml5AuthenticationProvider()
97+
provider.setValidateResponseAfterAssertions(true)
98+
----
99+
======
100+
101+
This will change the authentication steps as follows:
102+
103+
1. Verify the Response Signature, if any
104+
2. Decrypt the Response
105+
3. For each assertion, verify the signature, decrypt, and then validate its fields
106+
4. Validate Response attributes, like Destination and Issuer
107+
108+
Note that if you have a custom response authentication converter, then you are now responsible to check if the `NameID` element exists in the event that you need it.
109+
110+
Alternatively to updating your response authentication converter, you can specify a custom `ResponseValidator` that adds back in the check for the `NameID` element as follows:
111+
112+
[tabs]
113+
======
114+
Java::
115+
+
116+
[source,java,role="primary"]
117+
----
118+
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
119+
provider.setValidateResponseAfterAssertions(true);
120+
ResponseValidator responseValidator = ResponseValidator.withDefaults((responseToken) -> {
121+
Response response = responseToken.getResponse();
122+
Assertion assertion = CollectionUtils.firstElement(response.getAssertions());
123+
Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
124+
"Assertion [" + firstAssertion.getID() + "] is missing a subject");
125+
Saml2ResponseValidationResult failed = Saml2ResponseValidationResult.failure(error);
126+
if (assertion.getSubject() == null) {
127+
return failed;
128+
}
129+
if (assertion.getSubject().getNameID() == null) {
130+
return failed;
131+
}
132+
if (assertion.getSubject().getNameID().getValue() == null) {
133+
return failed;
134+
}
135+
return Saml2ResponseValidationResult.success();
136+
});
137+
provider.setResponseValidator(responseValidator);
138+
----
139+
140+
Kotlin::
141+
+
142+
[source,kotlin,role="secondary"]
143+
----
144+
val provider = OpenSaml5AuthenticationProvider()
145+
provider.setValidateResponseAfterAssertions(true)
146+
val responseValidator = ResponseValidator.withDefaults { responseToken: ResponseToken ->
147+
val response = responseToken.getResponse()
148+
val assertion = CollectionUtils.firstElement(response.getAssertions())
149+
val error = Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
150+
"Assertion [" + firstAssertion.getID() + "] is missing a subject")
151+
val failed = Saml2ResponseValidationResult.failure(error)
152+
if (assertion.getSubject() == null) {
153+
return@withDefaults failed
154+
}
155+
if (assertion.getSubject().getNameID() == null) {
156+
return@withDefaults failed
157+
}
158+
if (assertion.getSubject().getNameID().getValue() == null) {
159+
return@withDefaults failed
160+
}
161+
return@withDefaults Saml2ResponseValidationResult.success()
162+
}
163+
provider.setResponseValidator(responseValidator)
164+
----
165+
======

Diff for: docs/modules/ROOT/pages/servlet/saml2/login/authentication.adoc

+195
Original file line numberDiff line numberDiff line change
@@ -250,12 +250,135 @@ class SecurityConfig {
250250
----
251251
======
252252

253+
== Converting an `Assertion` into an `Authentication`
254+
255+
`OpenSamlXAuthenticationProvider#setResponseAuthenticationConverter` provides a way for you to change how it converts your assertion into an `Authentication` instance.
256+
257+
You can set a custom converter in the following way:
258+
259+
[tabs]
260+
======
261+
Java::
262+
+
263+
[source,java,role="primary"]
264+
----
265+
@Configuration
266+
@EnableWebSecurity
267+
public class SecurityConfig {
268+
@Autowired
269+
Converter<ResponseToken, Saml2Authentication> authenticationConverter;
270+
271+
@Bean
272+
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
273+
OpenSaml5AuthenticationProvider authenticationProvider = new OpenSaml5AuthenticationProvider();
274+
authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter);
275+
276+
http
277+
.authorizeHttpRequests((authz) -> authz
278+
.anyRequest().authenticated())
279+
.saml2Login((saml2) -> saml2
280+
.authenticationManager(new ProviderManager(authenticationProvider))
281+
);
282+
return http.build();
283+
}
284+
285+
}
286+
----
287+
288+
Kotlin::
289+
+
290+
[source,kotlin,role="secondary"]
291+
----
292+
@Configuration
293+
@EnableWebSecurity
294+
open class SecurityConfig {
295+
@Autowired
296+
var authenticationConverter: Converter<ResponseToken, Saml2Authentication>? = null
297+
298+
@Bean
299+
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
300+
val authenticationProvider = OpenSaml5AuthenticationProvider()
301+
authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter)
302+
http {
303+
authorizeRequests {
304+
authorize(anyRequest, authenticated)
305+
}
306+
saml2Login {
307+
authenticationManager = ProviderManager(authenticationProvider)
308+
}
309+
}
310+
return http.build()
311+
}
312+
}
313+
----
314+
======
315+
316+
The ensuing examples all build off of this common construct to show you different ways this converter comes in handy.
317+
253318
[[servlet-saml2login-opensamlauthenticationprovider-userdetailsservice]]
254319
== Coordinating with a `UserDetailsService`
255320

256321
Or, perhaps you would like to include user details from a legacy `UserDetailsService`.
257322
In that case, the response authentication converter can come in handy, as can be seen below:
258323

324+
[tabs]
325+
======
326+
Java::
327+
+
328+
[source,java,role="primary"]
329+
----
330+
@Component
331+
class MyUserDetailsResponseAuthenticationConverter implements Converter<ResponseToken, Saml2Authentication> {
332+
private final ResponseAuthenticationConverter delegate = new ResponseAuthenticationConverter();
333+
private final UserDetailsService userDetailsService;
334+
335+
MyUserDetailsResponseAuthenticationConverter(UserDetailsService userDetailsService) {
336+
this.userDetailsService = userDetailsService;
337+
}
338+
339+
@Override
340+
public Saml2Authentication convert(ResponseToken responseToken) {
341+
Saml2Authentication authentication = this.delegate.convert(responseToken); <1>
342+
UserDetails principal = this.userDetailsService.loadByUsername(username); <2>
343+
String saml2Response = authentication.getSaml2Response();
344+
Collection<GrantedAuthority> authorities = principal.getAuthorities();
345+
return new Saml2Authentication((AuthenticatedPrincipal) userDetails, saml2Response, authorities); <3>
346+
}
347+
348+
}
349+
----
350+
351+
Kotlin::
352+
+
353+
[source,kotlin,role="secondary"]
354+
----
355+
@Component
356+
open class MyUserDetailsResponseAuthenticationConverter(val delegate: ResponseAuthenticationConverter,
357+
UserDetailsService userDetailsService): Converter<ResponseToken, Saml2Authentication> {
358+
359+
@Override
360+
open fun convert(responseToken: ResponseToken): Saml2Authentication {
361+
val authentication = this.delegate.convert(responseToken) <1>
362+
val principal = this.userDetailsService.loadByUsername(username) <2>
363+
val saml2Response = authentication.getSaml2Response()
364+
val authorities = principal.getAuthorities()
365+
return Saml2Authentication(userDetails as AuthenticatedPrincipal, saml2Response, authorities) <3>
366+
}
367+
368+
}
369+
----
370+
======
371+
<1> First, call the default converter, which extracts attributes and authorities from the response
372+
<2> Second, call the xref:servlet/authentication/passwords/user-details-service.adoc#servlet-authentication-userdetailsservice[`UserDetailsService`] using the relevant information
373+
<3> Third, return an authentication that includes the user details
374+
375+
[TIP]
376+
====
377+
If your `UserDetailsService` returns a value that also implements `AuthenticatedPrincipal`, then you don't need a custom authentication implementation.
378+
====
379+
380+
Or, if you are using OpenSaml 4, then you can achieve something similar as follows:
381+
259382
[tabs]
260383
======
261384
Java::
@@ -336,6 +459,78 @@ open class SecurityConfig {
336459
It's not required to call ``OpenSaml4AuthenticationProvider``'s default authentication converter.
337460
It returns a `Saml2AuthenticatedPrincipal` containing the attributes it extracted from ``AttributeStatement``s as well as the single `ROLE_USER` authority.
338461

462+
=== Configuring the Principal Name
463+
464+
Sometimes, the principal name is not in the `<saml2:NameID>` element.
465+
In that case, you can configure the `ResponseAuthenticationConverter` with a custom strategy like so:
466+
467+
[tabs]
468+
======
469+
Java::
470+
+
471+
[source,java,role="primary"]
472+
----
473+
@Bean
474+
ResponseAuthenticationConverter authenticationConverter() {
475+
ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
476+
authenticationConverter.setPrincipalNameConverter((assertion) -> {
477+
// ... work with OpenSAML's Assertion object to extract the principal
478+
});
479+
return authenticationConverter;
480+
}
481+
----
482+
483+
Kotlin::
484+
+
485+
[source,kotlin,role="secondary"]
486+
----
487+
@Bean
488+
fun authenticationConverter(): ResponseAuthenticationConverter {
489+
val authenticationConverter: ResponseAuthenticationConverter = ResponseAuthenticationConverter()
490+
authenticationConverter.setPrincipalNameConverter { assertion ->
491+
// ... work with OpenSAML's Assertion object to extract the principal
492+
}
493+
return authenticationConverter
494+
}
495+
----
496+
======
497+
498+
=== Configuring a Principal's Granted Authorities
499+
500+
Spring Security automatically grants `ROLE_USER` when using `OpenSamlXAuhenticationProvider`.
501+
With `OpenSaml5AuthenticationProvider`, you can configure a different set of granted authorities like so:
502+
503+
[tabs]
504+
======
505+
Java::
506+
+
507+
[source,java,role="primary"]
508+
----
509+
@Bean
510+
ResponseAuthenticationConverter authenticationConverter() {
511+
ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
512+
authenticationConverter.setPrincipalNameConverter((assertion) -> {
513+
// ... grant the needed authorities based on attributes in the assertion
514+
});
515+
return authenticationConverter;
516+
}
517+
----
518+
519+
Kotlin::
520+
+
521+
[source,kotlin,role="secondary"]
522+
----
523+
@Bean
524+
fun authenticationConverter(): ResponseAuthenticationConverter {
525+
val authenticationConverter = ResponseAuthenticationConverter()
526+
authenticationConverter.setPrincipalNameConverter{ assertion ->
527+
// ... grant the needed authorities based on attributes in the assertion
528+
}
529+
return authenticationConverter
530+
}
531+
----
532+
======
533+
339534
[[servlet-saml2login-opensamlauthenticationprovider-additionalvalidation]]
340535
== Performing Additional Response Validation
341536

Diff for: docs/modules/ROOT/pages/servlet/saml2/logout.adoc

+2-2
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,7 @@ It's common to need to set other values in the `<saml2:LogoutRequest>` than the
339339

340340
By default, Spring Security will issue a `<saml2:LogoutRequest>` and supply:
341341

342-
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation`
342+
* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation`
343343
* The `ID` attribute - a GUID
344344
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
345345
* The `<NameID>` element - from `Authentication#getName`
@@ -424,7 +424,7 @@ It's common to need to set other values in the `<saml2:LogoutResponse>` than the
424424

425425
By default, Spring Security will issue a `<saml2:LogoutResponse>` and supply:
426426

427-
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation`
427+
* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation`
428428
* The `ID` attribute - a GUID
429429
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
430430
* The `<Status>` element - `SUCCESS`

0 commit comments

Comments
 (0)