Я использую 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 для приема самозаверяющих сертификатов и игнорирования проверки имени хоста без импорта сертификата в хранилище доверенных сертификатов?
Существуют различные движущиеся части, которые обрабатывают разные части потока 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
.
Не стоит слишком сильно ломать голову над именем хоста.
cacert
. Предлагаю сценарий для этого.KC_HOSTNAME
«среды» (и, возможно, KC_HOSTNAME_STRICT_BACKCHANNEL: true
тоже). Это укажет Keycloak, что использовать в качестве хоста в URI, которые он помещает в конфигурацию OpenID.isser-uri
позвольте автоматической настройке из конфигурации OpenID установить URI для JWK, авторизации, токена, информации о пользователе и т. д.).Вот и все. Нет необходимости взламывать конфигурацию и предоставлять столько @Bean
переопределений.
Проблема в моем случае заключалась в доступе к сертификату во время развертывания. Возможность импорта сертификата в хранилище доверенных сертификатов должна быть реализована в файле docker-entrypoint.sh, а сертификат должен быть смонтирован в контейнер. Все это должно быть возможно, но для определенных сред это немного менее «нестандартно».
Когда вы покидаете рабочий стол разработчика, такие решения, как letsencrypt, подходят гораздо лучше, чем самозаверяющие сертификаты. Менеджеры контейнеров часто имеют интеграцию с такими менеджерами сертификатов (например, использование letsencrypt в K8s довольно просто и хорошо документировано).
@ ch4mp @ch4mp нет, если ваше решение не имеет подключения к Интернету.
Конечно. Разработка веб-приложений без Интернета обычно вызывает проблемы. Надеюсь, это не так часто. И, кстати, мы можем поставить под сомнение использование SSL в привате. Сеть...
Я имею в виду, действительно ли вам нужен сквозной SSL в закрытом мире? И даже в этом случае, если вы не можете использовать роботов для выдачи сертификатов с доверенным корневым центром (который может быть внутренним), не следует ли рассмотреть возможность настройки «ручного» способа выдачи и развертывания таких сертификатов?
Это зависит от вашей склонности к риску. Если вы хотите защититься от злоумышленников, перехватывающих трафик, тогда да, пожалуйста, используйте TLS. Это метод уменьшения трения, позволяющий предоставлять самоподписанные сертификаты «из коробки».
Противные голоса и закрытые голоса без объяснения причин? ИМХО, это похоже на троллинг.