问题: 我的集成测试失败,响应代码为 403,而不是预期的 200 或 401。
环境: 我有一个使用 Spring Boot 和 Kotlin 运行的资源服务器。
安全: 每个端点基于角色的授权。 接受来自自己托管的 Keycloak 实例(包含领域角色)和多个其他 IDP(不包含角色)的不同 JWT。如果是外部 IDP(以及没有角色的代币),我会将发行者映射到自定义转换器中的角色。
@Component
class JwtAuthConverter(
private val properties: JwtAuthConverterProperties,
private val externalIssuers: ExternalIssuers,
) : Converter<Jwt?, AbstractAuthenticationToken?> {
override fun convert(jwt: Jwt): AbstractAuthenticationToken {
var authorities = extractRealmRoles(jwt)
if (authorities.isEmpty()) authorities = getRoleForExternalIssuer(jwt)
return JwtAuthenticationToken(jwt, authorities, getPrincipalClaimName(jwt))
}
private fun getPrincipalClaimName(jwt: Jwt): String {
val claimName = properties.principalAttribute ?: JwtClaimNames.SUB
return jwt.getClaim(claimName)
}
private fun extractRealmRoles(jwt: Jwt): Collection<GrantedAuthority> {
val realmAccess: RealmAccess? = jwt.getClaim("realm_access")
val resourceRoles =
realmAccess?.let {
realmAccess["roles"]
}
return resourceRoles?.map { role: String ->
SimpleGrantedAuthority("ROLE_$role")
} ?: emptySet()
}
private fun getRoleForExternalIssuer(jwt: Jwt): Collection<GrantedAuthority> {
val iss: String = jwt.getClaim("iss")
val roles: MutableCollection<GrantedAuthority> = mutableListOf()
externalIssuers.issuers.forEach { issuer: IssuerDetails ->
if (iss == issuer.uri) roles.add(SimpleGrantedAuthority("ROLE_${issuer.role}"))
}
return roles
}
}
@Component
class AuthManagerResolverProvider(
private val externalIssuers: ExternalIssuers,
private var jwtAuthConverter: JwtAuthConverter?
) {
@Bean
fun getAuthenticationManagerResolver(): JwtIssuerAuthenticationManagerResolver {
val authenticationManagers: MutableMap<String, AuthenticationManager> = HashMap()
val authenticationManagerResolver = JwtIssuerAuthenticationManagerResolver { key: String? ->
authenticationManagers[key]
}
externalIssuers.issuers.forEach { issuer: IssuerDetails ->
addManager(authenticationManagers, issuer.uri)
}
return authenticationManagerResolver
}
private fun addManager(authManagers: MutableMap<String, AuthenticationManager>, issuer: String) {
val authProvider = JwtAuthenticationProvider(JwtDecoders.fromOidcIssuerLocation(issuer))
authProvider.setJwtAuthenticationConverter(jwtAuthConverter)
authManagers[issuer] = AuthenticationManager {
authProvider.authenticate(it)
}
}
}
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
class SecurityConfig(
jwtAuthConverter: JwtAuthConverter,
externalIssuers: ExternalIssuers
) {
private val authManagerResolverProvider = AuthManagerResolverProvider(externalIssuers, jwtAuthConverter)
val authenticationManagerResolver = authManagerResolverProvider.getAuthenticationManagerResolver()
@Bean
@Throws(Exception::class)
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
http
.authorizeHttpRequests { auth ->
auth
.requestMatchers("/api/test/**").hasRole("tester")
// ... all the requestMatchers for each endpoint
.requestMatchers(HttpMethod.GET, "/v3/api-docs.yaml").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth2 ->
oauth2.authenticationManagerResolver(authenticationManagerResolver)
}
.sessionManagement { session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
.cors(Customizer.withDefaults())
.csrf { csrf -> csrf.disable() }
return http.build()
}
}
jwt:
auth:
converter:
principal-attribute: preferred_username
issuers:
- uri: http://known.issuer/realms/test
role: tester
- uri: http://localhost:8080/realms/test // my local keycloak
role: tester
/**
* @property issuers
*/
@Configuration
@ConfigurationProperties(prefix = "jwt.auth")
class ExternalIssuers(var issuers: List<IssuerDetails> = ArrayList())
/**
* @property uri
* @property role
*/
data class IssuerDetails(
var uri: String,
var role: String,
)
/**
* @property principalAttribute
*/
@Configuration
@ConfigurationProperties(prefix = "jwt.auth.converter")
data class JwtAuthConverterProperties(var principalAttribute: String? = null)
@Test
@WithMockJwtAuth(
claims =
OpenIdClaims(
name = "example-name",
iss = "http://known.issuer/realms/test",
),
)
fun `should succeed`() {
mockMvc.get("/api/test/123abc")
.andExpect {
status { isOk() }
}
}
在这次测试中我像往常一样不提供任何权限。举个例子:
@WithMockJwtAuth(authorities = ["ROLE_user"])
我希望发行人被视为已知发行人,并据此分配角色。
当我用 Postman 测试我的实现时,一切正常,但测试失败。
MockHttpServletResponse:
Status = 403
Error message = null
Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", WWW-Authenticate:"Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
非常感谢任何形式的帮助。
首先,请注意,您的测试运行可能只是因为您配置的发行者可以在网络上访问:使用您当前的配置,无法
@MockBean
JwtDecoder
,或者更准确地说,在您的情况下,无法实例化它(AuthManagerResolverProvider
位于您的 SecurityFilterChain
构建器内部)。
这很糟糕。您应该将
AuthManagerResolverProvider
暴露为 @Bean
以便在测试中进行模拟。
其次,在您的实现中,您可以为用户设置空名称(并非所有提供者都设置
preferred_username
声明)以及名称之间的冲突(某些提供者具有非唯一的 preferred_username
并且不同的提供者很可能具有相同的 preferred_username
)。你最好保留默认声明:sub
第三,通过您的实现,对任何外部发行者拥有某些高权限的用户可以轻松获得提升的权限:它所要做的就是在自己的授权服务器上授予自己:
{
"iss": "https://trusted.external.issuer",
"realm_access": {
"roles": [
"admin"
]
}
}
并且您无法与内部发行人的管理员之一产生影响......
最后,使用
@EnableMethodSecurity
并在conf中定义访问控制是很有趣的。
这是我用作 SecurityConf 的内容:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConf {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver)
throws Exception {
http
.authorizeHttpRequests(
auth -> auth
.requestMatchers(HttpMethod.GET, "/v3/api-docs.yaml")
.permitAll()
.anyRequest()
.authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver(
JwtAuthConverterProperties props,
Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) {
final Map<String, AuthenticationManager> jwtManagers = props.issuers
.stream()
.collect(Collectors.toMap(issuerProps -> issuerProps.getUrl().toString(), issuerProps -> {
final var decoder = NimbusJwtDecoder.withIssuerLocation(issuerProps.getUrl().toString()).build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerProps.getUrl().toString()));
final var provider = new JwtAuthenticationProvider(decoder);
provider.setJwtAuthenticationConverter(jwtAuthenticationConverter);
return provider::authenticate;
}));
return new JwtIssuerAuthenticationManagerResolver((AuthenticationManagerResolver<String>) jwtManagers::get);
}
@Component
public static class JwtAuthConverter implements Converter<Jwt, JwtAuthenticationToken> {
private final Map<URL, JwtAuthConverterProperties.IssuerProperties> props;
public JwtAuthConverter(JwtAuthConverterProperties props) {
this.props = props.getIssuerPropertiesByUrl();
}
@Override
public JwtAuthenticationToken convert(Jwt jwt) {
final var issuerProps = props.get(jwt.getIssuer());
if (issuerProps == null) {
throw new UnsupportedIssuerException(jwt.getIssuer());
}
final List<String> pathRoles = issuerProps.getRolesPath().map(p -> {
final List<String> roles = JsonPath.read(jwt.getClaims(), p);
return roles;
}).orElse(List.of());
final List<GrantedAuthority> authorities = Stream
.concat(issuerProps.getDefaultRole().stream(), pathRoles.stream())
.map(r -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_%s".formatted(r)))
.toList();
return new JwtAuthenticationToken(jwt, authorities);
}
}
@Configuration
@ConfigurationProperties(prefix = "jwt.auth")
@Data
public static class JwtAuthConverterProperties {
List<JwtAuthConverterProperties.IssuerProperties> issuers = List.of();
@Data
static class IssuerProperties {
private URL url;
private Optional<String> defaultRole = Optional.empty();
private Optional<String> rolesPath = Optional.empty();
}
public Map<URL, IssuerProperties> getIssuerPropertiesByUrl() {
return issuers.stream().collect(Collectors.toMap(IssuerProperties::getUrl, p -> p));
}
}
public static class UnsupportedIssuerException extends RuntimeException {
private static final long serialVersionUID = -3202752256943326716L;
UnsupportedIssuerException(URL issuer) {
super("\"\" is not listed in jwt.auth.issuers properties".formatted(issuer == null ? "" : issuer.toString()));
}
}
@ControllerAdvice
public static class ExceptionHandlers {
@ResponseStatus(code = HttpStatus.UNAUTHORIZED, reason = "Not a trusted issuer")
@ExceptionHandler(UnsupportedIssuerException.class)
public void handleUnsupportedIssuerException(UnsupportedIssuerException ex) {}
}
}
与:
jwt:
auth:
issuers:
- url: http://known.issuer/realms/test
default-role: tester
- url: http://localhost:8080/realms/test
roles-path: $.realm_access.roles
和:
@RestController
@RequestMapping("/api/test")
public class TestController {
@GetMapping("/greet")
@PreAuthorize("hasRole('tester')")
public String getGreet() {
return "Hello!";
}
}
此测试通过:
@WebMvcTest(controllers = TestController.class)
@Import(SecurityConf.class)
class TestControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
AuthenticationManagerResolver<HttpServletRequest> authenticationManagerResolver;
@Test
@WithAnonymousUser
void givenUserIsAnonymous_whenGetGreet_thenUnauthorized() throws Exception {
mockMvc.perform(get("/api/test/greet")).andExpect(status().isUnauthorized());
}
@Test
@WithMockAuthentication()
void givenUserHasMockedAuthenticationWithoutTesterRole_whenGetGreet_thenForbidden() throws Exception {
mockMvc.perform(get("/api/test/greet")).andExpect(status().isForbidden());
}
@Test
@WithMockAuthentication("ROLE_tester")
void givenUserHasMockedAuthenticationWithTesterRole_whenGetGreet_thenOk() throws Exception {
mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
}
@Test
@WithMockJwtAuth(claims = @OpenIdClaims(iss = "http://known.issuer/realms/test"))
void givenUserHasMockedJwtAuthenticationWithForcedRole_whenGetGreet_thenOk() throws Exception {
mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
}
@Test
@WithMockJwtAuth(claims = @OpenIdClaims(iss = "http://localhost:8080/realms/test", otherClaims = @Claims(jsonObjectClaims = @JsonObjectClaim(name = "realm_access", value = "{ \"roles\": [\"tester\"] }"))))
void givenUserHasMockedJwtAuthenticationWithTesterRealmRole_whenGetGreet_thenOk() throws Exception {
mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
}
@Test
@WithMockJwtAuth(claims = @OpenIdClaims(iss = "http://localhost:8080/realms/test", otherClaims = @Claims(jsonObjectClaims = @JsonObjectClaim(name = "realm_access", value = "{ \"roles\": [\"admin\"] }"))))
void givenUserHasMockedJwtAuthenticationWithoutTesterRealmRole_whenGetGreet_thenForbidden() throws Exception {
mockMvc.perform(get("/api/test/greet")).andExpect(status().isForbidden());
}
}