对于最新的稳定版本,请使用 Spring Security 7.0.4spring-doc.cadn.net.cn

验证 <saml2:Response>

为了验证 SAML 2.0 响应,Spring Security 使用 Saml2AuthenticationTokenConverter 来填充 Authentication 请求,并使用 OpenSaml4AuthenticationProvider 对其进行身份验证。spring-doc.cadn.net.cn

您可以通过多种方式进行配置,包括:spring-doc.cadn.net.cn

  1. 更改查找 RelyingPartyRegistration 的方式spring-doc.cadn.net.cn

  2. 为时间戳验证设置时钟偏移spring-doc.cadn.net.cn

  3. 将响应映射为 GrantedAuthority 实例列表spring-doc.cadn.net.cn

  4. 自定义断言验证策略spring-doc.cadn.net.cn

  5. 自定义用于解密响应和断言元素的策略spring-doc.cadn.net.cn

要配置这些内容,您需要在 DSL 中使用 saml2Login#authenticationManager 方法。spring-doc.cadn.net.cn

更改 SAML 响应处理端点

默认端点为 /login/saml2/sso/{registrationId}。 你可以在 DSL 和相关的元数据中进行如下修改:spring-doc.cadn.net.cn

@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()
}
relyingPartyRegistrationBuilder.assertionConsumerServiceLocation("/saml/SSO")
relyingPartyRegistrationBuilder.assertionConsumerServiceLocation("/saml/SSO")

变更RelyingPartyRegistration查找

默认情况下,此转换器将匹配 URL 中找到的任何关联的 <saml2:AuthnRequest> 或任何 registrationId。 或者,如果在这两种情况下都无法找到,则会尝试通过 <saml2:Response#Issuer> 元素进行查找。spring-doc.cadn.net.cn

在某些情况下,你可能需要更复杂的功能,例如当你支持 ARTIFACT 绑定时。 在这些情况下,你可以通过自定义的 AuthenticationConverter 来定制查找逻辑,其定制方式如下:spring-doc.cadn.net.cn

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

设置时钟偏差

系统时钟不同步的情况并不少见,尤其是授权方和依赖方之间。 因此,你可以为默认的断言验证器配置一些容差: OpenSaml4AuthenticationProviderspring-doc.cadn.net.cn

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        OpenSaml4AuthenticationProvider authenticationProvider = new OpenSaml4AuthenticationProvider();
        authenticationProvider.setAssertionValidator(OpenSaml4AuthenticationProvider
                .createDefaultAssertionValidatorWithParameters(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
                .createDefaultAssertionValidatorWithParameters(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()
    }
}

如果您正在使用OpenSAML 5,那么我们有一个更简单的方法,使用OpenSaml5AuthenticationProvider.AssertionValidatorspring-doc.cadn.net.cn

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        OpenSaml5AuthenticationProvider authenticationProvider = new OpenSaml5AuthenticationProvider();
        AssertionValidator assertionValidator = AssertionValidator.builder()
                .clockSkew(Duration.ofMinutes(10)).build();
		authenticationProvider.setAssertionValidator(assertionValidator);
        http
            .authorizeHttpRequests(authz -> authz
                .anyRequest().authenticated()
            )
            .saml2Login(saml2 -> saml2
                .authenticationManager(new ProviderManager(authenticationProvider))
            );
        return http.build();
	}
}
@Configuration @EnableWebSecurity
class SecurityConfig {
    @Bean
    @Throws(Exception::class)
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        val authenticationProvider = OpenSaml5AuthenticationProvider()
        val assertionValidator = AssertionValidator.builder().clockSkew(Duration.ofMinutes(10)).build()
        authenticationProvider.setAssertionValidator(assertionValidator)
        http {
            authorizeHttpRequests {
                authorize(anyRequest, authenticated)
            }
            saml2Login {
                authenticationManager = ProviderManager(authenticationProvider)
            }
        }
        return http.build()
    }
}

转换一个Assertion集成到Authentication

OpenSamlXAuthenticationProvider#setResponseAuthenticationConverter 提供了一种方式,让您自定义如何将断言转换为 Authentication 实例。spring-doc.cadn.net.cn

你可以通过以下方式设置自定义转换器:spring-doc.cadn.net.cn

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

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

以下所有示例都基于这一通用结构,向您展示该转换器在不同场景下的实用之处。spring-doc.cadn.net.cn

UserDetailsService

或者,您可能希望从一个传统的 UserDetailsService 中包含用户详细信息。 在这种情况下,响应身份验证转换器(response authentication converter)就会派上用场,如下所示:spring-doc.cadn.net.cn

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

}
@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 首先,调用默认转换器,该转换器从响应中提取属性和权限
2 其次,使用相关信息调用 UserDetailsService
3 第三,返回一个包含用户详细信息的认证对象

如果你的 UserDetailsService 返回的值也实现了 AuthenticatedPrincipal,那么你就不需要自定义身份验证实现。spring-doc.cadn.net.cn

或如果你正在使用OpenSaml 4,则可以类似地实现如下:spring-doc.cadn.net.cn

@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 权限。

配置主体名称

有时,主体名称并不在 <saml2:NameID> 元素中。 在这种情况下,你可以通过自定义策略来配置 ResponseAuthenticationConverter,如下所示:spring-doc.cadn.net.cn

@Bean
ResponseAuthenticationConverter authenticationConverter() {
	ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
	authenticationConverter.setPrincipalNameConverter((assertion) -> {
		// ... work with OpenSAML's Assertion object to extract the principal
	});
	return authenticationConverter;
}
@Bean
fun authenticationConverter(): ResponseAuthenticationConverter {
    val authenticationConverter: ResponseAuthenticationConverter = ResponseAuthenticationConverter()
    authenticationConverter.setPrincipalNameConverter { assertion ->
		// ... work with OpenSAML's Assertion object to extract the principal
    }
    return authenticationConverter
}

配置主体的授予权限

使用 ROLE_USER 时,Spring Security 会自动授予 OpenSamlXAuhenticationProvider 角色。 而使用 OpenSaml5AuthenticationProvider 时,你可以像下面这样配置一组不同的授予权限:spring-doc.cadn.net.cn

@Bean
ResponseAuthenticationConverter authenticationConverter() {
	ResponseAuthenticationConverter authenticationConverter = new ResponseAuthenticationConverter();
	authenticationConverter.setPrincipalNameConverter((assertion) -> {
		// ... grant the needed authorities based on attributes in the assertion
	});
	return authenticationConverter;
}
@Bean
fun authenticationConverter(): ResponseAuthenticationConverter {
    val authenticationConverter = ResponseAuthenticationConverter()
    authenticationConverter.setPrincipalNameConverter{ assertion ->
		// ... grant the needed authorities based on attributes in the assertion
    }
    return authenticationConverter
}

执行额外的响应验证

OpenSaml4AuthenticationProvider 在解密 Issuer 后会立即验证 DestinationResponse 的值。 您可以通过扩展默认验证器并将其与您自己的响应验证器串联起来来自定义验证逻辑,也可以完全用自己的验证器替换默认验证器。spring-doc.cadn.net.cn

例如,你可以抛出一个自定义异常,并在其中包含 Response 对象中可用的任何附加信息,如下所示:spring-doc.cadn.net.cn

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

使用 `OpenSaml5AuthenticationProvider`,您可以实现相同的功能,并且可以减少冗余代码:spring-doc.cadn.net.cn

OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
ResponseValidator responseValidator = ResponseValidator.withDefaults(myCustomValidator);
provider.setResponseValidator(responseValidator);

你还可以自定义 Spring Security 应执行哪些验证步骤。 例如,如果你想跳过 Response#InResponseTo 的验证,可以调用 ResponseValidator 的构造函数,并从列表中排除 InResponseToValidatorspring-doc.cadn.net.cn

OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
ResponseValidator responseValidator = new ResponseValidator(new DestinationValidator(), new IssuerValidator());
provider.setResponseValidator(responseValidator);

OpenSAML 在其 Asssertion#InResponseTo 类中执行 BearerSubjectConfirmationValidator 验证,该验证可通过 setAssertionValidator 进行配置。spring-doc.cadn.net.cn

执行额外的断言验证

OpenSaml4AuthenticationProvider 对 SAML 2.0 断言执行最少的验证。 在验证签名之后,它将:spring-doc.cadn.net.cn

  1. 验证 <AudienceRestriction><DelegationRestriction> 条件spring-doc.cadn.net.cn

  2. 验证 <SubjectConfirmation>,但不包括任何 IP 地址信息spring-doc.cadn.net.cn

要执行额外的验证,您可以配置自己的断言验证器,该验证器先委托给 OpenSaml4AuthenticationProvider 的默认验证器,然后再执行自身的验证逻辑。spring-doc.cadn.net.cn

例如,您可以使用 OpenSAML 的 OneTimeUseConditionValidator 来同时验证一个 <OneTimeUse> 条件,如下所示:spring-doc.cadn.net.cn

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>(因为你自己已经在做这些检查),你可以跳过这个步骤。

如果您正在使用OpenSAML 5,那么我们有更简单的方法使用OpenSaml5AuthenticationProvider.AssertionValidatorspring-doc.cadn.net.cn

OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
OneTimeUseConditionValidator validator = ...;
AssertionValidator assertionValidator = AssertionValidator.builder()
        .conditionValidators((c) -> c.add(validator)).build();
provider.setAssertionValidator(assertionValidator);
val provider = OpenSaml5AuthenticationProvider()
val validator: OneTimeUseConditionValidator = ...;
val assertionValidator = AssertionValidator.builder()
        .conditionValidators { add(validator) }.build()
provider.setAssertionValidator(assertionValidator)

你可以使用相同的构建器来移除你不希望使用的验证器,如下所示:spring-doc.cadn.net.cn

OpenSaml5AuthenticationProvider provider = new OpenSaml5AuthenticationProvider();
AssertionValidator assertionValidator = AssertionValidator.builder()
        .conditionValidators((c) -> c.removeIf(AudienceRestrictionValidator.class::isInstance)).build();
provider.setAssertionValidator(assertionValidator);
val provider = new OpenSaml5AuthenticationProvider()
val assertionValidator = AssertionValidator.builder()
        .conditionValidators {
			c: List<ConditionValidator> -> c.removeIf { it is AudienceRestrictionValidator }
        }.build()
provider.setAssertionValidator(assertionValidator)

自定义解密

Spring Security 自动使用在 RelyingPartyRegistration 中注册的解密 Saml2X509Credential 实例 来解密 <saml2:EncryptedAssertion><saml2:EncryptedAttribute><saml2:EncryptedID> 元素。spring-doc.cadn.net.cn

OpenSaml4AuthenticationProvider 提供了两种解密策略。 响应解密器用于解密 <saml2:Response> 中的加密元素,例如 <saml2:EncryptedAssertion>。 断言解密器用于解密 <saml2:Assertion> 中的加密元素,例如 <saml2:EncryptedAttribute><saml2:EncryptedID>spring-doc.cadn.net.cn

你可以用自己的解密策略替换 OpenSaml4AuthenticationProvider 的默认解密策略。 例如,如果你有一个单独的服务用于解密 <saml2:Response> 中的断言,你可以像下面这样使用它:spring-doc.cadn.net.cn

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> 中的各个元素,也可以自定义断言解密器:spring-doc.cadn.net.cn

provider.setAssertionElementsDecrypter((assertionToken) -> decryptionService.decrypt(assertionToken.getAssertion()));
provider.setAssertionElementsDecrypter { assertionToken -> decryptionService.decrypt(assertionToken.assertion) }
由于断言(assertions)可以与响应(responses)分别进行签名,因此存在两个独立的解密器。 如果在验证签名之前尝试解密已签名断言的元素,可能会导致签名失效。 如果你的断言方仅对响应进行签名,那么使用响应解密器来解密所有元素是安全的。

使用自定义认证管理器

当然,也可以使用 authenticationManager DSL 方法来执行完全自定义的 SAML 2.0 身份验证。 该身份验证管理器应接收一个包含 SAML 2.0 响应 XML 数据的 Saml2AuthenticationToken 对象。spring-doc.cadn.net.cn

@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

在依赖方已针对给定的断言方正确配置后,即可接收断言。 一旦依赖方验证了某个断言,结果将是一个包含 Saml2AuthenticationSaml2AuthenticatedPrincipalspring-doc.cadn.net.cn

这意味着你可以在控制器中像这样访问主体(principal):spring-doc.cadn.net.cn

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