From e57aafd63da8d15ab84d06f158a02dc1be6d3a43 Mon Sep 17 00:00:00 2001 From: Madhura Bhave Date: Tue, 9 Jan 2018 11:26:26 -0800 Subject: [PATCH] Provide EndpointRequest for WebFlux-based Security Closes gh-11022 --- .../security/reactive/EndpointRequest.java | 182 +++++++++++++++++ .../{ => servlet}/EndpointRequest.java | 15 +- .../reactive/EndpointRequestTests.java | 190 ++++++++++++++++++ .../{ => servlet}/EndpointRequestTests.java | 2 +- .../security/StaticResourceRequest.java | 2 +- .../main/asciidoc/spring-boot-features.adoc | 35 +++- ...cationContextServerWebExchangeMatcher.java | 104 ++++++++++ .../ApplicationContextRequestMatcher.java | 4 +- ...nContextServerWebExchangeMatcherTests.java | 157 +++++++++++++++ ...ApplicationContextRequestMatcherTests.java | 2 +- .../customsecurity/SecurityConfiguration.java | 2 +- .../src/main/resources/application.properties | 3 +- ...ampleSecureWebFluxCustomSecurityTests.java | 115 +++++++++++ .../SampleMethodSecurityApplication.java | 2 +- 14 files changed, 785 insertions(+), 30 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java rename spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/{ => servlet}/EndpointRequest.java (94%) create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java rename spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/{ => servlet}/EndpointRequestTests.java (98%) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java rename spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/{ => servlet}/ApplicationContextRequestMatcher.java (96%) create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcherTests.java rename spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/{ => servlet}/ApplicationContextRequestMatcherTests.java (98%) create mode 100644 spring-boot-samples/spring-boot-sample-secure-webflux/src/test/java/sample/secure/webflux/SampleSecureWebFluxCustomSecurityTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java new file mode 100644 index 00000000000..f81191ef627 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequest.java @@ -0,0 +1,182 @@ +/* + * Copyright 2012-2017 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 + * + * http://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 org.springframework.boot.actuate.autoconfigure.security.reactive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.autoconfigure.endpoint.web.EndpointPathProvider; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.security.reactive.ApplicationContextServerWebExchangeMatcher; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.PathPatternParserServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * Factory that can be used to create a {@link ServerWebExchangeMatcher} for actuator endpoint + * locations. + * + * @author Madhura Bhave + * @since 2.0.0 + */ +public final class EndpointRequest { + + private EndpointRequest() { + } + + /** + * Returns a matcher that includes all {@link Endpoint actuator endpoints}. The + * {@link EndpointServerWebExchangeMatcher#excluding(Class...) excluding} method can be used to + * further remove specific endpoints if required. For example:
+	 * EndpointServerWebExchangeMatcher.toAnyEndpoint().excluding(ShutdownEndpoint.class)
+	 * 
+ * @return the configured {@link ServerWebExchangeMatcher} + */ + public static EndpointServerWebExchangeMatcher toAnyEndpoint() { + return new EndpointServerWebExchangeMatcher(); + } + + /** + * Returns a matcher that includes the specified {@link Endpoint actuator endpoints}. + * For example:
+	 * EndpointRequest.to(ShutdownEndpoint.class, HealthEndpoint.class)
+	 * 
+ * @param endpoints the endpoints to include + * @return the configured {@link ServerWebExchangeMatcher} + */ + public static EndpointServerWebExchangeMatcher to(Class... endpoints) { + return new EndpointServerWebExchangeMatcher(endpoints); + } + + /** + * Returns a matcher that includes the specified {@link Endpoint actuator endpoints}. + * For example:
+	 * EndpointRequest.to("shutdown", "health")
+	 * 
+ * @param endpoints the endpoints to include + * @return the configured {@link ServerWebExchangeMatcher} + */ + public static EndpointServerWebExchangeMatcher to(String... endpoints) { + return new EndpointServerWebExchangeMatcher(endpoints); + } + + /** + * The {@link ServerWebExchangeMatcher} used to match against {@link Endpoint actuator endpoints}. + */ + public final static class EndpointServerWebExchangeMatcher + extends ApplicationContextServerWebExchangeMatcher { + + private final List includes; + + private final List excludes; + + private ServerWebExchangeMatcher delegate; + + private EndpointServerWebExchangeMatcher() { + super(EndpointPathProvider.class); + this.includes = Collections.emptyList(); + this.excludes = Collections.emptyList(); + } + + private EndpointServerWebExchangeMatcher(Class[] endpoints) { + super(EndpointPathProvider.class); + this.includes = Arrays.asList((Object[]) endpoints); + this.excludes = Collections.emptyList(); + } + + private EndpointServerWebExchangeMatcher(String[] endpoints) { + super(EndpointPathProvider.class); + this.includes = Arrays.asList((Object[]) endpoints); + this.excludes = Collections.emptyList(); + } + + private EndpointServerWebExchangeMatcher(List includes, List excludes) { + super(EndpointPathProvider.class); + this.includes = includes; + this.excludes = excludes; + } + + EndpointServerWebExchangeMatcher excluding(Class... endpoints) { + List excludes = new ArrayList<>(this.excludes); + excludes.addAll(Arrays.asList((Object[]) endpoints)); + return new EndpointServerWebExchangeMatcher(this.includes, excludes); + } + + EndpointServerWebExchangeMatcher excluding(String... endpoints) { + List excludes = new ArrayList<>(this.excludes); + excludes.addAll(Arrays.asList((Object[]) endpoints)); + return new EndpointServerWebExchangeMatcher(this.includes, excludes); + } + + @Override + protected void initialized(EndpointPathProvider endpointPathProvider) { + Set paths = new LinkedHashSet<>(this.includes.isEmpty() + ? endpointPathProvider.getPaths() : Collections.emptyList()); + streamPaths(this.includes, endpointPathProvider).forEach(paths::add); + streamPaths(this.excludes, endpointPathProvider).forEach(paths::remove); + this.delegate = new OrServerWebExchangeMatcher(getDelegateMatchers(paths)); + } + + private Stream streamPaths(List source, + EndpointPathProvider endpointPathProvider) { + return source.stream().filter(Objects::nonNull).map(this::getPathId) + .map(endpointPathProvider::getPath); + } + + private String getPathId(Object source) { + if (source instanceof String) { + return (String) source; + } + if (source instanceof Class) { + return getPathId((Class) source); + } + throw new IllegalStateException("Unsupported source " + source); + } + + private String getPathId(Class source) { + Endpoint annotation = AnnotationUtils.findAnnotation(source, Endpoint.class); + Assert.state(annotation != null, + () -> "Class " + source + " is not annotated with @Endpoint"); + return annotation.id(); + } + + private List getDelegateMatchers(Set paths) { + return paths.stream().map((path) -> new PathPatternParserServerWebExchangeMatcher(path + "/**")) + .collect(Collectors.toList()); + } + + @Override + protected Mono matches(ServerWebExchange exchange, + EndpointPathProvider context) { + return this.delegate.matches(exchange); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/EndpointRequest.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java similarity index 94% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/EndpointRequest.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java index 5ddc89bcc51..d17f60019bb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/EndpointRequest.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.security; +package org.springframework.boot.actuate.autoconfigure.security.servlet; import java.util.ArrayList; import java.util.Arrays; @@ -28,10 +28,9 @@ import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; -import org.springframework.beans.factory.BeanCreationException; import org.springframework.boot.actuate.autoconfigure.endpoint.web.EndpointPathProvider; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; -import org.springframework.boot.security.ApplicationContextRequestMatcher; +import org.springframework.boot.security.servlet.ApplicationContextRequestMatcher; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; @@ -172,16 +171,6 @@ public final class EndpointRequest { .collect(Collectors.toList()); } - @Override - public boolean matches(HttpServletRequest request) { - try { - return super.matches(request); - } - catch (BeanCreationException ex) { - return false; - } - } - @Override protected boolean matches(HttpServletRequest request, EndpointPathProvider context) { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java new file mode 100644 index 00000000000..ddf69001848 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/reactive/EndpointRequestTests.java @@ -0,0 +1,190 @@ +/* + * Copyright 2012-2017 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 + * + * http://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 org.springframework.boot.actuate.autoconfigure.security.reactive; + +import java.util.Arrays; +import java.util.List; + +import org.assertj.core.api.AssertDelegateTarget; +import org.junit.Test; + +import org.springframework.boot.actuate.autoconfigure.endpoint.web.EndpointPathProvider; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.adapter.HttpWebHandlerAdapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link EndpointRequest}. + * + * @author Madhura Bhave + */ +public class EndpointRequestTests { + + @Test + public void toAnyEndpointShouldMatchEndpointPath() { + ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint(); + assertMatcher(matcher).matches("/actuator/foo"); + assertMatcher(matcher).matches("/actuator/bar"); + } + + @Test + public void toAnyEndpointShouldNotMatchOtherPath() { + ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint(); + assertMatcher(matcher).doesNotMatch("/actuator/baz"); + } + + @Test + public void toEndpointClassShouldMatchEndpointPath() { + ServerWebExchangeMatcher matcher = EndpointRequest.to(FooEndpoint.class); + assertMatcher(matcher).matches("/actuator/foo"); + } + + @Test + public void toEndpointClassShouldNotMatchOtherPath() { + ServerWebExchangeMatcher matcher = EndpointRequest.to(FooEndpoint.class); + assertMatcher(matcher).doesNotMatch("/actuator/bar"); + } + + @Test + public void toEndpointIdShouldMatchEndpointPath() { + ServerWebExchangeMatcher matcher = EndpointRequest.to("foo"); + assertMatcher(matcher).matches("/actuator/foo"); + } + + @Test + public void toEndpointIdShouldNotMatchOtherPath() { + ServerWebExchangeMatcher matcher = EndpointRequest.to("foo"); + assertMatcher(matcher).doesNotMatch("/actuator/bar"); + } + + @Test + public void excludeByClassShouldNotMatchExcluded() { + ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint() + .excluding(FooEndpoint.class); + assertMatcher(matcher).doesNotMatch("/actuator/foo"); + assertMatcher(matcher).matches("/actuator/bar"); + } + + @Test + public void excludeByIdShouldNotMatchExcluded() { + ServerWebExchangeMatcher matcher = EndpointRequest.toAnyEndpoint().excluding("foo"); + assertMatcher(matcher).doesNotMatch("/actuator/foo"); + assertMatcher(matcher).matches("/actuator/bar"); + } + + private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher) { + return assertMatcher(matcher, new MockEndpointPathProvider()); + } + + private RequestMatcherAssert assertMatcher(ServerWebExchangeMatcher matcher, + EndpointPathProvider endpointPathProvider) { + StaticApplicationContext context = new StaticApplicationContext(); + context.registerBean(EndpointPathProvider.class, () -> endpointPathProvider); + return assertThat(new RequestMatcherAssert(context, matcher)); + } + + private static class RequestMatcherAssert implements AssertDelegateTarget { + + private final StaticApplicationContext context; + + private final ServerWebExchangeMatcher matcher; + + RequestMatcherAssert(StaticApplicationContext context, ServerWebExchangeMatcher matcher) { + this.context = context; + this.matcher = matcher; + } + + void matches(String path) { + ServerWebExchange exchange = webHandler().createExchange(MockServerHttpRequest.get(path).build(), new MockServerHttpResponse()); + matches(exchange); + } + + private void matches(ServerWebExchange exchange) { + assertThat(this.matcher.matches(exchange).block().isMatch()) + .as("Matches " + getRequestPath(exchange)).isTrue(); + } + + void doesNotMatch(String path) { + ServerWebExchange exchange = webHandler().createExchange(MockServerHttpRequest.get(path).build(), new MockServerHttpResponse()); + doesNotMatch(exchange); + } + + private void doesNotMatch(ServerWebExchange exchange) { + assertThat(this.matcher.matches(exchange).block().isMatch()) + .as("Does not match " + getRequestPath(exchange)).isFalse(); + } + + private TestHttpWebHandlerAdapter webHandler() { + TestHttpWebHandlerAdapter adapter = new TestHttpWebHandlerAdapter(mock(WebHandler.class)); + adapter.setApplicationContext(this.context); + return adapter; + } + + private String getRequestPath(ServerWebExchange exchange) { + return exchange.getRequest().getPath().toString(); + } + + } + + private static class TestHttpWebHandlerAdapter extends HttpWebHandlerAdapter { + + TestHttpWebHandlerAdapter(WebHandler delegate) { + super(delegate); + } + + @Override + protected ServerWebExchange createExchange(ServerHttpRequest request, ServerHttpResponse response) { + return super.createExchange(request, response); + } + + } + + private static class MockEndpointPathProvider implements EndpointPathProvider { + + @Override + public List getPaths() { + return Arrays.asList("/actuator/foo", "/actuator/bar"); + } + + @Override + public String getPath(String id) { + if ("foo".equals(id)) { + return "/actuator/foo"; + } + if ("bar".equals(id)) { + return "/actuator/bar"; + } + return null; + } + + } + + @Endpoint(id = "foo") + private static class FooEndpoint { + + } +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/EndpointRequestTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java similarity index 98% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/EndpointRequestTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java index e45f3ab46d9..026e9214b63 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/EndpointRequestTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/security/servlet/EndpointRequestTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.security; +package org.springframework.boot.actuate.autoconfigure.security.servlet; import java.util.Arrays; import java.util.List; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/StaticResourceRequest.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/StaticResourceRequest.java index cb592550199..ef37eb852db 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/StaticResourceRequest.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/StaticResourceRequest.java @@ -27,7 +27,7 @@ import java.util.stream.Stream; import javax.servlet.http.HttpServletRequest; import org.springframework.boot.autoconfigure.web.ServerProperties; -import org.springframework.boot.security.ApplicationContextRequestMatcher; +import org.springframework.boot.security.servlet.ApplicationContextRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc index 1c4c6b8eeb7..238c36f8e08 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc @@ -2882,6 +2882,17 @@ messages. Otherwise, the default password is not printed. You can change the username and password by providing a `spring.security.user.name` and `spring.security.user.password`. +The basic features you get by default in a web application are: + +* A `UserDetailsService` (or `ReactiveUserDetailsService` in case of a WebFlux application) +bean with in-memory store and a single user with a generated password (see +{dc-spring-boot}/autoconfigure/security/SecurityProperties.User.html[`SecurityProperties.User`] +for the properties of the user). +* Form-based login or HTTP Basic security (depending on Content-Type) for the entire +application (including actuator endpoints if actuator is on the classpath). + +== MVC Security + The default security configuration is implemented in `SecurityAutoConfiguration` and in the classes imported from there (`SpringBootWebSecurityConfiguration` for web security and `AuthenticationManagerConfiguration` for authentication configuration, which is also @@ -2894,15 +2905,6 @@ To also switch off the authentication manager configuration, you can add a bean There are several secure applications in the {github-code}/spring-boot-samples/[Spring Boot samples] to get you started with common use cases. -The basic features you get by default in a web application are: - -* A `UserDetailsService` bean with in-memory store and a single user with a generated -password (see -{dc-spring-boot}/autoconfigure/security/SecurityProperties.User.html[`SecurityProperties.User`] -for the properties of the user). -* Form-based login or HTTP Basic security (depending on Content-Type) for the entire -application (including actuator endpoints if actuator is on the classpath). - Access rules can be overridden by adding a custom `WebSecurityConfigurerAdapter`. Spring Boot provides convenience methods that can be used to override access rules for actuator endpoints and static resources. `EndpointRequest` can be used to create a `RequestMatcher` @@ -2910,7 +2912,22 @@ that is based on the `management.endpoints.web.base-path` property. `StaticResourceRequest` can be used to create a `RequestMatcher` for static resources in commonly used locations. +== WebFlux Security +The default security configuration is implemented in `ReactiveSecurityAutoConfiguration` and in +the classes imported from there (`WebFluxSecurityConfiguration` for web security +and `ReactiveAuthenticationManagerConfiguration` for authentication configuration, which is also +relevant in non-web applications). To switch off the default web application security +configuration completely, you can add a bean of type `WebFilterChainProxy` (doing +so does not disable the authentication manager configuration or Actuator's security). + +To also switch off the authentication manager configuration, you can add a bean of type +`ReactiveUserDetailsService` or `ReactiveAuthenticationManager`. + +Access rules can be configured by adding a custom `SecurityWebFilterChain`. Spring +Boot provides convenience methods that can be used to override access rules for actuator +endpoints and static resources. `EndpointRequest` can be used to create a `ServerWebExchangeMatcher` +that is based on the `management.endpoints.web.base-path` property. [[boot-features-security-oauth2]] === OAuth2 diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java new file mode 100644 index 00000000000..ef785f94c32 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcher.java @@ -0,0 +1,104 @@ +/* + * Copyright 2012-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 + * + * http://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 org.springframework.boot.security.reactive; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * {@link ApplicationContext} backed {@link ServerWebExchangeMatcher}. Can work directly with the + * {@link ApplicationContext}, obtain an existing bean or + * {@link AutowireCapableBeanFactory#createBean(Class, int, boolean) create a new bean} + * that is autowired in the usual way. + * + * @param The type of the context that the match method actually needs to use. Can be + * an {@link ApplicationContext}, a class of an {@link ApplicationContext#getBean(Class) + * existing bean} or a custom type that will be + * {@link AutowireCapableBeanFactory#createBean(Class, int, boolean) created} on demand. + * @author Madhura Bhave + * @since 2.0.0 + */ +public abstract class ApplicationContextServerWebExchangeMatcher implements ServerWebExchangeMatcher { + + private final Class contextClass; + + private C context; + + private Object contextLock = new Object(); + + public ApplicationContextServerWebExchangeMatcher(Class contextClass) { + Assert.notNull(contextClass, "Context class must not be null"); + this.contextClass = contextClass; + } + + @Override + public final Mono matches(ServerWebExchange exchange) { + return matches(exchange, getContext(exchange)); + } + + /** + * Decides whether the rule implemented by the strategy matches the supplied exchange. + * @param exchange the source exchange + * @param context the context instance + * @return if the exchange matches + */ + protected abstract Mono matches(ServerWebExchange exchange, C context); + + protected C getContext(ServerWebExchange exchange) { + if (this.context == null) { + synchronized (this.contextLock) { + this.context = createContext(exchange); + initialized(this.context); + } + } + return this.context; + } + + /** + * Called once the context has been initialized. + * @param context the initialized context + */ + protected void initialized(C context) { + } + + @SuppressWarnings("unchecked") + private C createContext(ServerWebExchange exchange) { + ApplicationContext context = exchange.getApplicationContext(); + if (context == null) { + throw new IllegalStateException("No WebApplicationContext found."); + } + if (this.contextClass.isInstance(context)) { + return (C) context; + } + try { + return context.getBean(this.contextClass); + } + catch (NoSuchBeanDefinitionException ex) { + return (C) context.getAutowireCapableBeanFactory().createBean( + this.contextClass, AutowireCapableBeanFactory.AUTOWIRE_CONSTRUCTOR, + false); + } + } + +} + diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/ApplicationContextRequestMatcher.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/servlet/ApplicationContextRequestMatcher.java similarity index 96% rename from spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/ApplicationContextRequestMatcher.java rename to spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/servlet/ApplicationContextRequestMatcher.java index a0b3351c95c..ac2ac2f6884 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/ApplicationContextRequestMatcher.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/security/servlet/ApplicationContextRequestMatcher.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.security; +package org.springframework.boot.security.servlet; import javax.servlet.http.HttpServletRequest; @@ -53,7 +53,7 @@ public abstract class ApplicationContextRequestMatcher implements RequestMatc } @Override - public boolean matches(HttpServletRequest request) { + public final boolean matches(HttpServletRequest request) { return matches(request, getContext(request)); } diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcherTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcherTests.java new file mode 100644 index 00000000000..7b25acdc66d --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/reactive/ApplicationContextServerWebExchangeMatcherTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2012-2017 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 + * + * http://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 org.springframework.boot.security.reactive; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.adapter.HttpWebHandlerAdapter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ApplicationContextServerWebExchangeMatcher}. + * + * @author Madhura Bhave + */ +public class ApplicationContextServerWebExchangeMatcherTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Test + public void createWhenContextClassIsNullShouldThrowException() { + this.thrown.expect(IllegalArgumentException.class); + this.thrown.expectMessage("Context class must not be null"); + new TestApplicationContextServerWebExchangeMatcher<>(null); + } + + @Test + public void matchesWhenContextClassIsApplicationContextShouldProvideContext() { + ServerWebExchange exchange = createHttpWebHandlerAdapter(); + StaticApplicationContext context = (StaticApplicationContext) exchange.getApplicationContext(); + assertThat(new TestApplicationContextServerWebExchangeMatcher<>(ApplicationContext.class) + .callMatchesAndReturnProvidedContext(exchange)).isEqualTo(context); + } + + @Test + public void matchesWhenContextClassIsExistingBeanShouldProvideBean() { + ServerWebExchange exchange = createHttpWebHandlerAdapter(); + StaticApplicationContext context = (StaticApplicationContext) exchange.getApplicationContext(); + context.registerSingleton("existingBean", ExistingBean.class); + assertThat(new TestApplicationContextServerWebExchangeMatcher<>(ExistingBean.class) + .callMatchesAndReturnProvidedContext(exchange)) + .isEqualTo(context.getBean(ExistingBean.class)); + } + + @Test + public void matchesWhenContextClassIsNewBeanShouldProvideBean() { + ServerWebExchange exchange = createHttpWebHandlerAdapter(); + StaticApplicationContext context = (StaticApplicationContext) exchange.getApplicationContext(); + context.registerSingleton("existingBean", ExistingBean.class); + assertThat(new TestApplicationContextServerWebExchangeMatcher<>(NewBean.class) + .callMatchesAndReturnProvidedContext(exchange).getBean()) + .isEqualTo(context.getBean(ExistingBean.class)); + } + + @Test + public void matchesWhenContextIsNull() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/path").build()); + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("No WebApplicationContext found."); + new TestApplicationContextServerWebExchangeMatcher<>(ExistingBean.class) + .callMatchesAndReturnProvidedContext(exchange); + } + + private ServerWebExchange createHttpWebHandlerAdapter() { + StaticApplicationContext context = new StaticApplicationContext(); + TestHttpWebHandlerAdapter adapter = new TestHttpWebHandlerAdapter(mock(WebHandler.class)); + adapter.setApplicationContext(context); + return adapter.createExchange(MockServerHttpRequest.get("/path").build(), new MockServerHttpResponse()); + } + + static class TestHttpWebHandlerAdapter extends HttpWebHandlerAdapter { + + TestHttpWebHandlerAdapter(WebHandler delegate) { + super(delegate); + } + + @Override + protected ServerWebExchange createExchange(ServerHttpRequest request, ServerHttpResponse response) { + return super.createExchange(request, response); + } + + } + + static class ExistingBean { + + } + + static class NewBean { + + private final ExistingBean bean; + + NewBean(ExistingBean bean) { + this.bean = bean; + } + + public ExistingBean getBean() { + return this.bean; + } + + } + + static class TestApplicationContextServerWebExchangeMatcher + extends ApplicationContextServerWebExchangeMatcher { + + private C providedContext; + + TestApplicationContextServerWebExchangeMatcher(Class context) { + super(context); + } + + C callMatchesAndReturnProvidedContext(ServerWebExchange exchange) { + matches(exchange); + return getProvidedContext(); + } + + @Override + protected Mono matches(ServerWebExchange exchange, C context) { + this.providedContext = context; + return MatchResult.match(); + } + + C getProvidedContext() { + return this.providedContext; + } + + } + +} + diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/ApplicationContextRequestMatcherTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/servlet/ApplicationContextRequestMatcherTests.java similarity index 98% rename from spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/ApplicationContextRequestMatcherTests.java rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/servlet/ApplicationContextRequestMatcherTests.java index b6cb1e3fdf8..eb656ead81e 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/ApplicationContextRequestMatcherTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/security/servlet/ApplicationContextRequestMatcherTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.security; +package org.springframework.boot.security.servlet; import javax.servlet.http.HttpServletRequest; diff --git a/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/SecurityConfiguration.java b/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/SecurityConfiguration.java index 09e84151d8c..945d6ebb819 100644 --- a/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/SecurityConfiguration.java +++ b/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/SecurityConfiguration.java @@ -16,7 +16,7 @@ package sample.actuator.customsecurity; -import org.springframework.boot.actuate.autoconfigure.security.EndpointRequest; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.boot.autoconfigure.security.StaticResourceRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/spring-boot-samples/spring-boot-sample-secure-webflux/src/main/resources/application.properties b/spring-boot-samples/spring-boot-sample-secure-webflux/src/main/resources/application.properties index cb7e159bf3a..093ae1eb74b 100644 --- a/spring-boot-samples/spring-boot-sample-secure-webflux/src/main/resources/application.properties +++ b/spring-boot-samples/spring-boot-sample-secure-webflux/src/main/resources/application.properties @@ -1,2 +1,3 @@ spring.security.user.name=user -spring.security.user.password=password \ No newline at end of file +spring.security.user.password=password +management.endpoints.web.expose=* \ No newline at end of file diff --git a/spring-boot-samples/spring-boot-sample-secure-webflux/src/test/java/sample/secure/webflux/SampleSecureWebFluxCustomSecurityTests.java b/spring-boot-samples/spring-boot-sample-secure-webflux/src/test/java/sample/secure/webflux/SampleSecureWebFluxCustomSecurityTests.java new file mode 100644 index 00000000000..db6e899b491 --- /dev/null +++ b/spring-boot-samples/spring-boot-sample-secure-webflux/src/test/java/sample/secure/webflux/SampleSecureWebFluxCustomSecurityTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2012-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 + * + * http://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.secure.webflux; + +import java.util.Base64; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.security.reactive.EndpointRequest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.WebTestClient; + +/** + * Integration tests for a secure reactive application with custom security. + * + * @author Madhura Bhave + */ +@RunWith(SpringRunner.class) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = { SampleSecureWebFluxCustomSecurityTests.SecurityConfiguration.class, + SampleSecureWebFluxApplication.class }) +public class SampleSecureWebFluxCustomSecurityTests { + + @Autowired + private WebTestClient webClient; + + @Test + public void userDefinedMappingsSecure() { + this.webClient.get().uri("/").accept(MediaType.APPLICATION_JSON).exchange() + .expectStatus().isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + public void healthAndInfoDontRequireAuthentication() { + this.webClient.get().uri("/actuator/health").accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk(); + this.webClient.get().uri("/actuator/info").accept(MediaType.APPLICATION_JSON) + .exchange().expectStatus().isOk(); + } + + @Test + public void actuatorsSecuredByRole() { + this.webClient.get().uri("/actuator/env").accept(MediaType.APPLICATION_JSON) + .header("Authorization", "basic " + getBasicAuth()).exchange() + .expectStatus().isForbidden(); + } + + @Test + public void actuatorsAccessibleOnCorrectLogin() { + this.webClient.get().uri("/actuator/env").accept(MediaType.APPLICATION_JSON) + .header("Authorization", "basic " + getBasicAuthForAdmin()).exchange() + .expectStatus().isOk(); + } + + @Configuration + static class SecurityConfiguration { + + @Bean + public MapReactiveUserDetailsService userDetailsService() { + return new MapReactiveUserDetailsService( + User.withDefaultPasswordEncoder().username("user").password("password") + .authorities("ROLE_USER").build(), + User.withDefaultPasswordEncoder().username("admin").password("admin") + .authorities("ROLE_ACTUATOR", "ROLE_USER").build()); + } + + @Bean + SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { + http + .authorizeExchange() + .matchers(EndpointRequest.to("health", "info")).permitAll() + .matchers(EndpointRequest.toAnyEndpoint()).hasRole("ACTUATOR") + .pathMatchers("/login").permitAll() + .anyExchange().authenticated() + .and() + .httpBasic(); + return http.build(); + } + + } + + private String getBasicAuth() { + return new String(Base64.getEncoder().encode(("user:password").getBytes())); + } + + private String getBasicAuthForAdmin() { + return new String(Base64.getEncoder().encode(("admin:admin").getBytes())); + } + +} + diff --git a/spring-boot-samples/spring-boot-sample-web-method-security/src/main/java/sample/security/method/SampleMethodSecurityApplication.java b/spring-boot-samples/spring-boot-sample-web-method-security/src/main/java/sample/security/method/SampleMethodSecurityApplication.java index 101c60be4e8..6789e3fcb11 100644 --- a/spring-boot-samples/spring-boot-sample-web-method-security/src/main/java/sample/security/method/SampleMethodSecurityApplication.java +++ b/spring-boot-samples/spring-boot-sample-web-method-security/src/main/java/sample/security/method/SampleMethodSecurityApplication.java @@ -19,7 +19,7 @@ package sample.security.method; import java.util.Date; import java.util.Map; -import org.springframework.boot.actuate.autoconfigure.security.EndpointRequest; +import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.context.annotation.Bean;