English 中文(简体)
测试多功能资源服务器,其功能基于要求
原标题:Testing multi tenenancy resource server with claim based role assignment

Problem: my integration tests fail with response code 403 instead of expected 200 or 401.

Environment: I have a resource server running with Spring Boot and Kotlin.

Security: Role based authorization of each endpoint. Accepting different JWTs from own hosted Keycloak instance (containing realm roles) and multiple other IDPs (without roles). In case of external IDPs (and tokens without roles) I map the issuer to a role in my custom converter.

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

In this test I don t provide any authorities as usual. For axample: @WithMockJwtAuth(authorities = ["ROLE_user"]) I expect the issuer to be recognized as known issuer and the role be assigned based on it. When I test my implementation with Postman, everything works fine, but the test fails.

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>。

This is pretty bad. You should expose the AuthManagerResolverProvider as a @Bean to be mock in tests (currently the AuthManagerResolverProvider is built inside your SecurityFilterChain builder).

Second, you d much better keep the default claim: sub for authentication name. With your implementation, you can have:

  • null name for users: not all providers set the preferred_username claim
  • collisions between names: some providers have non unique preferred_username and it is quite likely that different providers have the same preferred_username

Third, with your implementation, a user having enough rights on any of the external issuers can grant himself with elevated privileges in your system. Let s consider a token with the following payload:

{
  "iss": "https://trusted.external.issuer",
  "realm_access": {
    "roles": [
      "admin"
    ]
  }
}

you cannot make a difference with one of your admins from your internal issuer...

最后,使用<代码>@EnableMethod Security,并界定对你的控制器在座的终端点进行谷物控制。

这里指的是我作为“安全”使用的:

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

    @Test
    @WithJwt("external_admin.json")
    void givenUserIsExternalAdmin_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithJwt("internal_tester.json")
    void givenIsInternalTester_whenGetGreet_thenOk() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isOk());
    }

    @Test
    @WithJwt("internal_admin.json")
    void givenIsInternalAdmin_whenGetGreet_thenForbidden() throws Exception {
        mockMvc.perform(get("/api/test/greet")).andExpect(status().isForbidden());
    }

}

请注意,在上述测试中,在测试舱载荷的@WithJwt可能更容易使用。 这里是最后3种测试方法所需的测试资源:

  • external_admin.json
{
  "iss": "http://known.issuer/realms/test",
  "realm_access": {
    "roles": [
      "admin"
    ]
  }
}
  • internal_admin.json
{
  "iss": "http://localhost:8080/realms/test",
  "realm_access": {
    "roles": [
      "admin"
    ]
  }
}
  • internal_tester.json
{
  "iss": "http://localhost:8080/realms/test",
  "realm_access": {
    "roles": [
      "tester"
    ]
  }
}




相关问题
Selenium not working with Firefox 3.x on linux

I am using selenium-server , selenium rc for UI testing in my application . My dev box is Windows with FireFox 3.5 and every thing is running fine and cool. But when i try to run selenium tests on my ...

Best browser for testing under Safari Mobile on Linux?

I have an iPhone web app I m producing on a Linux machine. What s the best browser I can use to most closely mimic the feature-limited version of Safari present on the iPhone? (It s a "slimmed down" ...

Code Coverage Tools & Visual Studio 2008 Pro

Just wondering what people are using for code coverage tools when using MS Visual Studio 2008 Pro. We are using the built-in MS test project and unit testing tool (the one that come pre-installed ...

Is there any error checking web app cralwers out there?

Wondering if there was some sort of crawler we could use to test and re-test everything when changes are made to the web app so we know some new change didn t error out any existing pages. Or maybe a ...

热门标签