Authenticating `<saml2:Response>`s

为了验证 SAML 2.0 响应,Spring Security 使用 Saml2AuthenticationTokenConverter 填充 Authentication 请求,并使用 OpenSaml4AuthenticationProvider 进行身份验证。

To verify SAML 2.0 Responses, Spring Security uses Saml2AuthenticationTokenConverter to populate the Authentication request and OpenSaml4AuthenticationProvider to authenticate it.

可以通过多种方式对其进行配置,包括:

You can configure this in a number of ways including:

  1. Changing the way the RelyingPartyRegistration is Looked Up

  2. Setting a clock skew to timestamp validation

  3. Mapping the response to a list of GrantedAuthority instances

  4. Customizing the strategy for validating assertions

  5. Customizing the strategy for decrypting response and assertion elements

要配置这些,您需要在 DSL 中使用 saml2Login#authenticationManager 方法。

To configure these, you’ll use the saml2Login#authenticationManager method in the DSL.

Changing the SAML Response Processing Endpoint

默认端点是 /login/saml2/sso/{registrationId}。可以在 DSL 和关联元数据中对其进行更改,如下所示:

The default endpoint is /login/saml2/sso/{registrationId}. You can change this in the DSL and in the associated metadata like so:

  • Java

  • Kotlin

@Bean
SecurityFilterChain securityFilters(HttpSecurity http) throws Exception {
	http
        // ...
        .saml2Login((saml2) -> saml2.loginProcessingUrl("/saml2/login/sso"))
        // ...

    return http.build();
}
@Bean
fun securityFilters(val http: HttpSecurity): SecurityFilterChain {
	http {
        // ...
        .saml2Login {
            loginProcessingUrl = "/saml2/login/sso"
        }
        // ...
    }

    return http.build()
}

及:

and:

  • Java

  • Kotlin

relyingPartyRegistrationBuilder.assertionConsumerServiceLocation("/saml/SSO")
relyingPartyRegistrationBuilder.assertionConsumerServiceLocation("/saml/SSO")

Changing RelyingPartyRegistration lookup

默认情况下,此转换器将与任何关联的 <saml2:AuthnRequest> 或在 URL 中找到的任何 registrationId 进行匹配。或者,如果在这些情况下找不到,则尝试通过 <saml2:Response#Issuer> 元素查找它。

By default, this converter will match against any associated <saml2:AuthnRequest> or any registrationId it finds in the URL. Or, if it cannot find one in either of those cases, then it attempts to look it up by the <saml2:Response#Issuer> element.

在很多情况下,您可能需要更复杂的内容,例如在支持 ARTIFACT 绑定时。在这些情况下,可以通过自定义 AuthenticationConverter 来自定义查找,可以对其进行如下自定义:

There are a number of circumstances where you might need something more sophisticated, like if you are supporting ARTIFACT binding. In those cases, you can customize lookup through a custom AuthenticationConverter, which you can customize like so:

  • Java

  • Kotlin

@Bean
SecurityFilterChain securityFilters(HttpSecurity http, AuthenticationConverter authenticationConverter) throws Exception {
	http
        // ...
        .saml2Login((saml2) -> saml2.authenticationConverter(authenticationConverter))
        // ...

    return http.build();
}
@Bean
fun securityFilters(val http: HttpSecurity, val converter: AuthenticationConverter): SecurityFilterChain {
	http {
        // ...
        .saml2Login {
            authenticationConverter = converter
        }
        // ...
    }

    return http.build()
}

Setting a Clock Skew

声明方和依赖方的时间钟不完全同步并不少见。因此,您可以使用一定的容差来配置“OpenSaml4AuthenticationProvider”的默认断言验证器:

It’s not uncommon for the asserting and relying parties to have system clocks that aren’t perfectly synchronized. For that reason, you can configure `OpenSaml4AuthenticationProvider’s default assertion validator with some tolerance:

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
        authenticationProvider.setAssertionValidator(OpenSaml4AuthenticationProvider
                .createDefaultAssertionValidator(assertionToken -> {
                    Map<String, Object> params = new HashMap<>();
                    params.put(CLOCK_SKEW, Duration.ofMinutes(10).toMillis());
                    // ... other validation parameters
                    return new ValidationContext(params);
                })
        );

        http
            .authorizeHttpRequests(authz -> authz
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .authenticationManager(new ProviderManager(authenticationProvider))
            );
        return http.build();
    }
}
@Configuration
@EnableWebSecurity
open class SecurityConfig {
    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        val authenticationProvider = OpenSaml4AuthenticationProvider()
        authenticationProvider.setAssertionValidator(
            OpenSaml4AuthenticationProvider
                .createDefaultAssertionValidator(Converter<OpenSaml4AuthenticationProvider.AssertionToken, ValidationContext> {
                    val params: MutableMap<String, Any> = HashMap()
                    params[CLOCK_SKEW] =
                        Duration.ofMinutes(10).toMillis()
                    ValidationContext(params)
                })
        )
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            saml2Login {
                authenticationManager = ProviderManager(authenticationProvider)
            }
        }
        return http.build()
    }
}

Coordinating with a UserDetailsService

或者,您可能希望包含来自旧版的 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:

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    UserDetailsService userDetailsService;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
        authenticationProvider.setResponseAuthenticationConverter(responseToken -> {
            Saml2Authentication authentication = OpenSaml4AuthenticationProvider
                    .createDefaultResponseAuthenticationConverter() 1
                    .convert(responseToken);
            Assertion assertion = responseToken.getResponse().getAssertions().get(0);
            String username = assertion.getSubject().getNameID().getValue();
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); 2
            return MySaml2Authentication(userDetails, authentication); 3
        });

        http
            .authorizeHttpRequests(authz -> authz
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .authenticationManager(new ProviderManager(authenticationProvider))
            );
        return http.build();
    }
}
@Configuration
@EnableWebSecurity
open class SecurityConfig {
    @Autowired
    var userDetailsService: UserDetailsService? = null

    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        val authenticationProvider = OpenSaml4AuthenticationProvider()
        authenticationProvider.setResponseAuthenticationConverter { responseToken: OpenSaml4AuthenticationProvider.ResponseToken ->
            val authentication = OpenSaml4AuthenticationProvider
                .createDefaultResponseAuthenticationConverter() 1
                .convert(responseToken)
            val assertion: Assertion = responseToken.response.assertions[0]
            val username: String = assertion.subject.nameID.value
            val userDetails = userDetailsService!!.loadUserByUsername(username) 2
            MySaml2Authentication(userDetails, authentication) 3
        }
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            saml2Login {
                authenticationManager = ProviderManager(authenticationProvider)
            }
        }
        return http.build()
    }
}
1 First, call the default converter, which extracts attributes and authorities from the response
2 Second, call the UserDetailsService using the relevant information
3 Third, return a custom authentication that includes the user details

AttributeStatement`s as well as the single `ROLE_USER 主体中提取属性的 OpenSaml4AuthenticationProvider’s default authentication converter. It returns a `Saml2AuthenticatedPrincipal 不必调用。

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.

Performing Additional Response Validation

OpenSaml4AuthenticationProvider 在解密 Response 后立即验证 IssuerDestination 值。可以通过将默认验证器与您自己的响应验证器串联起来来自定义验证,或者您可以将其完全替换为您自己的。

OpenSaml4AuthenticationProvider validates the Issuer and Destination values right after decrypting the Response. You can customize the validation by extending the default validator concatenating with your own response validator, or you can replace it entirely with yours.

例如,您可以使用 Response 对象中可用的任何附加信息抛出自定义异常,如下所示:

For example, you can throw a custom exception with any additional information available in the Response object, like so:

OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
provider.setResponseValidator((responseToken) -> {
	Saml2ResponseValidatorResult result = OpenSamlAuthenticationProvider
		.createDefaultResponseValidator()
		.convert(responseToken)
		.concat(myCustomValidator.convert(responseToken));
	if (!result.getErrors().isEmpty()) {
		String inResponseTo = responseToken.getInResponseTo();
		throw new CustomSaml2AuthenticationException(result, inResponseTo);
	}
	return result;
});

Performing Additional Assertion Validation

OpenSaml4AuthenticationProvider 对 SAML 2.0 断言执行最小验证。在验证签名后,它将:

OpenSaml4AuthenticationProvider performs minimal validation on SAML 2.0 Assertions. After verifying the signature, it will:

  1. Validate <AudienceRestriction> and <DelegationRestriction> conditions

  2. Validate `<SubjectConfirmation>`s, expect for any IP address information

为了执行附加验证,您可以配置自己的断言验证器,该断言验证器委托给“OpenSaml4AuthenticationProvider”的默认验证器,然后执行自己的验证器。

To perform additional validation, you can configure your own assertion validator that delegates to `OpenSaml4AuthenticationProvider’s default and then performs its own.

例如,您可以使用 OpenSAML 的 OneTimeUseConditionValidator 来验证 <OneTimeUse> 条件,如下所示:

For example, you can use OpenSAML’s OneTimeUseConditionValidator to also validate a <OneTimeUse> condition, like so:

  • Java

  • Kotlin

OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
OneTimeUseConditionValidator validator = ...;
provider.setAssertionValidator(assertionToken -> {
    Saml2ResponseValidatorResult result = OpenSaml4AuthenticationProvider
            .createDefaultAssertionValidator()
            .convert(assertionToken);
    Assertion assertion = assertionToken.getAssertion();
    OneTimeUse oneTimeUse = assertion.getConditions().getOneTimeUse();
    ValidationContext context = new ValidationContext();
    try {
        if (validator.validate(oneTimeUse, assertion, context) = ValidationResult.VALID) {
            return result;
        }
    } catch (Exception e) {
        return result.concat(new Saml2Error(INVALID_ASSERTION, e.getMessage()));
    }
    return result.concat(new Saml2Error(INVALID_ASSERTION, context.getValidationFailureMessage()));
});
var provider = OpenSaml4AuthenticationProvider()
var validator: OneTimeUseConditionValidator = ...
provider.setAssertionValidator { assertionToken ->
    val result = OpenSaml4AuthenticationProvider
        .createDefaultAssertionValidator()
        .convert(assertionToken)
    val assertion: Assertion = assertionToken.assertion
    val oneTimeUse: OneTimeUse = assertion.conditions.oneTimeUse
    val context = ValidationContext()
    try {
        if (validator.validate(oneTimeUse, assertion, context) = ValidationResult.VALID) {
            return@setAssertionValidator result
        }
    } catch (e: Exception) {
        return@setAssertionValidator result.concat(Saml2Error(INVALID_ASSERTION, e.message))
    }
    result.concat(Saml2Error(INVALID_ASSERTION, context.validationFailureMessage))
}

尽管建议调用 OpenSaml4AuthenticationProvider’s default assertion validator. A circumstance where you would skip it would be if you don’t need it to check the `<AudienceRestriction><SubjectConfirmation>,但由于您对其进行自行处理,因此不必调用。

While recommended, it’s not necessary to call OpenSaml4AuthenticationProvider’s default assertion validator. A circumstance where you would skip it would be if you don’t need it to check the `<AudienceRestriction> or the <SubjectConfirmation> since you are doing those yourself.

Customizing Decryption

Spring Security 使用在 RelyingPartyRegistration 中注册的解密 Saml2X509Credential instances 自动解密 <saml2:EncryptedAssertion><saml2:EncryptedAttribute><saml2:EncryptedID> 元素。

Spring Security decrypts <saml2:EncryptedAssertion>, <saml2:EncryptedAttribute>, and <saml2:EncryptedID> elements automatically by using the decryption Saml2X509Credential instances registered in the RelyingPartyRegistration.

OpenSaml4AuthenticationProvider`公开 two decryption strategies。响应解密器用于解密 `<saml2:Response>`的加密元素,如 `<saml2:EncryptedAssertion>。断言解密器用于解密 <saml2:Assertion>`的加密元素,如 `<saml2:EncryptedAttribute>`和 `<saml2:EncryptedID>

OpenSaml4AuthenticationProvider exposes two decryption strategies. The response decrypter is for decrypting encrypted elements of the <saml2:Response>, like <saml2:EncryptedAssertion>. The assertion decrypter is for decrypting encrypted elements of the <saml2:Assertion>, like <saml2:EncryptedAttribute> and <saml2:EncryptedID>.

可替换 OpenSaml4AuthenticationProvider’s default decryption strategy with your own. For example, if you have a separate service that decrypts the assertions in a `<saml2:Response>,可像如下所示使用它:

You can replace OpenSaml4AuthenticationProvider’s default decryption strategy with your own. For example, if you have a separate service that decrypts the assertions in a `<saml2:Response>, you can use it instead like so:

  • Java

  • Kotlin

MyDecryptionService decryptionService = ...;
OpenSaml4AuthenticationProvider provider = new OpenSaml4AuthenticationProvider();
provider.setResponseElementsDecrypter((responseToken) -> decryptionService.decrypt(responseToken.getResponse()));
val decryptionService: MyDecryptionService = ...
val provider = OpenSaml4AuthenticationProvider()
provider.setResponseElementsDecrypter { responseToken -> decryptionService.decrypt(responseToken.response) }

如果你还在 RFC 1901 中解密各个元素,也可以自定义断言解密器:

If you are also decrypting individual elements in a <saml2:Assertion>, you can customize the assertion decrypter, too:

  • Java

  • Kotlin

provider.setAssertionElementsDecrypter((assertionToken) -> decryptionService.decrypt(assertionToken.getAssertion()));
provider.setAssertionElementsDecrypter { assertionToken -> decryptionService.decrypt(assertionToken.assertion) }

之所以有两个单独的解密器,是因为断言可以从响应中分别进行签名。在验证签名之前尝试解密已签名断言的元素可能会使签名无效。如果断言方只签署响应,则仅使用响应解密器解密所有元素是安全的。

There are two separate decrypters since assertions can be signed separately from responses. Trying to decrypt a signed assertion’s elements before signature verification may invalidate the signature. If your asserting party signs the response only, then it’s safe to decrypt all elements using only the response decrypter.

Using a Custom Authentication Manager

当然,authenticationManager DSL 方法也可用于执行完全自定义的 SAML 2.0 身份验证。此身份验证管理器应期望一个包含 SAML 2.0 响应 XML 数据的 Saml2AuthenticationToken 对象。

Of course, the authenticationManager DSL method can be also used to perform a completely custom SAML 2.0 authentication. This authentication manager should expect a Saml2AuthenticationToken object containing the SAML 2.0 Response XML data.

  • Java

  • Kotlin

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        AuthenticationManager authenticationManager = new MySaml2AuthenticationManager(...);
        http
            .authorizeHttpRequests(authorize -> authorize
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .authenticationManager(authenticationManager)
            )
        ;
        return http.build();
    }
}
@Configuration
@EnableWebSecurity
open class SecurityConfig {
    @Bean
    open fun filterChain(http: HttpSecurity): SecurityFilterChain {
        val customAuthenticationManager: AuthenticationManager = MySaml2AuthenticationManager(...)
        http {
            authorizeRequests {
                authorize(anyRequest, authenticated)
            }
            saml2Login {
                authenticationManager = customAuthenticationManager
            }
        }
        return http.build()
    }
}

Using Saml2AuthenticatedPrincipal

依靠方为给定的断言方正确配置后,即可接受断言。一旦依靠方验证断言,结果就是具有 RFC 1901 的 Saml2Authentication

With the relying party correctly configured for a given asserting party, it’s ready to accept assertions. Once the relying party validates an assertion, the result is a Saml2Authentication with a Saml2AuthenticatedPrincipal.

这意味着你可以像如下所示在控制器中访问主体:

This means that you can access the principal in your controller like so:

  • Java

  • Kotlin

@Controller
public class MainController {
	@GetMapping("/")
	public String index(@AuthenticationPrincipal Saml2AuthenticatedPrincipal principal, Model model) {
		String email = principal.getFirstAttribute("email");
		model.setAttribute("email", email);
		return "index";
	}
}
@Controller
class MainController {
    @GetMapping("/")
    fun index(@AuthenticationPrincipal principal: Saml2AuthenticatedPrincipal, model: Model): String {
        val email = principal.getFirstAttribute<String>("email")
        model.setAttribute("email", email)
        return "index"
    }
}

由于 SAML 2.0 规范允许每个属性拥有多个值,您既可以调用 getAttribute 来获取属性列表,也可以调用 getFirstAttribute 来获取列表中的第一个值。当您知道只有一个值时,getFirstAttribute 非常方便。

Because the SAML 2.0 specification allows for each attribute to have multiple values, you can either call getAttribute to get the list of attributes or getFirstAttribute to get the first in the list. getFirstAttribute is quite handy when you know that there is only one value.