如何在Spring Boot应用程序中测试Keycloak身份验证? [英] How to test Keycloak authentication in Spring Boot application?

查看:444
本文介绍了如何在Spring Boot应用程序中测试Keycloak身份验证?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

Spring Boot 项目中,我们启用了 Spring Security ,并应用了具有承载令牌的Keycloak身份验证,如以下文章中所述:

In a Spring Boot project we enabled Spring Security and applied Keycloak authentication with bearer token like described in the following articles:

https://www. keycloak.org/docs/3.2/securing_apps/topics/oidc/java/spring-security-adapter.html

https://www. keycloak.org/docs/3.2/securing_apps/topics/oidc/java/spring-boot-adapter.html

但是我找不到有关如何进行自动化测试以便应用Keycloak配置的任何建议.

But i can't find any recommendations how to make automation tests so that the Keycloak config is applied.

那么,启用Spring安全性后如何测试/模拟/验证Keycloak配置?一件非常烦人的事情:默认情况下,Spring会激活 csrf 安全过滤器,但是如何避免对其进行测试?

So, how to test/mock/verify the Keycloak configuration when Spring security is enabled? One really annoying thing: by default Spring activates csrf security filter, but how to avoid testing it?

(注意:我们使用不记名令牌,因此@WithMockUser在这种情况下似乎不适用)

(Note: we use bearer tokens, so looks like @WithMockUser is not applicable in this case)

一个奖励问题: 基本上,我们不想在每个控制器集成测试中都验证安全性,因此是否有可能与控制器集成测试(使用@SpringBootTest@WebAppConfiguration@AutoConfigureMockMvc等的控制器集成测试分开)来验证安全性?

A bonus question: basically we don't want to verify security on each controller integration test, so is it possible to verify security separately from the controllers integration tests (those which use @SpringBootTest, @WebAppConfiguration, @AutoConfigureMockMvc and so on?

推荐答案

一种解决方案是使用 WireMock 用于存根密钥斗篷授权服务器.因此,您可以使用库spring-cloud-contract-wiremock(请参见 https ://cloud.spring.io/spring-cloud-contract/1.1.x/multi/multi__spring_cloud_contract_wiremock.html ),它提供了简单的Spring Boot集成.您可以按照说明简单地添加依赖项.此外,我使用 jose4j 来创建模拟访问令牌,就像Keycloak和JWT一样.您需要做的就是对 Keycloak OpenId Configuration JSON Web密钥存储 的端点进行存根. Keycloak适配器仅在 Authorization标头中请求用于验证访问令牌的密钥.

One solution is using WireMock for stubbing the keycloak authorisation server. Therefore you can use the library spring-cloud-contract-wiremock(see https://cloud.spring.io/spring-cloud-contract/1.1.x/multi/multi__spring_cloud_contract_wiremock.html), which offers an easy spring boot integration. You can simply add the dependency as described. Furthermore i use jose4j for creating mocked access tokens the same way as Keycloak does as JWTs. All you have to do is stubbing the endpoints for Keycloak OpenId Configuration and the JSON Web Key Storage, since the Keycloak Adapter does only request those for validation of access tokens in the Authorization Header.

一个最小的独立工作示例,但是需要在一个地方进行自定义(请参阅重要说明),下面列出了一些解释:

A minimal working standalone example, that needs to be customized at one place though (see Important Notes), with a few explanations is listed in the following:

KeycloakTest.java:

@ExtendWith(SpringExtension.class)
@WebMvcTest(KeycloakTest.TestController.class)
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
@ContextConfiguration(classes= {KeycloakTest.TestController.class, SecurityConfig.class, CustomKeycloakSpringBootConfigResolver.class})
@AutoConfigureMockMvc
@AutoConfigureWireMock(port = 0) //random port, that is wired into properties with key wiremock.server.port
@TestPropertySource(locations = "classpath:wiremock.properties")
public class KeycloakTest {

    private static RsaJsonWebKey rsaJsonWebKey;

    private static boolean testSetupIsCompleted = false;

    @Value("${wiremock.server.baseUrl}")
    private String keycloakBaseUrl;

    @Value("${keycloak.realm}")
    private String keycloakRealm;

    @Autowired
    private MockMvc mockMvc;

    @BeforeEach
    public void setUp() throws IOException, JoseException {
        if(!testSetupIsCompleted) {
            // Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK
            rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
            rsaJsonWebKey.setKeyId("k1");
            rsaJsonWebKey.setAlgorithm(AlgorithmIdentifiers.RSA_USING_SHA256);
            rsaJsonWebKey.setUse("sig");

            String openidConfig = "{\n" +
                    "  \"issuer\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "\",\n" +
                    "  \"authorization_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/auth\",\n" +
                    "  \"token_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token\",\n" +
                    "  \"token_introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\",\n" +
                    "  \"userinfo_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/userinfo\",\n" +
                    "  \"end_session_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/logout\",\n" +
                    "  \"jwks_uri\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/certs\",\n" +
                    "  \"check_session_iframe\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/login-status-iframe.html\",\n" +
                    "  \"registration_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/clients-registrations/openid-connect\",\n" +
                    "  \"introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\"\n" +
                    "}";
            stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(openidConfig)
                    )
            );
            stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/protocol/openid-connect/certs", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(new JsonWebKeySet(rsaJsonWebKey).toJson())
                    )
            );
            testSetupIsCompleted = true;
        }
    }

    @Test
    public void When_access_token_is_in_header_Then_process_request_with_Ok() throws Exception {
        ResultActions resultActions = this.mockMvc
                .perform(get("/test")
                        .header("Authorization",String.format("Bearer %s", generateJWT(true)))
                );
        resultActions
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("hello"));
    }

    @Test
    public void When_access_token_is_missing_Then_redirect_to_login() throws Exception {
        ResultActions resultActions = this.mockMvc
                .perform(get("/test"));
        resultActions
                .andDo(print())
                .andExpect(status().isFound())
                .andExpect(redirectedUrl("/sso/login"));
    }

    private String generateJWT(boolean withTenantClaim) throws JoseException {

        // Create the Claims, which will be the content of the JWT
        JwtClaims claims = new JwtClaims();
        claims.setJwtId(UUID.randomUUID().toString()); // a unique identifier for the token
        claims.setExpirationTimeMinutesInTheFuture(10); // time when the token will expire (10 minutes from now)
        claims.setNotBeforeMinutesInThePast(0); // time before which the token is not yet valid (2 minutes ago)
        claims.setIssuedAtToNow(); // when the token was issued/created (now)
        claims.setAudience("account"); // to whom this token is intended to be sent
        claims.setIssuer(String.format("%s/auth/realms/%s",keycloakBaseUrl,keycloakRealm)); // who creates the token and signs it
        claims.setSubject(UUID.randomUUID().toString()); // the subject/principal is whom the token is about
        claims.setClaim("typ","Bearer"); // set type of token
        claims.setClaim("azp","example-client-id"); // Authorized party  (the party to which this token was issued)
        claims.setClaim("auth_time", NumericDate.fromMilliseconds(Instant.now().minus(11, ChronoUnit.SECONDS).toEpochMilli()).getValue()); // time when authentication occured
        claims.setClaim("session_state", UUID.randomUUID().toString()); // keycloak specific ???
        claims.setClaim("acr", "0"); //Authentication context class
        claims.setClaim("realm_access", Map.of("roles",List.of("offline_access","uma_authorization","user"))); //keycloak roles
        claims.setClaim("resource_access", Map.of("account",
                    Map.of("roles", List.of("manage-account","manage-account-links","view-profile"))
                )
        ); //keycloak roles
        claims.setClaim("scope","profile email");
        claims.setClaim("name", "John Doe"); // additional claims/attributes about the subject can be added
        claims.setClaim("email_verified",true);
        claims.setClaim("preferred_username", "doe.john");
        claims.setClaim("given_name", "John");
        claims.setClaim("family_name", "Doe");

        // A JWT is a JWS and/or a JWE with JSON claims as the payload.
        // In this example it is a JWS so we create a JsonWebSignature object.
        JsonWebSignature jws = new JsonWebSignature();

        // The payload of the JWS is JSON content of the JWT Claims
        jws.setPayload(claims.toJson());

        // The JWT is signed using the private key
        jws.setKey(rsaJsonWebKey.getPrivateKey());

        // Set the Key ID (kid) header because it's just the polite thing to do.
        // We only have one key in this example but a using a Key ID helps
        // facilitate a smooth key rollover process
        jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId());

        // Set the signature algorithm on the JWT/JWS that will integrity protect the claims
        jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);

        // set the type header
        jws.setHeader("typ","JWT");

        // Sign the JWS and produce the compact serialization or the complete JWT/JWS
        // representation, which is a string consisting of three dot ('.') separated
        // base64url-encoded parts in the form Header.Payload.Signature
        return jws.getCompactSerialization();
    }

    @RestController
    public static class TestController {
        @GetMapping("/test")
        public String test() {
            return "hello";
        }
    }

}

wiremock.properties:

wiremock.server.baseUrl=http://localhost:${wiremock.server.port}
keycloak.auth-server-url=${wiremock.server.baseUrl}/auth

测试设置

注释@AutoConfigureWireMock(port = 0)将在一个随机端口上启动WireMock服务器,该端口会自动设置为属性wiremock.server.port,因此可以使用它相应地覆盖Spring Boot Keycloak适配器的keycloak.auth-server-url属性(请参见 wiremock.properties )

Test Setup

The annotation @AutoConfigureWireMock(port = 0) will start a WireMock server at a random port, which is set to the property wiremock.server.port automatically, so it can be used to override the keycloak.auth-server-url property for the Spring Boot Keycloak Adapter accordingly (see wiremock.properties)

为了生成用作访问令牌 JWT ,我确实使用 jose4j 创建了一个RSA密钥对,该密钥对已声明为作为测试类属性,因为我确实需要在测试安装过程中与WireMock Server一起对其进行初始化.

For generating the JWT, that is used as a Access Token, i do create a RSA key pair with jose4j, that is declared as a test class attribute, since i do need to initialize it during test setup alongside the WireMock Server.

private static RsaJsonWebKey rsaJsonWebKey;

然后在测试设置过程中将其初始化如下:

It is then initialized during test setup as following:

rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
            rsaJsonWebKey.setKeyId("k1");
            rsaJsonWebKey.setAlgorithm(AlgorithmIdentifiers.RSA_USING_SHA256);
            rsaJsonWebKey.setUse("sig");

keyId 的选择无关紧要.只要设置好,您就可以选择任何想要的东西.所选择的算法 use 确实很重要,并且必须与示例中的完全一样.

The choice for the keyId does not matter. You can choose whatever you want, as long as it is set. The chosen algorithm and the use do matter though and must be adapted exactly as in the example.

由此,可以如下设置 Keycloak Stub JSON Web密钥存储 端点:

With this the JSON Web Key Storage endpoint of the Keycloak Stub can be set accordingly as follows:

stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/protocol/openid-connect/certs", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(new JsonWebKeySet(rsaJsonWebKey).toJson())
                    )
            );

除此以外,如前所述,需要将另一个终结点存根以进行密钥隐藏.如果未缓存,则keycloak适配器需要请求openid配置.作为最小的工作示例,需要在配置中定义所有端点,该配置是从 OpenId配置端点 返回的:

Except this another endpoint needs to be stubbed for keycloak as mentioned earlier. If not cached, the keycloak adapter needs to request the openid configuration. For a minimal working example all endpoints need to be defined in the config, that is returned from the OpenId Configuration Endpoint:

String openidConfig = "{\n" +
                    "  \"issuer\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "\",\n" +
                    "  \"authorization_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/auth\",\n" +
                    "  \"token_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token\",\n" +
                    "  \"token_introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\",\n" +
                    "  \"userinfo_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/userinfo\",\n" +
                    "  \"end_session_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/logout\",\n" +
                    "  \"jwks_uri\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/certs\",\n" +
                    "  \"check_session_iframe\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/login-status-iframe.html\",\n" +
                    "  \"registration_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/clients-registrations/openid-connect\",\n" +
                    "  \"introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\"\n" +
                    "}";
stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
                    .willReturn(aResponse()
                            .withHeader("Content-Type", "application/json")
                            .withBody(openidConfig)
                    )
            );

令牌生成

令牌的生成是在generateJWT()中实现的,大量使用了 jose4j .这里要注意的最重要一点是,与生成的 JWK 相同的私钥必须使用在Wiremock的测试设置过程中初始化的一项.

Token Generation

Generation of the token is implemented in generateJWT() with heavy use of jose4j . The most important point to note here is, that the private key of the same generated JWK as the one initialized during the test setup for wiremock has to be used.

jws.setKey(rsaJsonWebKey.getPrivateKey());

除此之外,代码主要根据 https://bitbucket的示例改编而成.org/b_c/jose4j/wiki/JWT%20Examples .
现在,可以根据自己的特定测试设置调整或扩展索赔要求. 发布的代码段中的最小示例代表Keycloak生产的JWT的典型示例.

Except this the the code is adapted mainly from the example at https://bitbucket.org/b_c/jose4j/wiki/JWT%20Examples.
One can now adjust or extend the claims as seen fit for one's own specific test setup. The minimal example in the posted snippet represents a typical example for a JWT produced by Keycloak.

生成的JWT可以照常用于 授权标头 中,以将请求发送到REST端点:

The generated JWT can be used as usual in the Authorization Header to send a request to a REST endpoint:

ResultActions resultActions = this.mockMvc
                .perform(get("/test")
                        .header("Authorization",String.format("Bearer %s", generateJWT(true)))
                );

为了表示一个独立的示例,测试类确实有一个简单的Restcontroller定义为内部类,用于测试.

For representing a standalone example the test class does have a simple Restcontroller defined as an inner class, that is used for the test.

@RestController
public static class TestController {
    @GetMapping("/test")
    public String test() {
        return "hello";
    }
}

重要注意事项

出于测试目的,我确实引入了自定义TestController,因此必须定义一个自定义ContextConfiguration,以将其加载到WebMvcTest中,如下所示:

Important Notes

I did introduce a custom TestController for testing purposes, so it had been neccessary to define a custom ContextConfiguration to load it in a WebMvcTest as follows:

@ContextConfiguration(classes= {KeycloakTest.TestController.class, SecurityConfig.class, CustomKeycloakSpringBootConfigResolver.class})

除了TestController本身之外,还包含了许多有关Spring Security和Keycloak适配器的配置Bean,例如SecurityConfig.classCustomKeycloakSpringBootConfigResolver.class,以使其正常工作.当然,这些需要替换为您自己的配置.为了完整起见,这些类也将在下面列出:

Apart from the TestController itself a bunch of Configuration Beans regarding Spring Security and the Keycloak Adapter are included like SecurityConfig.class and CustomKeycloakSpringBootConfigResolver.class to have it work. These need to be replaced by your own Configuration of course. For the sake of completeness those classes will be listed too in the following:

SecurityConfig.java:

@Configuration
@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) {
        SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper();
        grantedAuthorityMapper.setPrefix("ROLE_");

        KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
        keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper);
        auth.authenticationProvider(keycloakAuthenticationProvider);
    }

    /*
     * Workaround for reading the properties for the keycloak adapter (see https://stackoverflow.com/questions/57787768/issues-running-example-keycloak-spring-boot-app)
     */
    @Bean
    @Primary
    public KeycloakConfigResolver keycloakConfigResolver(KeycloakSpringBootProperties properties) {
        return new CustomKeycloakSpringBootConfigResolver(properties);
    }

    @Bean
    @Override
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }

    @Bean
    @Override
    @ConditionalOnMissingBean(HttpSessionManager.class)
    protected HttpSessionManager httpSessionManager() {
        return new HttpSessionManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
        http
                .authorizeRequests()
                .antMatchers("/**").hasRole("user")
                .anyRequest().authenticated()
                .and().csrf().disable();
    }
}

CustomKeycloakSpringBootConfigResolver.java:

 /*
  * Workaround for reading the properties for the keycloak adapter (see https://stackoverflow.com/questions/57787768/issues-running-example-keycloak-spring-boot-app)
  */
@Configuration
public class CustomKeycloakSpringBootConfigResolver extends KeycloakSpringBootConfigResolver {
    private final KeycloakDeployment keycloakDeployment;

    public CustomKeycloakSpringBootConfigResolver(KeycloakSpringBootProperties properties) {
        keycloakDeployment = KeycloakDeploymentBuilder.build(properties);
    }

    @Override
    public KeycloakDeployment resolve(HttpFacade.Request facade) {
        return keycloakDeployment;
    }
}

这篇关于如何在Spring Boot应用程序中测试Keycloak身份验证?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆