security - 如何在 Spring Boot 应用程序中测试 Keycloak 身份验证?

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

在 Spring Boot 项目中,我们启用了 Spring Security 并使用承载 token 应用 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 安全过滤器,但如何避免对其进行测试?

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

奖金问题:
基本上我们不想在每个 Controller 集成测试中验证安全性,那么是否可以从 Controller 集成测试(使用 @SpringBootTest@WebAppConfiguration@AutoConfigureMockMvc 等的 Controller 集成测试)中单独验证安全性?

最佳答案

一种解决方案是使用 WireMock用于 stub 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 相同的方式创建模拟访问 token 。您所要做的就是为 的端点 stub Keycloak OpenId 配置 JSON Web key 存储 ,因为 Keycloak Adapter 只请求验证授权 header 中的访问 token 。

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

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自动,所以它可以用来覆盖 keycloak.auth-server-url相应地 Spring Boot Keycloak 适配器的属性(参见 wiremock.properties )

为了生成用作访问 token 的 JWT,我确实使用 jose4j 创建了一个 RSA key 对,该 key 对被声明为测试类属性,因为我确实需要在测试设置期间与 WireMock 服务器一起对其进行初始化。

private static RsaJsonWebKey rsaJsonWebKey;

然后在测试设置期间对其进行初始化,如下所示:

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

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

有了这个 JSON Web key 存储 Keycloak Stub 的端点可以相应地设置如下:

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 stub 另一个端点。如果没有缓存,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)
                    )
            );

代币生成

token 的生成在 generateJWT() 中实现大量使用 jose4j 。这里需要注意的最重要的一点是,相同生成的私钥JWK因为必须使用在wiremock的测试设置期间初始化的那个。

jws.setKey(rsaJsonWebKey.getPrivateKey());

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

测试执行

生成的 JWT 可以在 中照常使用授权头向 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让它工作。当然,这些需要由您自己的 Configuration 替换。为了完整起见,这些类也将在下面列出:

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

关于security - 如何在 Spring Boot 应用程序中测试 Keycloak 身份验证?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/51711268/

相关文章:

groovy - 如何使用 Soap UI Groovy 编写项目中可用的所有 Soap 请求

amazon-web-services - AWS API Gateway 上的自动化测试

ios - 生成 P12 文件 Xcode?

mysql - 如何在 jpa 和 PagingAndSortingRepository 中设置每页限制结果?

java - Mockito 使用 Mockito.mockStatic() 模拟静态 void 方法

java - ModelMapper正在采用toString方法来转换属性?

security - 允许使用用户名或电子邮件地址登录的 Tomcat 安全容器

php - 如何确保其他程序员不会在我的站点中放置后门和回调!

c# - 绑定(bind) PasswordBox 密码是个坏主意吗?

visual-studio-2010 - 理解 VS2010 编码的 UI 调试跟踪