Merge branch '2.0.x'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<RequestRateLimiterGatewayFilterFactory.Config> {
|
||||
|
||||
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<Object> limiter = (config.rateLimiter == null) ? defaultRateLimiter : config.rateLimiter;
|
||||
KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver);
|
||||
RateLimiter<Object> 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<String, String> 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<String, String> 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> 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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("%"));
|
||||
|
||||
@@ -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<String, String> 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<Void> 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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user