Spring Security + Keycloak (с самоподписанными сертификатами) – как отключить проверку имени хоста?

Я использую Keycloak 24.0.0 с самоподписанным сертификатом.

Мое приложение Springboot выполняет аутентификацию в Keycloak, используя метод секретной аутентификации клиента и тип предоставления кода авторизации (через Spring Security 6.2):

    private ClientRegistration keycloakClientRegistration() {
        return ClientRegistration.withRegistrationId("keycloak")
                .clientId(keycloakInitializer.clientId())
                .clientSecret(keycloakInitializer.clientSecret())
                .authorizationUri("%s/realms/%s/protocol/openid-connect/auth".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .tokenUri("%s/realms/%s/protocol/openid-connect/token".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .userInfoUri("%s/realms/%s/protocol/openid-connect/userinfo".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) // Check if this is correct
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
                .scope(Scopes.names())
                .userNameAttributeName(IdTokenClaimNames.SUB) // Check if this is correct
                .issuerUri("%s/realms/%s".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .jwkSetUri("%s/realms/%s/protocol/openid-connect/certs".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .clientName(keycloakInitializer.clientName())
                .build();
    }

Я переопределяю JwtDecoder на собственный, который принимает RestTemplate, который опционально (на основе конфигурации) принимает самозаверяющие сертификаты и пропускает проверку имени хоста:


    @Bean
    public JwtDecoder jwtDecoder(RestTemplate restTemplate) {
        return NimbusJwtDecoder.withIssuerLocation("%s/realms/%s".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .restOperations(restTemplate).build();
    }


    @Bean
    public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) {
        return new RestTemplate(clientHttpRequestFactory);
    }

    @Bean
    public ClientHttpRequestFactory clientHttpRequestFactory(SslBundles sslBundles, @Value("${keycloak.accept-untrusted-certs}") boolean acceptUntrustedCerts) {
        SSLFactory defaultSslFactory = SSLFactory.builder()
                .withUnsafeTrustMaterial()
                .withUnsafeHostnameVerifier()
                .build();

        CloseableHttpClient httpClient;
        if (acceptUntrustedCerts) {
            LOGGER.info("Accepting untrusted certs for keycloak and ignoring hostname verification.");
            httpClient = HttpClients.custom().setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                            .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
                                    .setSslContext(defaultSslFactory.getSslContext())
                                    .setHostnameVerifier(defaultSslFactory.getHostnameVerifier())
                                    .build())
                            .build())
                    .build();
        } else {
            try {
                SSLContext sslContext = sslBundles.getBundle("keycloak").createSslContext();
                httpClient = HttpClients.custom().setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                                .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
                                        .setSslContext(sslContext)
                                        .build())
                                .build())
                        .build();
                LOGGER.info("Accepting supplied cert for keycloak and applying hostname verification.");
            } catch (NoSuchSslBundleException e) {
                LOGGER.info("Could not find an SSL Context for keycloak. Using default system SSL settings.");
                httpClient = HttpClients.createDefault();
            }
        }

        return new HttpComponentsClientHttpRequestFactory(httpClient);
    }

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

Я попытался переопределить это, предоставив OAuth2UserService свой собственный RestTemplate:

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(RestTemplate restTemplate) {
        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
        defaultOAuth2UserService.setRestOperations(restTemplate);
        return defaultOAuth2UserService;
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
        OidcUserService oidcUserService = new OidcUserService();
        oidcUserService.setOauth2UserService(userService);
        return oidcUserService;
    }

    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http, RestTemplate restTemplate) throws Exception {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = oauth2UserService(restTemplate);
        http.cors(Customizer.withDefaults())
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(new CookieCsrfTokenRepository())
                        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
                )
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers(new AntPathRequestMatcher("/api/**"))
                                .authenticated()
                                .anyRequest()
                                .permitAll()
                )
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
        http.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.oauth2Client(Customizer.withDefaults());
        http.oauth2Login((oauth2) -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                        .oidcUserService(oidcUserService(userService))
                        .userService(userService)
                ))
                .logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/"));
        return http.build();
    }

Но всякий раз, когда я аутентифицируюсь с помощью keycloak, я получаю следующую ошибку:

[invalid_token_response] An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: I/O error on POST request for "https://keycloak:8443/realms/MyRealm/protocol/openid-connect/token": PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

Обновлять

Если я переопределяю некоторые другие службы конечных точек, чтобы использовать собственный RestTemplate, например:

    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http, ClientHttpRequestFactory clientHttpRequestFactory) throws Exception {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = oauth2UserService(clientHttpRequestFactory);
        http.cors(Customizer.withDefaults())
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(new CookieCsrfTokenRepository())
                        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
                )
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers(new AntPathRequestMatcher("/api/**"))
                                .authenticated()
                                .anyRequest()
                                .permitAll()
                )
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
        http.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.oauth2Client(Customizer.withDefaults());
        http.oauth2Login((oauth2) -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                        .oidcUserService(oidcUserService(userService))
                        .userService(userService)
                ).tokenEndpoint(token -> token
                                .accessTokenResponseClient(authorizationCodeTokenResponseClient(clientHttpRequestFactory))
                ))
                .logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/"));
        return http.build();
    }

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(ClientHttpRequestFactory clientHttpRequestFactory) {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
        defaultOAuth2UserService.setRestOperations(restTemplate);
        return defaultOAuth2UserService;
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
        OidcUserService oidcUserService = new OidcUserService();
        oidcUserService.setOauth2UserService(userService);
        return oidcUserService;
    }

    private DefaultAuthorizationCodeTokenResponseClient authorizationCodeTokenResponseClient(ClientHttpRequestFactory clientHttpRequestFactory) {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
        restTemplate.setMessageConverters(Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

        DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
        tokenResponseClient.setRestOperations(restTemplate);
        return tokenResponseClient;
    }

тогда я получаю другую ошибку:

[invalid_id_token] An error occurred while attempting to decode the Jwt: Couldn't retrieve remote JWK set: org.springframework.web.client.ResourceAccessException: I/O error on GET request for "https://keycloak:8443/realms/MyRealm/protocol/openid-connect/certs": PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

Это похоже на большое переопределение, просто сказать ему принять мой сертификат.

Вопрос

Как настроить клиент OAuth2 для приема самозаверяющих сертификатов и игнорирования проверки имени хоста без импорта сертификата в хранилище доверенных сертификатов?

Противные голоса и закрытые голоса без объяснения причин? ИМХО, это похоже на троллинг.

ndtreviv 18.06.2024 17:22
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
Как вычислять биты и понимать побитовые операторы в Java - объяснение с примерами
В компьютерном программировании биты играют важнейшую роль в представлении и манипулировании данными на двоичном уровне. Побитовые операции...
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Поднятие тревоги для долго выполняющихся методов в Spring Boot
Приходилось ли вам сталкиваться с требованиями, в которых вас могли попросить поднять тревогу или выдать ошибку, когда метод Java занимает больше...
Полный курс Java для разработчиков веб-сайтов и приложений
Полный курс Java для разработчиков веб-сайтов и приложений
Получите сертификат Java Web и Application Developer, используя наш курс.
1
1
92
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Существуют различные движущиеся части, которые обрабатывают разные части потока OAuth. Каждый из них использует свой RestOperations.

Чтобы выяснить, какие детали неисправны, вы можете включить ведение журнала TRACE для org.springframework.security.

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

Прежде всего, создайте ClientHttpRequestFactory, который условно принимает самоподписанные сертификаты и игнорирует проверку имени хоста:

    @Bean
    public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) {
        return new RestTemplate(clientHttpRequestFactory);
    }

    @Bean
    public ClientHttpRequestFactory clientHttpRequestFactory(SslBundles sslBundles, @Value("${keycloak.accept-untrusted-certs}") boolean acceptUntrustedCerts) {
        SSLFactory defaultSslFactory = SSLFactory.builder()
                .withUnsafeTrustMaterial()
                .withUnsafeHostnameVerifier()
                .build();

        CloseableHttpClient httpClient;
        if (acceptUntrustedCerts) {
            LOGGER.info("Accepting untrusted certs for keycloak and ignoring hostname verification.");
            httpClient = HttpClients.custom().setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                            .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
                                    .setSslContext(defaultSslFactory.getSslContext())
                                    .setHostnameVerifier(defaultSslFactory.getHostnameVerifier())
                                    .build())
                            .build())
                    .build();
        } else {
            try {
                SSLContext sslContext = sslBundles.getBundle("keycloak").createSslContext();
                httpClient = HttpClients.custom().setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
                                .setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
                                        .setSslContext(sslContext)
                                        .build())
                                .build())
                        .build();
                LOGGER.info("Accepting supplied cert for keycloak and applying hostname verification.");
            } catch (NoSuchSslBundleException e) {
                LOGGER.info("Could not find an SSL Context for keycloak. Using default system SSL settings.");
                httpClient = HttpClients.createDefault();
            }
        }

        return new HttpComponentsClientHttpRequestFactory(httpClient);
    }

Затем используйте их в каждой из областей, которые вам нужно переопределить:

    @Bean
    public JwtDecoder jwtDecoder(RestTemplate restTemplate) {
        return NimbusJwtDecoder.withIssuerLocation("%s/realms/%s".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()))
                .restOperations(restTemplate).build();
    }

    @Bean
    public SecurityFilterChain resourceServerFilterChain(HttpSecurity http, ClientHttpRequestFactory clientHttpRequestFactory) throws Exception {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> userService = oauth2UserService(clientHttpRequestFactory);
        http.cors(Customizer.withDefaults())
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(new CookieCsrfTokenRepository())
                        .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
                )
                .authorizeHttpRequests(
                        auth -> auth
                                .requestMatchers(new AntPathRequestMatcher("/api/**"))
                                .authenticated()
                                .anyRequest()
                                .permitAll()
                )
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
        http.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        http.oauth2Client(Customizer.withDefaults());
        http.oauth2Login((oauth2) -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                        .oidcUserService(oidcUserService(userService))
                        .userService(userService)
                        .userAuthoritiesMapper(userAuthoritiesMapperForKeycloak())
                )
                .tokenEndpoint(token -> token
                        .accessTokenResponseClient(authorizationCodeTokenResponseClient(clientHttpRequestFactory))
                )
        ).logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/"));
        return http.build();
    }

    // Handles the calls to /certs
    @Bean
    public JwtDecoderFactory<ClientRegistration> customJwtDecoderFactory(RestTemplate restTemplate) {
        return new CustomJwtDecoderFactory(restTemplate, "%s/realms/%s/protocol/openid-connect/certs".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()));
    }

    static class CustomJwtDecoderFactory implements JwtDecoderFactory<ClientRegistration> {

        private RestTemplate restTemplate;
        private String jwtSetUri;

        public CustomJwtDecoderFactory(RestTemplate restTemplate, String jwtSetUri) {
            this.restTemplate = restTemplate;
            this.jwtSetUri = jwtSetUri;
        }

        public JwtDecoder createDecoder(ClientRegistration reg) {
            return NimbusJwtDecoder.withJwkSetUri(jwtSetUri)
                    .restOperations(restTemplate).build();
        }
    }

    private OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService(ClientHttpRequestFactory clientHttpRequestFactory) {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

        DefaultOAuth2UserService defaultOAuth2UserService = new DefaultOAuth2UserService();
        defaultOAuth2UserService.setRestOperations(restTemplate);
        return defaultOAuth2UserService;
    }

    private OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService(OAuth2UserService<OAuth2UserRequest, OAuth2User> userService) {
        OidcUserService oidcUserService = new OidcUserService();
        oidcUserService.setOauth2UserService(userService);
        return oidcUserService;
    }

    // Handles the calls to /token
    private DefaultAuthorizationCodeTokenResponseClient authorizationCodeTokenResponseClient(ClientHttpRequestFactory clientHttpRequestFactory) {
        RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory);
        restTemplate.setMessageConverters(Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());

        DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
        tokenResponseClient.setRestOperations(restTemplate);
        return tokenResponseClient;
    }

Учитывая объем кода и переопределений, необходимых для реализации этого варианта использования, кажется, гораздо проще просто добавить сертификат в хранилище доверенных сертификатов. Предполагается, что ваша компонентная архитектура упрощает эту задачу.

Я согласен с последним абзацем: гораздо проще (и безопаснее) использовать правильное CN в сертификате, добавить сертификат в хранилища доверенных сертификатов и настроить Keycloak на использование значения в CN как hostname.

ch4mp 18.06.2024 17:53
Ответ принят как подходящий

Не стоит слишком сильно ломать голову над именем хоста.

  • Создайте сертификат с правильным CN (имя хоста) и добавьте его в файлы JRE/JDK cacert. Предлагаю сценарий для этого.
  • Настройте Keycloak для использования этого сертификата. Также установите свойство имени хоста. В файле компоновки докера это делается путем определения KC_HOSTNAME «среды» (и, возможно, KC_HOSTNAME_STRICT_BACKCHANNEL: true тоже). Это укажет Keycloak, что использовать в качестве хоста в URI, которые он помещает в конфигурацию OpenID.
  • Используйте свое имя хоста в конфигурации Spring (в isser-uri позвольте автоматической настройке из конфигурации OpenID установить URI для JWK, авторизации, токена, информации о пользователе и т. д.).
  • Добавьте этот сертификат в доверенные хранилища вашей ОС. README.md приведенного выше репозитория содержит инструкции для Windows и OS X. Это удалит предупреждения из вашего браузера, когда сертификат используется для посещаемой вами страницы (например, пользовательский интерфейс администратора Keycloak).

Вот и все. Нет необходимости взламывать конфигурацию и предоставлять столько @Bean переопределений.

Проблема в моем случае заключалась в доступе к сертификату во время развертывания. Возможность импорта сертификата в хранилище доверенных сертификатов должна быть реализована в файле docker-entrypoint.sh, а сертификат должен быть смонтирован в контейнер. Все это должно быть возможно, но для определенных сред это немного менее «нестандартно».

ndtreviv 19.06.2024 10:48

Когда вы покидаете рабочий стол разработчика, такие решения, как letsencrypt, подходят гораздо лучше, чем самозаверяющие сертификаты. Менеджеры контейнеров часто имеют интеграцию с такими менеджерами сертификатов (например, использование letsencrypt в K8s довольно просто и хорошо документировано).

ch4mp 19.06.2024 19:01

@ ch4mp @ch4mp нет, если ваше решение не имеет подключения к Интернету.

ndtreviv 20.06.2024 10:51

Конечно. Разработка веб-приложений без Интернета обычно вызывает проблемы. Надеюсь, это не так часто. И, кстати, мы можем поставить под сомнение использование SSL в привате. Сеть...

ch4mp 20.06.2024 12:57

Я имею в виду, действительно ли вам нужен сквозной SSL в закрытом мире? И даже в этом случае, если вы не можете использовать роботов для выдачи сертификатов с доверенным корневым центром (который может быть внутренним), не следует ли рассмотреть возможность настройки «ручного» способа выдачи и развертывания таких сертификатов?

ch4mp 20.06.2024 13:17

Это зависит от вашей склонности к риску. Если вы хотите защититься от злоумышленников, перехватывающих трафик, тогда да, пожалуйста, используйте TLS. Это метод уменьшения трения, позволяющий предоставлять самоподписанные сертификаты «из коробки».

ndtreviv 20.06.2024 17:28

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

Ошибка конфигурации безопасности при обновлении версии приложения весенней загрузки с 2.x.x до 3.3.0
Spring 3 использует сопоставитель объектов для преобразования значения (десериализации) из List<Map<String,Object> в некоторый DTO выдает ошибку для поля Instant
Как я могу распечатать наносекунды в временной метке журнала при входе в Spring Boot 3.3 и Java 17, используя logback или log4j?
Можно ли разместить плагин apm-agent-java внутри JAR-файла приложения?
Доступ запрещен даже при использовании AnonymousAuthenticationFilter
HTTP-запрос возвращает 200 OK, но в ответе Spring Boot нет содержимого
Ошибка создания bean-компонента с именем «entityManagerFactory» при обновлении приложения до Spring boot 3.3.0
Как выполнить пакетное задание Spring, которое может считывать данные из базы данных через регулярные промежутки времени
Должен ли я применить @Transactional(readOnly = true) к простому методу запроса?
Обновление Spring Boot JPA с использованием составных первичных ключей