【问题标题】:How to test Keycloak authentication in Spring Boot application?如何在 Spring Boot 应用程序中测试 Keycloak 身份验证?
【发布时间】:2019-01-13 15:04:35
【问题描述】:

Spring Boot 项目中,我们启用了 Spring Security 并使用不记名令牌应用 Keycloak 身份验证,如以下文章所述:

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 配置的建议。

那么,当启用 Spring 安全性时,如何测试/模拟/验证 Keycloak 配置?一件很烦人的事:默认情况下 Spring 会激活 csrf 安全过滤器,但如何避免测试呢?

(注意:我们使用不记名令牌,所以看起来@WithMockUser在这种情况下不适用)

一个额外的问题: 基本上我们不想在每个控制器集成测试中验证安全性,所以是否可以与控制器集成测试分开验证安全性(使用@SpringBootTest@WebAppConfiguration@AutoConfigureMockMvc 等的那些?

【问题讨论】:

    标签: security spring-boot automated-tests keycloak bearer-token


    【解决方案1】:

    一种解决方案是使用WireMock 对keycloak 授权服务器进行存根。因此,您可以使用库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 ConfigurationJSON Web Key Storage 的端点存根,因为Keycloak 适配器 只请求那些验证授权标头中的访问令牌。

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

    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 Adapter 的keycloak.auth-server-url 属性(请参阅wiremock.properties)

    为了生成 JWT,用作 Access Token,我使用 jose4j 创建了一个 RSA 密钥对,声明为作为测试类属性,因为我确实需要在测试设置期间与 WireMock 服务器一起初始化它。

    private static RsaJsonWebKey rsaJsonWebKey;
    

    然后在测试设置期间初始化如下:

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

    keyId 的选择无关紧要。你可以选择任何你想要的,只要它被设置好了。选择的算法使用确实很重要,并且必须完全按照示例中的方式进行调整。

    这样,Keycloak StubJSON Web Key Storage 端点可以相应地设置如下:

    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 存根另一个端点,如前所述。如果没有缓存,keycloak 适配器需要请求 openid 配置。对于一个最小的工作示例,所有端点都需要在配置中定义,即从 OpenId 配置端点返回:

    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 。这里要注意的最重要的一点是,必须使用与在测试设置期间为wiremock 初始化的相同生成的JWK 的私钥。

    jws.setKey(rsaJsonWebKey.getPrivateKey());
    

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

    测试执行

    生成的 JWT 可以像往常一样在 Authorization Header 中用于向 REST 端点发送请求:

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

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

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

    重要提示

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

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

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

    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;
        }
    }
    

    【讨论】:

    • 能否将所有代码推送到 GitHub 存储库中?
    【解决方案2】:

    仅适用于“奖励”问题的部分答案(@Component unit-tests):我刚刚写了一组库to ease unit-testing of secured Spring apps。我只运行这样的测试和 e2e 测试(包括富客户端前端和实际授权服务器)。

    它包括一个@WithMockKeycloackAuth 注释,以及Keycloak 专用的MockMvc 请求后处理器

    示例用法:

    @RunWith(SpringRunner.class)
    @WebMvcTest(GreetingController.class)
    @ContextConfiguration(classes = GreetingApp.class)
    @ComponentScan(basePackageClasses = { KeycloakSecurityComponents.class, KeycloakSpringBootConfigResolver.class })
    public class GreetingControllerTests extends ServletUnitTestingSupport {
        @MockBean
        MessageService messageService;
    
        @Test
        @WithMockKeycloackAuth("TESTER")
        public void whenUserIsNotGrantedWithAuthorizedPersonelThenSecretRouteIsNotAccessible() throws Exception {
            mockMvc().get("/secured-route").andExpect(status().isForbidden());
        }
    
        @Test
        @WithMockKeycloackAuth("AUTHORIZED_PERSONNEL")
        public void whenUserIsGrantedWithAuthorizedPersonelThenSecretRouteIsAccessible() throws Exception {
            mockMvc().get("/secured-route").andExpect(content().string(is("secret route")));
        }
    
        @Test
        @WithMockKeycloakAuth(
                authorities = { "USER", "AUTHORIZED_PERSONNEL" },
                id = @IdTokenClaims(sub = "42"),
                oidc = @OidcStandardClaims(
                        email = "ch4mp@c4-soft.com",
                        emailVerified = true,
                        nickName = "Tonton-Pirate",
                        preferredUsername = "ch4mpy"),
                otherClaims = @ClaimSet(stringClaims = @StringClaim(name = "foo", value = "bar")))
        public void whenAuthenticatedWithKeycloakAuthenticationTokenThenCanGreet() throws Exception {
            mockMvc().get("/greet")
                    .andExpect(status().isOk())
                    .andExpect(content().string(startsWith("Hello ch4mpy! You are granted with ")))
                    .andExpect(content().string(containsString("AUTHORIZED_PERSONNEL")))
                    .andExpect(content().string(containsString("USER")));
    }
    

    maven-central 提供了不同的库,根据您的用例选择以下之一(仅@WithMockKeycloakAuth 或更多工具,例如 MockMvc fluent API):

    <dependency>
      <groupId>com.c4-soft.springaddons</groupId>
      <artifactId>spring-security-oauth2-test-addons</artifactId>
      <version>2.4.1</version>
      <scope>test</scope>
    </dependency>
    

    <dependency>
      <groupId>com.c4-soft.springaddons</groupId>
      <artifactId>spring-security-oauth2-test-webmvc-addons</artifactId>
      <version>2.4.1</version>
      <scope>test</scope>
    </dependency>
    

    【讨论】:

    • 哇,我试过了:github.com/ch4mpy/spring-addons 很棒,它甚至适用于 WebFluxTest。感谢那个回购@ch4mp
    • 这个插件看起来很有趣。尽管尝试在带有 JUnit5 和 Keycloak 12 的 Spring Boot 2 应用程序中使用它时出现“必须在配置中设置 'realm'”错误。如果没有运行真正的 keycloak 实例,为什么我必须设置这些?
    • 很可能是因为 keycloak spring-boot lib 在设置身份验证转换器或其他东西时需要它。只需输入随机值(或无法访问的服务器的实际值),您就会看到测试执行。
    • 这个库做得很好!第一次工作!并且只需要一个非常清晰的注释!太棒了。
    • 谁能告诉我可以用这个库模拟客户角色吗?
    【解决方案3】:

    我在 activiti 项目上工作,我们一直在使用带有 spring boot 的 keycloak 并遇到了同样的问题。有一个名为 KeycloakSecurityContextClientRequestInterceptor that we've customized a little bit 的 keycloak 测试助手类。这引用了用于测试的领域和用户。我们set those properties in tests that use keycloak。这也可以用于switch users during a set of tests

    对于我们不想使用 keycloak 的测试,到目前为止,我们一直遵循一种做法,即在我们的项目中将它们保持在不同的级别,因此在不同的子模块中。这让我们可以将 keycloak maven 依赖项排除在该层之外,这样就不会在它们上启用 keycloak。

    【讨论】:

    • 看起来不是合适的解决方案,因为 testUsertestPassword 在主要来源中实现,我想避免。
    • 公平点。您可以使用不同的领域进行测试,以便该特定用户不包含在您的实时系统上的领域中,或者可能在用于测试的 docker 容器启动时使用脚本添加该用户。我忘了解释使用 keycloak 的测试是针对我们从 docker maven 插件开始的 keycloak docker 容器运行的。
    • 从测试代码中获取密码的其他选项可能是从环境变量或 Maven 变量而不是属性文件中设置密码。或者使用 Spring boot jasypt 之类的道具加密。
    • 对不起,我刚刚意识到您的意思是我们已将 KeycloakSecurityContextClientRequestInterceptor 放在 src/main 下,而您希望它在 src/test 下。我们只将它放在 src/main 下,以便将它放在一个库中,以便我们可以在其他存储库中重复使用。
    猜你喜欢
    • 2021-06-24
    • 2020-08-19
    • 2019-01-14
    • 1970-01-01
    • 2018-04-15
    • 2022-11-10
    • 2017-12-12
    • 2021-08-30
    • 2019-05-31
    相关资源
    最近更新 更多