使用基于声明的角色分配来测试多租户资源服务器

问题描述 投票:0回答:1

问题: 我的集成测试失败,响应代码为 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 = []

非常感谢任何形式的帮助。

testing spring-security spring-security-oauth2 multi-tenant mockmvc
1个回答
0
投票

首先,请注意,您的测试运行可能只是因为您配置的发行者可以在网络上访问:使用您当前的配置,无法

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

}
© www.soinside.com 2019 - 2024. All rights reserved.