From d6a87532a95d5c6c8f1d877032a7dea17e074954 Mon Sep 17 00:00:00 2001 From: Joe Grandja Date: Fri, 15 Mar 2024 10:13:25 -0400 Subject: [PATCH] Add sample for PKI Mutual-TLS client authentication method Issue gh-1558 --- .../config/AuthorizationServerConfig.java | 18 ++++- .../sample/config/TomcatServerConfig.java | 46 +++++++++++++ .../src/main/resources/application.yml | 20 +++++- .../demo-client/samples-demo-client.gradle | 1 + .../sample/config/RestTemplateConfig.java | 66 +++++++++++++++++++ .../java/sample/config/WebClientConfig.java | 57 ++++++++++++++-- .../sample/web/AuthorizationController.java | 19 +++++- .../src/main/resources/application.yml | 24 +++++++ .../resources/templates/page-templates.html | 3 +- .../sample/config/ResourceServerConfig.java | 14 ++-- .../src/main/resources/application.yml | 2 +- 11 files changed, 253 insertions(+), 17 deletions(-) create mode 100644 samples/demo-authorizationserver/src/main/java/sample/config/TomcatServerConfig.java create mode 100644 samples/demo-client/src/main/java/sample/config/RestTemplateConfig.java diff --git a/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java index 0746516e..c1049068 100644 --- a/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java +++ b/samples/demo-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java @@ -131,7 +131,7 @@ public class AuthorizationServerConfig { // @formatter:off @Bean public JdbcRegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) { - RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) + RegisteredClient messagingClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("messaging-client") .clientSecret("{noop}secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) @@ -166,11 +166,25 @@ public class AuthorizationServerConfig { .scope("message.read") .build(); + RegisteredClient mtlsDemoClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("mtls-demo-client") + .clientAuthenticationMethod(new ClientAuthenticationMethod("tls_client_auth")) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("message.read") + .scope("message.write") + .clientSettings( + ClientSettings.builder() + .x509CertificateSubjectDN("CN=demo-client-sample,OU=Spring Samples,O=Spring,C=US") + .build() + ) + .build(); + // Save registered client's in db as if in-memory JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate); - registeredClientRepository.save(registeredClient); + registeredClientRepository.save(messagingClient); registeredClientRepository.save(deviceClient); registeredClientRepository.save(tokenExchangeClient); + registeredClientRepository.save(mtlsDemoClient); return registeredClientRepository; } diff --git a/samples/demo-authorizationserver/src/main/java/sample/config/TomcatServerConfig.java b/samples/demo-authorizationserver/src/main/java/sample/config/TomcatServerConfig.java new file mode 100644 index 00000000..3d378211 --- /dev/null +++ b/samples/demo-authorizationserver/src/main/java/sample/config/TomcatServerConfig.java @@ -0,0 +1,46 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.config; + +import org.apache.catalina.connector.Connector; + +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.WebServerFactoryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * @author Joe Grandja + * @since 1.3 + */ +@Configuration(proxyBeanMethods = false) +public class TomcatServerConfig { + + @Bean + public WebServerFactoryCustomizer connectorCustomizer() { + return (tomcat) -> tomcat.addAdditionalTomcatConnectors(createHttpConnector()); + } + + private Connector createHttpConnector() { + Connector connector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL); + connector.setScheme("http"); + connector.setPort(9000); + connector.setSecure(false); + connector.setRedirectPort(9443); + return connector; + } + +} diff --git a/samples/demo-authorizationserver/src/main/resources/application.yml b/samples/demo-authorizationserver/src/main/resources/application.yml index 78f30d9d..6a982ef0 100644 --- a/samples/demo-authorizationserver/src/main/resources/application.yml +++ b/samples/demo-authorizationserver/src/main/resources/application.yml @@ -1,7 +1,25 @@ server: - port: 9000 + port: 9443 + ssl: + bundle: demo-authorizationserver + client-auth: want spring: + ssl: + bundle: + jks: + demo-authorizationserver: + key: + alias: demo-authorizationserver-sample + password: password + keystore: + location: classpath:keystore.p12 + password: password + type: PKCS12 + truststore: + location: classpath:keystore.p12 + password: password + type: PKCS12 security: oauth2: client: diff --git a/samples/demo-client/samples-demo-client.gradle b/samples/demo-client/samples-demo-client.gradle index 09f15c71..f112f488 100644 --- a/samples/demo-client/samples-demo-client.gradle +++ b/samples/demo-client/samples-demo-client.gradle @@ -23,6 +23,7 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-client" implementation "org.springframework:spring-webflux" implementation "io.projectreactor.netty:reactor-netty" + implementation "org.apache.httpcomponents.client5:httpclient5" implementation "org.webjars:webjars-locator-core" implementation "org.webjars:bootstrap:5.2.3" implementation "org.webjars:popper.js:2.9.3" diff --git a/samples/demo-client/src/main/java/sample/config/RestTemplateConfig.java b/samples/demo-client/src/main/java/sample/config/RestTemplateConfig.java new file mode 100644 index 00000000..557f2d30 --- /dev/null +++ b/samples/demo-client/src/main/java/sample/config/RestTemplateConfig.java @@ -0,0 +1,66 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package sample.config; + +import java.util.function.Supplier; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.socket.PlainConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; + +/** + * @author Joe Grandja + * @since 1.3 + */ +@Configuration(proxyBeanMethods = false) +public class RestTemplateConfig { + + @Bean + Supplier clientHttpRequestFactory(SslBundles sslBundles) { + return () -> { + SslBundle sslBundle = sslBundles.getBundle("demo-client"); + final SSLContext sslContext = sslBundle.createSslContext(); + final SSLConnectionSocketFactory sslConnectionSocketFactory = + new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE); + final Registry socketFactoryRegistry = RegistryBuilder.create() + .register("http", PlainConnectionSocketFactory.getSocketFactory()) + .register("https", sslConnectionSocketFactory) + .build(); + final BasicHttpClientConnectionManager connectionManager = + new BasicHttpClientConnectionManager(socketFactoryRegistry); + final CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connectionManager) + .build(); + return new HttpComponentsClientHttpRequestFactory(httpClient); + }; + } + +} diff --git a/samples/demo-client/src/main/java/sample/config/WebClientConfig.java b/samples/demo-client/src/main/java/sample/config/WebClientConfig.java index 274ddccf..5208d7eb 100644 --- a/samples/demo-client/src/main/java/sample/config/WebClientConfig.java +++ b/samples/demo-client/src/main/java/sample/config/WebClientConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2023 the original author or authors. + * Copyright 2020-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,17 +15,33 @@ */ package sample.config; +import java.util.Arrays; +import java.util.function.Supplier; + import sample.authorization.DeviceCodeOAuth2AuthorizedClientProvider; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.endpoint.DefaultClientCredentialsTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequest; +import org.springframework.security.oauth2.client.endpoint.OAuth2ClientCredentialsGrantRequestEntityConverter; +import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository; import org.springframework.security.oauth2.client.web.reactive.function.client.ServletOAuth2AuthorizedClientExchangeFilterFunction; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; /** @@ -33,7 +49,7 @@ import org.springframework.web.reactive.function.client.WebClient; * @author Steve Riesenberg * @since 0.0.1 */ -@Configuration +@Configuration(proxyBeanMethods = false) public class WebClientConfig { @Bean @@ -50,14 +66,28 @@ public class WebClientConfig { @Bean public OAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository clientRegistrationRepository, - OAuth2AuthorizedClientRepository authorizedClientRepository) { + OAuth2AuthorizedClientRepository authorizedClientRepository, + RestTemplateBuilder restTemplateBuilder, + Supplier clientHttpRequestFactory) { + + // @formatter:off + RestTemplate restTemplate = restTemplateBuilder + .requestFactory(clientHttpRequestFactory) + .messageConverters(Arrays.asList( + new FormHttpMessageConverter(), + new OAuth2AccessTokenResponseHttpMessageConverter())) + .errorHandler(new OAuth2ErrorResponseErrorHandler()) + .build(); + // @formatter:on // @formatter:off OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder() .authorizationCode() .refreshToken() - .clientCredentials() + .clientCredentials(clientCredentials -> + clientCredentials.accessTokenResponseClient( + createClientCredentialsTokenResponseClient(restTemplate))) .provider(new DeviceCodeOAuth2AuthorizedClientProvider()) .build(); // @formatter:on @@ -73,4 +103,23 @@ public class WebClientConfig { return authorizedClientManager; } + private static OAuth2AccessTokenResponseClient createClientCredentialsTokenResponseClient( + RestTemplate restTemplate) { + DefaultClientCredentialsTokenResponseClient clientCredentialsTokenResponseClient = + new DefaultClientCredentialsTokenResponseClient(); + clientCredentialsTokenResponseClient.setRestOperations(restTemplate); + + OAuth2ClientCredentialsGrantRequestEntityConverter clientCredentialsGrantRequestEntityConverter = + new OAuth2ClientCredentialsGrantRequestEntityConverter(); + clientCredentialsGrantRequestEntityConverter.addParametersConverter(authorizationGrantRequest -> { + MultiValueMap parameters = new LinkedMultiValueMap<>(); + // client_id parameter is required for tls_client_auth method + parameters.add(OAuth2ParameterNames.CLIENT_ID, authorizationGrantRequest.getClientRegistration().getClientId()); + return parameters; + }); + clientCredentialsTokenResponseClient.setRequestEntityConverter(clientCredentialsGrantRequestEntityConverter); + + return clientCredentialsTokenResponseClient; + } + } diff --git a/samples/demo-client/src/main/java/sample/web/AuthorizationController.java b/samples/demo-client/src/main/java/sample/web/AuthorizationController.java index ad9b454a..91480ef4 100644 --- a/samples/demo-client/src/main/java/sample/web/AuthorizationController.java +++ b/samples/demo-client/src/main/java/sample/web/AuthorizationController.java @@ -84,8 +84,8 @@ public class AuthorizationController { return "index"; } - @GetMapping(value = "/authorize", params = "grant_type=client_credentials") - public String clientCredentialsGrant(Model model) { + @GetMapping(value = "/authorize", params = {"grant_type=client_credentials", "client_auth=client_secret"}) + public String clientCredentialsGrantUsingClientSecret(Model model) { String[] messages = this.webClient .get() @@ -99,6 +99,21 @@ public class AuthorizationController { return "index"; } + @GetMapping(value = "/authorize", params = {"grant_type=client_credentials", "client_auth=mtls"}) + public String clientCredentialsGrantUsingMutualTLS(Model model) { + + String[] messages = this.webClient + .get() + .uri(this.messagesBaseUri) + .attributes(clientRegistrationId("mtls-demo-client-client-credentials")) + .retrieve() + .bodyToMono(String[].class) + .block(); + model.addAttribute("messages", messages); + + return "index"; + } + @GetMapping(value = "/authorize", params = "grant_type=token_exchange") public String tokenExchangeGrant(Model model) { diff --git a/samples/demo-client/src/main/resources/application.yml b/samples/demo-client/src/main/resources/application.yml index 18e63127..cee14eb7 100644 --- a/samples/demo-client/src/main/resources/application.yml +++ b/samples/demo-client/src/main/resources/application.yml @@ -9,6 +9,21 @@ logging: org.springframework.security.oauth2: INFO spring: + ssl: + bundle: + jks: + demo-client: + key: + alias: demo-client-sample + password: password + keystore: + location: classpath:keystore.p12 + password: password + type: PKCS12 + truststore: + location: classpath:keystore.p12 + password: password + type: PKCS12 thymeleaf: cache: false security: @@ -53,9 +68,18 @@ spring: authorization-grant-type: urn:ietf:params:oauth:grant-type:device_code scope: message.read,message.write client-name: messaging-client-device-code + mtls-demo-client-client-credentials: + provider: spring-tls + client-id: mtls-demo-client + client-authentication-method: tls_client_auth + authorization-grant-type: client_credentials + scope: message.read,message.write + client-name: mtls-demo-client-client-credentials provider: spring: issuer-uri: http://localhost:9000 + spring-tls: + token-uri: https://localhost:9443/oauth2/token messages: base-uri: http://127.0.0.1:8090/messages diff --git a/samples/demo-client/src/main/resources/templates/page-templates.html b/samples/demo-client/src/main/resources/templates/page-templates.html index 1704bdee..71ee8876 100644 --- a/samples/demo-client/src/main/resources/templates/page-templates.html +++ b/samples/demo-client/src/main/resources/templates/page-templates.html @@ -24,7 +24,8 @@ diff --git a/samples/messages-resource/src/main/java/sample/config/ResourceServerConfig.java b/samples/messages-resource/src/main/java/sample/config/ResourceServerConfig.java index 9dd1a438..08ec09c5 100644 --- a/samples/messages-resource/src/main/java/sample/config/ResourceServerConfig.java +++ b/samples/messages-resource/src/main/java/sample/config/ResourceServerConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2022 the original author or authors. + * Copyright 2020-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ package sample.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; @@ -34,11 +35,12 @@ public class ResourceServerConfig { SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/messages/**") - .authorizeHttpRequests() - .requestMatchers("/messages/**").hasAuthority("SCOPE_message.read") - .and() - .oauth2ResourceServer() - .jwt(); + .authorizeHttpRequests(authorize -> + authorize.requestMatchers("/messages/**").hasAuthority("SCOPE_message.read") + ) + .oauth2ResourceServer(oauth2ResourceServer -> + oauth2ResourceServer.jwt(Customizer.withDefaults()) + ); return http.build(); } // @formatter:on diff --git a/samples/messages-resource/src/main/resources/application.yml b/samples/messages-resource/src/main/resources/application.yml index 1e01f945..5a78b0db 100644 --- a/samples/messages-resource/src/main/resources/application.yml +++ b/samples/messages-resource/src/main/resources/application.yml @@ -14,4 +14,4 @@ spring: oauth2: resourceserver: jwt: - issuer-uri: http://localhost:9000 + jwk-set-uri: http://localhost:9000/oauth2/jwks