diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 892c10f9..7d6e28b4 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -547,6 +547,8 @@ The `KeyResolver` interface allows pluggable strategies to derive the key for li The default implementation of `KeyResolver` is the `PrincipalNameKeyResolver` which retrieves the `Principal` from the `ServerWebExchange` and calls `Principal.getName()`. +By default, if the `KeyResolver` does not find a key, requests will be denied. This behavior can be adjust with the `spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key` (true or false) and `spring.cloud.gateway.filter.request-rate-limiter.empty-key-status-code` properties. + NOTE: The RequestRateLimiter is not configurable via the "shortcut" notation. The example below is __invalid__ .application.properties diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index bb3f58dd..9d099c77 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -557,13 +557,14 @@ public class GatewayAutoConfiguration { @Bean(name = PrincipalNameKeyResolver.BEAN_NAME) @ConditionalOnBean(RateLimiter.class) + @ConditionalOnMissingBean(KeyResolver.class) public PrincipalNameKeyResolver principalNameKeyResolver() { return new PrincipalNameKeyResolver(); } @Bean @ConditionalOnBean({RateLimiter.class, KeyResolver.class}) - public RequestRateLimiterGatewayFilterFactory requestRateLimiterGatewayFilterFactory(RateLimiter rateLimiter, PrincipalNameKeyResolver resolver) { + public RequestRateLimiterGatewayFilterFactory requestRateLimiterGatewayFilterFactory(RateLimiter rateLimiter, KeyResolver resolver) { return new RequestRateLimiterGatewayFilterFactory(rateLimiter, resolver); } diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RedirectToGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RedirectToGatewayFilterFactory.java index 53706880..5ac15183 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RedirectToGatewayFilterFactory.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RedirectToGatewayFilterFactory.java @@ -22,12 +22,12 @@ import java.util.Arrays; import java.util.List; import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.support.HttpStatusHolder; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.Assert; -import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.parse; import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus; import reactor.core.publisher.Mono; @@ -55,14 +55,17 @@ public class RedirectToGatewayFilterFactory extends AbstractGatewayFilterFactory } public GatewayFilter apply(String statusString, String urlString) { - final HttpStatus httpStatus = parse(statusString); + HttpStatusHolder httpStatus = HttpStatusHolder.parse(statusString); Assert.isTrue(httpStatus.is3xxRedirection(), "status must be a 3xx code, but was " + statusString); final URI url = URI.create(urlString); return apply(httpStatus, url); } public GatewayFilter apply(HttpStatus httpStatus, URI uri) { + return apply(new HttpStatusHolder(httpStatus, null), uri); + } + public GatewayFilter apply(HttpStatusHolder httpStatus, URI uri) { return (exchange, chain) -> chain.filter(exchange).then(Mono.defer(() -> { if (!exchange.getResponse().isCommitted()) { diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java index cbffce7c..24275f02 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java @@ -17,25 +17,37 @@ package org.springframework.cloud.gateway.filter.factory; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter; import org.springframework.cloud.gateway.route.Route; +import org.springframework.cloud.gateway.support.HttpStatusHolder; import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; import org.springframework.http.HttpStatus; -import java.util.Map; +import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus; /** * User Request Rate Limiter filter. See https://stripe.com/blog/rate-limiters and */ +@ConfigurationProperties("spring.cloud.gateway.filter.request-rate-limiter") public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilterFactory { public static final String KEY_RESOLVER_KEY = "keyResolver"; + private static final String EMPTY_KEY = "____EMPTY_KEY__"; private final RateLimiter defaultRateLimiter; private final KeyResolver defaultKeyResolver; + /** Switch to deny requests if the Key Resolver returns an empty key, defaults to true. */ + private boolean denyEmptyKey = true; + + /** HttpStatus to return when denyEmptyKey is true, defaults to FORBIDDEN. */ + private String emptyKeyStatusCode = HttpStatus.FORBIDDEN.toString(); + public RequestRateLimiterGatewayFilterFactory(RateLimiter defaultRateLimiter, KeyResolver defaultKeyResolver) { super(Config.class); @@ -51,37 +63,68 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte return defaultRateLimiter; } + public boolean isDenyEmptyKey() { + return denyEmptyKey; + } + + public void setDenyEmptyKey(boolean denyEmptyKey) { + this.denyEmptyKey = denyEmptyKey; + } + + public String getEmptyKeyStatusCode() { + return emptyKeyStatusCode; + } + + public void setEmptyKeyStatusCode(String emptyKeyStatusCode) { + this.emptyKeyStatusCode = emptyKeyStatusCode; + } + @SuppressWarnings("unchecked") @Override public GatewayFilter apply(Config config) { - KeyResolver resolver = (config.keyResolver == null) ? defaultKeyResolver : config.keyResolver; - RateLimiter limiter = (config.rateLimiter == null) ? defaultRateLimiter : config.rateLimiter; + KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver); + RateLimiter limiter = getOrDefault(config.rateLimiter, defaultRateLimiter); + boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey); + HttpStatusHolder emptyKeyStatus = HttpStatusHolder.parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode)); return (exchange, chain) -> { Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR); - return resolver.resolve(exchange).flatMap(key -> - // TODO: if key is empty? - limiter.isAllowed(route.getId(), key).flatMap(response -> { - - for (Map.Entry header : response.getHeaders().entrySet()) { - exchange.getResponse().getHeaders().add(header.getKey(), header.getValue()); - } - - if (response.isAllowed()) { - return chain.filter(exchange); - } - - exchange.getResponse().setStatusCode(config.getStatusCode()); + return resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY).flatMap(key -> { + if (EMPTY_KEY.equals(key)) { + if (denyEmpty) { + setResponseStatus(exchange, emptyKeyStatus); return exchange.getResponse().setComplete(); - })); + } + return chain.filter(exchange); + } + return limiter.isAllowed(route.getId(), key).flatMap(response -> { + + for (Map.Entry header : response.getHeaders().entrySet()) { + exchange.getResponse().getHeaders().add(header.getKey(), header.getValue()); + } + + if (response.isAllowed()) { + return chain.filter(exchange); + } + + setResponseStatus(exchange, config.getStatusCode()); + return exchange.getResponse().setComplete(); + }); + }); }; } + private T getOrDefault(T configValue, T defaultValue) { + return (configValue != null) ? configValue : defaultValue; + } + public static class Config { private KeyResolver keyResolver; private RateLimiter rateLimiter; private HttpStatus statusCode = HttpStatus.TOO_MANY_REQUESTS; + private Boolean denyEmptyKey; + private String emptyKeyStatus; public KeyResolver getKeyResolver() { return keyResolver; @@ -108,6 +151,24 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte this.statusCode = statusCode; return this; } + + public Boolean getDenyEmptyKey() { + return denyEmptyKey; + } + + public Config setDenyEmptyKey(Boolean denyEmptyKey) { + this.denyEmptyKey = denyEmptyKey; + return this; + } + + public String getEmptyKeyStatus() { + return emptyKeyStatus; + } + + public Config setEmptyKeyStatus(String emptyKeyStatus) { + this.emptyKeyStatus = emptyKeyStatus; + return this; + } } } diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/SetStatusGatewayFilterFactory.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/SetStatusGatewayFilterFactory.java index f7acade4..1f25e3a1 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/SetStatusGatewayFilterFactory.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/filter/factory/SetStatusGatewayFilterFactory.java @@ -23,9 +23,7 @@ import java.util.List; import reactor.core.publisher.Mono; import org.springframework.cloud.gateway.filter.GatewayFilter; -import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; -import org.springframework.http.HttpStatus; -import org.springframework.http.server.reactive.AbstractServerHttpResponse; +import org.springframework.cloud.gateway.support.HttpStatusHolder; import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus; @@ -47,13 +45,7 @@ public class SetStatusGatewayFilterFactory extends AbstractGatewayFilterFactory< @Override public GatewayFilter apply(Config config) { - final HttpStatus status = ServerWebExchangeUtils.parse(config.status); - final Integer intStatus; - if (status == null) { - intStatus = Integer.parseInt(config.status); - } else { - intStatus = null; - } + HttpStatusHolder statusHolder = HttpStatusHolder.parse(config.status); return (exchange, chain) -> { // option 1 (runs in filter order) @@ -67,13 +59,7 @@ public class SetStatusGatewayFilterFactory extends AbstractGatewayFilterFactory< return chain.filter(exchange).then(Mono.fromRunnable(() -> { // check not really needed, since it is guarded in setStatusCode, // but it's a good example - if (!exchange.getResponse().isCommitted()) { - if (status != null) { // standard status - setResponseStatus(exchange, status); - } else if (intStatus != null && exchange.getResponse() instanceof AbstractServerHttpResponse) { //non-standard - ((AbstractServerHttpResponse)exchange.getResponse()).setStatusCodeValue(intStatus); - } - } + setResponseStatus(exchange, statusHolder); })); }; } diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/HttpStatusHolder.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/HttpStatusHolder.java new file mode 100644 index 00000000..51035033 --- /dev/null +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/HttpStatusHolder.java @@ -0,0 +1,124 @@ +/* + * Copyright 2013-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.springframework.cloud.gateway.support; + +import org.springframework.core.style.ToStringCreator; +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; + +public class HttpStatusHolder { + + private final HttpStatus httpStatus; + private final Integer status; + + public static HttpStatusHolder parse(String status) { + final HttpStatus httpStatus = ServerWebExchangeUtils.parse(status); + final Integer intStatus; + if (httpStatus == null) { + intStatus = Integer.parseInt(status); + } else { + intStatus = null; + } + + return new HttpStatusHolder(httpStatus, intStatus); + } + + public HttpStatusHolder(HttpStatus httpStatus, Integer status) { + Assert.isTrue(httpStatus != null || status != null, + "httpStatus and status may not both be null"); + this.httpStatus = httpStatus; + this.status = status; + } + + public HttpStatus getHttpStatus() { + return httpStatus; + } + + public Integer getStatus() { + return status; + } + + /** + * Whether this status code is in the HTTP series + * {@link org.springframework.http.HttpStatus.Series#INFORMATIONAL}. + */ + public boolean is1xxInformational() { + return HttpStatus.Series.INFORMATIONAL.equals(getSeries()); + } + + /** + * Whether this status code is in the HTTP series + * {@link org.springframework.http.HttpStatus.Series#SUCCESSFUL}. + */ + public boolean is2xxSuccessful() { + return HttpStatus.Series.SUCCESSFUL.equals(getSeries()); + } + + /** + * Whether this status code is in the HTTP series + * {@link org.springframework.http.HttpStatus.Series#REDIRECTION}. + */ + public boolean is3xxRedirection() { + return HttpStatus.Series.REDIRECTION.equals(getSeries()); + } + + + /** + * Whether this status code is in the HTTP series + * {@link org.springframework.http.HttpStatus.Series#CLIENT_ERROR}. + */ + public boolean is4xxClientError() { + return HttpStatus.Series.CLIENT_ERROR.equals(getSeries()); + } + + /** + * Whether this status code is in the HTTP series + * {@link org.springframework.http.HttpStatus.Series#SERVER_ERROR}. + */ + public boolean is5xxServerError() { + return HttpStatus.Series.SERVER_ERROR.equals(getSeries()); + } + + public HttpStatus.Series getSeries() { + if (httpStatus != null) { + return httpStatus.series(); + } + if (status != null) { + return HttpStatus.Series.valueOf(status); + } + return null; + } + + + /** + * Whether this status code is in the HTTP series + * {@link org.springframework.http.HttpStatus.Series#CLIENT_ERROR} or + * {@link org.springframework.http.HttpStatus.Series#SERVER_ERROR}. + */ + public boolean isError() { + return is4xxClientError() || is5xxServerError(); + } + + @Override + public String toString() { + return new ToStringCreator(this) + .append("httpStatus", httpStatus) + .append("status", status) + .toString(); + } +} diff --git a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java index c0626abc..2164b4ec 100644 --- a/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java +++ b/spring-cloud-gateway-core/src/main/java/org/springframework/cloud/gateway/support/ServerWebExchangeUtils.java @@ -28,6 +28,7 @@ import reactor.core.publisher.Mono; import org.springframework.cloud.gateway.handler.AsyncPredicate; import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.AbstractServerHttpResponse; import org.springframework.web.server.ServerWebExchange; /** @@ -80,6 +81,21 @@ public class ServerWebExchangeUtils { return response; } + public static boolean setResponseStatus(ServerWebExchange exchange, HttpStatusHolder statusHolder) { + if (exchange.getResponse().isCommitted()) { + return false; + } + if (statusHolder.getHttpStatus() != null) { + return setResponseStatus(exchange, statusHolder.getHttpStatus()); + } + if (statusHolder.getStatus() != null + && exchange.getResponse() instanceof AbstractServerHttpResponse) { //non-standard + ((AbstractServerHttpResponse)exchange.getResponse()).setStatusCodeValue(statusHolder.getStatus()); + return true; + } + return false; + } + public static boolean containsEncodedParts(URI uri) { boolean encoded = (uri.getRawQuery() != null && uri.getRawQuery().contains("%")) || (uri.getPath() != null && uri.getRawPath().contains("%")); diff --git a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java index 24b16f32..d5280a67 100644 --- a/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java +++ b/spring-cloud-gateway-core/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactoryTests.java @@ -19,6 +19,7 @@ import org.springframework.cloud.gateway.test.BaseWebClientTests; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; import org.springframework.http.HttpStatus; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.web.server.MockServerWebExchange; @@ -52,17 +53,14 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe @MockBean private GatewayFilterChain filterChain; - @Autowired - @Qualifier("resolver1") - KeyResolver resolver1; - @Autowired @Qualifier("resolver2") KeyResolver resolver2; @Test public void allowedWorks() { - assertFilterFactory(resolver1, "allowedkey", true, HttpStatus.OK); + // tests that auto wired as default works + assertFilterFactory(null, "allowedkey", true, HttpStatus.OK); } @Test @@ -70,14 +68,31 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe assertFilterFactory(resolver2, "notallowedkey", false, HttpStatus.TOO_MANY_REQUESTS); } + @Test + public void emptyKeyDenied() { + assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.FORBIDDEN); + } + + @Test + public void emptyKeyAllowed() { + assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.OK, false); + } + private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus) { + assertFilterFactory(keyResolver, key, allowed, expectedStatus, null); + } + + private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus, + Boolean denyEmptyKey) { String tokensRemaining = allowed ? "1" : "0"; Map headers = Collections.singletonMap("X-Tokens-Remaining", tokensRemaining); - when(rateLimiter.isAllowed("myroute", key)) - .thenReturn(Mono.just(new Response(allowed, headers))); + if (key != null) { + when(rateLimiter.isAllowed("myroute", key)) + .thenReturn(Mono.just(new Response(allowed, headers))); + } MockServerHttpRequest request = MockServerHttpRequest.get("/").build(); MockServerWebExchange exchange = MockServerWebExchange.from(request); @@ -89,6 +104,9 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe when(this.filterChain.filter(exchange)).thenReturn(Mono.empty()); RequestRateLimiterGatewayFilterFactory factory = this.context.getBean(RequestRateLimiterGatewayFilterFactory.class); + if (denyEmptyKey != null) { + factory.setDenyEmptyKey(denyEmptyKey); + } GatewayFilter filter = factory.apply(config -> config.setKeyResolver(keyResolver)); Mono response = filter.filter(exchange, this.filterChain); @@ -105,6 +123,7 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe @Import(BaseWebClientTests.DefaultTestConfig.class) public static class TestConfig { @Bean + @Primary KeyResolver resolver1() { return exchange -> Mono.just("allowedkey"); }