diff --git a/samples/boot/webflux-x509/spring-security-samples-boot-webflux-x509.gradle b/samples/boot/webflux-x509/spring-security-samples-boot-webflux-x509.gradle new file mode 100644 index 0000000000..57196d2b2b --- /dev/null +++ b/samples/boot/webflux-x509/spring-security-samples-boot-webflux-x509.gradle @@ -0,0 +1,11 @@ +apply plugin: 'io.spring.convention.spring-sample-boot' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-config') + compile project(':spring-security-web') + compile 'org.springframework.boot:spring-boot-starter-webflux' + + testCompile project(':spring-security-test') + testCompile 'org.springframework.boot:spring-boot-starter-test' +} diff --git a/samples/boot/webflux-x509/src/main/java/sample/MeController.java b/samples/boot/webflux-x509/src/main/java/sample/MeController.java new file mode 100644 index 0000000000..f7a3958784 --- /dev/null +++ b/samples/boot/webflux-x509/src/main/java/sample/MeController.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2018 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; + +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +/** + * @author Alexey Nesterov + * @since 5.2 + */ +@RestController +@RequestMapping("/me") +public class MeController { + + @GetMapping + public Mono me() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(authentication -> "Hello, " + authentication.getName()); + } +} diff --git a/samples/boot/webflux-x509/src/main/java/sample/WebfluxX509Application.java b/samples/boot/webflux-x509/src/main/java/sample/WebfluxX509Application.java new file mode 100644 index 0000000000..a6871f7c72 --- /dev/null +++ b/samples/boot/webflux-x509/src/main/java/sample/WebfluxX509Application.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2018 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; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.server.SecurityWebFilterChain; + +/** + * @author Alexey Nesterov + * @since 5.2 + */ +@SpringBootApplication +public class WebfluxX509Application { + + @Bean + public ReactiveUserDetailsService reactiveUserDetailsService() { + return new MapReactiveUserDetailsService( + User.withUsername("client").password("").authorities("ROLE_USER").build() + ); + } + + @Bean + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + // @formatter:off + http + .x509() + .and() + .authorizeExchange() + .anyExchange().authenticated(); + // @formatter:on + + return http.build(); + } + + public static void main(String[] args) { + SpringApplication.run(WebfluxX509Application.class); + } +} diff --git a/samples/boot/webflux-x509/src/main/resources/application.yml b/samples/boot/webflux-x509/src/main/resources/application.yml new file mode 100644 index 0000000000..cdeb85fa7b --- /dev/null +++ b/samples/boot/webflux-x509/src/main/resources/application.yml @@ -0,0 +1,8 @@ +server: + port: 8443 + ssl: + key-store: 'classpath:./certs/server.p12' + key-store-password: 'password' + client-auth: need + trust-store: 'classpath:./certs/server.p12' + trust-store-password: 'password' diff --git a/samples/boot/webflux-x509/src/main/resources/certs/curl_app.sh b/samples/boot/webflux-x509/src/main/resources/certs/curl_app.sh new file mode 100644 index 0000000000..dbf7af4b43 --- /dev/null +++ b/samples/boot/webflux-x509/src/main/resources/certs/curl_app.sh @@ -0,0 +1,2 @@ + curl -vvvv --cacert out/DevCA.crt --cert out/localhost.crt --key out/localhost.key https://localhost:8443/me + diff --git a/samples/boot/webflux-x509/src/main/resources/certs/server.p12 b/samples/boot/webflux-x509/src/main/resources/certs/server.p12 new file mode 100644 index 0000000000..8096cc73b9 Binary files /dev/null and b/samples/boot/webflux-x509/src/main/resources/certs/server.p12 differ diff --git a/samples/boot/webflux-x509/src/test/java/sample/WebfluxX509ApplicationTest.java b/samples/boot/webflux-x509/src/test/java/sample/WebfluxX509ApplicationTest.java new file mode 100644 index 0000000000..604692d5ad --- /dev/null +++ b/samples/boot/webflux-x509/src/test/java/sample/WebfluxX509ApplicationTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2018 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; + +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContextBuilder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.LocalServerPort; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.netty.http.client.HttpClient; + +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.UnrecoverableEntryException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class WebfluxX509ApplicationTest { + + @LocalServerPort + int port; + + @Test + public void shouldExtractAuthenticationFromCertificate() throws Exception { + WebTestClient webTestClient = createWebTestClientWithClientCertificate(); + webTestClient + .get().uri("/me") + .exchange() + .expectStatus().isOk() + .expectBody() + .consumeWith(result -> { + String responseBody = new String(result.getResponseBody()); + assertThat(responseBody).contains("Hello, client"); + }); + } + + private WebTestClient createWebTestClientWithClientCertificate() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException, UnrecoverableEntryException { + ClassPathResource serverKeystore = new ClassPathResource("/certs/server.p12"); + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(serverKeystore.getInputStream(), "password".toCharArray()); + + X509Certificate devCA = (X509Certificate) keyStore.getCertificate("DevCA"); + + X509Certificate clientCrt = (X509Certificate) keyStore.getCertificate("client"); + KeyStore.Entry keyStoreEntry = keyStore.getEntry("client", + new KeyStore.PasswordProtection("password".toCharArray())); + PrivateKey clientKey = ((KeyStore.PrivateKeyEntry) keyStoreEntry).getPrivateKey(); + + SslContextBuilder sslContextBuilder = SslContextBuilder + .forClient().clientAuth(ClientAuth.REQUIRE) + .trustManager(devCA) + .keyManager(clientKey, clientCrt); + + HttpClient httpClient = HttpClient.create().secure(sslContextSpec -> sslContextSpec.sslContext(sslContextBuilder)); + ClientHttpConnector httpConnector = new ReactorClientHttpConnector(httpClient); + + return WebTestClient + .bindToServer(httpConnector) + .baseUrl("https://localhost:" + port) + .build(); + } +}