diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index 282896dd83..a5f6cc3548 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -133,6 +133,7 @@ import org.springframework.security.web.server.authorization.AuthorizationContex import org.springframework.security.web.server.authorization.AuthorizationWebFilter; import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager; import org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter; +import org.springframework.security.web.server.authorization.IpAddressReactiveAuthorizationManager; import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler; import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler; import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository; @@ -1682,6 +1683,17 @@ public class ServerHttpSecurity { return access(AuthenticatedReactiveAuthorizationManager.authenticated()); } + /** + * Require a specific IP address or range using an IP/Netmask (e.g. + * 192.168.1.0/24). + * @param ipAddress the address or range of addresses from which the request + * must come. + * @return the {@link AuthorizeExchangeSpec} to configure + */ + public AuthorizeExchangeSpec hasIpAddress(String ipAddress) { + return access(IpAddressReactiveAuthorizationManager.hasIpAddress(ipAddress)); + } + /** * Allows plugging in a custom authorization strategy * @param manager the authorization manager to use diff --git a/web/src/main/java/org/springframework/security/web/server/authorization/IpAddressReactiveAuthorizationManager.java b/web/src/main/java/org/springframework/security/web/server/authorization/IpAddressReactiveAuthorizationManager.java new file mode 100644 index 0000000000..5b814a5276 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authorization/IpAddressReactiveAuthorizationManager.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.authorization; + +import reactor.core.publisher.Mono; + +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.IpAddressServerWebExchangeMatcher; +import org.springframework.util.Assert; + +/** + * A {@link ReactiveAuthorizationManager}, that determines if the current request contains + * the specified address or range of addresses + * + * @author Guirong Hu + * @since 5.7 + */ +public final class IpAddressReactiveAuthorizationManager implements ReactiveAuthorizationManager { + + private final IpAddressServerWebExchangeMatcher ipAddressExchangeMatcher; + + IpAddressReactiveAuthorizationManager(String ipAddress) { + this.ipAddressExchangeMatcher = new IpAddressServerWebExchangeMatcher(ipAddress); + } + + @Override + public Mono check(Mono authentication, AuthorizationContext context) { + return Mono.just(context.getExchange()).flatMap(this.ipAddressExchangeMatcher::matches) + .map((matchResult) -> new AuthorizationDecision(matchResult.isMatch())); + } + + /** + * Creates an instance of {@link IpAddressReactiveAuthorizationManager} with the + * provided IP address. + * @param ipAddress the address or range of addresses from which the request must + * @return the new instance + */ + public static IpAddressReactiveAuthorizationManager hasIpAddress(String ipAddress) { + Assert.notNull(ipAddress, "This IP address is required; it must not be null"); + return new IpAddressReactiveAuthorizationManager(ipAddress); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java b/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java new file mode 100644 index 0000000000..29354ac3b6 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.util.matcher; + +import reactor.core.publisher.Mono; + +import org.springframework.security.web.util.matcher.IpAddressMatcher; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * Matches a request based on IP Address or subnet mask matching against the remote + * address. + * + * @author Guirong Hu + * @since 5.7 + */ +public class IpAddressServerWebExchangeMatcher implements ServerWebExchangeMatcher { + + private final IpAddressMatcher ipAddressMatcher; + + /** + * Takes a specific IP address or a range specified using the IP/Netmask (e.g. + * 192.168.1.0/24 or 202.24.0.0/14). + * @param ipAddress the address or range of addresses from which the request must + * come. + */ + public IpAddressServerWebExchangeMatcher(String ipAddress) { + Assert.hasText(ipAddress, "IP address cannot be empty"); + this.ipAddressMatcher = new IpAddressMatcher(ipAddress); + } + + @Override + public Mono matches(ServerWebExchange exchange) { + // @formatter:off + return Mono.justOrEmpty(exchange.getRequest().getRemoteAddress()) + .map((remoteAddress) -> remoteAddress.getAddress().getHostAddress()) + .map(this.ipAddressMatcher::matches) + .flatMap((matches) -> matches ? MatchResult.match() : MatchResult.notMatch()) + .switchIfEmpty(MatchResult.notMatch()); + // @formatter:on + } + + @Override + public String toString() { + return "IpAddressServerWebExchangeMatcher{ipAddressMatcher=" + this.ipAddressMatcher + '}'; + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authorization/IpAddressReactiveAuthorizationManagerTests.java b/web/src/test/java/org/springframework/security/web/server/authorization/IpAddressReactiveAuthorizationManagerTests.java new file mode 100644 index 0000000000..5b42423e2c --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authorization/IpAddressReactiveAuthorizationManagerTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.authorization; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link IpAddressReactiveAuthorizationManager} + * + * @author Guirong Hu + */ +public class IpAddressReactiveAuthorizationManagerTests { + + @Test + public void checkWhenHasIpv6AddressThenReturnTrue() throws UnknownHostException { + IpAddressReactiveAuthorizationManager v6manager = IpAddressReactiveAuthorizationManager + .hasIpAddress("fe80::21f:5bff:fe33:bd68"); + boolean granted = v6manager.check(null, context("fe80::21f:5bff:fe33:bd68")).block().isGranted(); + assertThat(granted).isTrue(); + } + + @Test + public void checkWhenHasIpv6AddressThenReturnFalse() throws UnknownHostException { + IpAddressReactiveAuthorizationManager v6manager = IpAddressReactiveAuthorizationManager + .hasIpAddress("fe80::21f:5bff:fe33:bd68"); + boolean granted = v6manager.check(null, context("fe80::1c9a:7cfd:29a8:a91e")).block().isGranted(); + assertThat(granted).isFalse(); + } + + @Test + public void checkWhenHasIpv4AddressThenReturnTrue() throws UnknownHostException { + IpAddressReactiveAuthorizationManager v4manager = IpAddressReactiveAuthorizationManager + .hasIpAddress("192.168.1.104"); + boolean granted = v4manager.check(null, context("192.168.1.104")).block().isGranted(); + assertThat(granted).isTrue(); + } + + @Test + public void checkWhenHasIpv4AddressThenReturnFalse() throws UnknownHostException { + IpAddressReactiveAuthorizationManager v4manager = IpAddressReactiveAuthorizationManager + .hasIpAddress("192.168.1.104"); + boolean granted = v4manager.check(null, context("192.168.100.15")).block().isGranted(); + assertThat(granted).isFalse(); + } + + private static AuthorizationContext context(String ipAddress) throws UnknownHostException { + MockServerWebExchange exchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/") + .remoteAddress(new InetSocketAddress(InetAddress.getByName(ipAddress), 8080))).build(); + return new AuthorizationContext(exchange); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcherTests.java b/web/src/test/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcherTests.java new file mode 100644 index 0000000000..3c26dfdfd9 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcherTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.util.matcher; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link IpAddressServerWebExchangeMatcher} + * + * @author Guirong Hu + */ +@ExtendWith(MockitoExtension.class) +public class IpAddressServerWebExchangeMatcherTests { + + @Test + public void matchesWhenIpv6RangeAndIpv6AddressThenTrue() throws UnknownHostException { + ServerWebExchange ipv6Exchange = exchange("fe80::21f:5bff:fe33:bd68"); + ServerWebExchangeMatcher.MatchResult matches = new IpAddressServerWebExchangeMatcher("fe80::21f:5bff:fe33:bd68") + .matches(ipv6Exchange).block(); + assertThat(matches.isMatch()).isTrue(); + } + + @Test + public void matchesWhenIpv6RangeAndIpv4AddressThenFalse() throws UnknownHostException { + ServerWebExchange ipv4Exchange = exchange("192.168.1.104"); + ServerWebExchangeMatcher.MatchResult matches = new IpAddressServerWebExchangeMatcher("fe80::21f:5bff:fe33:bd68") + .matches(ipv4Exchange).block(); + assertThat(matches.isMatch()).isFalse(); + } + + @Test + public void matchesWhenIpv4RangeAndIpv4AddressThenTrue() throws UnknownHostException { + ServerWebExchange ipv4Exchange = exchange("192.168.1.104"); + ServerWebExchangeMatcher.MatchResult matches = new IpAddressServerWebExchangeMatcher("192.168.1.104") + .matches(ipv4Exchange).block(); + assertThat(matches.isMatch()).isTrue(); + } + + @Test + public void matchesWhenIpv4SubnetAndIpv4AddressThenTrue() throws UnknownHostException { + ServerWebExchange ipv4Exchange = exchange("192.168.1.104"); + IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("192.168.1.0/24"); + assertThat(matcher.matches(ipv4Exchange).block().isMatch()).isTrue(); + } + + @Test + public void matchesWhenIpv4SubnetAndIpv4AddressThenFalse() throws UnknownHostException { + ServerWebExchange ipv4Exchange = exchange("192.168.1.104"); + IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("192.168.1.128/25"); + assertThat(matcher.matches(ipv4Exchange).block().isMatch()).isFalse(); + } + + @Test + public void matchesWhenIpv6SubnetAndIpv6AddressThenTrue() throws UnknownHostException { + ServerWebExchange ipv6Exchange = exchange("2001:DB8:0:FFFF:FFFF:FFFF:FFFF:FFFF"); + IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("2001:DB8::/48"); + assertThat(matcher.matches(ipv6Exchange).block().isMatch()).isTrue(); + } + + @Test + public void matchesWhenIpv6SubnetAndIpv6AddressThenFalse() throws UnknownHostException { + ServerWebExchange ipv6Exchange = exchange("2001:DB8:1:0:0:0:0:0"); + IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("2001:DB8::/48"); + assertThat(matcher.matches(ipv6Exchange).block().isMatch()).isFalse(); + } + + @Test + public void matchesWhenZeroMaskAndAnythingThenTrue() throws UnknownHostException { + IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("0.0.0.0/0"); + assertThat(matcher.matches(exchange("123.4.5.6")).block().isMatch()).isTrue(); + assertThat(matcher.matches(exchange("192.168.0.159")).block().isMatch()).isTrue(); + matcher = new IpAddressServerWebExchangeMatcher("192.168.0.159/0"); + assertThat(matcher.matches(exchange("123.4.5.6")).block().isMatch()).isTrue(); + assertThat(matcher.matches(exchange("192.168.0.159")).block().isMatch()).isTrue(); + } + + @Test + public void constructorWhenIpv4AddressMaskTooLongThenIllegalArgumentException() { + String ipv4AddressWithTooLongMask = "192.168.1.104/33"; + assertThatIllegalArgumentException() + .isThrownBy(() -> new IpAddressServerWebExchangeMatcher(ipv4AddressWithTooLongMask)) + .withMessage(String.format("IP address %s is too short for bitmask of length %d", "192.168.1.104", 33)); + } + + @Test + public void constructorWhenIpv6AddressMaskTooLongThenIllegalArgumentException() { + String ipv6AddressWithTooLongMask = "fe80::21f:5bff:fe33:bd68/129"; + assertThatIllegalArgumentException() + .isThrownBy(() -> new IpAddressServerWebExchangeMatcher(ipv6AddressWithTooLongMask)) + .withMessage(String.format("IP address %s is too short for bitmask of length %d", + "fe80::21f:5bff:fe33:bd68", 129)); + } + + private static ServerWebExchange exchange(String ipAddress) throws UnknownHostException { + return MockServerWebExchange.builder(MockServerHttpRequest.get("/") + .remoteAddress(new InetSocketAddress(InetAddress.getByName(ipAddress), 8080))).build(); + } + +}