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
This commit is contained in:
@@ -58,3 +58,108 @@ Xml::
|
||||
<b:bean id="saml2PostProcessor" class="org.example.MySaml2WebSsoAuthenticationFilterBeanPostProcessor"/>
|
||||
----
|
||||
======
|
||||
|
||||
== Validate Response After Validating Assertions
|
||||
|
||||
In Spring Security 6, the order of authenticating a `<saml2:Response>` is as follows:
|
||||
|
||||
1. Verify the Response Signature, if any
|
||||
2. Decrypt the Response
|
||||
3. Validate Response attributes, like Destination and Issuer
|
||||
4. For each assertion, verify the signature, decrypt, and then validate its fields
|
||||
5. Check to ensure that the response has at least one assertion with a name field
|
||||
|
||||
This ordering sometimes poses challenges since some response validation is being done in Step 3 and some in Step 5.
|
||||
Specifically, this poses a chellenge when an application doesn't have a name field and doesn't need it to be validated.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
This will add support ``ResponseAuthenticationConverter``s that don't use the `NameID` element in their `Authentication` instance and so don't need it validated.
|
||||
|
||||
To opt-in to this behavior in advance, use `OpenSaml5AuthenticationProvider#setValidateResponseAfterAssertions` to `true` like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
|
||||
provider.setValidateResponseAfterAssertions(true);
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
val provider = OpenSaml5AuthenticationProvider()
|
||||
provider.setValidateResponseAfterAssertions(true)
|
||||
----
|
||||
======
|
||||
|
||||
This will change the authentication steps as follows:
|
||||
|
||||
1. Verify the Response Signature, if any
|
||||
2. Decrypt the Response
|
||||
3. For each assertion, verify the signature, decrypt, and then validate its fields
|
||||
4. Validate Response attributes, like Destination and Issuer
|
||||
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
|
||||
provider.setValidateResponseAfterAssertions(true);
|
||||
ResponseValidator responseValidator = ResponseValidator.withDefaults((responseToken) -> {
|
||||
Response response = responseToken.getResponse();
|
||||
Assertion assertion = CollectionUtils.firstElement(response.getAssertions());
|
||||
Saml2Error error = new Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
|
||||
"Assertion [" + firstAssertion.getID() + "] is missing a subject");
|
||||
Saml2ResponseValidationResult failed = Saml2ResponseValidationResult.failure(error);
|
||||
if (assertion.getSubject() == null) {
|
||||
return failed;
|
||||
}
|
||||
if (assertion.getSubject().getNameID() == null) {
|
||||
return failed;
|
||||
}
|
||||
if (assertion.getSubject().getNameID().getValue() == null) {
|
||||
return failed;
|
||||
}
|
||||
return Saml2ResponseValidationResult.success();
|
||||
});
|
||||
provider.setResponseValidator(responseValidator);
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
val provider = OpenSaml5AuthenticationProvider()
|
||||
provider.setValidateResponseAfterAssertions(true)
|
||||
val responseValidator = ResponseValidator.withDefaults { responseToken: ResponseToken ->
|
||||
val response = responseToken.getResponse()
|
||||
val assertion = CollectionUtils.firstElement(response.getAssertions())
|
||||
val error = Saml2Error(Saml2ErrorCodes.SUBJECT_NOT_FOUND,
|
||||
"Assertion [" + firstAssertion.getID() + "] is missing a subject")
|
||||
val failed = Saml2ResponseValidationResult.failure(error)
|
||||
if (assertion.getSubject() == null) {
|
||||
return@withDefaults failed
|
||||
}
|
||||
if (assertion.getSubject().getNameID() == null) {
|
||||
return@withDefaults failed
|
||||
}
|
||||
if (assertion.getSubject().getNameID().getValue() == null) {
|
||||
return@withDefaults failed
|
||||
}
|
||||
return@withDefaults Saml2ResponseValidationResult.success()
|
||||
}
|
||||
provider.setResponseValidator(responseValidator)
|
||||
----
|
||||
======
|
||||
|
||||
@@ -250,12 +250,135 @@ class SecurityConfig {
|
||||
----
|
||||
======
|
||||
|
||||
== Converting an `Assertion` into an `Authentication`
|
||||
|
||||
`OpenSamlXAuthenticationProvider#setResponseAuthenticationConverter` provides a way for you to change how it converts your assertion into an `Authentication` instance.
|
||||
|
||||
You can set a custom converter in the following way:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
@Autowired
|
||||
Converter<ResponseToken, Saml2Authentication> authenticationConverter;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
OpenSaml5AuthenticationProvider authenticationProvider = new OpenSaml5AuthenticationProvider();
|
||||
authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter);
|
||||
|
||||
http
|
||||
.authorizeHttpRequests((authz) -> authz
|
||||
.anyRequest().authenticated())
|
||||
.saml2Login((saml2) -> saml2
|
||||
.authenticationManager(new ProviderManager(authenticationProvider))
|
||||
);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
open class SecurityConfig {
|
||||
@Autowired
|
||||
var authenticationConverter: Converter<ResponseToken, Saml2Authentication>? = null
|
||||
|
||||
@Bean
|
||||
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
val authenticationProvider = OpenSaml5AuthenticationProvider()
|
||||
authenticationProvider.setResponseAuthenticationConverter(this.authenticationConverter)
|
||||
http {
|
||||
authorizeRequests {
|
||||
authorize(anyRequest, authenticated)
|
||||
}
|
||||
saml2Login {
|
||||
authenticationManager = ProviderManager(authenticationProvider)
|
||||
}
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
The ensuing examples all build off of this common construct to show you different ways this converter comes in handy.
|
||||
|
||||
[[servlet-saml2login-opensamlauthenticationprovider-userdetailsservice]]
|
||||
== Coordinating with a `UserDetailsService`
|
||||
|
||||
Or, perhaps you would like to include user details from a legacy `UserDetailsService`.
|
||||
In that case, the response authentication converter can come in handy, as can be seen below:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Component
|
||||
class MyUserDetailsResponseAuthenticationConverter implements Converter<ResponseToken, Saml2Authentication> {
|
||||
private final ResponseAuthenticationConverter delegate = new ResponseAuthenticationConverter();
|
||||
private final UserDetailsService userDetailsService;
|
||||
|
||||
MyUserDetailsResponseAuthenticationConverter(UserDetailsService userDetailsService) {
|
||||
this.userDetailsService = userDetailsService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Saml2Authentication convert(ResponseToken responseToken) {
|
||||
Saml2Authentication authentication = this.delegate.convert(responseToken); <1>
|
||||
UserDetails principal = this.userDetailsService.loadByUsername(username); <2>
|
||||
String saml2Response = authentication.getSaml2Response();
|
||||
Collection<GrantedAuthority> authorities = principal.getAuthorities();
|
||||
return new Saml2Authentication((AuthenticatedPrincipal) userDetails, saml2Response, authorities); <3>
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Component
|
||||
open class MyUserDetailsResponseAuthenticationConverter(val delegate: ResponseAuthenticationConverter,
|
||||
UserDetailsService userDetailsService): Converter<ResponseToken, Saml2Authentication> {
|
||||
|
||||
@Override
|
||||
open fun convert(responseToken: ResponseToken): Saml2Authentication {
|
||||
val authentication = this.delegate.convert(responseToken) <1>
|
||||
val principal = this.userDetailsService.loadByUsername(username) <2>
|
||||
val saml2Response = authentication.getSaml2Response()
|
||||
val authorities = principal.getAuthorities()
|
||||
return Saml2Authentication(userDetails as AuthenticatedPrincipal, saml2Response, authorities) <3>
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
======
|
||||
<1> First, call the default converter, which extracts attributes and authorities from the response
|
||||
<2> Second, call the xref:servlet/authentication/passwords/user-details-service.adoc#servlet-authentication-userdetailsservice[`UserDetailsService`] using the relevant information
|
||||
<3> Third, return an authentication that includes the user details
|
||||
|
||||
[TIP]
|
||||
====
|
||||
If your `UserDetailsService` returns a value that also implements `AuthenticatedPrincipal`, then you don't need a custom authentication implementation.
|
||||
====
|
||||
|
||||
Or, if you are using OpenSaml 4, then you can achieve something similar as follows:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
@@ -336,6 +459,78 @@ open class SecurityConfig {
|
||||
It's not required to call ``OpenSaml4AuthenticationProvider``'s default authentication converter.
|
||||
It returns a `Saml2AuthenticatedPrincipal` containing the attributes it extracted from ``AttributeStatement``s as well as the single `ROLE_USER` authority.
|
||||
|
||||
=== Configuring the Principal Name
|
||||
|
||||
Sometimes, the principal name is not in the `<saml2:NameID>` element.
|
||||
In that case, you can configure the `ResponseAuthenticationConverter` with a custom strategy like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
ResponseAuthenticationConverter authenticationConverter() {
|
||||
ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
|
||||
authenticationConverter.setPrincipalNameConverter((assertion) -> {
|
||||
// ... work with OpenSAML's Assertion object to extract the principal
|
||||
});
|
||||
return authenticationConverter;
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
fun authenticationConverter(): ResponseAuthenticationConverter {
|
||||
val authenticationConverter: ResponseAuthenticationConverter = ResponseAuthenticationConverter()
|
||||
authenticationConverter.setPrincipalNameConverter { assertion ->
|
||||
// ... work with OpenSAML's Assertion object to extract the principal
|
||||
}
|
||||
return authenticationConverter
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
=== Configuring a Principal's Granted Authorities
|
||||
|
||||
Spring Security automatically grants `ROLE_USER` when using `OpenSamlXAuhenticationProvider`.
|
||||
With `OpenSaml5AuthenticationProvider`, you can configure a different set of granted authorities like so:
|
||||
|
||||
[tabs]
|
||||
======
|
||||
Java::
|
||||
+
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
ResponseAuthenticationConverter authenticationConverter() {
|
||||
ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
|
||||
authenticationConverter.setPrincipalNameConverter((assertion) -> {
|
||||
// ... grant the needed authorities based on attributes in the assertion
|
||||
});
|
||||
return authenticationConverter;
|
||||
}
|
||||
----
|
||||
|
||||
Kotlin::
|
||||
+
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
fun authenticationConverter(): ResponseAuthenticationConverter {
|
||||
val authenticationConverter = ResponseAuthenticationConverter()
|
||||
authenticationConverter.setPrincipalNameConverter{ assertion ->
|
||||
// ... grant the needed authorities based on attributes in the assertion
|
||||
}
|
||||
return authenticationConverter
|
||||
}
|
||||
----
|
||||
======
|
||||
|
||||
[[servlet-saml2login-opensamlauthenticationprovider-additionalvalidation]]
|
||||
== Performing Additional Response Validation
|
||||
|
||||
|
||||
@@ -339,7 +339,7 @@ It's common to need to set other values in the `<saml2:LogoutRequest>` than the
|
||||
|
||||
By default, Spring Security will issue a `<saml2:LogoutRequest>` and supply:
|
||||
|
||||
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation`
|
||||
* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceLocation`
|
||||
* The `ID` attribute - a GUID
|
||||
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
|
||||
* 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
|
||||
|
||||
By default, Spring Security will issue a `<saml2:LogoutResponse>` and supply:
|
||||
|
||||
* The `Destination` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation`
|
||||
* The `DestinationValidator` attribute - from `RelyingPartyRegistration#getAssertingPartyMetadata#getSingleLogoutServiceResponseLocation`
|
||||
* The `ID` attribute - a GUID
|
||||
* The `<Issuer>` element - from `RelyingPartyRegistration#getEntityId`
|
||||
* The `<Status>` element - `SUCCESS`
|
||||
|
||||
Reference in New Issue
Block a user