Как проверить аутентификацию Keycloak в приложении Spring Boot?

В проекте 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.

Итак, как протестировать / смоделировать / проверить конфигурацию Keycloak при включенной безопасности Spring? Одна очень неприятная вещь: по умолчанию Spring активирует фильтр безопасности csrf, но как избежать его тестирования?

(Примечание: мы используем токены на предъявителя, поэтому @WithMockUser не применимо в этом случае)

Дополнительный вопрос: в основном мы не хотим проверять безопасность на каждом тесте интеграции контроллеров, поэтому можно проверить безопасность отдельно от тестов интеграции контроллеров (тех, которые используют @SpringBootTest, @WebAppConfiguration, @AutoConfigureMockMvc и так далее?

1 ответ

Одним из решений является использование WireMock для заглушки сервера авторизации keycloak. Поэтому вы можете использовать библиотекуspring-cloud-contract-wiremock(см. https://cloud.spring.io/spring-cloud-contract/1.1.x/multi/multi__spring_cloud_contract_wiremock.html), который предлагает простую интеграцию с весенней загрузкой. Вы можете просто добавить зависимость, как описано. Кроме того, я использую jose4j для создания имитированных токенов доступа так же, как Keycloak делает JWT. Все, что вам нужно сделать, это заглушить конечные точки для конфигурации Keycloak OpenId и хранилища веб-ключей JSON, поскольку адаптер 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 автоматически, поэтому его можно использовать для отмены keycloak.auth-server-urlсвойство для адаптера Spring Boot Keycloak соответственно (см. wiremock.properties)

Для создания JWT, который используется в качестве токена доступа, я создаю пару ключей RSA с помощью jose4j, который объявлен как атрибут тестового класса, поскольку мне нужно инициализировать его во время настройки теста вместе с сервером WireMock.

private static RsaJsonWebKey rsaJsonWebKey;

Затем он инициализируется во время настройки теста следующим образом:

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

Выбор keyId не имеет значения. Вы можете выбрать все, что хотите, если это установлено. Однако выбранный алгоритм и его использование имеют значение и должны быть адаптированы точно так же, как в примере.

При этом конечная точка хранилища веб-ключей JSON заглушки Keycloak может быть настроена следующим образом:

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. Здесь наиболее важно отметить, что должен использоваться закрытый ключ того же сгенерированного JWK, что и тот, который был инициализирован во время тестовой настройки для Wiremock.

jws.setKey(rsaJsonWebKey.getPrivateKey());

Кроме того, код адаптирован в основном из примера на https://bitbucket.org/b_c/jose4j/wiki/JWT%20Examples.
Теперь можно скорректировать или расширить требования в соответствии с его собственными настройками тестирования. Минимальный пример в опубликованном фрагменте представляет собой типичный пример JWT, созданного Keycloak.

Выполнение теста

Сгенерированный 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, например SecurityConfig.class а также CustomKeycloakSpringBootConfigResolver.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://stackru.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://stackru.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;
    }
}

Частичный ответ только на "бонусный" вопрос (@Componentunit-tests): я просто написал набор библиотек, чтобы упростить модульное тестирование защищенных приложений Spring. Я запускаю только такие тесты и тесты 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"),
            privateClaims = @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.3.4</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>com.c4-soft.springaddons</groupId>
  <artifactId>spring-security-oauth2-test-webmvc-addons</artifactId>
  <version>2.3.4</version>
  <scope>test</scope>
</dependency>

Я работаю над проектом activiti, и мы использовали keycloak с весенней загрузкой и задавали те же вопросы. Существует вспомогательный класс тестирования keycloak под названием KeycloakSecurityContextClientRequestInterceptor, который мы немного настроили. Это относится к области и пользователю для тестирования. Мы устанавливаем эти свойства в тестах, которые используют keycloak. Это также может быть использовано для переключения пользователей во время набора тестов.

Для тестов, где мы не хотим использовать keycloak, мы до сих пор придерживались практики держать их на другом уровне в нашем проекте и, следовательно, в другом подмодуле. Это позволяет нам скрыть зависимости maven от keycloak из этого слоя, чтобы keycloak просто не включался на них.

Другие вопросы по тегам