diff --git a/core/idrepo/rest-cxf/pom.xml b/core/idrepo/rest-cxf/pom.xml index 1e2d86e3043..f2c0bf2193a 100644 --- a/core/idrepo/rest-cxf/pom.xml +++ b/core/idrepo/rest-cxf/pom.xml @@ -49,6 +49,11 @@ under the License. jakarta.persistence-api + + javax.cache + cache-api + + tools.jackson.jakarta.rs jackson-jakarta-rs-json-provider @@ -128,6 +133,11 @@ under the License. mockito-junit-jupiter test + + com.github.ben-manes.caffeine + jcache + test + diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java index 0865d2aa2c3..7196be2577b 100644 --- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/IdRepoRESTCXFContext.java @@ -20,10 +20,17 @@ import io.swagger.v3.oas.models.security.SecurityScheme; import jakarta.validation.Validator; +import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.TimeUnit; +import javax.cache.Cache; +import javax.cache.CacheManager; +import javax.cache.configuration.MutableConfiguration; +import javax.cache.expiry.TouchedExpiryPolicy; import org.apache.cxf.Bus; import org.apache.cxf.endpoint.Server; import org.apache.cxf.feature.Feature; @@ -227,6 +234,35 @@ public AddETagFilter addETagFilter() { return new AddETagFilter(); } + @ConditionalOnMissingBean + @Bean + public RateLimitFilter rateLimitFilter( + final RESTProperties props, + @Qualifier(RateLimitFilter.CACHE) + final Cache cxfRateLimitCache) { + + return new RateLimitFilter(props, cxfRateLimitCache); + } + + @ConditionalOnMissingBean(name = RateLimitFilter.CACHE) + @Bean(name = RateLimitFilter.CACHE) + public Cache rateLimitCache( + final CacheManager cacheManager, + final RESTProperties restProperties) { + + long windowMillis = Optional.ofNullable(restProperties.getRateLimit().getWindow()). + orElse(Duration.ofMinutes(1)).toMillis(); + long lockMillis = Optional.ofNullable(restProperties.getRateLimit().getLock()). + orElse(Duration.ofMinutes(1)).toMillis(); + long expiry = Math.max(1L, Math.max(windowMillis, lockMillis)); + + return cacheManager.createCache(RateLimitFilter.CACHE, + new MutableConfiguration(). + setTypes(String.class, RateLimitFilter.ClientWindow.class). + setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( + new javax.cache.expiry.Duration(TimeUnit.MILLISECONDS, expiry)))); + } + @ConditionalOnMissingBean(name = { "openApiCustomizer", "syncopeOpenApiCustomizer" }) @Bean public OpenApiCustomizer openApiCustomizer(final DomainHolder domainHolder, final Environment env) { @@ -278,6 +314,7 @@ public Server restContainer( final List services, final AddETagFilter addETagFilter, final AddDomainFilter addDomainFilter, + final RateLimitFilter rateLimitFilter, final ContextProvider searchContextProvider, final JacksonJsonProvider jsonProvider, final DateParamConverterProvider dateParamConverterProvider, @@ -308,6 +345,7 @@ public Server restContainer( jsonProvider, restServiceExceptionMapper, searchContextProvider, + rateLimitFilter, addDomainFilter, addETagFilter)); diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java index 5fc8473f15b..a7ddd4d04f3 100644 --- a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RESTProperties.java @@ -18,6 +18,9 @@ */ package org.apache.syncope.core.rest.cxf; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; import org.apache.syncope.core.provisioning.java.ExecutorProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; @@ -25,10 +28,82 @@ @ConfigurationProperties("rest") public class RESTProperties { + public static class RateLimitProperties { + + private boolean enabled; + + private int maxRequests = 300; + + private Duration window = Duration.ofMinutes(1); + + private Duration lock = Duration.ofMinutes(1); + + private String forwardedForHeader = "X-Forwarded-For"; + + private final Set excludedAddresses = new HashSet<>(); + + private final Set trustedProxies = new HashSet<>(); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public int getMaxRequests() { + return maxRequests; + } + + public void setMaxRequests(final int maxRequests) { + this.maxRequests = maxRequests; + } + + public Duration getWindow() { + return window; + } + + public void setWindow(final Duration window) { + this.window = window; + } + + public Duration getLock() { + return lock; + } + + public void setLock(final Duration lock) { + this.lock = lock; + } + + public String getForwardedForHeader() { + return forwardedForHeader; + } + + public void setForwardedForHeader(final String forwardedForHeader) { + this.forwardedForHeader = forwardedForHeader; + } + + public Set getExcludedAddresses() { + return excludedAddresses; + } + + public Set getTrustedProxies() { + return trustedProxies; + } + } + @NestedConfigurationProperty private final ExecutorProperties batchExecutor = new ExecutorProperties(); + @NestedConfigurationProperty + private final RateLimitProperties rateLimitProperties = new RateLimitProperties(); + public ExecutorProperties getBatchExecutor() { return batchExecutor; } + + public RateLimitProperties getRateLimit() { + return rateLimitProperties; + } } diff --git a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java new file mode 100644 index 00000000000..fe334fa9aa8 --- /dev/null +++ b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RateLimitFilter.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.syncope.core.rest.cxf; + +import jakarta.annotation.Priority; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.container.PreMatching; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.Provider; +import java.io.Serializable; +import java.time.Duration; +import java.util.Arrays; +import java.util.Optional; +import javax.cache.Cache; +import org.apache.commons.lang3.StringUtils; + +@Provider +@PreMatching +@Priority(Priorities.AUTHENTICATION - 100) +public class RateLimitFilter implements ContainerRequestFilter { + + public static final String CACHE = "RateLimitFilterCache"; + + protected record RateLimitDecision(boolean allowed, long retryAfterSeconds) { + + } + + public record ClientWindow(long windowStartMillis, int count, long lockedUntilMillis) implements Serializable { + + private static final long serialVersionUID = -473897805205955157L; + + } + + protected final RESTProperties.RateLimitProperties props; + + protected final Cache clients; + + @Context + protected HttpServletRequest request; + + public RateLimitFilter(final RESTProperties props, final Cache clients) { + this.props = props.getRateLimit(); + this.clients = clients; + } + + @Override + public void filter(final ContainerRequestContext requestContext) { + if (!props.isEnabled() || props.getMaxRequests() <= 0) { + return; + } + + if (isExcluded()) { + return; + } + + String key = clientAddress(); + RateLimitDecision decision = allow(key, System.currentTimeMillis()); + if (!decision.allowed()) { + requestContext.abortWith(Response.status(429). + header(HttpHeaders.RETRY_AFTER, decision.retryAfterSeconds()). + build()); + } + } + + protected RateLimitDecision allow(final String key, final long now) { + return clients.invoke(key, (entry, args) -> { + ClientWindow client = entry.exists() + ? entry.getValue() + : new ClientWindow(now, 0, 0); + + if (now < client.lockedUntilMillis()) { + return new RateLimitDecision(false, retryAfterSeconds(client.lockedUntilMillis() - now)); + } + + long windowStartMillis = client.windowStartMillis(); + int count = client.count(); + if (now - windowStartMillis >= toMillis(props.getWindow())) { + windowStartMillis = now; + count = 0; + } + + count++; + if (count > props.getMaxRequests()) { + entry.setValue(new ClientWindow(windowStartMillis, count, now + toMillis(props.getLock()))); + return new RateLimitDecision(false, retryAfterSeconds(toMillis(props.getLock()))); + } + + entry.setValue(new ClientWindow(windowStartMillis, count, client.lockedUntilMillis())); + return new RateLimitDecision(true, 0); + }); + } + + protected String clientAddress() { + String remoteAddress = remoteAddress(); + + if (props.getTrustedProxies().contains(remoteAddress)) { + String forwardedFor = Optional.ofNullable(request). + map(req -> req.getHeader(props.getForwardedForHeader())). + flatMap(RateLimitFilter::firstForwardedFor). + orElse(null); + if (StringUtils.isNotBlank(forwardedFor)) { + return forwardedFor; + } + } + + return remoteAddress; + } + + protected boolean isExcluded() { + return props.getExcludedAddresses().contains(remoteAddress()); + } + + protected String remoteAddress() { + return requestRemoteAddress(). + filter(StringUtils::isNotBlank). + orElse("unknown"); + } + + protected Optional requestRemoteAddress() { + return Optional.ofNullable(request).map(HttpServletRequest::getRemoteAddr); + } + + protected static Optional firstForwardedFor(final String header) { + return Arrays.stream(StringUtils.split(header, ',')). + map(String::trim). + filter(StringUtils::isNotBlank). + findFirst(); + } + + protected static long toMillis(final Duration duration) { + return Math.max(1L, Optional.ofNullable(duration).orElse(Duration.ofMinutes(1)).toMillis()); + } + + protected static long retryAfterSeconds(final long millis) { + return Math.max(1L, (long) Math.ceil(millis / 1000.0d)); + } +} diff --git a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java new file mode 100644 index 00000000000..833aaaab614 --- /dev/null +++ b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RateLimitFilterTest.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.syncope.core.rest.cxf; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import java.time.Duration; +import javax.cache.Cache; +import javax.cache.Caching; +import javax.cache.configuration.MutableConfiguration; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.test.util.ReflectionTestUtils; + +class RateLimitFilterTest { + + private static final Cache CACHE = + Caching.getCachingProvider().getCacheManager().createCache( + RateLimitFilter.CACHE, new MutableConfiguration<>()); + + private static RateLimitFilter filter(final MockHttpServletRequest request) { + RESTProperties props = new RESTProperties(); + props.getRateLimit().setEnabled(true); + props.getRateLimit().setMaxRequests(2); + props.getRateLimit().setWindow(Duration.ofMinutes(1)); + props.getRateLimit().setLock(Duration.ofSeconds(30)); + + RateLimitFilter filter = new RateLimitFilter(props, CACHE); + ReflectionTestUtils.setField(filter, "request", request); + return filter; + } + + @Test + void blocksWhenClientExceedsLimit() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.10"); + + RateLimitFilter filter = filter(request); + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + + filter.filter(requestContext); + filter.filter(requestContext); + verify(requestContext, never()).abortWith(any()); + + filter.filter(requestContext); + + ArgumentCaptor response = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(response.capture()); + assertEquals(429, response.getValue().getStatus()); + assertEquals("30", response.getValue().getHeaderString(HttpHeaders.RETRY_AFTER)); + } + + @Test + void usesForwardedForOnlyFromTrustedProxy() { + RESTProperties props = new RESTProperties(); + props.getRateLimit().setEnabled(true); + props.getRateLimit().setMaxRequests(1); + props.getRateLimit().getTrustedProxies().add("127.0.0.1"); + + MockHttpServletRequest trustedProxyRequest = new MockHttpServletRequest(); + trustedProxyRequest.setRemoteAddr("127.0.0.1"); + trustedProxyRequest.addHeader("X-Forwarded-For", "203.0.113.10, 198.51.100.20"); + + RateLimitFilter trustedProxyFilter = new RateLimitFilter(props, CACHE); + ReflectionTestUtils.setField(trustedProxyFilter, "request", trustedProxyRequest); + assertEquals("203.0.113.10", trustedProxyFilter.clientAddress()); + + MockHttpServletRequest untrustedRequest = new MockHttpServletRequest(); + untrustedRequest.setRemoteAddr("198.51.100.30"); + untrustedRequest.addHeader("X-Forwarded-For", "203.0.113.10"); + + RateLimitFilter untrustedFilter = new RateLimitFilter(props, CACHE); + ReflectionTestUtils.setField(untrustedFilter, "request", untrustedRequest); + assertEquals("198.51.100.30", untrustedFilter.clientAddress()); + } + + @Test + void skipsExcludedRemoteAddress() { + RESTProperties props = new RESTProperties(); + props.getRateLimit().setEnabled(true); + props.getRateLimit().setMaxRequests(1); + props.getRateLimit().getExcludedAddresses().add("10.0.0.20"); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.20"); + + RateLimitFilter filter = new RateLimitFilter(props, CACHE); + ReflectionTestUtils.setField(filter, "request", request); + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + + filter.filter(requestContext); + filter.filter(requestContext); + + verify(requestContext, never()).abortWith(any()); + } +} diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java index e552a70c3f7..afafd89d292 100644 --- a/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java @@ -28,6 +28,8 @@ public class AuthenticationAttemptThrottler { + public static final String CACHE = "AuthenticationAttemptCache"; + public record Attempts(Deque failures, long blockedUntil) implements Serializable { private static final long serialVersionUID = 8023582605543650484L; @@ -41,8 +43,6 @@ private Attempts() { } } - public static final String CACHE = "AuthenticationAttemptCache"; - protected static String key(final String domain, final String username) { return StringUtils.defaultString(domain) + ':' + StringUtils.defaultString(username); } diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java index 9693780dbd3..33cb97081fd 100644 --- a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java @@ -33,10 +33,10 @@ */ public class SyncopeBasicAuthenticationEntryPoint extends BasicAuthenticationEntryPoint { - private final SecurityProperties securityProperties; + protected final SecurityProperties.AuthenticationErrorProperties props; public SyncopeBasicAuthenticationEntryPoint(final SecurityProperties securityProperties) { - this.securityProperties = securityProperties; + this.props = securityProperties.getAuthenticationError(); } @Override @@ -45,10 +45,10 @@ public void commence(final HttpServletRequest request, final HttpServletResponse response.addHeader( RESTHeaders.ERROR_INFO, - securityProperties.getAuthenticationError().isExposeDetails() + props.isExposeDetails() ? authException.getMessage() : StringUtils.defaultIfBlank( - securityProperties.getAuthenticationError().getGenericMessage(), + props.getGenericMessage(), SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE)); if (authException instanceof RateLimitAuthenticationException rateLimit) { response.addHeader(HttpHeaders.RETRY_AFTER, Long.toString(rateLimit.getRetryAfterSeconds())); diff --git a/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java index 191b6b00289..04d9714e37d 100644 --- a/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java +++ b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java @@ -38,7 +38,7 @@ class SyncopeBasicAuthenticationEntryPointTest { private SecurityProperties securityProperties; @BeforeEach - public void setUp() throws Exception { + void setUp() throws Exception { securityProperties = new SecurityProperties(); entryPoint = new SyncopeBasicAuthenticationEntryPoint(securityProperties); entryPoint.setRealmName("Apache Syncope authentication"); @@ -56,12 +56,13 @@ void rateLimitAuthenticationExceptionReturnsTooManyRequests() throws Exception { assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), response.getStatus()); assertEquals("30", response.getHeader(HttpHeaders.RETRY_AFTER)); - assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + assertEquals( + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, response.getHeader(RESTHeaders.ERROR_INFO)); } @Test - public void badCredentialsExposeGenericErrorInfo() throws Exception { + void badCredentialsExposeGenericErrorInfo() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); entryPoint.commence( @@ -70,12 +71,13 @@ public void badCredentialsExposeGenericErrorInfo() throws Exception { new BadCredentialsException("rossini: invalid password provided")); assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); - assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + assertEquals( + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, response.getHeader(RESTHeaders.ERROR_INFO)); } @Test - public void missingUserExposeGenericErrorInfo() throws Exception { + void missingUserExposeGenericErrorInfo() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); entryPoint.commence( @@ -84,12 +86,13 @@ public void missingUserExposeGenericErrorInfo() throws Exception { new UsernameNotFoundException("not-a-user")); assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); - assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + assertEquals( + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, response.getHeader(RESTHeaders.ERROR_INFO)); } @Test - public void disabledUserExposeGenericErrorInfo() throws Exception { + void disabledUserExposeGenericErrorInfo() throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); entryPoint.commence( @@ -98,12 +101,13 @@ public void disabledUserExposeGenericErrorInfo() throws Exception { new DisabledException("User rossini is suspended")); assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus()); - assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + assertEquals( + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, response.getHeader(RESTHeaders.ERROR_INFO)); } @Test - public void genericErrorInfoCanBeConfigured() throws Exception { + void genericErrorInfoCanBeConfigured() throws Exception { securityProperties.getAuthenticationError().setGenericMessage("Login failed"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -117,7 +121,7 @@ public void genericErrorInfoCanBeConfigured() throws Exception { } @Test - public void detailsCanBeExposedWhenConfigured() throws Exception { + void detailsCanBeExposedWhenConfigured() throws Exception { securityProperties.getAuthenticationError().setExposeDetails(true); MockHttpServletResponse response = new MockHttpServletResponse(); diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java index 529b4762630..a403d8bc220 100644 --- a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java +++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/AuthenticationITCase.java @@ -330,19 +330,19 @@ public void authenticationFailureErrorInfoIsGeneric() { NotAuthorizedException e = assertThrows( NotAuthorizedException.class, () -> CLIENT_FACTORY.create(getUUIDString(), "anypassword").self()); - assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + assertEquals( + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO)); UserCR userCR = UserITCase.getUniqueSample("genericAuthError@syncope.apache.org"); userCR.getRoles().add("User manager"); - UserTO userTO = createUser(userCR).getEntity(); - assertNotNull(userTO); e = assertThrows( NotAuthorizedException.class, () -> CLIENT_FACTORY.create(userTO.getUsername(), "wrongpwd")); - assertEquals(SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, + assertEquals( + SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE, e.getResponse().getHeaderString(RESTHeaders.ERROR_INFO)); } diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index 6533ae2292c..da64d10609a 100644 --- a/src/main/asciidoc/reference-guide/usage/core.adoc +++ b/src/main/asciidoc/reference-guide/usage/core.adoc @@ -48,6 +48,68 @@ protocol://host:port/syncope/ .... ==== +==== REST Rate Limiting +Rate limiting can be applied to REST requests before authentication and request processing. + +This feature is disabled by default and can be enabled with the following properties: + +[cols="1,1,3",options="header"] +|=== +|Property +|Default +|Description +|`rest.rateLimit.enabled` +|`false` +|Enables or disables REST request rate limiting. +|`rest.rateLimit.maxRequests` +|`300` +|Maximum number of requests allowed from the same client address within the configured time window. +|`rest.rateLimit.window` +|`1m` +|Time window used to count requests from the same client address. +|`rest.rateLimit.lock` +|`1m` +|Amount of time for which the client address is blocked after exceeding the configured request limit. +|`rest.rateLimit.forwardedForHeader` +|`X-Forwarded-For` +|HTTP header used to resolve the original client address when the request comes from a trusted proxy. +|`rest.rateLimit.trustedProxies` +|Empty +|Set of proxy addresses whose `X-Forwarded-For` header value is +trusted. The header is ignored for requests not coming from one of these addresses. +|`rest.rateLimit.excludedAddresses` +|Empty +|Set of client addresses excluded from rate limiting. This is where addresses for trusted internal clients, +such as Admin Console or Enduser instances calling the Core REST API, should be configured when their +traffic must not be rate limited. +|=== + +Addresses are matched exactly as reported by the servlet request remote address. CIDR ranges and wildcard +patterns are not supported. + +When Core is deployed behind a reverse proxy, configure the proxy address under +`rest.rateLimit.trustedProxies` to allow for the original client from the configured `rest.rateLimit.forwardedForHeader` +to be identified. + +Add addresses to `rest.rateLimit.excludedAddresses` only for callers that should bypass rate limiting +entirely, for example trusted Console instances or internal monitoring clients. + +Example: +[source,properties] +---- +rest.rateLimit.enabled=true +rest.rateLimit.maxRequests=300 +rest.rateLimit.window=1m +rest.rateLimit.lock=1m +rest.rateLimit.trustedProxies=127.0.0.1 +rest.rateLimit.excludedAddresses=127.0.0.1,10.0.0.10 +---- +When the limit is exceeded, the following HTTP response is returned: +[source] +---- +HTTP/1.1 429 Too Many Requests +Retry-After: +---- + ==== REST Authentication and Authorization The <> authentication and authorization is based on https://spring.io/projects/spring-security[Spring Security^].