Spring Security集成Keycloak(自签名证书):如何禁用主机名验证?
我使用Keycloak 24.0.0(自签名证书),Spring Boot应用通过Spring Security 6.2采用客户端密钥认证+授权码授权类型对接Keycloak做身份验证。核心配置与遇到的问题如下:
ClientRegistration配置
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) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") .scope(Scopes.names()) .userNameAttributeName(IdTokenClaimNames.SUB) .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(); }
自定义RestTemplate与JwtDecoder配置
我已实现支持接受自签名证书、跳过主机名验证的RestTemplate(通过配置开关控制),并自定义JwtDecoder使用该实例:
@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,不使用我定义的实例,导致认证时证书错误:
[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
- 尝试覆盖
OAuth2UserService、AuthorizationCodeTokenResponseClient等组件后,又出现JWK集获取的证书错误:
[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客户端使其接受自签名证书并忽略主机名验证。
不用逐个覆盖OAuth2组件,可通过以下两种方式实现全局统一配置:
方式一:标记自定义RestTemplate为全局优先Bean
Spring Security的OAuth2组件会优先使用容器中已有的RestTemplate实例,只需给自定义RestTemplate添加@Primary注解,确保所有组件自动复用该实例:
@Primary @Bean public RestTemplate restTemplate(ClientHttpRequestFactory clientHttpRequestFactory) { RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory); // 统一添加OAuth2所需的消息转换器和错误处理器 restTemplate.setMessageConverters(Arrays.asList( new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter(), new MappingJackson2HttpMessageConverter() )); restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); return restTemplate; }
配置完成后,DefaultOAuth2UserService、DefaultAuthorizationCodeTokenResponseClient等组件会自动使用这个带SSL配置的RestTemplate,无需手动逐个设置。
方式二:统一配置Nimbus与OAuth2组件的SSL上下文
针对JWK集获取等Nimbus发起的请求,直接配置其使用自定义SSL上下文,同时封装公共方法复用同一个ClientHttpRequestFactory:
1. 完善JwtDecoder配置
@Bean public JwtDecoder jwtDecoder(ClientHttpRequestFactory clientHttpRequestFactory) { RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory); restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); NimbusJwtDecoder decoder = NimbusJwtDecoder.withIssuerLocation( "%s/realms/%s".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()) ).restOperations(restTemplate).build(); // 强制JWK集获取使用自定义RestTemplate decoder.setJwkSetUri( "%s/realms/%s/protocol/openid-connect/certs".formatted(keycloakInitializer.serverUrl(), keycloakInitializer.realm()), restTemplate ); return decoder; }
2. 封装公共方法复用SSL配置
// 公共方法:创建带OAuth2配置的RestTemplate private RestTemplate createOAuth2RestTemplate(ClientHttpRequestFactory clientHttpRequestFactory) { RestTemplate restTemplate = new RestTemplate(clientHttpRequestFactory); restTemplate.setMessageConverters(Arrays.asList( new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter(), new MappingJackson2HttpMessageConverter() )); restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler()); return restTemplate; } // 在SecurityFilterChain中统一使用 @Bean public SecurityFilterChain resourceServerFilterChain(HttpSecurity http, ClientHttpRequestFactory clientHttpRequestFactory) throws Exception { RestTemplate oauth2RestTemplate = createOAuth2RestTemplate(clientHttpRequestFactory); // 配置OAuth2UserService DefaultOAuth2UserService userService = new DefaultOAuth2UserService(); userService.setRestOperations(oauth2RestTemplate); // 配置OidcUserService OidcUserService oidcUserService = new OidcUserService(); oidcUserService.setOauth2UserService(userService); // 配置TokenResponseClient DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient(); tokenResponseClient.setRestOperations(oauth2RestTemplate); // 构建SecurityFilterChain 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) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .oauth2Client(Customizer.withDefaults()) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .oidcUserService(oidcUserService) .userService(userService) ) .tokenEndpoint(token -> token.accessTokenResponseClient(tokenResponseClient)) ) .logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/")); return http.build(); }
关键注意事项
- 仅在开发环境使用
withUnsafeTrustMaterial()和withUnsafeHostnameVerifier(),生产环境必须使用可信证书或导入自定义证书到信任库。 - 确保
keycloak.accept-untrusted-certs配置仅在开发环境开启,避免生产环境的安全风险。
内容的提问来源于stack exchange,提问作者ndtreviv

