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^].