验证 <saml2:Response>

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

您可以通过多种方式进行配置,包括

  1. 更改 RelyingPartyRegistration 的查找方式

  2. 设置时间戳验证的时钟偏差

  3. 将响应映射到 GrantedAuthority 实例列表

  4. 自定义断言验证策略

  5. 自定义响应和断言元素解密策略

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

更改 SAML 响应处理端点

默认端点为 /login/saml2/sso/{registrationId}。您可以在 DSL 和关联的元数据中更改它,如下所示

  • 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()
}

以及

  • Java

  • Kotlin

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

更改 RelyingPartyRegistration 查找

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

在许多情况下,您可能需要更复杂的方案,例如您是否支持 ARTIFACT 绑定。在这些情况下,您可以通过自定义 AuthenticationConverter 自定义查找,您可以像这样自定义它

  • 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()
}

设置时钟偏差

断言方和依赖方拥有未完全同步的系统时钟的情况并不少见。出于这个原因,您可以为 OpenSaml4AuthenticationProvider 的默认断言验证器配置一些容差

  • 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()
    }
}

UserDetailsService 协调

或者,也许您希望包含来自遗留 UserDetailsService 的用户详细信息。在这种情况下,响应身份验证转换器会派上用场,如下所示

  • 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 首先,调用默认转换器,它从响应中提取属性和权限
2 其次,使用相关信息调用 UserDetailsService
3 第三,返回一个包含用户详细信息的自定义身份验证
不需要调用 OpenSaml4AuthenticationProvider 的默认身份验证转换器。它返回一个 Saml2AuthenticatedPrincipal,其中包含它从 AttributeStatement 中提取的属性以及单个 ROLE_USER 权限。

执行其他响应验证

OpenSaml4AuthenticationProvider 在解密 Response 后立即验证 IssuerDestination 值。您可以通过扩展默认验证器并连接您自己的响应验证器来自定义验证,或者您可以完全替换为您自己的验证器。

例如,您可以使用任何其他可在 Response 对象中获得的信息抛出自定义异常,如下所示

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;
});

执行其他断言验证

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

  1. 验证 <AudienceRestriction><DelegationRestriction> 条件

  2. 验证 <SubjectConfirmation>,除了任何 IP 地址信息

要执行其他验证,您可以配置您自己的断言验证器,该验证器委托给 OpenSaml4AuthenticationProvider 的默认验证器,然后执行其自己的验证。

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

  • 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 的默认断言验证器。您跳过它的情况是,如果您不需要它来检查 <AudienceRestriction><SubjectConfirmation>,因为您自己正在执行这些操作。

自定义解密

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

OpenSaml4AuthenticationProvider 公开了 两种解密策略。响应解密器用于解密 <saml2:Response> 中的加密元素,例如 <saml2:EncryptedAssertion>。断言解密器用于解密 <saml2:Assertion> 中的加密元素,例如 <saml2:EncryptedAttribute><saml2:EncryptedID>

您可以用自己的解密策略替换 OpenSaml4AuthenticationProvider 的默认解密策略。例如,如果您有一个单独的服务来解密 <saml2:Response> 中的断言,您可以像这样使用它:

  • 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) }

如果您还需要解密 <saml2:Assertion> 中的单个元素,您也可以自定义断言解密器。

  • Java

  • Kotlin

provider.setAssertionElementsDecrypter((assertionToken) -> decryptionService.decrypt(assertionToken.getAssertion()));
provider.setAssertionElementsDecrypter { assertionToken -> decryptionService.decrypt(assertionToken.assertion) }
有两个单独的解密器,因为断言可以与响应分开签名。在签名验证之前尝试解密已签名断言的元素可能会使签名无效。如果您的断言方仅对响应进行签名,则可以使用响应解密器安全地解密所有元素。

使用自定义身份验证管理器

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

  • 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()
    }
}

使用 Saml2AuthenticatedPrincipal

对于给定的断言方,如果依赖方配置正确,则它已准备好接受断言。一旦依赖方验证了断言,结果将是包含 Saml2AuthenticatedPrincipalSaml2Authentication

这意味着您可以在控制器中像这样访问主体:

  • 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 非常方便。