diff --git a/config/spring-security-config.gradle b/config/spring-security-config.gradle index 4e95a7ee37..146778ed23 100644 --- a/config/spring-security-config.gradle +++ b/config/spring-security-config.gradle @@ -15,6 +15,7 @@ dependencies { optional project(':spring-security-oauth2-client') optional project(':spring-security-openid') optional project(':spring-security-web') + optional project(':spring-security-webflux') optional 'org.aspectj:aspectjweaver' optional 'org.springframework:spring-jdbc' optional 'org.springframework:spring-tx' @@ -27,6 +28,7 @@ dependencies { testCompile project(':spring-security-aspects') testCompile project(':spring-security-cas') testCompile project(path : ':spring-security-core', configuration : 'tests') + testCompile project(path : ':spring-security-webflux', configuration : 'tests') testCompile apachedsDependencies testCompile powerMockDependencies testCompile spockDependencies diff --git a/config/src/main/java/org/springframework/security/config/web/server/AbstractServerWebExchangeMatcherRegistry.java b/config/src/main/java/org/springframework/security/config/web/server/AbstractServerWebExchangeMatcherRegistry.java new file mode 100644 index 0000000000..53862ef648 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/AbstractServerWebExchangeMatcherRegistry.java @@ -0,0 +1,120 @@ +/* + * Copyright 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.security.config.web.server; + +import org.springframework.http.HttpMethod; +import org.springframework.security.web.server.util.matcher.OrServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; + +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + + +/** + * @author Rob Winch + * @since 5.0 + */ +abstract class AbstractServerWebExchangeMatcherRegistry { + + /** + * Maps any request. + * + * @return the object that is chained after creating the {@link ServerWebExchangeMatcher} + */ + public T anyExchange() { + return matcher(ServerWebExchangeMatchers.anyExchange()); + } + + /** + * Maps a {@link List} of + * {@link org.springframework.security.web.server.util.matcher.PathMatcherServerWebExchangeMatcher} + * instances. + * + * @param method the {@link HttpMethod} to use for any + * {@link HttpMethod}. + * + * @return the object that is chained after creating the {@link ServerWebExchangeMatcher} + */ + public T antMatchers(HttpMethod method) { + return antMatchers(method, new String[] { "/**" }); + } + + /** + * Maps a {@link List} of + * {@link org.springframework.security.web.server.util.matcher.PathMatcherServerWebExchangeMatcher} + * instances. + * + * @param method the {@link HttpMethod} to use or {@code null} for any + * {@link HttpMethod}. + * @param antPatterns the ant patterns to create. If {@code null} or empty, then matches on nothing. + * {@link org.springframework.security.web.server.util.matcher.PathMatcherServerWebExchangeMatcher} from + * + * @return the object that is chained after creating the {@link ServerWebExchangeMatcher} + */ + public T antMatchers(HttpMethod method, String... antPatterns) { + return matcher(ServerWebExchangeMatchers.antMatchers(method, antPatterns)); + } + + /** + * Maps a {@link List} of + * {@link org.springframework.security.web.server.util.matcher.PathMatcherServerWebExchangeMatcher} + * instances that do not care which {@link HttpMethod} is used. + * + * @param antPatterns the ant patterns to create + * {@link org.springframework.security.web.server.util.matcher.PathMatcherServerWebExchangeMatcher} from + * + * @return the object that is chained after creating the {@link ServerWebExchangeMatcher} + */ + public T antMatchers(String... antPatterns) { + return matcher(ServerWebExchangeMatchers.antMatchers(antPatterns)); + } + + /** + * Associates a list of {@link ServerWebExchangeMatcher} instances + * + * @param matchers the {@link ServerWebExchangeMatcher} instances + * + * @return the object that is chained after creating the {@link ServerWebExchangeMatcher} + */ + public T matchers(ServerWebExchangeMatcher... matchers) { + return registerMatcher(new OrServerWebExchangeMatcher(matchers)); + } + + /** + * Subclasses should implement this method for returning the object that is chained to + * the creation of the {@link ServerWebExchangeMatcher} instances. + * + * @param matcher the {@link ServerWebExchangeMatcher} instances that were created + * @return the chained Object for the subclass which allows association of something + * else to the {@link ServerWebExchangeMatcher} + */ + protected abstract T registerMatcher(ServerWebExchangeMatcher matcher); + + /** + * Associates a {@link ServerWebExchangeMatcher} instances + * + * @param matcher the {@link ServerWebExchangeMatcher} instance + * + * @return the object that is chained after creating the {@link ServerWebExchangeMatcher} + */ + private T matcher(ServerWebExchangeMatcher matcher) { + return registerMatcher(matcher); + } +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/AuthorizeExchangeBuilder.java b/config/src/main/java/org/springframework/security/config/web/server/AuthorizeExchangeBuilder.java new file mode 100644 index 0000000000..9f4baf4d10 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/AuthorizeExchangeBuilder.java @@ -0,0 +1,91 @@ +/* + * Copyright 2002-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.security.config.web.server; + +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.web.server.authorization.AuthorizationWebFilter; +import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.web.server.WebFilter; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AuthorizeExchangeBuilder extends AbstractServerWebExchangeMatcherRegistry { + private DelegatingReactiveAuthorizationManager.Builder managerBldr = DelegatingReactiveAuthorizationManager.builder(); + private ServerWebExchangeMatcher matcher; + private boolean anyExchangeRegistered; + + @Override + public Access anyExchange() { + Access result = super.anyExchange(); + anyExchangeRegistered = true; + return result; + } + + @Override + protected Access registerMatcher(ServerWebExchangeMatcher matcher) { + if(anyExchangeRegistered) { + throw new IllegalStateException("Cannot register " + matcher + " which would be unreachable because anyExchange() has already been registered."); + } + if(this.matcher != null) { + throw new IllegalStateException("The matcher " + matcher + " does not have an access rule defined"); + } + this.matcher = matcher; + return new Access(); + } + + public WebFilter build() { + if(this.matcher != null) { + throw new IllegalStateException("The matcher " + matcher + " does not have an access rule defined"); + } + return new AuthorizationWebFilter(managerBldr.build()); + } + + public final class Access { + + public void permitAll() { + access( (a,e) -> Mono.just(new AuthorizationDecision(true))); + } + + public void denyAll() { + access( (a,e) -> Mono.just(new AuthorizationDecision(false))); + } + + public void hasRole(String role) { + access(AuthorityAuthorizationManager.hasRole(role)); + } + + public void hasAuthority(String authority) { + access(AuthorityAuthorizationManager.hasAuthority(authority)); + } + + public void authenticated() { + access(AuthenticatedAuthorizationManager.authenticated()); + } + + public void access(ReactiveAuthorizationManager manager) { + managerBldr.add(matcher, manager); + matcher = null; + } + } +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/HeaderBuilder.java b/config/src/main/java/org/springframework/security/config/web/server/HeaderBuilder.java new file mode 100644 index 0000000000..7d61d3d203 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/HeaderBuilder.java @@ -0,0 +1,128 @@ +/* + * Copyright 2002-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.security.config.web.server; + +import org.springframework.security.web.server.header.CacheControlHttpHeadersWriter; +import org.springframework.security.web.server.header.CompositeHttpHeadersWriter; +import org.springframework.security.web.server.header.ContentTypeOptionsHttpHeadersWriter; +import org.springframework.security.web.server.header.HttpHeaderWriterWebFilter; +import org.springframework.security.web.server.header.HttpHeadersWriter; +import org.springframework.security.web.server.header.StrictTransportSecurityHttpHeadersWriter; +import org.springframework.security.web.server.header.XFrameOptionsHttpHeadersWriter; +import org.springframework.security.web.server.header.XXssProtectionHttpHeadersWriter; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class HeaderBuilder { + private final List writers; + + private CacheControlHttpHeadersWriter cacheControl = new CacheControlHttpHeadersWriter(); + + private ContentTypeOptionsHttpHeadersWriter contentTypeOptions = new ContentTypeOptionsHttpHeadersWriter(); + + private StrictTransportSecurityHttpHeadersWriter hsts = new StrictTransportSecurityHttpHeadersWriter(); + + private XFrameOptionsHttpHeadersWriter frameOptions = new XFrameOptionsHttpHeadersWriter(); + + private XXssProtectionHttpHeadersWriter xss = new XXssProtectionHttpHeadersWriter(); + + public HeaderBuilder() { + this.writers = new ArrayList<>(Arrays.asList(cacheControl, contentTypeOptions, hsts, frameOptions, xss)); + } + + public CacheSpec cache() { + return new CacheSpec(); + } + + public ContentTypeOptionsSpec contentTypeOptions() { + return new ContentTypeOptionsSpec(); + } + + public FrameOptionsSpec frameOptions() { + return new FrameOptionsSpec(); + } + + public HstsSpec hsts() { + return new HstsSpec(); + } + + public HttpHeaderWriterWebFilter build() { + HttpHeadersWriter writer = new CompositeHttpHeadersWriter(writers); + return new HttpHeaderWriterWebFilter(writer); + } + + public XssProtectionSpec xssProtection() { + return new XssProtectionSpec(); + } + + public class CacheSpec { + public void disable() { + writers.remove(cacheControl); + } + + private CacheSpec() {} + } + + public class ContentTypeOptionsSpec { + public void disable() { + writers.remove(contentTypeOptions); + } + + private ContentTypeOptionsSpec() {} + } + + public class FrameOptionsSpec { + public void mode(XFrameOptionsHttpHeadersWriter.Mode mode) { + frameOptions.setMode(mode); + } + public void disable() { + writers.remove(frameOptions); + } + + private FrameOptionsSpec() {} + } + + public class HstsSpec { + public void maxAge(Duration maxAge) { + hsts.setMaxAge(maxAge); + } + + public void includeSubdomains(boolean includeSubDomains) { + hsts.setIncludeSubDomains(includeSubDomains); + } + + public void disable() { + writers.remove(hsts); + } + + private HstsSpec() {} + } + + public class XssProtectionSpec { + public void disable() { + writers.remove(xss); + } + + private XssProtectionSpec() {} + } +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/HttpBasicBuilder.java b/config/src/main/java/org/springframework/security/config/web/server/HttpBasicBuilder.java new file mode 100644 index 0000000000..1439e15b03 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/HttpBasicBuilder.java @@ -0,0 +1,58 @@ +/* + * Copyright 2002-2016 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.security.config.web.server; + +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.AuthenticationWebFilter; +import org.springframework.security.web.server.HttpBasicAuthenticationConverter; +import org.springframework.security.web.server.authentication.DefaultAuthenticationSuccessHandler; +import org.springframework.security.web.server.authentication.www.HttpBasicAuthenticationEntryPoint; +import org.springframework.security.web.server.context.SecurityContextRepository; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class HttpBasicBuilder { + private ReactiveAuthenticationManager authenticationManager; + + private SecurityContextRepository securityContextRepository; + + private AuthenticationEntryPoint entryPoint = new HttpBasicAuthenticationEntryPoint(); + + public HttpBasicBuilder authenticationManager(ReactiveAuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + return this; + } + + public HttpBasicBuilder securityContextRepository(SecurityContextRepository securityContextRepository) { + this.securityContextRepository = securityContextRepository; + return this; + } + + public AuthenticationWebFilter build() { + AuthenticationWebFilter authenticationFilter = new AuthenticationWebFilter(authenticationManager); + authenticationFilter.setEntryPoint(entryPoint); + authenticationFilter.setAuthenticationConverter(new HttpBasicAuthenticationConverter()); + if(securityContextRepository != null) { + DefaultAuthenticationSuccessHandler handler = new DefaultAuthenticationSuccessHandler(); + handler.setSecurityContextRepository(securityContextRepository); + authenticationFilter.setAuthenticationSuccessHandler(handler); + } + return authenticationFilter; + } +} diff --git a/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java new file mode 100644 index 0000000000..fa993eab41 --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/web/server/HttpSecurity.java @@ -0,0 +1,101 @@ +/* + * Copyright 2002-2016 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.security.config.web.server; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.web.server.context.SecurityContextRepositoryWebFilter; +import org.springframework.security.web.server.WebFilterChainFilter; +import org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter; +import org.springframework.security.web.server.context.SecurityContextRepository; +import org.springframework.util.Assert; +import org.springframework.web.server.WebFilter; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class HttpSecurity { + private AuthorizeExchangeBuilder authorizeExchangeBuilder; + + private HeaderBuilder headers = new HeaderBuilder(); + private HttpBasicBuilder httpBasic; + private ReactiveAuthenticationManager authenticationManager; + + private Optional securityContextRepository = Optional.empty(); + + public HttpSecurity securityContextRepository(SecurityContextRepository securityContextRepository) { + Assert.notNull(securityContextRepository, "securityContextRepository cannot be null"); + this.securityContextRepository = Optional.of(securityContextRepository); + return this; + } + + public HttpBasicBuilder httpBasic() { + if(httpBasic == null) { + httpBasic = new HttpBasicBuilder(); + } + return httpBasic; + } + + public HeaderBuilder headers() { + return headers; + } + + public AuthorizeExchangeBuilder authorizeExchange() { + if(authorizeExchangeBuilder == null) { + authorizeExchangeBuilder = new AuthorizeExchangeBuilder(); + } + return authorizeExchangeBuilder; + } + + public HttpSecurity authenticationManager(ReactiveAuthenticationManager manager) { + this.authenticationManager = manager; + return this; + } + + public WebFilter build() { + List filters = new ArrayList<>(); + if(headers != null) { + filters.add(headers.build()); + } + securityContextRepositoryWebFilter().ifPresent( f-> filters.add(f)); + if(httpBasic != null) { + httpBasic.authenticationManager(authenticationManager); + securityContextRepository.ifPresent( scr -> httpBasic.securityContextRepository(scr)) ; + filters.add(httpBasic.build()); + } + if(authorizeExchangeBuilder != null) { + filters.add(new ExceptionTranslationWebFilter()); + filters.add(authorizeExchangeBuilder.build()); + } + return new WebFilterChainFilter(filters); + } + + public static HttpSecurity http() { + return new HttpSecurity(); + } + + private Optional securityContextRepositoryWebFilter() { + return securityContextRepository + .flatMap( r -> Optional.of(new SecurityContextRepositoryWebFilter(r))); + } + + + private HttpSecurity() {} +} diff --git a/config/src/test/java/org/springframework/security/config/web/server/AuthorizeExchangeBuilderTests.java b/config/src/test/java/org/springframework/security/config/web/server/AuthorizeExchangeBuilderTests.java new file mode 100644 index 0000000000..6496477ccc --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/AuthorizeExchangeBuilderTests.java @@ -0,0 +1,112 @@ +/* + * + * * Copyright 2002-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.security.config.web.server; + +import org.junit.Test; +import org.springframework.http.HttpMethod; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter; +import org.springframework.test.web.reactive.server.WebTestClient; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AuthorizeExchangeBuilderTests { + AuthorizeExchangeBuilder authorization = new AuthorizeExchangeBuilder(); + + @Test + public void antMatchersWhenMethodAndPatternsThenDiscriminatesByMethod() { + authorization.antMatchers(HttpMethod.POST, "/a", "/b").denyAll(); + authorization.anyExchange().permitAll(); + + WebTestClient client = buildClient(); + + client.get() + .uri("/a") + .exchange() + .expectStatus().isOk(); + + client.get() + .uri("/b") + .exchange() + .expectStatus().isOk(); + + client.post() + .uri("/a") + .exchange() + .expectStatus().isUnauthorized(); + + client.post() + .uri("/b") + .exchange() + .expectStatus().isUnauthorized(); + } + + + @Test + public void antMatchersWhenPatternsThenAnyMethod() { + authorization.antMatchers("/a", "/b").denyAll(); + authorization.anyExchange().permitAll(); + + WebTestClient client = buildClient(); + + client.get() + .uri("/a") + .exchange() + .expectStatus().isUnauthorized(); + + client.get() + .uri("/b") + .exchange() + .expectStatus().isUnauthorized(); + + client.post() + .uri("/a") + .exchange() + .expectStatus().isUnauthorized(); + + client.post() + .uri("/b") + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test(expected = IllegalStateException.class) + public void antMatchersWhenNoAccessAndAnotherMatcherThenThrowsException() { + authorization.antMatchers("/incomplete"); + authorization.antMatchers("/throws-exception"); + } + + @Test(expected = IllegalStateException.class) + public void anyExchangeWhenFollowedByMatcherThenThrowsException() { + authorization.anyExchange().denyAll(); + authorization.antMatchers("/never-reached"); + } + + @Test(expected = IllegalStateException.class) + public void buildWhenMatcherDefinedWithNoAccessThenThrowsException() { + authorization.antMatchers("/incomplete"); + authorization.build(); + } + + private WebTestClient buildClient() { + return WebTestClientBuilder.bindToWebFilters(new ExceptionTranslationWebFilter(), authorization.build()).build(); + } +} diff --git a/config/src/test/java/org/springframework/security/config/web/server/HeaderBuilderTests.java b/config/src/test/java/org/springframework/security/config/web/server/HeaderBuilderTests.java new file mode 100644 index 0000000000..082e064e29 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/HeaderBuilderTests.java @@ -0,0 +1,144 @@ +/* + * + * * Copyright 2002-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.security.config.web.server; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.server.header.ContentTypeOptionsHttpHeadersWriter; +import org.springframework.security.web.server.header.StrictTransportSecurityHttpHeadersWriter; +import org.springframework.security.web.server.header.XFrameOptionsHttpHeadersWriter; +import org.springframework.security.web.server.header.XXssProtectionHttpHeadersWriter; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class HeaderBuilderTests { + HeaderBuilder headers = new HeaderBuilder(); + + HttpHeaders expectedHeaders = new HttpHeaders(); + + Set ignoredHeaderNames = Collections.singleton(HttpHeaders.CONTENT_TYPE); + + @Before + public void setup() { + expectedHeaders.add(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY, "max-age=31536000 ; includeSubDomains"); + expectedHeaders.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0, must-revalidate"); + expectedHeaders.add(HttpHeaders.PRAGMA, "no-cache"); + expectedHeaders.add(HttpHeaders.EXPIRES, "0"); + expectedHeaders.add(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS, "nosniff"); + expectedHeaders.add(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS, "DENY"); + expectedHeaders.add(XXssProtectionHttpHeadersWriter.X_XSS_PROTECTION, "1 ; mode=block"); + } + + @Test + public void headersWhenDefaultsThenAllDefaultsWritten() { + assertHeaders(); + } + + @Test + public void headersWhenCacheDisableThenCacheNotWritten() { + expectedHeaders.remove(HttpHeaders.CACHE_CONTROL); + expectedHeaders.remove(HttpHeaders.PRAGMA); + expectedHeaders.remove(HttpHeaders.EXPIRES); + headers.cache().disable(); + + assertHeaders(); + } + + @Test + public void headersWhenContentOptionsDisableThenContentTypeOptionsNotWritten() { + expectedHeaders.remove(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS); + headers.contentTypeOptions().disable(); + + assertHeaders(); + } + + @Test + public void headersWhenHstsDisableThenHstsNotWritten() { + expectedHeaders.remove(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY); + headers.hsts().disable(); + + assertHeaders(); + } + + @Test + public void headersWhenHstsCustomThenCustomHstsWritten() { + expectedHeaders.remove(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY); + expectedHeaders.add(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY, "max-age=60"); + headers.hsts().maxAge(Duration.ofSeconds(60)); + headers.hsts().includeSubdomains(false); + + assertHeaders(); + } + + @Test + public void headersWhenFrameOptionsDisableThenFrameOptionsNotWritten() { + expectedHeaders.remove(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS); + headers.frameOptions().disable(); + + assertHeaders(); + } + + @Test + public void headersWhenFrameOptionsModeThenFrameOptionsCustomMode() { + expectedHeaders.remove(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS); + expectedHeaders.add(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS, "SAMEORIGIN"); + headers.frameOptions().mode(XFrameOptionsHttpHeadersWriter.Mode.SAMEORIGIN); + + assertHeaders(); + } + + @Test + public void headersWhenXssProtectionDisableThenXssProtectionNotWritten() { + expectedHeaders.remove("X-Xss-Protection"); + headers.xssProtection().disable(); + + assertHeaders(); + } + + private void assertHeaders() { + WebTestClient client = buildClient(); + FluxExchangeResult response = client.get() + .uri("https://example.com/") + .exchange() + .returnResult(String.class); + + Map> responseHeaders = response.getResponseHeaders(); + ignoredHeaderNames.stream().forEach(responseHeaders::remove); + + assertThat(responseHeaders).describedAs(response.toString()).isEqualTo(expectedHeaders); + } + + private WebTestClient buildClient() { + return WebTestClientBuilder.bindToWebFilters(headers.build()).build(); + } +} diff --git a/config/src/test/java/org/springframework/security/config/web/server/HttpSecurityTests.java b/config/src/test/java/org/springframework/security/config/web/server/HttpSecurityTests.java new file mode 100644 index 0000000000..41800c820b --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/web/server/HttpSecurityTests.java @@ -0,0 +1,107 @@ +/* + * + * * Copyright 2002-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.security.config.web.server; + +import org.apache.http.HttpHeaders; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.server.context.SecurityContextRepository; +import org.springframework.security.web.server.context.WebSessionSecurityContextRepository; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.FluxExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.WebSession; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class HttpSecurityTests { + @Mock + SecurityContextRepository contextRepository; + @Mock + ReactiveAuthenticationManager authenticationManager; + + HttpSecurity http; + + @Before + public void setup() { + http = HttpSecurity.http(); + } + + @Test + public void defaults() { + http.securityContextRepository(this.contextRepository); + + WebTestClient client = buildClient(); + + FluxExchangeResult result = client.get() + .uri("/") + .exchange() + .expectHeader().valueMatches(HttpHeaders.CACHE_CONTROL, ".+") + .returnResult(String.class); + + assertThat(result.getResponseCookies()).isEmpty(); + // there is no need to try and load the SecurityContext by default + verifyZeroInteractions(contextRepository); + } + + @Test + public void basic() { + given(this.authenticationManager.authenticate(any())).willReturn(Mono.just(new TestingAuthenticationToken("rob", "rob", "ROLE_USER", "ROLE_ADMIN"))); + + http.securityContextRepository(new WebSessionSecurityContextRepository()); + http.httpBasic(); + http.authenticationManager(authenticationManager); + AuthorizeExchangeBuilder authorize = http.authorizeExchange(); + authorize.anyExchange().authenticated(); + + WebTestClient client = buildClient(); + + EntityExchangeResult result = client + .filter(basicAuthentication("rob", "rob")) + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectHeader().valueMatches(HttpHeaders.CACHE_CONTROL, ".+") + .expectBody().consumeAsStringWith( b-> assertThat(b).isEqualTo("ok")) + .returnResult(); + + assertThat(result.getResponseCookies().getFirst("SESSION")).isNotNull(); + } + + private WebTestClient buildClient() { + return WebTestClientBuilder.bindToWebFilters(http.build()).build(); + } +} diff --git a/core/spring-security-core.gradle b/core/spring-security-core.gradle index 0697721429..a01999f2fb 100644 --- a/core/spring-security-core.gradle +++ b/core/spring-security-core.gradle @@ -18,6 +18,7 @@ dependencies { included includeProject optional 'com.fasterxml.jackson.core:jackson-databind' + optional 'io.projectreactor:reactor-core' optional 'javax.annotation:jsr250-api' optional 'net.sf.ehcache:ehcache' optional 'org.aspectj:aspectjrt' @@ -26,6 +27,7 @@ dependencies { testCompile powerMockDependencies testCompile 'commons-collections:commons-collections' + testCompile 'io.projectreactor.addons:reactor-test' testCompile 'org.skyscreamer:jsonassert' testCompile 'org.slf4j:jcl-over-slf4j' testCompile 'org.springframework:spring-test' diff --git a/core/src/main/java/org/springframework/security/authentication/MapUserDetailsRepository.java b/core/src/main/java/org/springframework/security/authentication/MapUserDetailsRepository.java new file mode 100644 index 0000000000..4e4ec82a30 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/MapUserDetailsRepository.java @@ -0,0 +1,55 @@ +/* + * + * * Copyright 2002-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.security.authentication; + +import java.util.Collection; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class MapUserDetailsRepository implements UserDetailsRepository { + private final Map users; + + public MapUserDetailsRepository(Collection users) { + Assert.notEmpty(users, "users cannot be null or empty"); + this.users = users.stream().collect(Collectors.toMap( u -> getKey(u.getName()), Function.identity())); + } + + @Override + public Mono findByUsername(String username) { + String key = getKey(username); + UserDetails result = users.get(key); + return result == null ? Mono.empty() : Mono.just(User.withUserDetails(result).build()); + } + + private String getKey(String username) { + return username.toLowerCase(); + } +} diff --git a/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManager.java b/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManager.java new file mode 100644 index 0000000000..715bda9e3c --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManager.java @@ -0,0 +1,30 @@ +/* + * Copyright 2002-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.security.authentication; + +import org.springframework.security.core.Authentication; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public interface ReactiveAuthenticationManager { + + Mono authenticate(Authentication authentication); +} diff --git a/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerAdapter.java b/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerAdapter.java new file mode 100644 index 0000000000..99472d3fe2 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ReactiveAuthenticationManagerAdapter.java @@ -0,0 +1,53 @@ +/* + * Copyright 2002-2016 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.security.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Adapts an AuthenticationManager to the reactive APIs. This is somewhat necessary because many of the ways that + * credentials are stored (i.e. JDBC, LDAP, etc) do not have reactive implementations. What's more is it is generally + * considered best practice to store passwords in a hash that is intentionally slow which would block ever request + * from coming in unless it was put on another thread. + * + * @author Rob Winch + * @since 5.0 + */ +public class ReactiveAuthenticationManagerAdapter implements ReactiveAuthenticationManager { + private final AuthenticationManager authenticationManager; + + public ReactiveAuthenticationManagerAdapter(AuthenticationManager authenticationManager) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationManager = authenticationManager; + } + + @Override + public Mono authenticate(Authentication token) { + return Mono.just(token) + .publishOn(Schedulers.elastic()) + .flatMap( t -> { + try { + return Mono.just(authenticationManager.authenticate(t)); + } catch(Throwable error) { + return Mono.error(error); + } + }) + .filter( a -> a.isAuthenticated()); + } +} diff --git a/core/src/main/java/org/springframework/security/authentication/UserDetailsRepository.java b/core/src/main/java/org/springframework/security/authentication/UserDetailsRepository.java new file mode 100644 index 0000000000..5a6d4f4c5f --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/UserDetailsRepository.java @@ -0,0 +1,10 @@ +package org.springframework.security.authentication; + +import org.springframework.security.core.userdetails.UserDetails; + +import reactor.core.publisher.Mono; + +public interface UserDetailsRepository { + + Mono findByUsername(String username); +} diff --git a/core/src/main/java/org/springframework/security/authentication/UserDetailsRepositoryAuthenticationManager.java b/core/src/main/java/org/springframework/security/authentication/UserDetailsRepositoryAuthenticationManager.java new file mode 100644 index 0000000000..769c78bc24 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/UserDetailsRepositoryAuthenticationManager.java @@ -0,0 +1,47 @@ +/* + * + * * Copyright 2002-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.security.authentication; + +import org.springframework.security.core.Authentication; + +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class UserDetailsRepositoryAuthenticationManager implements ReactiveAuthenticationManager { + private final UserDetailsRepository repository; + + public UserDetailsRepositoryAuthenticationManager(UserDetailsRepository userDetailsRepository) { + Assert.notNull(userDetailsRepository, "userDetailsRepository cannot be null"); + this.repository = userDetailsRepository; + } + + @Override + public Mono authenticate(Authentication authentication) { + final String username = authentication.getName(); + return repository + .findByUsername(username) + .filter( u -> u.getPassword().equals(authentication.getCredentials())) + .switchIfEmpty( Mono.error(new BadCredentialsException("Invalid Credentials")) ) + .map( u -> new UsernamePasswordAuthenticationToken(u, u.getPassword(), u.getAuthorities()) ); + } +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthenticatedAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AuthenticatedAuthorizationManager.java new file mode 100644 index 0000000000..02ebc7d15c --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthenticatedAuthorizationManager.java @@ -0,0 +1,42 @@ +/* + * + * * Copyright 2002-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.security.authorization; + +import org.springframework.security.core.Authentication; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AuthenticatedAuthorizationManager implements ReactiveAuthorizationManager { + + @Override + public Mono check(Mono authentication, T object) { + return authentication + .map(a -> new AuthorizationDecision(a.isAuthenticated())) + .defaultIfEmpty(new AuthorizationDecision(false)); + } + + public static AuthenticatedAuthorizationManager authenticated() { + return new AuthenticatedAuthorizationManager<>(); + } + + private AuthenticatedAuthorizationManager() {} +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationManager.java new file mode 100644 index 0000000000..68a7d39913 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorityAuthorizationManager.java @@ -0,0 +1,56 @@ +/* + * + * * Copyright 2002-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.security.authorization; + +import org.springframework.security.core.Authentication; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AuthorityAuthorizationManager implements ReactiveAuthorizationManager { + private final String authority; + + private AuthorityAuthorizationManager(String authority) { + this.authority = authority; + } + + @Override + public Mono check(Mono authentication, T object) { + return authentication + .filter(a -> a.isAuthenticated()) + .flatMapIterable( a -> a.getAuthorities()) + .map( g-> g.getAuthority()) + .hasElement(this.authority) + .map( hasAuthority -> new AuthorizationDecision(hasAuthority)) + .defaultIfEmpty(new AuthorizationDecision(false)); + } + + public static AuthorityAuthorizationManager hasAuthority(String authority) { + Assert.notNull(authority, "authority cannot be null"); + return new AuthorityAuthorizationManager<>(authority); + } + + public static AuthorityAuthorizationManager hasRole(String role) { + Assert.notNull(role, "role cannot be null"); + return hasAuthority("ROLE_" + role); + } +} diff --git a/core/src/main/java/org/springframework/security/authorization/AuthorizationDecision.java b/core/src/main/java/org/springframework/security/authorization/AuthorizationDecision.java new file mode 100644 index 0000000000..70b21044bc --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/AuthorizationDecision.java @@ -0,0 +1,35 @@ +/* + * + * * Copyright 2002-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.security.authorization; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AuthorizationDecision { + private final boolean granted; + + public AuthorizationDecision(boolean granted) { + this.granted = granted; + } + + public boolean isGranted() { + return granted; + } +} diff --git a/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationManager.java b/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationManager.java new file mode 100644 index 0000000000..cde5e93d5e --- /dev/null +++ b/core/src/main/java/org/springframework/security/authorization/ReactiveAuthorizationManager.java @@ -0,0 +1,39 @@ +/* + * + * * Copyright 2002-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.security.authorization; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public interface ReactiveAuthorizationManager { + Mono check(Mono authentication, T object); + + default Mono verify(Mono authentication, T object) { + return check(authentication, object) + .filter( d -> d.isGranted()) + .switchIfEmpty( Mono.error(new AccessDeniedException("Access Denied")) ) + .flatMap( d -> Mono.empty() ); + } +} diff --git a/core/src/test/java/org/springframework/security/authentication/MapUserDetailsRepositoryTests.java b/core/src/test/java/org/springframework/security/authentication/MapUserDetailsRepositoryTests.java new file mode 100644 index 0000000000..f3977e21ea --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/MapUserDetailsRepositoryTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 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.security.authentication; + + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; + +import org.junit.Test; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +import reactor.core.publisher.Mono; + +public class MapUserDetailsRepositoryTests { + private static final UserDetails USER_DETAILS = User.withUsername("user") + .password("password") + .roles("USER") + .build(); + + private MapUserDetailsRepository users = new MapUserDetailsRepository(Arrays.asList(USER_DETAILS)); + + @Test(expected = IllegalArgumentException.class) + public void constructorNullUsers() { + Collection users = null; + new MapUserDetailsRepository(users); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorEmptyUsers() { + Collection users = Collections.emptyList(); + new MapUserDetailsRepository(users); + } + + @Test + public void findByUsernameWhenFoundThenReturns() { + assertThat((users.findByUsername(USER_DETAILS.getUsername()).block())).isEqualTo(USER_DETAILS); + } + + @Test + public void findByUsernameWhenDifferentCaseThenReturns() { + assertThat((users.findByUsername("uSeR").block())).isEqualTo(USER_DETAILS); + } + + @Test + public void findByUsernameWhenClearCredentialsThenFindByUsernameStillHasCredentials() { + User foundUser = users.findByUsername(USER_DETAILS.getUsername()).cast(User.class).block(); + assertThat(foundUser.getPassword()).isNotEmpty(); + foundUser.eraseCredentials(); + assertThat(foundUser.getPassword()).isNull(); + + foundUser = users.findByUsername(USER_DETAILS.getUsername()).cast(User.class).block(); + assertThat(foundUser.getPassword()).isNotEmpty(); + } + + @Test + public void findByUsernameWhenNotFoundThenEmpty() { + assertThat((users.findByUsername("notfound"))).isEqualTo(Mono.empty()); + } +} diff --git a/core/src/test/java/org/springframework/security/authentication/ReactiveAuthenticationManagerAdapterTests.java b/core/src/test/java/org/springframework/security/authentication/ReactiveAuthenticationManagerAdapterTests.java new file mode 100644 index 0000000000..24df3a3c3d --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ReactiveAuthenticationManagerAdapterTests.java @@ -0,0 +1,89 @@ +/* + * + * * Copyright 2002-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.security.authentication; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.security.core.Authentication; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.when; + + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class ReactiveAuthenticationManagerAdapterTests { + @Mock + AuthenticationManager delegate; + @Mock + Authentication authentication; + + ReactiveAuthenticationManagerAdapter manager; + + @Before + public void setup() { + manager = new ReactiveAuthenticationManagerAdapter(delegate); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorNullAuthenticationManager() { + new ReactiveAuthenticationManagerAdapter(null); + } + + @Test + public void authenticateWhenSuccessThenSucces() { + when(delegate.authenticate(any())).thenReturn(authentication); + when(authentication.isAuthenticated()).thenReturn(true); + + Authentication result = manager.authenticate(authentication).block(); + + assertThat(result).isEqualTo(authentication); + } + + @Test + public void authenticateWhenReturnNotAuthenticatedThenError() { + when(delegate.authenticate(any())).thenReturn(authentication); + + Authentication result = manager.authenticate(authentication).block(); + + assertThat(result).isNull(); + } + + @Test + public void authenticateWhenBadCredentialsThenError() { + when(delegate.authenticate(any())).thenThrow(new BadCredentialsException("Failed")); + when(authentication.isAuthenticated()).thenReturn(true); + + Mono result = manager.authenticate(authentication); + + StepVerifier.create(result) + .expectError(BadCredentialsException.class) + .verify(); + } +} diff --git a/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryAuthenticationManagerTests.java b/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryAuthenticationManagerTests.java new file mode 100644 index 0000000000..64da95587c --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryAuthenticationManagerTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2002-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.security.authentication; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.User; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class UserDetailsRepositoryAuthenticationManagerTests { + @Mock + UserDetailsRepository repository; + UserDetailsRepositoryAuthenticationManager manager; + String username; + String password; + + @Before + public void setup() { + manager = new UserDetailsRepositoryAuthenticationManager(repository); + username = "user"; + password = "pass"; + } + + @Test(expected = IllegalArgumentException.class) + public void constructorNullUserDetailsRepository() { + UserDetailsRepository udr = null; + new UserDetailsRepositoryAuthenticationManager(udr); + } + + @Test + public void authenticateWhenUserNotFoundThenBadCredentials() { + when(repository.findByUsername(username)).thenReturn(Mono.empty()); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); + Mono authentication = manager.authenticate(token); + + StepVerifier + .create(authentication) + .expectError(BadCredentialsException.class) + .verify(); + } + + @Test + public void authenticateWhenPasswordNotEqualThenBadCredentials() { + User user = new User(username, password, AuthorityUtils.createAuthorityList("ROLE_USER")); + when(repository.findByUsername(user.getUsername())).thenReturn(Mono.just(user)); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password + "INVALID"); + Mono authentication = manager.authenticate(token); + + StepVerifier + .create(authentication) + .expectError(BadCredentialsException.class) + .verify(); + } + + @Test + public void authenticateWhenSuccessThenSuccess() { + User user = new User(username, password, AuthorityUtils.createAuthorityList("ROLE_USER")); + when(repository.findByUsername(user.getUsername())).thenReturn(Mono.just(user)); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); + Authentication authentication = manager.authenticate(token).block(); + + assertThat(authentication).isEqualTo(authentication); + } + +} diff --git a/core/src/test/java/org/springframework/security/authorization/AuthenticatedAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/AuthenticatedAuthorizationManagerTests.java new file mode 100644 index 0000000000..6a3892c4d0 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/AuthenticatedAuthorizationManagerTests.java @@ -0,0 +1,77 @@ +/* + * + * * Copyright 2002-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.security.authorization; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.security.core.Authentication; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class AuthenticatedAuthorizationManagerTests { + @Mock + Authentication authentication; + + AuthenticatedAuthorizationManager manager = AuthenticatedAuthorizationManager.authenticated(); + + @Test + public void checkWhenAuthenticatedThenReturnTrue() { + when(authentication.isAuthenticated()).thenReturn(true); + + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isTrue(); + } + + @Test + public void checkWhenNotAuthenticatedThenReturnFalse() { + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + @Test + public void checkWhenEmptyThenReturnFalse() { + boolean granted = manager.check(Mono.empty(), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + + @Test + public void checkWhenErrorThenError() { + Mono result = manager.check(Mono.error(new RuntimeException("ooops")), null); + + StepVerifier + .create(result) + .expectError() + .verify(); + } +} diff --git a/core/src/test/java/org/springframework/security/authorization/AuthorityAuthorizationManagerTests.java b/core/src/test/java/org/springframework/security/authorization/AuthorityAuthorizationManagerTests.java new file mode 100644 index 0000000000..8e89eaccc1 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authorization/AuthorityAuthorizationManagerTests.java @@ -0,0 +1,133 @@ +/* + * + * * Copyright 2002-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.security.authorization; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.Collection; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.springframework.security.core.authority.AuthorityUtils.createAuthorityList; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class AuthorityAuthorizationManagerTests { + @Mock + Authentication authentication; + + AuthorityAuthorizationManager manager = AuthorityAuthorizationManager.hasAuthority("ADMIN"); + + @Test + public void checkWhenHasAuthorityAndNotAuthenticatedThenReturnFalse() { + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + @Test + public void checkWhenHasAuthorityAndEmptyThenReturnFalse() { + boolean granted = manager.check(Mono.empty(), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + @Test + public void checkWhenHasAuthorityAndErrorThenError() { + Mono result = manager.check(Mono.error(new RuntimeException("ooops")), null); + + StepVerifier + .create(result) + .expectError() + .verify(); + } + + @Test + public void checkWhenHasAuthorityAndAuthenticatedAndNoAuthoritiesThenReturnFalse() { + when(authentication.isAuthenticated()).thenReturn(true); + when(authentication.getAuthorities()).thenReturn(Collections.emptyList()); + + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + @Test + public void checkWhenHasAuthorityAndAuthenticatedAndWrongAuthoritiesThenReturnFalse() { + authentication = new TestingAuthenticationToken("rob", "secret", "ROLE_ADMIN"); + + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + @Test + public void checkWhenHasAuthorityAndAuthorizedThenReturnTrue() { + authentication = new TestingAuthenticationToken("rob", "secret", "ADMIN"); + + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isTrue(); + } + + @Test + public void checkWhenHasRoleAndAuthorizedThenReturnTrue() { + manager = AuthorityAuthorizationManager.hasRole("ADMIN"); + authentication = new TestingAuthenticationToken("rob", "secret", "ROLE_ADMIN"); + + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isTrue(); + } + + @Test + public void checkWhenHasRoleAndNotAuthorizedThenReturnTrue() { + manager = AuthorityAuthorizationManager.hasRole("ADMIN"); + authentication = new TestingAuthenticationToken("rob", "secret", "ADMIN"); + + boolean granted = manager.check(Mono.just(authentication), null).block().isGranted(); + + assertThat(granted).isFalse(); + } + + @Test(expected = IllegalArgumentException.class) + public void hasRoleWhenNullThenException() { + String role = null; + AuthorityAuthorizationManager.hasRole(role); + } + + @Test(expected = IllegalArgumentException.class) + public void hasAuthorityWhenNullThenException() { + String authority = null; + AuthorityAuthorizationManager.hasAuthority(authority); + } +} diff --git a/gradle/dependency-management.gradle b/gradle/dependency-management.gradle index b3c6cb2b8f..eee22cb6a9 100644 --- a/gradle/dependency-management.gradle +++ b/gradle/dependency-management.gradle @@ -1,6 +1,7 @@ dependencyManagement { dependencies { dependency 'cglib:cglib-nodep:3.2.5' + dependency 'com.squareup.okhttp3:mockwebserver:3.7.0' dependency 'opensymphony:sitemesh:2.4.2' dependency 'org.gebish:geb-spock:0.10.0' dependency 'org.jasig.cas:cas-server-webapp:4.0.0' @@ -26,6 +27,7 @@ dependencyManagement { dependencyManagement { imports { + mavenBom 'io.projectreactor:reactor-bom:Bismuth-M1' mavenBom 'org.springframework.data:spring-data-releasetrain:Kay-M3' mavenBom 'org.springframework:spring-framework-bom:5.0.0.RC1' } diff --git a/samples/javaconfig/hellowebflux/spring-security-samples-javaconfig-hellowebflux.gradle b/samples/javaconfig/hellowebflux/spring-security-samples-javaconfig-hellowebflux.gradle new file mode 100644 index 0000000000..2b6dd88356 --- /dev/null +++ b/samples/javaconfig/hellowebflux/spring-security-samples-javaconfig-hellowebflux.gradle @@ -0,0 +1,16 @@ +apply plugin: 'io.spring.convention.spring-sample' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-config') + compile project(':spring-security-webflux') + compile 'com.fasterxml.jackson.core:jackson-databind' + compile 'io.netty:netty-buffer' + compile 'io.projectreactor.ipc:reactor-netty' + compile 'org.springframework:spring-context' + compile 'org.springframework:spring-webflux' + + testCompile 'io.projectreactor.addons:reactor-test' + testCompile 'org.skyscreamer:jsonassert' + testCompile 'org.springframework:spring-test' +} diff --git a/samples/javaconfig/hellowebflux/src/integration-test/java/sample/SecurityTests.java b/samples/javaconfig/hellowebflux/src/integration-test/java/sample/SecurityTests.java new file mode 100644 index 0000000000..4ad395f119 --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/integration-test/java/sample/SecurityTests.java @@ -0,0 +1,221 @@ +/* + * Copyright 2002-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 sample; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.security.web.server.header.ContentTypeOptionsHttpHeadersWriter; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.reactive.server.ExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; + +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Base64; + +import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = Application.class) +@TestPropertySource(properties = "server.port=0") +public class SecurityTests { + @Value("#{@nettyContext.address().getPort()}") + int port; + + WebTestClient rest; + + @Before + public void setup() { + this.rest = WebTestClient.bindToServer() + .responseTimeout(Duration.ofDays(1)) + .baseUrl("http://localhost:" + this.port) + .build(); + } + + @Test + public void basicRequired() throws Exception { + this.rest + .get() + .uri("/users") + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + public void basicWorks() throws Exception { + this.rest + .filter(robsCredentials()) + .get() + .uri("/users") + .exchange() + .expectStatus().isOk() + .expectBody().json("[{\"id\":null,\"username\":\"rob\",\"password\":\"rob\",\"firstname\":\"Rob\",\"lastname\":\"Winch\"},{\"id\":null,\"username\":\"admin\",\"password\":\"admin\",\"firstname\":\"Admin\",\"lastname\":\"User\"}]"); + } + + @Test + public void basicWhenPasswordInvalid401() throws Exception { + this.rest + .filter(invalidPassword()) + .get() + .uri("/users") + .exchange() + .expectStatus().isUnauthorized() + .expectBody().isEmpty(); + } + + @Test + public void authorizationAdmin403() throws Exception { + this.rest + .filter(robsCredentials()) + .get() + .uri("/admin") + .exchange() + .expectStatus().isEqualTo(HttpStatus.FORBIDDEN) + .expectBody().isEmpty(); + } + + @Test + public void authorizationAdmin200() throws Exception { + this.rest + .filter(adminCredentials()) + .get() + .uri("/admin") + .exchange() + .expectStatus().isOk(); + } + + @Test + public void basicMissingUser401() throws Exception { + this.rest + .filter(basicAuthentication("missing-user", "password")) + .get() + .uri("/admin") + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + public void basicInvalidPassword401() throws Exception { + this.rest + .filter(invalidPassword()) + .get() + .uri("/admin") + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + public void basicInvalidParts401() throws Exception { + this.rest + .get() + .uri("/admin") + .header("Authorization", "Basic " + base64Encode("no colon")) + .exchange() + .expectStatus().isUnauthorized(); + } + + @Test + public void sessionWorks() throws Exception { + ExchangeResult result = this.rest + .filter(robsCredentials()) + .get() + .uri("/users") + .exchange() + .returnResult(String.class); + + String session = result.getResponseHeaders().getFirst("Set-Cookie"); + + this.rest + .get() + .uri("/users") + .header("Cookie", session) + .exchange() + .expectStatus().isOk(); + } + + @Test + public void me() throws Exception { + this.rest + .filter(robsCredentials()) + .get() + .uri("/me") + .exchange() + .expectStatus().isOk() + .expectBody().json("{\"username\" : \"rob\"}"); + } + + @Test + public void monoMe() throws Exception { + this.rest + .filter(robsCredentials()) + .get() + .uri("/mono/me") + .exchange() + .expectStatus().isOk() + .expectBody().json("{\"username\" : \"rob\"}"); + } + + @Test + public void principal() throws Exception { + this.rest + .filter(robsCredentials()) + .get() + .uri("/principal") + .exchange() + .expectStatus().isOk() + .expectBody().json("{\"username\" : \"rob\"}"); + } + + @Test + public void headers() throws Exception { + this.rest + .filter(robsCredentials()) + .get() + .uri("/principal") + .exchange() + .expectHeader().valueEquals(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, max-age=0, must-revalidate") + .expectHeader().valueEquals(HttpHeaders.EXPIRES, "0") + .expectHeader().valueEquals(HttpHeaders.PRAGMA, "no-cache") + .expectHeader().valueEquals(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS, ContentTypeOptionsHttpHeadersWriter.NOSNIFF); + } + + private ExchangeFilterFunction robsCredentials() { + return basicAuthentication("rob","rob"); + } + + private ExchangeFilterFunction invalidPassword() { + return basicAuthentication("rob","INVALID"); + } + + private ExchangeFilterFunction adminCredentials() { + return basicAuthentication("admin","admin"); + } + + private String base64Encode(String value) { + return Base64.getEncoder().encodeToString(value.getBytes(Charset.defaultCharset())); + } +} diff --git a/samples/javaconfig/hellowebflux/src/integration-test/java/sample/UserRepositoryTests.java b/samples/javaconfig/hellowebflux/src/integration-test/java/sample/UserRepositoryTests.java new file mode 100644 index 0000000000..f77c7fefa9 --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/integration-test/java/sample/UserRepositoryTests.java @@ -0,0 +1,51 @@ +/* + * Copyright 2002-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 sample; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +@SuppressWarnings("unused") +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = Application.class) +@TestPropertySource(properties = "server.port=0") +public class UserRepositoryTests { + + @Autowired UserRepository repository; + + String robUsername = "rob"; + + @Test + public void findByUsernameWhenUsernameMatchesThenFound() { + assertThat(repository.findByUsername(this.robUsername).block()).isNotNull(); + } + + @Test + public void findByUsernameWhenUsernameDoesNotMatchThenFound() { + assertThat(repository.findByUsername(this.robUsername + "NOTFOUND").block()).isNull(); + } +} diff --git a/samples/javaconfig/hellowebflux/src/main/java/sample/Application.java b/samples/javaconfig/hellowebflux/src/main/java/sample/Application.java new file mode 100644 index 0000000000..6b2d40eee9 --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/main/java/sample/Application.java @@ -0,0 +1,111 @@ +/* + * Copyright 2002-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 sample; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.UserDetailsRepositoryAuthenticationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.config.web.server.AuthorizeExchangeBuilder; +import org.springframework.security.config.web.server.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.reactive.result.method.annotation.AuthenticationPrincipalArgumentResolver; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import org.springframework.security.web.server.context.WebSessionSecurityContextRepository; +import org.springframework.web.reactive.DispatcherHandler; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.config.WebFluxConfigurer; +import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer; +import org.springframework.web.server.WebFilter; +import reactor.core.publisher.Mono; +import reactor.ipc.netty.NettyContext; +import reactor.ipc.netty.http.server.HttpServer; + +import static org.springframework.security.config.web.server.HttpSecurity.http; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Configuration +@EnableWebFlux +@ComponentScan +public class Application implements WebFluxConfigurer { + @Value("${server.port:8080}") + private int port = 8080; + + @Autowired + private ReactiveAdapterRegistry adapterRegistry = new ReactiveAdapterRegistry(); + + public static void main(String[] args) throws Exception { + try(AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Application.class)) { + context.getBean(NettyContext.class).onClose().block(); + } + } + + @Override + public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) { + configurer.addCustomResolver(authenticationPrincipalArgumentResolver()); + } + + @Bean + public NettyContext nettyContext(ApplicationContext context) { + HttpHandler handler = DispatcherHandler.toHttpHandler(context); + ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(handler); + HttpServer httpServer = HttpServer.create("localhost", port); + return httpServer.newHandler(adapter).block(); + } + + @Bean + public AuthenticationPrincipalArgumentResolver authenticationPrincipalArgumentResolver() { + return new AuthenticationPrincipalArgumentResolver(adapterRegistry); + } + + @Bean + WebFilter springSecurityFilterChain(ReactiveAuthenticationManager manager) throws Exception { + HttpSecurity http = http(); + http.securityContextRepository(new WebSessionSecurityContextRepository()); + http.authenticationManager(manager); + http.httpBasic(); + + AuthorizeExchangeBuilder authorize = http.authorizeExchange(); + authorize.antMatchers("/admin/**").hasRole("ADMIN"); + authorize.antMatchers("/users/{user}/**").access(this::currentUserMatchesPath); + authorize.anyExchange().authenticated(); + return http.build(); + } + + private Mono currentUserMatchesPath(Mono authentication, AuthorizationContext context) { + return authentication + .map( a -> context.getVariables().get("user").equals(a.getName())) + .map( granted -> new AuthorizationDecision(granted)); + } + + @Bean + public ReactiveAuthenticationManager authenticationManager(UserRepositoryUserDetailsRepository udr) { + return new UserDetailsRepositoryAuthenticationManager(udr); + } +} diff --git a/samples/javaconfig/hellowebflux/src/main/java/sample/MapUserRepository.java b/samples/javaconfig/hellowebflux/src/main/java/sample/MapUserRepository.java new file mode 100644 index 0000000000..ed775a1b33 --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/main/java/sample/MapUserRepository.java @@ -0,0 +1,58 @@ +/* + * + * * Copyright 2002-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 sample; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Service +public class MapUserRepository implements UserRepository { + private final Map users = new HashMap<>(); + + public MapUserRepository() { + save(new User("rob", "rob", "Rob", "Winch")).block(); + save(new User("admin", "admin", "Admin", "User")).block(); + } + + @Override + public Flux findAll() { + return Flux.fromIterable(users.values()); + } + + @Override + public Mono findByUsername(String username) { + User result = users.get(username); + + return result == null ? Mono.empty() : Mono.just(result); + } + + public Mono save(User user) { + users.put(user.getUsername(), user); + return Mono.just(user); + } +} diff --git a/samples/javaconfig/hellowebflux/src/main/java/sample/User.java b/samples/javaconfig/hellowebflux/src/main/java/sample/User.java new file mode 100644 index 0000000000..ef81772c6b --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/main/java/sample/User.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-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 sample; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class User { + + private Long id; + private String username; + private String password; + private String firstname; + private String lastname; + + public User() {} + + public User(User copy) { + this(copy.getUsername(), copy.getPassword(), copy.getFirstname(), copy.getLastname()); + } + + public User(String username, String password, String firstname, String lastname) { + super(); + this.username = username; + this.password = password; + this.firstname = firstname; + this.lastname = lastname; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } +} diff --git a/samples/javaconfig/hellowebflux/src/main/java/sample/UserController.java b/samples/javaconfig/hellowebflux/src/main/java/sample/UserController.java new file mode 100644 index 0000000000..f8fe22aae9 --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/main/java/sample/UserController.java @@ -0,0 +1,77 @@ +/* + * Copyright 2002-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 sample; + +import java.security.Principal; +import java.util.Collections; +import java.util.Map; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import org.springframework.web.server.WebSession; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RestController +public class UserController { + private final UserRepository users; + + public UserController(UserRepository users) { + this.users = users; + } + + @GetMapping("/me") + public Mono> me(@AuthenticationPrincipal User user) { + return me(Mono.just(user)); + } + + @GetMapping("/mono/me") + public Mono> me(@AuthenticationPrincipal Mono user) { + return user.flatMap( u -> Mono.just(Collections.singletonMap("username", u.getUsername()))); + } + + @GetMapping("/mono/session") + public Mono> Session(Mono session) { + return session.flatMap( s -> Mono.just(s.getAttributes())); + } + + @GetMapping("/users") + public Flux users() { + return this.users.findAll(); + } + + @GetMapping("/principal") + public Mono> principal(Principal principal) { + return principal(Mono.just(principal)); + } + + @GetMapping("/mono/principal") + public Mono> principal(Mono principal) { + return principal.flatMap( p -> Mono.just(Collections.singletonMap("username", p.getName()))); + } + + @GetMapping("/admin") + public Map admin() { + return Collections.singletonMap("isadmin", "true"); + } +} diff --git a/samples/javaconfig/hellowebflux/src/main/java/sample/UserRepository.java b/samples/javaconfig/hellowebflux/src/main/java/sample/UserRepository.java new file mode 100644 index 0000000000..58fd96b437 --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/main/java/sample/UserRepository.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-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 sample; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public interface UserRepository { + + Flux findAll(); + + Mono findByUsername(String username); + + Mono save(User user); +} diff --git a/samples/javaconfig/hellowebflux/src/main/java/sample/UserRepositoryUserDetailsRepository.java b/samples/javaconfig/hellowebflux/src/main/java/sample/UserRepositoryUserDetailsRepository.java new file mode 100644 index 0000000000..d475e7b10b --- /dev/null +++ b/samples/javaconfig/hellowebflux/src/main/java/sample/UserRepositoryUserDetailsRepository.java @@ -0,0 +1,87 @@ +/* + * Copyright 2002-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 sample; + +import java.util.Collection; +import java.util.List; + +import org.springframework.security.authentication.UserDetailsRepository; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +@Component +public class UserRepositoryUserDetailsRepository implements UserDetailsRepository { + private final UserRepository users; + + public UserRepositoryUserDetailsRepository(UserRepository users) { + super(); + this.users = users; + } + + @Override + public Mono findByUsername(String username) { + return this.users + .findByUsername(username) + .map(UserDetailsAdapter::new); + } + + @SuppressWarnings("serial") + private static class UserDetailsAdapter extends User implements UserDetails { + private static List USER_ROLES = AuthorityUtils.createAuthorityList("ROLE_USER"); + private static List ADMIN_ROLES = AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER"); + + private UserDetailsAdapter(User delegate) { + super(delegate); + } + + @Override + public Collection getAuthorities() { + return isAdmin() ? ADMIN_ROLES : USER_ROLES ; + } + + private boolean isAdmin() { + return getUsername().contains("admin"); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + } +} diff --git a/samples/javaconfig/messages/spring-security-samples-javaconfig-messages.gradle b/samples/javaconfig/messages/spring-security-samples-javaconfig-messages.gradle index 4be98f7716..5f3214cc8a 100644 --- a/samples/javaconfig/messages/spring-security-samples-javaconfig-messages.gradle +++ b/samples/javaconfig/messages/spring-security-samples-javaconfig-messages.gradle @@ -25,5 +25,5 @@ dependencies { compile 'org.springframework:spring-webmvc' compile 'org.thymeleaf:thymeleaf-spring5' - provided 'javax.servlet:javax.servlet-api' + providedCompile 'javax.servlet:javax.servlet-api' } diff --git a/webflux/spring-security-webflux.gradle b/webflux/spring-security-webflux.gradle new file mode 100644 index 0000000000..f4061056ac --- /dev/null +++ b/webflux/spring-security-webflux.gradle @@ -0,0 +1,13 @@ +apply plugin: 'io.spring.convention.spring-module' + +dependencies { + compile project(':spring-security-core') + compile project(':spring-security-web') + compile 'org.springframework:spring-webflux' + + testCompile 'io.projectreactor.addons:reactor-test' + testCompile 'org.springframework:spring-test' + + integrationTestCompile 'com.squareup.okhttp3:mockwebserver' + integrationTestCompile 'io.projectreactor.ipc:reactor-netty' +} diff --git a/webflux/src/integration-test/java/webclient/oauth2/poc/WebClientOAuth2PocTests.java b/webflux/src/integration-test/java/webclient/oauth2/poc/WebClientOAuth2PocTests.java new file mode 100644 index 0000000000..0bf0efd0a1 --- /dev/null +++ b/webflux/src/integration-test/java/webclient/oauth2/poc/WebClientOAuth2PocTests.java @@ -0,0 +1,103 @@ +/* + * + * * Copyright 2002-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 webclient.oauth2.poc; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class WebClientOAuth2PocTests { + + private MockWebServer server; + + private WebClient webClient; + + + @Before + public void setup() { + this.server = new MockWebServer(); + String baseUrl = this.server.url("/").toString(); + this.webClient = WebClient.create(baseUrl); + } + + @After + public void shutdown() throws Exception { + this.server.shutdown(); + } + + @Test + public void httpBasicWhenNeeded() throws Exception { + this.server.enqueue(new MockResponse().setResponseCode(401).setHeader("WWW-Authenticate", "Basic realm=\"Test\"")); + this.server.enqueue(new MockResponse().setResponseCode(200).setBody("OK")); + + ClientResponse response = this.webClient + .filter(basicIfNeeded("rob", "rob")) + .get() + .uri("/") + .exchange() + .block(); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + + assertThat(this.server.takeRequest().getHeader("Authorization")).isNull(); + assertThat(this.server.takeRequest().getHeader("Authorization")).isEqualTo("Basic cm9iOnJvYg=="); + } + + + @Test + public void httpBasicWhenNotNeeded() throws Exception { + this.server.enqueue(new MockResponse().setResponseCode(200).setBody("OK")); + + ClientResponse response = this.webClient + .filter(basicIfNeeded("rob", "rob")) + .get() + .uri("/") + .exchange() + .block(); + + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK); + + assertThat(this.server.getRequestCount()).isEqualTo(1); + assertThat(this.server.takeRequest().getHeader("Authorization")).isNull(); + } + + private ExchangeFilterFunction basicIfNeeded(String username, String password) { + return (request, next) -> + next.exchange(request) + .filter( r -> !HttpStatus.UNAUTHORIZED.equals(r.statusCode())) + .switchIfEmpty( Mono.defer(() -> { + return basicAuthentication(username, password).filter(request, next); + })); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/reactive/result/method/annotation/AuthenticationPrincipalArgumentResolver.java b/webflux/src/main/java/org/springframework/security/web/reactive/result/method/annotation/AuthenticationPrincipalArgumentResolver.java new file mode 100644 index 0000000000..b42407d46a --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/reactive/result/method/annotation/AuthenticationPrincipalArgumentResolver.java @@ -0,0 +1,115 @@ +/* + * + * * Copyright 2002-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.security.web.reactive.result.method.annotation; + +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.expression.BeanResolver; +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolverSupport; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.lang.annotation.Annotation; + +/** + * Resolves the Authentication + * @author Rob Winch + * @since 5.0 + */ +public class AuthenticationPrincipalArgumentResolver extends HandlerMethodArgumentResolverSupport { + + private ExpressionParser parser = new SpelExpressionParser(); + + private BeanResolver beanResolver; + + public AuthenticationPrincipalArgumentResolver(ReactiveAdapterRegistry adapterRegistry) { + super(adapterRegistry); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return findMethodAnnotation(AuthenticationPrincipal.class, parameter) != null; + } + + @Override + public Mono resolveArgument(MethodParameter parameter, BindingContext bindingContext, + ServerWebExchange exchange) { + ReactiveAdapter adapter = getAdapterRegistry().getAdapter(parameter.getParameterType()); + return exchange.getPrincipal() + .ofType(Authentication.class) + .flatMap( a -> { + Object p = resolvePrincipal(parameter, a.getPrincipal()); + Mono principal = Mono.justOrEmpty(p); + return adapter == null ? principal : Mono.just(adapter.fromPublisher(principal)); + }); + } + + private Object resolvePrincipal(MethodParameter parameter, Object principal) { + AuthenticationPrincipal authPrincipal = findMethodAnnotation( + AuthenticationPrincipal.class, parameter); + + String expressionToParse = authPrincipal.expression(); + if (StringUtils.hasLength(expressionToParse)) { + StandardEvaluationContext context = new StandardEvaluationContext(); + context.setRootObject(principal); + context.setVariable("this", principal); + context.setBeanResolver(beanResolver); + + Expression expression = this.parser.parseExpression(expressionToParse); + principal = expression.getValue(context); + } + + return principal; + } + + /** + * Obtains the specified {@link Annotation} on the specified {@link MethodParameter}. + * + * @param annotationClass the class of the {@link Annotation} to find on the + * {@link MethodParameter} + * @param parameter the {@link MethodParameter} to search for an {@link Annotation} + * @return the {@link Annotation} that was found or null. + */ + private T findMethodAnnotation(Class annotationClass, + MethodParameter parameter) { + T annotation = parameter.getParameterAnnotation(annotationClass); + if (annotation != null) { + return annotation; + } + Annotation[] annotationsToSearch = parameter.getParameterAnnotations(); + for (Annotation toSearch : annotationsToSearch) { + annotation = AnnotationUtils.findAnnotation(toSearch.annotationType(), + annotationClass); + if (annotation != null) { + return annotation; + } + } + return null; + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/AuthenticationEntryPoint.java b/webflux/src/main/java/org/springframework/security/web/server/AuthenticationEntryPoint.java new file mode 100644 index 0000000000..de4305aaa5 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/AuthenticationEntryPoint.java @@ -0,0 +1,34 @@ +/* + * + * * Copyright 2002-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.security.web.server; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.server.ServerWebExchange; + + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public interface AuthenticationEntryPoint { + + Mono commence(ServerWebExchange exchange, AuthenticationException e); +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/HttpBasicAuthenticationConverter.java b/webflux/src/main/java/org/springframework/security/web/server/HttpBasicAuthenticationConverter.java new file mode 100644 index 0000000000..3a8bbcb911 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/HttpBasicAuthenticationConverter.java @@ -0,0 +1,73 @@ +/* + * + * * Copyright 2002-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.security.web.server; + +import java.util.Base64; +import java.util.function.Function; + +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class HttpBasicAuthenticationConverter implements Function> { + + public static final String BASIC = "Basic "; + + @Override + public Mono apply(ServerWebExchange serverWebExchange) { + ServerHttpRequest request = serverWebExchange.getRequest(); + + String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if(authorization == null) { + return Mono.empty(); + } + + String credentials = authorization.length() <= BASIC.length() ? + "" : authorization.substring(BASIC.length(), authorization.length()); + byte[] decodedCredentials = base64Decode(credentials); + String decodedAuthz = new String(decodedCredentials); + String[] userParts = decodedAuthz.split(":"); + + if(userParts.length != 2) { + return Mono.empty(); + } + + String username = userParts[0]; + String password = userParts[1]; + + return Mono.just(new UsernamePasswordAuthenticationToken(username, password)); + } + + private byte[] base64Decode(String value) { + try { + return Base64.getDecoder().decode(value); + } catch(Exception e) { + return new byte[0]; + } + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/WebFilterChainFilter.java b/webflux/src/main/java/org/springframework/security/web/server/WebFilterChainFilter.java new file mode 100644 index 0000000000..903af6b457 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/WebFilterChainFilter.java @@ -0,0 +1,68 @@ +/* + * + * * Copyright 2002-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.security.web.server; + +import java.util.Iterator; +import java.util.List; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class WebFilterChainFilter implements WebFilter { + private final List filters; + + public WebFilterChainFilter(List filters) { + super(); + this.filters = filters; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + SecurityWebFilterChain delegate = new SecurityWebFilterChain(chain, filters.iterator()); + return delegate.filter(exchange); + } + + static class SecurityWebFilterChain implements WebFilterChain { + private final WebFilterChain delegate; + private final Iterator filters; + + public SecurityWebFilterChain(WebFilterChain delegate, Iterator filters) { + super(); + this.delegate = delegate; + this.filters = filters; + } + + @Override + public Mono filter(ServerWebExchange exchange) { + if (filters.hasNext()) { + WebFilter filter = filters.next(); + return filter.filter(exchange, this); + } else { + return delegate.filter(exchange); + } + } + + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authentication/AuthenticationSuccessHandler.java b/webflux/src/main/java/org/springframework/security/web/server/authentication/AuthenticationSuccessHandler.java new file mode 100644 index 0000000000..6761115c15 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authentication/AuthenticationSuccessHandler.java @@ -0,0 +1,32 @@ +/* + * + * * Copyright 2002-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.security.web.server.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public interface AuthenticationSuccessHandler { + Mono success(Authentication authentication, ServerWebExchange exchange, WebFilterChain chain); +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java b/webflux/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java new file mode 100644 index 0000000000..4aca0b1830 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authentication/AuthenticationWebFilter.java @@ -0,0 +1,75 @@ +/* + * + * * Copyright 2002-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.security.web.server.authentication; + +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.security.web.server.HttpBasicAuthenticationConverter; +import org.springframework.security.web.server.authentication.www.HttpBasicAuthenticationEntryPoint; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class AuthenticationWebFilter implements WebFilter { + + private final ReactiveAuthenticationManager authenticationManager; + + private AuthenticationSuccessHandler authenticationSuccessHandler = new DefaultAuthenticationSuccessHandler(); + + private Function> authenticationConverter = new HttpBasicAuthenticationConverter(); + + private AuthenticationEntryPoint entryPoint = new HttpBasicAuthenticationEntryPoint(); + + public AuthenticationWebFilter(ReactiveAuthenticationManager authenticationManager) { + Assert.notNull(authenticationManager, "authenticationManager cannot be null"); + this.authenticationManager = authenticationManager; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return authenticationConverter.apply(exchange) + .switchIfEmpty(Mono.defer(() -> chain.filter(exchange).cast(Authentication.class))) + .flatMap( token -> authenticationManager.authenticate(token) + .flatMap(authentication -> authenticationSuccessHandler.success(authentication, exchange, chain)) + .onErrorResume( AuthenticationException.class, t -> entryPoint.commence(exchange, t)) + ); + } + + public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) { + this.authenticationSuccessHandler = authenticationSuccessHandler; + } + + public void setAuthenticationConverter(Function> authenticationConverter) { + this.authenticationConverter = authenticationConverter; + } + + public void setEntryPoint(AuthenticationEntryPoint entryPoint) { + this.entryPoint = entryPoint; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authentication/DefaultAuthenticationSuccessHandler.java b/webflux/src/main/java/org/springframework/security/web/server/authentication/DefaultAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..097e9bce5f --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authentication/DefaultAuthenticationSuccessHandler.java @@ -0,0 +1,56 @@ +/* + * + * * Copyright 2002-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.security.web.server.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextImpl; +import org.springframework.security.web.server.context.SecurityContextRepository; +import org.springframework.security.web.server.context.ServerWebExchangeAttributeSecurityContextRepository; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class DefaultAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + private SecurityContextRepository securityContextRepository = new ServerWebExchangeAttributeSecurityContextRepository(); + + private AuthenticationSuccessHandler delegate = new WebFilterChainAuthenticationSuccessHandler(); + + @Override + public Mono success(Authentication authentication, ServerWebExchange exchange, WebFilterChain chain) { + SecurityContextImpl securityContext = new SecurityContextImpl(); + securityContext.setAuthentication(authentication); + return securityContextRepository.save(exchange, securityContext) + .flatMap( wrappedExchange -> delegate.success(authentication, wrappedExchange, chain)); + } + + public void setDelegate(AuthenticationSuccessHandler delegate) { + Assert.notNull(delegate, "delegate cannot be null"); + this.delegate = delegate; + } + + public void setSecurityContextRepository(SecurityContextRepository securityContextRepository) { + Assert.notNull(securityContextRepository, "securityContextRepository cannot be null"); + this.securityContextRepository = securityContextRepository; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authentication/WebFilterChainAuthenticationSuccessHandler.java b/webflux/src/main/java/org/springframework/security/web/server/authentication/WebFilterChainAuthenticationSuccessHandler.java new file mode 100644 index 0000000000..e56f11688c --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authentication/WebFilterChainAuthenticationSuccessHandler.java @@ -0,0 +1,35 @@ +/* + * + * * Copyright 2002-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.security.web.server.authentication; + +import org.springframework.security.core.Authentication; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class WebFilterChainAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + @Override + public Mono success(Authentication authentication, ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(exchange); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authentication/www/HttpBasicAuthenticationEntryPoint.java b/webflux/src/main/java/org/springframework/security/web/server/authentication/www/HttpBasicAuthenticationEntryPoint.java new file mode 100644 index 0000000000..040833e8bd --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authentication/www/HttpBasicAuthenticationEntryPoint.java @@ -0,0 +1,42 @@ +/* + * + * * Copyright 2002-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.security.web.server.authentication.www; + +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class HttpBasicAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public Mono commence(ServerWebExchange exchange, AuthenticationException e) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().set("WWW-Authenticate", "Basic realm=\"Realm\""); + return Mono.empty(); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authorization/AccessDeniedHandler.java b/webflux/src/main/java/org/springframework/security/web/server/authorization/AccessDeniedHandler.java new file mode 100644 index 0000000000..d06d63746f --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authorization/AccessDeniedHandler.java @@ -0,0 +1,33 @@ +/* + * + * * Copyright 2002-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.security.web.server.authorization; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public interface AccessDeniedHandler { + + Mono handle(ServerWebExchange exchange, AccessDeniedException denied); +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authorization/AuthorizationContext.java b/webflux/src/main/java/org/springframework/security/web/server/authorization/AuthorizationContext.java new file mode 100644 index 0000000000..99f9f2ed91 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authorization/AuthorizationContext.java @@ -0,0 +1,50 @@ +/* + * + * * Copyright 2002-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.security.web.server.authorization; + +import org.springframework.web.server.ServerWebExchange; + +import java.util.Collections; +import java.util.Map; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AuthorizationContext { + private final ServerWebExchange exchange; + private final Map variables; + + public AuthorizationContext(ServerWebExchange exchange) { + this(exchange, Collections.emptyMap()); + } + + public AuthorizationContext(ServerWebExchange exchange, Map variables) { + this.exchange = exchange; + this.variables = variables; + } + + public ServerWebExchange getExchange() { + return exchange; + } + + public Map getVariables() { + return Collections.unmodifiableMap(variables); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java b/webflux/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java new file mode 100644 index 0000000000..25aada0964 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authorization/AuthorizationWebFilter.java @@ -0,0 +1,48 @@ +/* + * + * * Copyright 2002-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.security.web.server.authorization; + + +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.www.HttpBasicAuthenticationEntryPoint; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class AuthorizationWebFilter implements WebFilter { + private ReactiveAuthorizationManager accessDecisionManager; + + public AuthorizationWebFilter(ReactiveAuthorizationManager accessDecisionManager) { + this.accessDecisionManager = accessDecisionManager; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return accessDecisionManager.verify(exchange.getPrincipal(), exchange) + .switchIfEmpty( Mono.defer(() -> chain.filter(exchange)) ); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authorization/DelegatingReactiveAuthorizationManager.java b/webflux/src/main/java/org/springframework/security/web/server/authorization/DelegatingReactiveAuthorizationManager.java new file mode 100644 index 0000000000..0b9728487e --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authorization/DelegatingReactiveAuthorizationManager.java @@ -0,0 +1,75 @@ +/* + * + * * Copyright 2002-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.security.web.server.authorization; + +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class DelegatingReactiveAuthorizationManager implements ReactiveAuthorizationManager { + private final LinkedHashMap> mappings; + + private DelegatingReactiveAuthorizationManager(LinkedHashMap> mappings) { + this.mappings = mappings; + } + + @Override + public Mono check(Mono authentication, ServerWebExchange exchange) { + for(Map.Entry> entry : mappings.entrySet()) { + ServerWebExchangeMatcher matcher = entry.getKey(); + ServerWebExchangeMatcher.MatchResult match = matcher.matches(exchange); + if(match.isMatch()) { + Map variables = match.getVariables(); + AuthorizationContext context = new AuthorizationContext(exchange, variables); + return entry.getValue().check(authentication, context); + } + } + return Mono.just(new AuthorizationDecision(false)); + } + + public static DelegatingReactiveAuthorizationManager.Builder builder() { + return new DelegatingReactiveAuthorizationManager.Builder(); + } + + public static class Builder { + private final LinkedHashMap> mappings = new LinkedHashMap<>(); + + private Builder() { + } + + public DelegatingReactiveAuthorizationManager.Builder add(ServerWebExchangeMatcher matcher, ReactiveAuthorizationManager manager) { + this.mappings.put(matcher, manager); + return this; + } + + public DelegatingReactiveAuthorizationManager build() { + return new DelegatingReactiveAuthorizationManager(mappings); + } + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authorization/ExceptionTranslationWebFilter.java b/webflux/src/main/java/org/springframework/security/web/server/authorization/ExceptionTranslationWebFilter.java new file mode 100644 index 0000000000..cf400875b7 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authorization/ExceptionTranslationWebFilter.java @@ -0,0 +1,51 @@ +/* + * + * * Copyright 2002-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.security.web.server.authorization; + + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.security.web.server.authentication.www.HttpBasicAuthenticationEntryPoint; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class ExceptionTranslationWebFilter implements WebFilter { + private AuthenticationEntryPoint entryPoint = new HttpBasicAuthenticationEntryPoint(); + + private AccessDeniedHandler accessDeniedHandler = new HttpStatusAccessDeniedHandler(HttpStatus.FORBIDDEN); + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(exchange) + .onErrorResume(AccessDeniedException.class, denied -> { + return exchange.getPrincipal() + .switchIfEmpty( Mono.defer( () -> entryPoint.commence(exchange, new AuthenticationCredentialsNotFoundException("Not Authenticated", denied)))) + .flatMap( principal -> accessDeniedHandler.handle(exchange, denied)); + }); + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/authorization/HttpStatusAccessDeniedHandler.java b/webflux/src/main/java/org/springframework/security/web/server/authorization/HttpStatusAccessDeniedHandler.java new file mode 100644 index 0000000000..2996952eaa --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/authorization/HttpStatusAccessDeniedHandler.java @@ -0,0 +1,43 @@ +/* + * + * * Copyright 2002-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.security.web.server.authorization; + +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class HttpStatusAccessDeniedHandler implements AccessDeniedHandler { + private final HttpStatus httpStatus; + + public HttpStatusAccessDeniedHandler(HttpStatus httpStatus) { + this.httpStatus = httpStatus; + } + + @Override + public Mono handle(ServerWebExchange exchange, AccessDeniedException e) { + exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN); + return Mono.empty(); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepository.java b/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepository.java new file mode 100644 index 0000000000..7d31efa538 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepository.java @@ -0,0 +1,30 @@ +/* + * + * * Copyright 2002-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.security.web.server.context; + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +public interface SecurityContextRepository { + + Mono save(ServerWebExchange exchange, SecurityContext context); + + Mono load(ServerWebExchange exchange); +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepositoryServerWebExchange.java b/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepositoryServerWebExchange.java new file mode 100644 index 0000000000..a3c657b6bc --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepositoryServerWebExchange.java @@ -0,0 +1,48 @@ +/* + * + * * Copyright 2002-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.security.web.server.context; + +import java.security.Principal; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebExchangeDecorator; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +final class SecurityContextRepositoryServerWebExchange extends ServerWebExchangeDecorator { + private final SecurityContextRepository repository; + + public SecurityContextRepositoryServerWebExchange(ServerWebExchange delegate, SecurityContextRepository repository) { + super(delegate); + this.repository = repository; + } + + @Override + @SuppressWarnings("unchecked") + public Mono getPrincipal() { + return Mono.defer(() -> + this.repository.load(this) + .filter(c -> c.getAuthentication() != null) + .flatMap(c -> Mono.just((T) c.getAuthentication())) + ); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepositoryWebFilter.java b/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepositoryWebFilter.java new file mode 100644 index 0000000000..68615ffeff --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/context/SecurityContextRepositoryWebFilter.java @@ -0,0 +1,46 @@ +/* + * + * * Copyright 2002-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.security.web.server.context; + +import org.springframework.security.web.server.context.SecurityContextRepository; +import org.springframework.security.web.server.context.SecurityContextRepositoryServerWebExchange; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class SecurityContextRepositoryWebFilter implements WebFilter { + private final SecurityContextRepository repository; + + public SecurityContextRepositoryWebFilter(SecurityContextRepository repository) { + Assert.notNull(repository, "repository cannot be null"); + this.repository = repository; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + SecurityContextRepositoryServerWebExchange delegate = + new SecurityContextRepositoryServerWebExchange(exchange, repository); + return chain.filter(delegate); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/context/ServerWebExchangeAttributeSecurityContextRepository.java b/webflux/src/main/java/org/springframework/security/web/server/context/ServerWebExchangeAttributeSecurityContextRepository.java new file mode 100644 index 0000000000..cf276b0b80 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/context/ServerWebExchangeAttributeSecurityContextRepository.java @@ -0,0 +1,41 @@ +/* + * + * * Copyright 2002-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.security.web.server.context; + + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class ServerWebExchangeAttributeSecurityContextRepository implements SecurityContextRepository { + final String ATTR = "USER"; + + public Mono save(ServerWebExchange exchange, SecurityContext context) { + exchange.getAttributes().put(ATTR, context); + return Mono.just(new SecurityContextRepositoryServerWebExchange(exchange, this)); + } + + public Mono load(ServerWebExchange exchange) { + return Mono.justOrEmpty(exchange.getAttribute(ATTR)); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/context/WebSessionSecurityContextRepository.java b/webflux/src/main/java/org/springframework/security/web/server/context/WebSessionSecurityContextRepository.java new file mode 100644 index 0000000000..db05cff0db --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/context/WebSessionSecurityContextRepository.java @@ -0,0 +1,45 @@ +/* + * + * * Copyright 2002-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.security.web.server.context; + +import org.springframework.security.core.context.SecurityContext; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class WebSessionSecurityContextRepository implements SecurityContextRepository { + final String SESSION_ATTR = "USER"; + + public Mono save(ServerWebExchange exchange, SecurityContext context) { + return exchange.getSession() + .doOnNext(session -> session.getAttributes().put(SESSION_ATTR, context)) + .flatMap( session -> Mono.just(new SecurityContextRepositoryServerWebExchange(exchange, this))); + } + + public Mono load(ServerWebExchange exchange) { + return exchange.getSession().flatMap( session -> { + SecurityContext context = (SecurityContext) session.getAttributes().get(SESSION_ATTR); + return context == null ? Mono.empty() : Mono.just(context); + }); + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/CacheControlHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/CacheControlHttpHeadersWriter.java new file mode 100644 index 0000000000..13e70c0cac --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/CacheControlHttpHeadersWriter.java @@ -0,0 +1,61 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class CacheControlHttpHeadersWriter implements HttpHeadersWriter { + + /** + * The value for expires value + */ + public static final String EXPIRES_VALUE = "0"; + + /** + * The value for pragma value + */ + public static final String PRAGMA_VALUE = "no-cache"; + + /** + * The value for cache control value + */ + public static final String CACHE_CONTRTOL_VALUE = "no-cache, no-store, max-age=0, must-revalidate"; + + /** + * The delegate to write all the cache control related headers + */ + private static final HttpHeadersWriter CACHE_HEADERS = StaticHttpHeadersWriter.builder() + .header(HttpHeaders.CACHE_CONTROL, CacheControlHttpHeadersWriter.CACHE_CONTRTOL_VALUE) + .header(HttpHeaders.PRAGMA, CacheControlHttpHeadersWriter.PRAGMA_VALUE) + .header(HttpHeaders.EXPIRES, CacheControlHttpHeadersWriter.EXPIRES_VALUE) + .build(); + + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return CACHE_HEADERS.writeHttpHeaders(exchange); + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/CompositeHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/CompositeHttpHeadersWriter.java new file mode 100644 index 0000000000..47fa4f179a --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/CompositeHttpHeadersWriter.java @@ -0,0 +1,51 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class CompositeHttpHeadersWriter implements HttpHeadersWriter { + private final List writers; + + public CompositeHttpHeadersWriter(HttpHeadersWriter... writers) { + this(Arrays.asList(writers)); + } + + public CompositeHttpHeadersWriter(List writers) { + this.writers = writers; + } + + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + Stream> results = writers.stream().map( writer -> writer.writeHttpHeaders(exchange)); + return Mono.when(results.collect(Collectors.toList())); + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/ContentTypeOptionsHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/ContentTypeOptionsHttpHeadersWriter.java new file mode 100644 index 0000000000..ae7cc47304 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/ContentTypeOptionsHttpHeadersWriter.java @@ -0,0 +1,48 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * Adds X-Content-Type-Options: nosniff + * + * @author Rob Winch + * @since 5.0 + */ +public class ContentTypeOptionsHttpHeadersWriter implements HttpHeadersWriter { + + public static final String X_CONTENT_OPTIONS = "X-Content-Type-Options"; + + public static final String NOSNIFF = "nosniff"; + + + /** + * The delegate to write all the cache control related headers + */ + private static final HttpHeadersWriter CONTENT_TYPE_HEADERS = StaticHttpHeadersWriter.builder() + .header(X_CONTENT_OPTIONS, NOSNIFF) + .build(); + + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return CONTENT_TYPE_HEADERS.writeHttpHeaders(exchange); + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/HttpHeaderWriterWebFilter.java b/webflux/src/main/java/org/springframework/security/web/server/header/HttpHeaderWriterWebFilter.java new file mode 100644 index 0000000000..3d70dd2aec --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/HttpHeaderWriterWebFilter.java @@ -0,0 +1,48 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +/** + * Invokes a {@link HttpHeadersWriter} on + * {@link ServerHttpResponse#beforeCommit(java.util.function.Supplier)}. + * + * @author Rob Winch + * @since 5.0 + */ +public class HttpHeaderWriterWebFilter implements WebFilter { + private final HttpHeadersWriter writer; + + public HttpHeaderWriterWebFilter(HttpHeadersWriter writer) { + super(); + this.writer = writer; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + exchange.getResponse().beforeCommit(() -> writer.writeHttpHeaders(exchange)); + return chain.filter(exchange); + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/HttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/HttpHeadersWriter.java new file mode 100644 index 0000000000..edd79bde41 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/HttpHeadersWriter.java @@ -0,0 +1,43 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import java.util.function.Supplier; + +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * Interface for writing headers just before the response is committed. + * + * @author Rob Winch + * @since 5.0 + */ +public interface HttpHeadersWriter { + + /** + * Write the headers to the response. + * + * @param exchange + * @return A Mono which is returned to the {@link Supplier} of the + * {@link ServerHttpResponse#beforeCommit(Supplier)}. + */ + Mono writeHttpHeaders(ServerWebExchange exchange); +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/StaticHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/StaticHttpHeadersWriter.java new file mode 100644 index 0000000000..647eb1cb06 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/StaticHttpHeadersWriter.java @@ -0,0 +1,70 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import java.util.Arrays; +import java.util.Collections; + +import org.springframework.http.HttpHeaders; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class StaticHttpHeadersWriter implements HttpHeadersWriter { + private final HttpHeaders headersToAdd; + + public StaticHttpHeadersWriter(HttpHeaders headersToAdd) { + this.headersToAdd = headersToAdd; + } + + /* (non-Javadoc) + * @see org.springframework.security.web.server.HttpHeadersWriter#writeHttpHeaders(org.springframework.web.server.ServerWebExchange) + */ + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + HttpHeaders headers = exchange.getResponse().getHeaders(); + boolean containsOneHeaderToAdd = Collections.disjoint(headers.keySet(), this.headersToAdd.keySet()); + if(containsOneHeaderToAdd) { + this.headersToAdd.forEach((name, values) -> { + headers.put(name, values); + }); + } + return Mono.empty(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private HttpHeaders headers = new HttpHeaders(); + + public Builder header(String headerName, String...values) { + headers.put(headerName, Arrays.asList(values)); + return this; + } + + public StaticHttpHeadersWriter build() { + return new StaticHttpHeadersWriter(headers); + } + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/StrictTransportSecurityHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/StrictTransportSecurityHttpHeadersWriter.java new file mode 100644 index 0000000000..8aba5daaf2 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/StrictTransportSecurityHttpHeadersWriter.java @@ -0,0 +1,77 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import java.time.Duration; + +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public final class StrictTransportSecurityHttpHeadersWriter implements HttpHeadersWriter { + public static final String STRICT_TRANSPORT_SECURITY = "Strict-Transport-Security"; + + private String maxAge; + + private String subdomain; + + private HttpHeadersWriter delegate; + + /** + * + */ + public StrictTransportSecurityHttpHeadersWriter() { + setIncludeSubDomains(true); + setMaxAge(Duration.ofDays(365L)); + updateDelegate(); + } + + /* (non-Javadoc) + * @see org.springframework.security.web.server.HttpHeadersWriter#writeHttpHeaders(org.springframework.http.HttpHeaders) + */ + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return isSecure(exchange) ? delegate.writeHttpHeaders(exchange) : Mono.empty(); + } + + public void setIncludeSubDomains(boolean includeSubDomains) { + subdomain = includeSubDomains ? " ; includeSubDomains" : ""; + updateDelegate(); + } + + public void setMaxAge(Duration maxAge) { + this.maxAge = "max-age=" + maxAge.getSeconds(); + updateDelegate(); + } + + private void updateDelegate() { + delegate = StaticHttpHeadersWriter.builder() + .header(STRICT_TRANSPORT_SECURITY, maxAge + subdomain) + .build(); + } + + private boolean isSecure(ServerWebExchange exchange) { + String scheme = exchange.getRequest().getURI().getScheme(); + boolean isSecure = scheme != null && scheme.equalsIgnoreCase("https"); + return isSecure; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/XContentTypeOptionsHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/XContentTypeOptionsHttpHeadersWriter.java new file mode 100644 index 0000000000..ce2e21f861 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/XContentTypeOptionsHttpHeadersWriter.java @@ -0,0 +1,49 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * Adds X-Content-Type-Options: nosniff + * + * @author Rob Winch + * @since 5.0 + */ +public class XContentTypeOptionsHttpHeadersWriter implements HttpHeadersWriter { + + public static final String X_CONTENT_OPTIONS = "X-Content-Options"; + + public static final String NOSNIFF = "nosniff"; + + + /** + * The delegate to write all the cache control related headers + */ + private static final HttpHeadersWriter CONTENT_TYPE_HEADERS = StaticHttpHeadersWriter.builder() + .header(X_CONTENT_OPTIONS, NOSNIFF) + .build(); + + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return CONTENT_TYPE_HEADERS.writeHttpHeaders(exchange); + } + +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/XFrameOptionsHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/XFrameOptionsHttpHeadersWriter.java new file mode 100644 index 0000000000..5f2000dd6f --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/XFrameOptionsHttpHeadersWriter.java @@ -0,0 +1,93 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class XFrameOptionsHttpHeadersWriter implements HttpHeadersWriter { + public static final String X_FRAME_OPTIONS = "X-Frame-Options"; + + private HttpHeadersWriter delegate = createDelegate(Mode.DENY); + + /* + * (non-Javadoc) + * + * @see org.springframework.security.web.server.HttpHeadersWriter# + * writeHttpHeaders(org.springframework.web.server.ServerWebExchange) + */ + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return delegate.writeHttpHeaders(exchange); + } + + /** + * Sets the X-Frame-Options mode. There is no support for ALLOW-FROM because + * not all + * browsers support it. Consider using X-Frame-Options with + * Content-Security-Policy frame-ancestors. + * + * @param mode + */ + public void setMode(Mode mode) { + this.delegate = createDelegate(mode); + } + + /** + * The X-Frame-Options values. There is no support for ALLOW-FROM because + * not all + * browsers support it. Consider using X-Frame-Options with + * Content-Security-Policy frame-ancestors. + * + * @author Rob Winch + * @since 5.0 + */ + public enum Mode { + /** + * A browser receiving content with this header field MUST NOT display + * this content in any frame. + */ + DENY, + /** + * A browser receiving content with this header field MUST NOT display + * this content in any frame from a page of different origin than the + * content itself. + * + * If a browser or plugin cannot reliably determine whether or not the + * origin of the content and the frame are the same, this MUST be + * treated as "DENY". + */ + SAMEORIGIN; + } + + private static HttpHeadersWriter createDelegate(Mode mode) { + // @formatter:off + return StaticHttpHeadersWriter.builder().header(X_FRAME_OPTIONS, mode.name()).build(); + // @formatter:on + + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/header/XXssProtectionHttpHeadersWriter.java b/webflux/src/main/java/org/springframework/security/web/server/header/XXssProtectionHttpHeadersWriter.java new file mode 100644 index 0000000000..03ecd02829 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/header/XXssProtectionHttpHeadersWriter.java @@ -0,0 +1,116 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class XXssProtectionHttpHeadersWriter implements HttpHeadersWriter { + public static final String X_XSS_PROTECTION = "X-XSS-Protection"; + + private boolean enabled; + + private boolean block; + + private HttpHeadersWriter delegate; + + /** + * + */ + public XXssProtectionHttpHeadersWriter() { + this.enabled = true; + this.block = true; + updateDelegate(); + } + + /* (non-Javadoc) + * @see org.springframework.security.web.server.HttpHeadersWriter#writeHttpHeaders(org.springframework.web.server.ServerWebExchange) + */ + @Override + public Mono writeHttpHeaders(ServerWebExchange exchange) { + return delegate.writeHttpHeaders(exchange); + } + + /** + * If true, will contain a value of 1. For example: + * + *
+	 * X-XSS-Protection: 1
+	 * 
+ * + * or if {@link #setBlock(boolean)} is true + * + * + *
+	 * X-XSS-Protection: 1; mode=block
+	 * 
+ * + * If false, will explicitly disable specify that X-XSS-Protection is disabled. For + * example: + * + *
+	 * X-XSS-Protection: 0
+	 * 
+ * + * @param enabled the new value + */ + public void setEnabled(boolean enabled) { + if (!enabled) { + setBlock(false); + } + this.enabled = enabled; + updateDelegate(); + } + + /** + * If false, will not specify the mode as blocked. In this instance, any content will + * be attempted to be fixed. If true, the content will be replaced with "#". + * + * @param block the new value + */ + public void setBlock(boolean block) { + if (!enabled && block) { + throw new IllegalArgumentException( + "Cannot set block to true with enabled false"); + } + this.block = block; + updateDelegate(); + } + + private void updateDelegate() { + + this.delegate = StaticHttpHeadersWriter.builder() + .header(X_XSS_PROTECTION, createHeaderValue()) + .build(); + } + + private String createHeaderValue() { + if (!enabled) { + return "0"; + } + if(!block) { + return "1"; + } + return "1 ; mode=block"; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/util/matcher/AndServerWebExchangeMatcher.java b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/AndServerWebExchangeMatcher.java new file mode 100644 index 0000000000..0d94d77469 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/AndServerWebExchangeMatcher.java @@ -0,0 +1,62 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class AndServerWebExchangeMatcher implements ServerWebExchangeMatcher { + private final List matchers; + + public AndServerWebExchangeMatcher(List matchers) { + Assert.notEmpty(matchers, "matchers cannot be empty"); + this.matchers = matchers; + } + + public AndServerWebExchangeMatcher(ServerWebExchangeMatcher... matchers) { + this(Arrays.asList(matchers)); + } + + /* (non-Javadoc) + * @see org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher#matches(org.springframework.web.server.ServerWebExchange) + */ + @Override + public MatchResult matches(ServerWebExchange exchange) { + Map variables = new HashMap<>(); + return matchers.stream() + .map(m -> m.matches(exchange)) + .peek( m -> variables.putAll(m.getVariables())) + .allMatch(m -> m.isMatch()) ? MatchResult.match(variables) : MatchResult.notMatch(); + } + + @Override + public String toString() { + return "AndServerWebExchangeMatcher{" + + "matchers=" + matchers + + '}'; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/util/matcher/OrServerWebExchangeMatcher.java b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/OrServerWebExchangeMatcher.java new file mode 100644 index 0000000000..94f0519017 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/OrServerWebExchangeMatcher.java @@ -0,0 +1,62 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; + +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class OrServerWebExchangeMatcher implements ServerWebExchangeMatcher { + private final List matchers; + + public OrServerWebExchangeMatcher(List matchers) { + Assert.notEmpty(matchers, "matchers cannot be empty"); + this.matchers = matchers; + } + + + public OrServerWebExchangeMatcher(ServerWebExchangeMatcher... matchers) { + this(Arrays.asList(matchers)); + } + + /* (non-Javadoc) + * @see org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher#matches(org.springframework.web.server.ServerWebExchange) + */ + @Override + public MatchResult matches(ServerWebExchange exchange) { + return matchers.stream() + .map(m -> m.matches(exchange)) + .filter(m -> m.isMatch()) + .findFirst() + .orElse(MatchResult.notMatch()); + } + + @Override + public String toString() { + return "OrServerWebExchangeMatcher{" + + "matchers=" + matchers + + '}'; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/util/matcher/PathMatcherServerWebExchangeMatcher.java b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/PathMatcherServerWebExchangeMatcher.java new file mode 100644 index 0000000000..9d1614a344 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/PathMatcherServerWebExchangeMatcher.java @@ -0,0 +1,81 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.Assert; +import org.springframework.util.PathMatcher; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.support.HttpRequestPathHelper; + +/** + * @author Rob Winch + * @since 5.0 + */ +public final class PathMatcherServerWebExchangeMatcher implements ServerWebExchangeMatcher { + private HttpRequestPathHelper helper = new HttpRequestPathHelper(); + + private PathMatcher pathMatcher = new AntPathMatcher(); + + private final String pattern; + private final HttpMethod method; + + public PathMatcherServerWebExchangeMatcher(String pattern) { + this(pattern, null); + } + + public PathMatcherServerWebExchangeMatcher(String pattern, HttpMethod method) { + Assert.notNull(pattern, "pattern cannot be null"); + this.pattern = pattern; + this.method = method; + } + + @Override + public MatchResult matches(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + if(this.method != null && !this.method.equals(request.getMethod())) { + return MatchResult.notMatch(); + } + String path = helper.getLookupPathForRequest(exchange); + boolean match = pathMatcher.match(pattern, path); + if(!match) { + return MatchResult.notMatch(); + } + Map pathVariables = pathMatcher.extractUriTemplateVariables(pattern, path); + Map variables = new HashMap<>(pathVariables); + return MatchResult.match(variables); + } + + public void setPathMatcher(PathMatcher pathMatcher) { + Assert.notNull(pathMatcher, "pathMatcher cannot be null"); + this.pathMatcher = pathMatcher; + } + + @Override + public String toString() { + return "PathMatcherServerWebExchangeMatcher{" + + "pattern='" + pattern + '\'' + + ", method=" + method + + '}'; + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatcher.java b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatcher.java new file mode 100644 index 0000000000..79f8523196 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatcher.java @@ -0,0 +1,63 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import java.util.Collections; +import java.util.Map; + +import org.springframework.web.server.ServerWebExchange; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public interface ServerWebExchangeMatcher { + + MatchResult matches(ServerWebExchange exchange); + + class MatchResult { + private final boolean match; + private final Map variables; + + private MatchResult(boolean match, Map variables) { + this.match = match; + this.variables = variables; + } + + public boolean isMatch() { + return match; + } + + public Map getVariables() { + return variables; + } + + public static MatchResult match() { + return match(Collections.emptyMap()); + } + + public static MatchResult match(Map variables) { + return new MatchResult(true, variables); + } + + public static MatchResult notMatch() { + return new MatchResult(false, Collections.emptyMap()); + } + } +} diff --git a/webflux/src/main/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatchers.java b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatchers.java new file mode 100644 index 0000000000..e882529c79 --- /dev/null +++ b/webflux/src/main/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatchers.java @@ -0,0 +1,59 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import org.springframework.http.HttpMethod; +import org.springframework.web.server.ServerWebExchange; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Rob Winch + * @since 5.0 + */ +public abstract class ServerWebExchangeMatchers { + + public static ServerWebExchangeMatcher antMatchers(HttpMethod method, String... patterns) { + List matchers = new ArrayList<>(patterns.length); + for (String pattern : patterns) { + matchers.add(new PathMatcherServerWebExchangeMatcher(pattern, method)); + } + return new OrServerWebExchangeMatcher(matchers); + } + + public static ServerWebExchangeMatcher antMatchers(String... patterns) { + return antMatchers(null, patterns); + } + + public static ServerWebExchangeMatcher matchers(ServerWebExchangeMatcher... matchers) { + return new OrServerWebExchangeMatcher(matchers); + } + + public static ServerWebExchangeMatcher anyExchange() { + return new ServerWebExchangeMatcher() { + @Override + public MatchResult matches(ServerWebExchange exchange) { + return ServerWebExchangeMatcher.MatchResult.match(); + } + }; + } + + private ServerWebExchangeMatchers() { + } +} diff --git a/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java b/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java new file mode 100644 index 0000000000..92b6f7c8e0 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestClientBuilder.java @@ -0,0 +1,50 @@ +/* + * + * * Copyright 2002-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.security.test.web.reactive.server; + +import org.springframework.http.HttpStatus; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.reactive.server.WebTestClient.Builder; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.WebFilter; + +/** + * Provides a convenient mechanism for running {@link WebTestClient} against + * {@link WebFilter} + * + * @author Rob Winch + * @since 5.0 + * + */ +public class WebTestClientBuilder { + + public static Builder bindToWebFilters(WebFilter... webFilters) { + return WebTestClient.bindToController(new Http200RestController()).webFilter(webFilters).configureClient(); + } + + @RestController + static class Http200RestController { + @RequestMapping("/**") + @ResponseStatus(HttpStatus.OK) + public String ok() { + return "ok"; + } + } +} diff --git a/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestHandler.java b/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestHandler.java new file mode 100644 index 0000000000..1ce7e9d19f --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/test/web/reactive/server/WebTestHandler.java @@ -0,0 +1,63 @@ +/* + * + * * Copyright 2002-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.security.test.web.reactive.server; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest.BaseBuilder; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebHandler; +import org.springframework.web.server.handler.FilteringWebHandler; + +import reactor.core.publisher.Mono; + +import java.util.Arrays; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +public class WebTestHandler { + private final WebHandler handler; + + private WebTestHandler(WebHandler handler) { + this.handler = handler; + } + + public WebHandlerResult exchange(BaseBuilder baseBuilder) { + ServerWebExchange exchange = baseBuilder.toExchange(); + handler.handle(exchange).block(); + return new WebHandlerResult(exchange); + } + + public static class WebHandlerResult { + private final ServerWebExchange exchange; + + private WebHandlerResult(ServerWebExchange exchange) { + this.exchange = exchange; + } + + public ServerWebExchange getExchange() { + return exchange; + } + } + + public static WebTestHandler bindToWebFilters(WebFilter... filters) { + return new WebTestHandler(new FilteringWebHandler(exchange -> Mono.empty(), Arrays.asList(filters))); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/method/ResolvableMethod.java b/webflux/src/test/java/org/springframework/security/web/method/ResolvableMethod.java new file mode 100644 index 0000000000..8d7170cb34 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/method/ResolvableMethod.java @@ -0,0 +1,679 @@ +/* + * + * * Copyright 2002-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.security.web.method; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.aopalliance.intercept.MethodInterceptor; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.target.EmptyTargetSource; +import org.springframework.cglib.core.SpringNamingPolicy; +import org.springframework.cglib.proxy.Callback; +import org.springframework.cglib.proxy.Enhancer; +import org.springframework.cglib.proxy.Factory; +import org.springframework.cglib.proxy.MethodProxy; +import org.springframework.core.LocalVariableTableParameterNameDiscoverer; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.objenesis.ObjenesisException; +import org.springframework.objenesis.SpringObjenesis; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.ValueConstants; + +import static java.util.stream.Collectors.joining; + +/** + * Convenience class to resolve method parameters from hints. + * + *

Background

+ * + *

When testing annotated methods we create test classes such as + * "TestController" with a diverse range of method signatures representing + * supported annotations and argument types. It becomes challenging to use + * naming strategies to keep track of methods and arguments especially in + * combination with variables for reflection metadata. + * + *

The idea with {@link ResolvableMethod} is NOT to rely on naming techniques + * but to use hints to zero in on method parameters. Such hints can be strongly + * typed and explicit about what is being tested. + * + *

1. Declared Return Type

+ * + * When testing return types it's likely to have many methods with a unique + * return type, possibly with or without an annotation. + * + *
+ *
+ * import static org.springframework.web.method.ResolvableMethod.on;
+ * import static org.springframework.web.method.MvcAnnotationPredicates.requestMapping;
+ *
+ * // Return type
+ * on(TestController.class).resolveReturnType(Foo.class);
+ * on(TestController.class).resolveReturnType(List.class, Foo.class);
+ * on(TestController.class).resolveReturnType(Mono.class, responseEntity(Foo.class));
+ *
+ * // Annotation + return type
+ * on(TestController.class).annotPresent(RequestMapping.class).resolveReturnType(Bar.class);
+ *
+ * // Annotation not present
+ * on(TestController.class).annotNotPresent(RequestMapping.class).resolveReturnType();
+ *
+ * // Annotation with attributes
+ * on(TestController.class).annot(requestMapping("/foo").params("p")).resolveReturnType();
+ * 
+ * + *

2. Method Arguments

+ * + * When testing method arguments it's more likely to have one or a small number + * of methods with a wide array of argument types and parameter annotations. + * + *
+ *
+ * import static org.springframework.web.method.MvcAnnotationPredicates.requestParam;
+ *
+ * ResolvableMethod testMethod = ResolvableMethod.on(getClass()).named("handle").build();
+ *
+ * testMethod.arg(Foo.class);
+ * testMethod.annotPresent(RequestParam.class).arg(Integer.class);
+ * testMethod.annotNotPresent(RequestParam.class)).arg(Integer.class);
+ * testMethod.annot(requestParam().name("c").notRequired()).arg(Integer.class);
+ * 
+ * + *

3. Mock Handler Method Invocation

+ * + * Locate a method by invoking it through a proxy of the target handler: + * + *
+ *
+ * ResolvableMethod.on(TestController.class).mockCall(o -> o.handle(null)).method();
+ * 
+ * + * @author Rossen Stoyanchev + * @since 5.0 + */ +public class ResolvableMethod { + + private static final Log logger = LogFactory.getLog(ResolvableMethod.class); + + private static final SpringObjenesis objenesis = new SpringObjenesis(); + + private static final ParameterNameDiscoverer nameDiscoverer = + new LocalVariableTableParameterNameDiscoverer(); + + + private final Method method; + + + private ResolvableMethod(Method method) { + Assert.notNull(method, "method is required"); + this.method = method; + } + + + /** + * Return the resolved method. + */ + public Method method() { + return this.method; + } + + /** + * Return the declared return type of the resolved method. + */ + public MethodParameter returnType() { + return new SynthesizingMethodParameter(this.method, -1); + } + + /** + * Find a unique argument matching the given type. + * @param type the expected type + * @param generics optional array of generic types + */ + public MethodParameter arg(Class type, Class... generics) { + return new ArgResolver().arg(type, generics); + } + + /** + * Find a unique argument matching the given type. + * @param type the expected type + * @param generic at least one generic type + * @param generics optional array of generic types + */ + public MethodParameter arg(Class type, ResolvableType generic, ResolvableType... generics) { + return new ArgResolver().arg(type, generic, generics); + } + + /** + * Find a unique argument matching the given type. + * @param type the expected type + */ + public MethodParameter arg(ResolvableType type) { + return new ArgResolver().arg(type); + } + + /** + * Filter on method arguments with annotation. + * See {@link MvcAnnotationPredicates}. + */ + @SafeVarargs + public final ArgResolver annot(Predicate... filter) { + return new ArgResolver(filter); + } + + @SafeVarargs + public final ArgResolver annotPresent(Class... annotationTypes) { + return new ArgResolver().annotPresent(annotationTypes); + } + + /** + * Filter on method arguments that don't have the given annotation type(s). + * @param annotationTypes the annotation types + */ + @SafeVarargs + public final ArgResolver annotNotPresent(Class... annotationTypes) { + return new ArgResolver().annotNotPresent(annotationTypes); + } + + + @Override + public String toString() { + return "ResolvableMethod=" + formatMethod(); + } + + private String formatMethod() { + return this.method().getName() + + Arrays.stream(this.method.getParameters()) + .map(this::formatParameter) + .collect(joining(",\n\t", "(\n\t", "\n)")); + } + + private String formatParameter(Parameter param) { + Annotation[] annot = param.getAnnotations(); + return annot.length > 0 ? + Arrays.stream(annot).map(this::formatAnnotation).collect(joining(",", "[", "]")) + " " + param : + param.toString(); + } + + private String formatAnnotation(Annotation annotation) { + Map map = AnnotationUtils.getAnnotationAttributes(annotation); + map.forEach((key, value) -> { + if (value.equals(ValueConstants.DEFAULT_NONE)) { + map.put(key, "NONE"); + } + }); + return annotation.annotationType().getName() + map; + } + + private static ResolvableType toResolvableType(Class type, Class... generics) { + return ObjectUtils.isEmpty(generics) ? + ResolvableType.forClass(type) : + ResolvableType.forClassWithGenerics(type, generics); + } + + private static ResolvableType toResolvableType(Class type, ResolvableType generic, ResolvableType... generics) { + ResolvableType[] genericTypes = new ResolvableType[generics.length + 1]; + genericTypes[0] = generic; + System.arraycopy(generics, 0, genericTypes, 1, generics.length); + return ResolvableType.forClassWithGenerics(type, genericTypes); + } + + + /** + * Main entry point providing access to a {@code ResolvableMethod} builder. + */ + public static Builder on(Class objectClass) { + return new Builder<>(objectClass); + } + + + /** + * Builder for {@code ResolvableMethod}. + */ + public static class Builder { + + private final Class objectClass; + + private final List> filters = new ArrayList<>(4); + + + private Builder(Class objectClass) { + Assert.notNull(objectClass, "Class must not be null"); + this.objectClass = objectClass; + } + + + private void addFilter(String message, Predicate filter) { + this.filters.add(new LabeledPredicate<>(message, filter)); + } + + /** + * Filter on methods with the given name. + */ + public Builder named(String methodName) { + addFilter("methodName=" + methodName, m -> m.getName().equals(methodName)); + return this; + } + + /** + * Filter on annotated methods. + * See {@link MvcAnnotationPredicates}. + */ + @SafeVarargs + public final Builder annot(Predicate... filters) { + this.filters.addAll(Arrays.asList(filters)); + return this; + } + + /** + * Filter on methods annotated with the given annotation type. + * @see #annot(Predicate[]) + * @see MvcAnnotationPredicates + */ + @SafeVarargs + public final Builder annotPresent(Class... annotationTypes) { + String message = "annotationPresent=" + Arrays.toString(annotationTypes); + addFilter(message, method -> + Arrays.stream(annotationTypes).allMatch(annotType -> + AnnotatedElementUtils.findMergedAnnotation(method, annotType) != null)); + return this; + } + + /** + * Filter on methods not annotated with the given annotation type. + */ + @SafeVarargs + public final Builder annotNotPresent(Class... annotationTypes) { + String message = "annotationNotPresent=" + Arrays.toString(annotationTypes); + addFilter(message, method -> { + if (annotationTypes.length != 0) { + return Arrays.stream(annotationTypes).noneMatch(annotType -> + AnnotatedElementUtils.findMergedAnnotation(method, annotType) != null); + } + else { + return method.getAnnotations().length == 0; + } + }); + return this; + } + + /** + * Filter on methods returning the given type. + * @param returnType the return type + * @param generics optional array of generic types + */ + public Builder returning(Class returnType, Class... generics) { + return returning(toResolvableType(returnType, generics)); + } + + /** + * Filter on methods returning the given type with generics. + * @param returnType the return type + * @param generic at least one generic type + * @param generics optional extra generic types + */ + public Builder returning(Class returnType, ResolvableType generic, ResolvableType... generics) { + return returning(toResolvableType(returnType, generic, generics)); + } + + /** + * Filter on methods returning the given type. + * @param returnType the return type + */ + public Builder returning(ResolvableType returnType) { + String expected = returnType.toString(); + String message = "returnType=" + expected; + addFilter(message, m -> expected.equals(ResolvableType.forMethodReturnType(m).toString())); + return this; + } + + /** + * Build a {@code ResolvableMethod} from the provided filters which must + * resolve to a unique, single method. + * + *

See additional resolveXxx shortcut methods going directly to + * {@link Method} or return type parameter. + * + * @throws IllegalStateException for no match or multiple matches + */ + public ResolvableMethod build() { + Set methods = MethodIntrospector.selectMethods(this.objectClass, this::isMatch); + Assert.state(!methods.isEmpty(), "No matching method: " + this); + Assert.state(methods.size() == 1, "Multiple matching methods: " + this + formatMethods(methods)); + return new ResolvableMethod(methods.iterator().next()); + } + + private boolean isMatch(Method method) { + return this.filters.stream().allMatch(p -> p.test(method)); + } + + private String formatMethods(Set methods) { + return "\nMatched:\n" + methods.stream() + .map(Method::toGenericString).collect(joining(",\n\t", "[\n\t", "\n]")); + } + + public ResolvableMethod mockCall(Consumer invoker) { + MethodInvocationInterceptor interceptor = new MethodInvocationInterceptor(); + T proxy = initProxy(this.objectClass, interceptor); + invoker.accept(proxy); + Method method = interceptor.getInvokedMethod(); + return new ResolvableMethod(method); + } + + + // Build & resolve shortcuts... + + /** + * Resolve and return the {@code Method} equivalent to: + *

{@code build().method()} + */ + public final Method resolveMethod() { + return build().method(); + } + + /** + * Resolve and return the {@code Method} equivalent to: + *

{@code named(methodName).build().method()} + */ + public Method resolveMethod(String methodName) { + return named(methodName).build().method(); + } + + /** + * Resolve and return the declared return type equivalent to: + *

{@code build().returnType()} + */ + public final MethodParameter resolveReturnType() { + return build().returnType(); + } + + /** + * Shortcut to the unique return type equivalent to: + *

{@code returning(returnType).build().returnType()} + * @param returnType the return type + * @param generics optional array of generic types + */ + public MethodParameter resolveReturnType(Class returnType, Class... generics) { + return returning(returnType, generics).build().returnType(); + } + + /** + * Shortcut to the unique return type equivalent to: + *

{@code returning(returnType).build().returnType()} + * @param returnType the return type + * @param generic at least one generic type + * @param generics optional extra generic types + */ + public MethodParameter resolveReturnType(Class returnType, ResolvableType generic, + ResolvableType... generics) { + + return returning(returnType, generic, generics).build().returnType(); + } + + public MethodParameter resolveReturnType(ResolvableType returnType) { + return returning(returnType).build().returnType(); + } + + + @Override + public String toString() { + return "ResolvableMethod.Builder[\n" + + "\tobjectClass = " + this.objectClass.getName() + ",\n" + + "\tfilters = " + formatFilters() + "\n]"; + } + + private String formatFilters() { + return this.filters.stream().map(Object::toString) + .collect(joining(",\n\t\t", "[\n\t\t", "\n\t]")); + } + } + + /** + * Predicate with a descriptive label. + */ + private static class LabeledPredicate implements Predicate { + + private final String label; + + private final Predicate delegate; + + + private LabeledPredicate(String label, Predicate delegate) { + this.label = label; + this.delegate = delegate; + } + + + @Override + public boolean test(T method) { + return this.delegate.test(method); + } + + @Override + public Predicate and(Predicate other) { + return this.delegate.and(other); + } + + @Override + public Predicate negate() { + return this.delegate.negate(); + } + + @Override + public Predicate or(Predicate other) { + return this.delegate.or(other); + } + + @Override + public String toString() { + return this.label; + } + } + + /** + * Resolver for method arguments. + */ + public class ArgResolver { + + private final List> filters = new ArrayList<>(4); + + + @SafeVarargs + private ArgResolver(Predicate... filter) { + this.filters.addAll(Arrays.asList(filter)); + } + + /** + * Filter on method arguments with annotations. + * See {@link MvcAnnotationPredicates}. + */ + @SafeVarargs + public final ArgResolver annot(Predicate... filters) { + this.filters.addAll(Arrays.asList(filters)); + return this; + } + + /** + * Filter on method arguments that have the given annotations. + * @param annotationTypes the annotation types + * @see #annot(Predicate[]) + * @see MvcAnnotationPredicates + */ + @SafeVarargs + public final ArgResolver annotPresent(Class... annotationTypes) { + this.filters.add(param -> Arrays.stream(annotationTypes).allMatch(param::hasParameterAnnotation)); + return this; + } + + /** + * Filter on method arguments that don't have the given annotations. + * @param annotationTypes the annotation types + */ + @SafeVarargs + public final ArgResolver annotNotPresent(Class... annotationTypes) { + this.filters.add(param -> + (annotationTypes.length != 0) ? + Arrays.stream(annotationTypes).noneMatch(param::hasParameterAnnotation) : + param.getParameterAnnotations().length == 0); + return this; + } + + /** + * Resolve the argument also matching to the given type. + * @param type the expected type + */ + public MethodParameter arg(Class type, Class... generics) { + return arg(toResolvableType(type, generics)); + } + + /** + * Resolve the argument also matching to the given type. + * @param type the expected type + */ + public MethodParameter arg(Class type, ResolvableType generic, ResolvableType... generics) { + return arg(toResolvableType(type, generic, generics)); + } + + /** + * Resolve the argument also matching to the given type. + * @param type the expected type + */ + public MethodParameter arg(ResolvableType type) { + this.filters.add(p -> type.toString().equals(ResolvableType.forMethodParameter(p).toString())); + return arg(); + } + + /** + * Resolve the argument. + */ + public final MethodParameter arg() { + List matches = applyFilters(); + Assert.state(!matches.isEmpty(), () -> + "No matching arg in method\n" + formatMethod()); + Assert.state(matches.size() == 1, () -> + "Multiple matching args in method\n" + formatMethod() + "\nMatches:\n\t" + matches); + return matches.get(0); + } + + + private List applyFilters() { + List matches = new ArrayList<>(); + for (int i = 0; i < method.getParameterCount(); i++) { + MethodParameter param = new SynthesizingMethodParameter(method, i); + param.initParameterNameDiscovery(nameDiscoverer); + if (this.filters.stream().allMatch(p -> p.test(param))) { + matches.add(param); + } + } + return matches; + } + } + + private static class MethodInvocationInterceptor + implements org.springframework.cglib.proxy.MethodInterceptor, MethodInterceptor { + + private Method invokedMethod; + + + Method getInvokedMethod() { + return this.invokedMethod; + } + + @Override + public Object intercept(Object object, Method method, Object[] args, MethodProxy proxy) { + if (ReflectionUtils.isObjectMethod(method)) { + return ReflectionUtils.invokeMethod(method, object, args); + } + else { + this.invokedMethod = method; + return null; + } + } + + @Override + public Object invoke(org.aopalliance.intercept.MethodInvocation inv) throws Throwable { + return intercept(inv.getThis(), inv.getMethod(), inv.getArguments(), null); + } + } + + @SuppressWarnings("unchecked") + private static T initProxy(Class type, MethodInvocationInterceptor interceptor) { + Assert.notNull(type, "'type' must not be null"); + if (type.isInterface()) { + ProxyFactory factory = new ProxyFactory(EmptyTargetSource.INSTANCE); + factory.addInterface(type); + factory.addInterface(Supplier.class); + factory.addAdvice(interceptor); + return (T) factory.getProxy(); + } + + else { + Enhancer enhancer = new Enhancer(); + enhancer.setSuperclass(type); + enhancer.setInterfaces(new Class[] {Supplier.class}); + enhancer.setNamingPolicy(SpringNamingPolicy.INSTANCE); + enhancer.setCallbackType(org.springframework.cglib.proxy.MethodInterceptor.class); + + Class proxyClass = enhancer.createClass(); + Object proxy = null; + + if (objenesis.isWorthTrying()) { + try { + proxy = objenesis.newInstance(proxyClass, enhancer.getUseCache()); + } + catch (ObjenesisException ex) { + logger.debug("Objenesis failed, falling back to default constructor", ex); + } + } + + if (proxy == null) { + try { + proxy = ReflectionUtils.accessibleConstructor(proxyClass).newInstance(); + } + catch (Throwable ex) { + throw new IllegalStateException("Unable to instantiate proxy " + + "via both Objenesis and default constructor fails as well", ex); + } + } + + ((Factory) proxy).setCallbacks(new Callback[] {interceptor}); + return (T) proxy; + } + } + +} diff --git a/webflux/src/test/java/org/springframework/security/web/reactive/result/method/annotation/AuthenticationPrincipalArgumentResolverTests.java b/webflux/src/test/java/org/springframework/security/web/reactive/result/method/annotation/AuthenticationPrincipalArgumentResolverTests.java new file mode 100644 index 0000000000..af21526abf --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/reactive/result/method/annotation/AuthenticationPrincipalArgumentResolverTests.java @@ -0,0 +1,167 @@ +/* + * + * * Copyright 2002-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.security.web.reactive.result.method.annotation; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.core.MethodParameter; +import org.springframework.core.ReactiveAdapterRegistry; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.web.method.ResolvableMethod; +import org.springframework.web.reactive.BindingContext; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.lang.annotation.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class AuthenticationPrincipalArgumentResolverTests { + @Mock + ServerWebExchange exchange; + @Mock + BindingContext bindingContext; + @Mock + Authentication authentication; + + ResolvableMethod authenticationPrincipal = ResolvableMethod.on(getClass()).named("authenticationPrincipal").build(); + ResolvableMethod spel = ResolvableMethod.on(getClass()).named("spel").build(); + ResolvableMethod meta = ResolvableMethod.on(getClass()).named("meta").build(); + + AuthenticationPrincipalArgumentResolver resolver; + + @Before + public void setup() { + resolver = new AuthenticationPrincipalArgumentResolver(new ReactiveAdapterRegistry()); + } + + @Test + public void supportsParameterAuthenticationPrincipal() throws Exception { + assertThat(resolver.supportsParameter(this.authenticationPrincipal.arg(String.class))).isTrue(); + } + + @Test + public void supportsParameterCurrentUser() throws Exception { + assertThat(resolver.supportsParameter(this.meta.arg(String.class))).isTrue(); + } + + @Test + public void resolveArgumentWhenIsAuthenticationThenObtainsPrincipal() throws Exception { + MethodParameter parameter = this.authenticationPrincipal.arg(String.class); + when(authentication.getPrincipal()).thenReturn("user"); + when(exchange.getPrincipal()).thenReturn(Mono.just(authentication)); + + Mono argument = resolver.resolveArgument(parameter, bindingContext, exchange); + + assertThat(argument.block()).isEqualTo(authentication.getPrincipal()); + } + + @Test + public void resolveArgumentWhenIsNotAuthenticationThenMonoEmpty() throws Exception { + MethodParameter parameter = this.authenticationPrincipal.arg(String.class); + when(exchange.getPrincipal()).thenReturn(Mono.just(() -> "")); + + Mono argument = resolver.resolveArgument(parameter, bindingContext, exchange); + + assertThat(argument).isNotNull(); + assertThat(argument.block()).isNull(); + } + + @Test + public void resolveArgumentWhenIsEmptyThenMonoEmpty() throws Exception { + MethodParameter parameter = this.authenticationPrincipal.arg(String.class); + when(authentication.getPrincipal()).thenReturn("user"); + when(exchange.getPrincipal()).thenReturn(Mono.empty()); + + Mono argument = resolver.resolveArgument(parameter, bindingContext, exchange); + + assertThat(argument).isNotNull(); + assertThat(argument.block()).isNull(); + } + + @Test + public void resolveArgumentWhenMonoIsAuthenticationThenObtainsPrincipal() throws Exception { + MethodParameter parameter = this.authenticationPrincipal.arg(Mono.class, String.class); + when(authentication.getPrincipal()).thenReturn("user"); + when(exchange.getPrincipal()).thenReturn(Mono.just(authentication)); + + Mono argument = resolver.resolveArgument(parameter, bindingContext, exchange); + + assertThat(argument.cast(Mono.class).block().block()).isEqualTo(authentication.getPrincipal()); + } + + @Test + public void resolveArgumentWhenSpelThenObtainsPrincipal() throws Exception { + MyUser user = new MyUser(3L); + MethodParameter parameter = this.spel.arg(Long.class); + when(authentication.getPrincipal()).thenReturn(user); + when(exchange.getPrincipal()).thenReturn(Mono.just(authentication)); + + Mono argument = resolver.resolveArgument(parameter, bindingContext, exchange); + + assertThat(argument.block()).isEqualTo(user.getId()); + } + + @Test + public void resolveArgumentWhenMetaThenObtainsPrincipal() throws Exception { + MethodParameter parameter = this.meta.arg(String.class); + when(authentication.getPrincipal()).thenReturn("user"); + when(exchange.getPrincipal()).thenReturn(Mono.just(authentication)); + + Mono argument = resolver.resolveArgument(parameter, bindingContext, exchange); + + assertThat(argument.block()).isEqualTo("user"); + } + + + void authenticationPrincipal(@AuthenticationPrincipal String principal, @AuthenticationPrincipal Mono monoPrincipal) {} + + void spel(@AuthenticationPrincipal(expression = "id") Long id) {} + + void meta(@CurrentUser String principal) {} + + static class MyUser { + private final Long id; + + MyUser(Long id) { + this.id = id; + } + + public Long getId() { + return id; + } + } + + @Target({ ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) + @Retention(RetentionPolicy.RUNTIME) + @Documented + @AuthenticationPrincipal + public @interface CurrentUser {} +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/HttpBasicAuthenticationConverterTests.java b/webflux/src/test/java/org/springframework/security/web/server/HttpBasicAuthenticationConverterTests.java new file mode 100644 index 0000000000..4784f0638d --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/HttpBasicAuthenticationConverterTests.java @@ -0,0 +1,83 @@ +/* + * + * * Copyright 2002-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.security.web.server; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class HttpBasicAuthenticationConverterTests { + + HttpBasicAuthenticationConverter converter = new HttpBasicAuthenticationConverter(); + MockServerHttpRequest.BaseBuilder request = MockServerHttpRequest.get("/"); + + @Test + public void applyWhenNoAuthorizationHeaderThenEmpty() { + Mono result = converter.apply(request.toExchange()); + + assertThat(result.block()).isNull(); + } + + @Test + public void applyWhenEmptyAuthorizationHeaderThenEmpty() { + Mono result = converter.apply(request.header(HttpHeaders.AUTHORIZATION, "").toExchange()); + + assertThat(result.block()).isNull(); + } + + @Test + public void applyWhenOnlyBasicAuthorizationHeaderThenEmpty() { + Mono result = converter.apply(request.header(HttpHeaders.AUTHORIZATION, "Basic ").toExchange()); + + assertThat(result.block()).isNull(); + } + + @Test + public void applyWhenNotBase64ThenEmpty() { + Mono result = converter.apply(request.header(HttpHeaders.AUTHORIZATION, "Basic z").toExchange()); + + assertThat(result.block()).isNull(); + } + + @Test + public void applyWhenNoSemicolonThenEmpty() { + Mono result = converter.apply(request.header(HttpHeaders.AUTHORIZATION, "Basic dXNlcg==").toExchange()); + + assertThat(result.block()).isNull(); + } + + @Test + public void applyWhenUserPasswordThenAuthentication() { + Mono result = converter.apply(request.header(HttpHeaders.AUTHORIZATION, "Basic dXNlcjpwYXNzd29yZA==").toExchange()); + + UsernamePasswordAuthenticationToken authentication = result.cast(UsernamePasswordAuthenticationToken.class).block(); + assertThat(authentication.getPrincipal()).isEqualTo("user"); + assertThat(authentication.getCredentials()).isEqualTo("password"); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java b/webflux/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java new file mode 100644 index 0000000000..8b2c8b5a8e --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/authentication/AuthenticationWebFilterTests.java @@ -0,0 +1,235 @@ +/* + * + * * Copyright 2002-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.security.web.server.authentication; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.ReactiveAuthenticationManager; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.security.web.server.AuthenticationEntryPoint; +import org.springframework.test.web.reactive.server.EntityExchangeResult; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication; + + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class AuthenticationWebFilterTests { + @Mock + AuthenticationSuccessHandler successHandler; + @Mock + Function> authenticationConverter; + @Mock + ReactiveAuthenticationManager authenticationManager; + @Mock + AuthenticationEntryPoint entryPoint; + + AuthenticationWebFilter filter; + + @Before + public void setup() { + filter = new AuthenticationWebFilter(authenticationManager); + filter.setAuthenticationSuccessHandler(successHandler); + filter.setAuthenticationConverter(authenticationConverter); + filter.setEntryPoint(entryPoint); + } + + @Test + public void filterWhenDefaultsAndNoAuthenticationThenContinues() { + filter = new AuthenticationWebFilter(authenticationManager); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + EntityExchangeResult result = client.get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectBody().consumeAsStringWith(b -> assertThat(b).isEqualTo("ok")) + .returnResult(); + + verifyZeroInteractions(authenticationManager); + assertThat(result.getResponseCookies()).isEmpty(); + } + + @Test + public void filterWhenDefaultsAndAuthenticationSuccessThenContinues() { + when(authenticationManager.authenticate(any())).thenReturn(Mono.just(new TestingAuthenticationToken("test","this", "ROLE"))); + filter = new AuthenticationWebFilter(authenticationManager); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + EntityExchangeResult result = client + .filter(basicAuthentication("test","this")) + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectBody().consumeAsStringWith(b -> assertThat(b).isEqualTo("ok")) + .returnResult(); + + assertThat(result.getResponseCookies()).isEmpty(); + } + + @Test + public void filterWhenDefaultsAndAuthenticationFailThenUnauthorized() { + when(authenticationManager.authenticate(any())).thenReturn(Mono.error(new BadCredentialsException("failed"))); + filter = new AuthenticationWebFilter(authenticationManager); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + EntityExchangeResult result = client + .filter(basicAuthentication("test", "this")) + .get() + .uri("/") + .exchange() + .expectStatus().isUnauthorized() + .expectHeader().valueMatches("WWW-Authenticate", "Basic realm=\"Realm\"") + .expectBody().isEmpty(); + + assertThat(result.getResponseCookies()).isEmpty(); + } + + @Test + public void filterWhenConvertEmptyThenOk() { + when(authenticationConverter.apply(any())).thenReturn(Mono.empty()); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + EntityExchangeResult result = client + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectBody().consumeAsStringWith(b -> assertThat(b).isEqualTo("ok")) + .returnResult(); + + verifyZeroInteractions(authenticationManager, successHandler, entryPoint); + } + + @Test + public void filterWhenConvertErrorThenServerError() { + when(authenticationConverter.apply(any())).thenReturn(Mono.error(new RuntimeException("Unexpected"))); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + client + .get() + .uri("/") + .exchange() + .expectStatus().is5xxServerError() + .expectBody().isEmpty(); + + verifyZeroInteractions(authenticationManager, successHandler, entryPoint); + } + + @Test + public void filterWhenConvertAndAuthenticationSuccessThenSuccessHandler() { + Mono authentication = Mono.just(new TestingAuthenticationToken("test", "this", "ROLE_USER")); + when(authenticationConverter.apply(any())).thenReturn(authentication); + when(authenticationManager.authenticate(any())).thenReturn(authentication); + when(successHandler.success(any(),any(),any())).thenReturn(Mono.empty()); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + client + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectBody().isEmpty(); + + verify(successHandler).success(eq(authentication.block()), any(), any()); + verifyZeroInteractions(entryPoint); + } + + @Test + public void filterWhenConvertAndAuthenticationFailThenEntryPoint() { + Mono authentication = Mono.just(new TestingAuthenticationToken("test", "this", "ROLE_USER")); + when(authenticationConverter.apply(any())).thenReturn(authentication); + when(authenticationManager.authenticate(any())).thenReturn(Mono.error(new BadCredentialsException("Failed"))); + when(entryPoint.commence(any(),any())).thenReturn(Mono.empty()); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + client + .get() + .uri("/") + .exchange() + .expectStatus().isOk() + .expectBody().isEmpty(); + + verify(entryPoint).commence(any(),any()); + verifyZeroInteractions(successHandler); + } + + @Test + public void filterWhenConvertAndAuthenticationExceptionThenServerError() { + Mono authentication = Mono.just(new TestingAuthenticationToken("test", "this", "ROLE_USER")); + when(authenticationConverter.apply(any())).thenReturn(authentication); + when(authenticationManager.authenticate(any())).thenReturn(Mono.error(new RuntimeException("Failed"))); + when(entryPoint.commence(any(),any())).thenReturn(Mono.empty()); + + WebTestClient client = WebTestClientBuilder + .bindToWebFilters(filter) + .build(); + + client + .get() + .uri("/") + .exchange() + .expectStatus().is5xxServerError() + .expectBody().isEmpty(); + + verifyZeroInteractions(successHandler, entryPoint); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/context/SecurityContextRepositoryWebFilterTests.java b/webflux/src/test/java/org/springframework/security/web/server/context/SecurityContextRepositoryWebFilterTests.java new file mode 100644 index 0000000000..3a33f25c5c --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/context/SecurityContextRepositoryWebFilterTests.java @@ -0,0 +1,92 @@ +/* + * + * * Copyright 2002-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.security.web.server.context; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.security.test.web.reactive.server.WebTestHandler; +import reactor.core.publisher.Mono; + +import java.security.Principal; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; + + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class SecurityContextRepositoryWebFilterTests { + @Mock + SecurityContextRepository repository; + + MockServerHttpRequest.BaseBuilder exchange = MockServerHttpRequest.get("/"); + + SecurityContextRepositoryWebFilter filter; + + WebTestHandler filters; + + + @Before + public void setup() { + filter = new SecurityContextRepositoryWebFilter(repository); + filters = WebTestHandler.bindToWebFilters(filter); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorNullSecurityContextRepository() { + SecurityContextRepository repository = null; + new SecurityContextRepositoryWebFilter(repository); + } + + @Test + public void filterWhenNoPrincipalAccessThenNoInteractions() { + filters.exchange(exchange); + + verifyZeroInteractions(repository); + } + + @Test + public void filterWhenGetPrincipalMonoThenNoInteractions() { + filters = WebTestHandler.bindToWebFilters(filter, (e,c) -> { + Mono p = e.getPrincipal(); + return c.filter(e); + }); + + filters.exchange(exchange); + + verifyZeroInteractions(repository); + } + + @Test + public void filterWhenGetPrincipalThenInteract() { + when(repository.load(any())).thenReturn(Mono.empty()); + filters = WebTestHandler.bindToWebFilters(filter, (e,c) -> e.getPrincipal().flatMap( p-> c.filter(e))) ; + + filters.exchange(exchange); + + verify(repository).load(any()); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/CacheControlHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/CacheControlHttpHeadersWriterTests.java new file mode 100644 index 0000000000..7efcb3a0b5 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/CacheControlHttpHeadersWriterTests.java @@ -0,0 +1,83 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * + * @author Rob Winch + * @since 5.0 + * + */ +public class CacheControlHttpHeadersWriterTests { + CacheControlHttpHeadersWriter writer = new CacheControlHttpHeadersWriter(); + + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + + @Test + public void writeHeadersWhenCacheHeadersThenWritesAllCacheControl() { + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(3); + assertThat(headers.get(HttpHeaders.CACHE_CONTROL)).containsOnly(CacheControlHttpHeadersWriter.CACHE_CONTRTOL_VALUE); + assertThat(headers.get(HttpHeaders.EXPIRES)).containsOnly(CacheControlHttpHeadersWriter.EXPIRES_VALUE); + assertThat(headers.get(HttpHeaders.PRAGMA)).containsOnly(CacheControlHttpHeadersWriter.PRAGMA_VALUE); + } + + @Test + public void writeHeadersWhenCacheControlThenNoCacheControlHeaders() { + String cacheControl = "max-age=1234"; + + headers.set(HttpHeaders.CACHE_CONTROL, cacheControl); + + writer.writeHttpHeaders(exchange); + + assertThat(headers.get(HttpHeaders.CACHE_CONTROL)).containsOnly(cacheControl); + } + + @Test + public void writeHeadersWhenPragmaThenNoCacheControlHeaders() { + String pragma = "1"; + headers.set(HttpHeaders.PRAGMA, pragma); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(HttpHeaders.PRAGMA)).containsOnly(pragma); + } + + @Test + public void writeHeadersWhenExpiresThenNoCacheControlHeaders() { + String expires = "1"; + headers.set(HttpHeaders.EXPIRES, expires); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(HttpHeaders.EXPIRES)).containsOnly(expires); + } + +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/CompositeHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/CompositeHttpHeadersWriterTests.java new file mode 100644 index 0000000000..e215e08585 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/CompositeHttpHeadersWriterTests.java @@ -0,0 +1,102 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class CompositeHttpHeadersWriterTests { + @Mock + HttpHeadersWriter writer1; + + @Mock + HttpHeadersWriter writer2; + + CompositeHttpHeadersWriter writer; + + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + @Before + public void setup() { + writer = new CompositeHttpHeadersWriter(Arrays.asList(writer1, writer2)); + } + + @Test + public void writeHttpHeadersWhenErrorNoErrorThenError() { + when(writer1.writeHttpHeaders(exchange)).thenReturn(Mono.error(new RuntimeException())); + when(writer2.writeHttpHeaders(exchange)).thenReturn(Mono.empty()); + + Mono result = writer.writeHttpHeaders(exchange); + + StepVerifier.create(result) + .expectError() + .verify(); + + verify(writer1).writeHttpHeaders(exchange); + verify(writer2).writeHttpHeaders(exchange); + } + + @Test + public void writeHttpHeadersWhenErrorErrorThenError() { + when(writer1.writeHttpHeaders(exchange)).thenReturn(Mono.error(new RuntimeException())); + when(writer2.writeHttpHeaders(exchange)).thenReturn(Mono.error(new RuntimeException())); + + Mono result = writer.writeHttpHeaders(exchange); + + StepVerifier.create(result) + .expectError() + .verify(); + + verify(writer1).writeHttpHeaders(exchange); + verify(writer2).writeHttpHeaders(exchange); + } + + @Test + public void writeHttpHeadersWhenNoErrorThenNoError() { + when(writer1.writeHttpHeaders(exchange)).thenReturn(Mono.empty()); + when(writer2.writeHttpHeaders(exchange)).thenReturn(Mono.empty()); + + Mono result = writer.writeHttpHeaders(exchange); + + StepVerifier.create(result) + .expectComplete() + .verify(); + + verify(writer1).writeHttpHeaders(exchange); + verify(writer2).writeHttpHeaders(exchange); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/HttpHeaderWriterWebFilterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/HttpHeaderWriterWebFilterTests.java new file mode 100644 index 0000000000..18c1779e7a --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/HttpHeaderWriterWebFilterTests.java @@ -0,0 +1,77 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.security.test.web.reactive.server.WebTestHandler; +import org.springframework.security.test.web.reactive.server.WebTestHandler.WebHandlerResult; +import org.springframework.security.test.web.reactive.server.WebTestClientBuilder; +import org.springframework.test.web.reactive.server.WebTestClient; + +import reactor.core.publisher.Mono; + +/** + * + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class HttpHeaderWriterWebFilterTests { + @Mock + HttpHeadersWriter writer; + + HttpHeaderWriterWebFilter filter; + + @Before + public void setup() { + when(writer.writeHttpHeaders(any())).thenReturn(Mono.empty()); + filter = new HttpHeaderWriterWebFilter(writer); + } + + @Test + public void filterWhenCompleteThenWritten() { + WebTestClient rest = WebTestClientBuilder.bindToWebFilters(filter).build(); + + rest.get().uri("/foo").exchange(); + + verify(writer).writeHttpHeaders(any()); + } + + @Test + public void filterWhenNotCompleteThenNotWritten() { + WebTestHandler handler = WebTestHandler.bindToWebFilters(filter); + + WebHandlerResult result = handler.exchange(MockServerHttpRequest.get("/foo")); + + verify(writer, never()).writeHttpHeaders(any()); + + result.getExchange().getResponse().setComplete(); + + verify(writer).writeHttpHeaders(any()); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/StaticHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/StaticHttpHeadersWriterTests.java new file mode 100644 index 0000000000..1e828c69aa --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/StaticHttpHeadersWriterTests.java @@ -0,0 +1,89 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class StaticHttpHeadersWriterTests { + + StaticHttpHeadersWriter writer = StaticHttpHeadersWriter.builder() + .header(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS, ContentTypeOptionsHttpHeadersWriter.NOSNIFF) + .build(); + + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + + @Test + public void writeHeadersWhenSingleHeaderThenWritesHeader() { + writer.writeHttpHeaders(exchange); + + assertThat(headers.get(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS)).containsOnly(ContentTypeOptionsHttpHeadersWriter.NOSNIFF); + } + + @Test + public void writeHeadersWhenSingleHeaderAndHeaderWrittenThenSuccess() { + String headerValue = "other"; + headers.set(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS, headerValue); + + writer.writeHttpHeaders(exchange); + + assertThat(headers.get(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS)).containsOnly(headerValue); + } + + @Test + public void writeHeadersWhenMultiHeaderThenWritesAllHeaders() { + writer = StaticHttpHeadersWriter.builder() + .header(HttpHeaders.CACHE_CONTROL, CacheControlHttpHeadersWriter.CACHE_CONTRTOL_VALUE) + .header(HttpHeaders.PRAGMA, CacheControlHttpHeadersWriter.PRAGMA_VALUE) + .header(HttpHeaders.EXPIRES, CacheControlHttpHeadersWriter.EXPIRES_VALUE) + .build(); + + writer.writeHttpHeaders(exchange); + + assertThat(headers.get(HttpHeaders.CACHE_CONTROL)).containsOnly(CacheControlHttpHeadersWriter.CACHE_CONTRTOL_VALUE); + assertThat(headers.get(HttpHeaders.PRAGMA)).containsOnly(CacheControlHttpHeadersWriter.PRAGMA_VALUE); + assertThat(headers.get(HttpHeaders.EXPIRES)).containsOnly(CacheControlHttpHeadersWriter.EXPIRES_VALUE); + } + + @Test + public void writeHeadersWhenMultiHeaderAndSingleWrittenThenNoHeadersOverridden() { + String headerValue = "other"; + headers.set(HttpHeaders.CACHE_CONTROL, headerValue); + + writer = StaticHttpHeadersWriter.builder() + .header(HttpHeaders.CACHE_CONTROL, CacheControlHttpHeadersWriter.CACHE_CONTRTOL_VALUE) + .header(HttpHeaders.PRAGMA, CacheControlHttpHeadersWriter.PRAGMA_VALUE) + .header(HttpHeaders.EXPIRES, CacheControlHttpHeadersWriter.EXPIRES_VALUE) + .build(); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(HttpHeaders.CACHE_CONTROL)).containsOnly(headerValue); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/StrictTransportSecurityHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/StrictTransportSecurityHttpHeadersWriterTests.java new file mode 100644 index 0000000000..46445ab35f --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/StrictTransportSecurityHttpHeadersWriterTests.java @@ -0,0 +1,97 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Arrays; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class StrictTransportSecurityHttpHeadersWriterTests { + StrictTransportSecurityHttpHeadersWriter hsts = new StrictTransportSecurityHttpHeadersWriter(); + + ServerWebExchange exchange; + + @Test + public void writeHttpHeadersWhenHttpsThenWrites() { + exchange = MockServerHttpRequest.get("https://example.com/").toExchange(); + + hsts.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers).containsEntry(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY, + Arrays.asList("max-age=31536000 ; includeSubDomains")); + } + + @Test + public void writeHttpHeadersWhenCustomMaxAgeThenWrites() { + Duration maxAge = Duration.ofDays(1); + hsts.setMaxAge(maxAge); + exchange = MockServerHttpRequest.get("https://example.com/").toExchange(); + + hsts.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers).containsEntry(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY, + Arrays.asList("max-age=" + maxAge.getSeconds() + " ; includeSubDomains")); + } + + @Test + public void writeHttpHeadersWhenCustomIncludeSubDomainsThenWrites() { + hsts.setIncludeSubDomains(false); + exchange = MockServerHttpRequest.get("https://example.com/").toExchange(); + + hsts.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers).containsEntry(StrictTransportSecurityHttpHeadersWriter.STRICT_TRANSPORT_SECURITY, + Arrays.asList("max-age=31536000")); + } + + @Test + public void writeHttpHeadersWhenNullSchemeThenNoHeaders() { + exchange = MockServerHttpRequest.get("/").toExchange(); + + hsts.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).isEmpty(); + } + + @Test + public void writeHttpHeadersWhenHttpThenNoHeaders() { + exchange = MockServerHttpRequest.get("http://example.com/").toExchange(); + + hsts.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).isEmpty(); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/XContentTypeOptionsHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/XContentTypeOptionsHttpHeadersWriterTests.java new file mode 100644 index 0000000000..05c7005632 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/XContentTypeOptionsHttpHeadersWriterTests.java @@ -0,0 +1,57 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class XContentTypeOptionsHttpHeadersWriterTests { + + ContentTypeOptionsHttpHeadersWriter writer = new ContentTypeOptionsHttpHeadersWriter(); + + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + + @Test + public void writeHeadersWhenNoHeadersThenWriteHeaders() { + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS)).containsOnly(ContentTypeOptionsHttpHeadersWriter.NOSNIFF); + } + + @Test + public void writeHeadersWhenHeaderWrittenThenDoesNotOverrride() { + String headerValue = "value"; + headers.set(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS, headerValue); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(ContentTypeOptionsHttpHeadersWriter.X_CONTENT_OPTIONS)).containsOnly(headerValue); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/XFrameOptionsHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/XFrameOptionsHttpHeadersWriterTests.java new file mode 100644 index 0000000000..903192c922 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/XFrameOptionsHttpHeadersWriterTests.java @@ -0,0 +1,86 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class XFrameOptionsHttpHeadersWriterTests { + + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + XFrameOptionsHttpHeadersWriter writer; + + @Before + public void setup() { + writer = new XFrameOptionsHttpHeadersWriter(); + } + + @Test + public void writeHeadersWhenUsingDefaultsThenWritesDeny() { + writer.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS)).containsOnly("DENY"); + } + + @Test + public void writeHeadersWhenUsingExplicitDenyThenWritesDeny() { + writer.setMode(XFrameOptionsHttpHeadersWriter.Mode.DENY); + + writer.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS)).containsOnly("DENY"); + } + + @Test + public void writeHeadersWhenUsingSameOriginThenWritesSameOrigin() { + writer.setMode(XFrameOptionsHttpHeadersWriter.Mode.SAMEORIGIN); + + writer.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS)).containsOnly("SAMEORIGIN"); + } + + @Test + public void writeHeadersWhenAlreadyWrittenThenWritesHeader() { + String headerValue = "other"; + exchange.getResponse().getHeaders().set(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS, headerValue); + + writer.writeHttpHeaders(exchange); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + assertThat(headers).hasSize(1); + assertThat(headers.get(XFrameOptionsHttpHeadersWriter.X_FRAME_OPTIONS)).containsOnly(headerValue); + } + +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/header/XXssProtectionHttpHeadersWriterTests.java b/webflux/src/test/java/org/springframework/security/web/server/header/XXssProtectionHttpHeadersWriterTests.java new file mode 100644 index 0000000000..a44d862f61 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/header/XXssProtectionHttpHeadersWriterTests.java @@ -0,0 +1,77 @@ +/* + * + * * Copyright 2002-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.security.web.server.header; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class XXssProtectionHttpHeadersWriterTests { + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + HttpHeaders headers = exchange.getResponse().getHeaders(); + + XXssProtectionHttpHeadersWriter writer = new XXssProtectionHttpHeadersWriter(); + + @Test + public void writeHeadersWhenNoHeadersThenWriteHeaders() { + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(XXssProtectionHttpHeadersWriter.X_XSS_PROTECTION)).containsOnly("1 ; mode=block"); + } + + @Test + public void writeHeadersWhenBlockFalseThenWriteHeaders() { + writer.setBlock(false); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(XXssProtectionHttpHeadersWriter.X_XSS_PROTECTION)).containsOnly("1"); + } + + @Test + public void writeHeadersWhenEnabledFalseThenWriteHeaders() { + writer.setEnabled(false); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(XXssProtectionHttpHeadersWriter.X_XSS_PROTECTION)).containsOnly("0"); + } + + @Test + public void writeHeadersWhenHeaderWrittenThenDoesNotOverrride() { + String headerValue = "value"; + headers.set(XXssProtectionHttpHeadersWriter.X_XSS_PROTECTION, headerValue); + + writer.writeHttpHeaders(exchange); + + assertThat(headers).hasSize(1); + assertThat(headers.get(XXssProtectionHttpHeadersWriter.X_XSS_PROTECTION)).containsOnly(headerValue); + } + +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/util/matcher/AndServerWebExchangeMatcherTests.java b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/AndServerWebExchangeMatcherTests.java new file mode 100644 index 0000000000..f1805c11fb --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/AndServerWebExchangeMatcherTests.java @@ -0,0 +1,116 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.web.server.ServerWebExchange; + +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class AndServerWebExchangeMatcherTests { + @Mock + ServerWebExchange exchange; + @Mock + ServerWebExchangeMatcher matcher1; + @Mock + ServerWebExchangeMatcher matcher2; + + AndServerWebExchangeMatcher matcher; + + @Before + public void setUp() throws Exception { + matcher = new AndServerWebExchangeMatcher(matcher1, matcher2); + } + + @Test + public void matchesWhenTrueTrueThenTrue() throws Exception { + Map params1 = Collections.singletonMap("foo", "bar"); + Map params2 = Collections.singletonMap("x", "y"); + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.match(params1)); + when(matcher2.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.match(params2)); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isTrue(); + assertThat(matches.getVariables()).hasSize(2); + assertThat(matches.getVariables()).containsAllEntriesOf(params1); + assertThat(matches.getVariables()).containsAllEntriesOf(params2); + + verify(matcher1).matches(exchange); + verify(matcher2).matches(exchange); + } + + @Test + public void matchesWhenFalseFalseThenFalseAndMatcher2NotInvoked() throws Exception { + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch()); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isFalse(); + assertThat(matches.getVariables()).isEmpty(); + + verify(matcher1).matches(exchange); + verify(matcher2, never()).matches(exchange); + } + + @Test + public void matchesWhenTrueFalseThenFalse() throws Exception { + Map params = Collections.singletonMap("foo", "bar"); + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.match(params)); + when(matcher2.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch()); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isFalse(); + assertThat(matches.getVariables()).isEmpty(); + + verify(matcher1).matches(exchange); + verify(matcher2).matches(exchange); + } + + @Test + public void matchesWhenFalseTrueThenFalse() throws Exception { + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch()); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isFalse(); + assertThat(matches.getVariables()).isEmpty(); + + verify(matcher1).matches(exchange); + verify(matcher2, never()).matches(exchange); + } + +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/util/matcher/OrServerWebExchangeMatcherTests.java b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/OrServerWebExchangeMatcherTests.java new file mode 100644 index 0000000000..719ad71448 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/OrServerWebExchangeMatcherTests.java @@ -0,0 +1,99 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.web.server.ServerWebExchange; + +import java.util.Collections; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class OrServerWebExchangeMatcherTests { + @Mock + ServerWebExchange exchange; + @Mock + ServerWebExchangeMatcher matcher1; + @Mock + ServerWebExchangeMatcher matcher2; + + OrServerWebExchangeMatcher matcher; + + @Before + public void setUp() throws Exception { + matcher = new OrServerWebExchangeMatcher(matcher1, matcher2); + } + + @Test + public void matchesWhenFalseFalseThenFalse() throws Exception { + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch()); + when(matcher2.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch()); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isFalse(); + assertThat(matches.getVariables()).isEmpty(); + + verify(matcher1).matches(exchange); + verify(matcher2).matches(exchange); + } + + @Test + public void matchesWhenTrueFalseThenTrueAndMatcher2NotInvoked() throws Exception { + Map params = Collections.singletonMap("foo", "bar"); + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.match(params)); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isTrue(); + assertThat(matches.getVariables()).isEqualTo(params); + + verify(matcher1).matches(exchange); + verify(matcher2, never()).matches(exchange); + } + + @Test + public void matchesWhenFalseTrueThenTrue() throws Exception { + Map params = Collections.singletonMap("foo", "bar"); + when(matcher1.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.notMatch()); + when(matcher2.matches(exchange)).thenReturn(ServerWebExchangeMatcher.MatchResult.match(params)); + + ServerWebExchangeMatcher.MatchResult matches = matcher.matches(exchange); + + assertThat(matches.isMatch()).isTrue(); + assertThat(matches.getVariables()).isEqualTo(params); + + verify(matcher1).matches(exchange); + verify(matcher2).matches(exchange); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/util/matcher/PathMatcherServerWebExchangeMatcherTests.java b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/PathMatcherServerWebExchangeMatcherTests.java new file mode 100644 index 0000000000..122992aa54 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/PathMatcherServerWebExchangeMatcherTests.java @@ -0,0 +1,115 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.http.server.reactive.MockServerWebExchange; +import org.springframework.util.PathMatcher; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.DefaultWebSessionManager; + +/** + * @author Rob Winch + * @since 5.0 + */ +@RunWith(MockitoJUnitRunner.class) +public class PathMatcherServerWebExchangeMatcherTests { + @Mock + PathMatcher pathMatcher; + MockServerWebExchange exchange; + PathMatcherServerWebExchangeMatcher matcher; + String pattern; + String path; + + @Before + public void setup() { + MockServerHttpRequest request = MockServerHttpRequest.post("/path").build(); + MockServerHttpResponse response = new MockServerHttpResponse(); + DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); + exchange = request.toExchange(); + pattern = "/pattern"; + path = "/path"; + + matcher = new PathMatcherServerWebExchangeMatcher(pattern); + matcher.setPathMatcher(pathMatcher); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorPatternWhenPatternNullThenThrowsException() { + new PathMatcherServerWebExchangeMatcher(null); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorPatternAndMethodWhenPatternNullThenThrowsException() { + new PathMatcherServerWebExchangeMatcher(null, HttpMethod.GET); + } + + @Test + public void matchesWhenPathMatcherTrueThenReturnTrue() { + when(pathMatcher.match(pattern, path)).thenReturn(true); + + assertThat(matcher.matches(exchange).isMatch()).isTrue(); + } + + @Test + public void matchesWhenPathMatcherFalseThenReturnFalse() { + when(pathMatcher.match(pattern, path)).thenReturn(false); + + assertThat(matcher.matches(exchange).isMatch()).isFalse(); + + verify(pathMatcher).match(pattern, path); + } + + @Test + public void matchesWhenPathMatcherTrueAndMethodTrueThenReturnTrue() { + matcher = new PathMatcherServerWebExchangeMatcher(pattern, exchange.getRequest().getMethod()); + matcher.setPathMatcher(pathMatcher); + when(pathMatcher.match(pattern, path)).thenReturn(true); + + assertThat(matcher.matches(exchange).isMatch()).isTrue(); + } + + @Test + public void matchesWhenPathMatcherTrueAndMethodFalseThenReturnFalse() { + HttpMethod method = HttpMethod.OPTIONS; + assertThat(exchange.getRequest().getMethod()).isNotEqualTo(method); + matcher = new PathMatcherServerWebExchangeMatcher(pattern, method); + matcher.setPathMatcher(pathMatcher); + + assertThat(matcher.matches(exchange).isMatch()).isFalse(); + + verifyZeroInteractions(pathMatcher); + } + + @Test(expected = IllegalArgumentException.class) + public void setPathMatcherWhenNullThenThrowException() { + matcher.setPathMatcher(null); + } +} diff --git a/webflux/src/test/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatchersTests.java b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatchersTests.java new file mode 100644 index 0000000000..c42f1774f4 --- /dev/null +++ b/webflux/src/test/java/org/springframework/security/web/server/util/matcher/ServerWebExchangeMatchersTests.java @@ -0,0 +1,86 @@ +/* + * + * * Copyright 2002-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.security.web.server.util.matcher; + +import org.junit.Test; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.antMatchers; +import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.anyExchange; + +/** + * @author Rob Winch + * @since 5.0 + */ +public class ServerWebExchangeMatchersTests { + ServerWebExchange exchange = MockServerHttpRequest.get("/").toExchange(); + + @Test + public void antMatchersWhenSingleAndSamePatternThenMatches() throws Exception { + assertThat(antMatchers("/").matches(exchange).isMatch()).isTrue(); + } + + @Test + public void antMatchersWhenSingleAndSamePatternAndMethodThenMatches() throws Exception { + assertThat(antMatchers(HttpMethod.GET, "/").matches(exchange).isMatch()).isTrue(); + } + + @Test + public void antMatchersWhenSingleAndSamePatternAndDiffMethodThenDoesNotMatch() throws Exception { + assertThat(antMatchers(HttpMethod.POST, "/").matches(exchange).isMatch()).isFalse(); + } + + @Test + public void antMatchersWhenSingleAndDifferentPatternThenDoesNotMatch() throws Exception { + assertThat(antMatchers("/foobar").matches(exchange).isMatch()).isFalse(); + } + + @Test + public void antMatchersWhenMultiThenMatches() throws Exception { + assertThat(antMatchers("/foobar", "/").matches(exchange).isMatch()).isTrue(); + } + + @Test + public void anyExchangeWhenMockThenMatches() { + ServerWebExchange mockExchange = mock(ServerWebExchange.class); + + assertThat(anyExchange().matches(mockExchange).isMatch()).isTrue(); + + verifyZeroInteractions(mockExchange); + } + + /** + * If a LinkedMap is used and anyRequest equals anyRequest then the following is added: + * anyRequest() -> authenticated() + * antMatchers("/admin/**") -> hasRole("ADMIN") + * anyRequest() -> permitAll + * + * will result in the first entry being overridden + */ + @Test + public void anyExchangeWhenTwoCreatedThenDifferentToPreventIssuesInMap() { + assertThat(anyExchange()).isNotEqualTo(anyExchange()); + } +}