This is an automated email from the ASF dual-hosted git repository.
massx1 pushed a commit to branch 4_1_X
in repository https://gitbox.apache.org/repos/asf/syncope.git
The following commit(s) were added to refs/heads/4_1_X by this push:
new 9e91350c09 [SYNCOPE-1976] Add configurable REST rate limiting (#1420)
9e91350c09 is described below
commit 9e91350c09f9645777246c1e89604b709962de11
Author: Massimiliano Perrone <[email protected]>
AuthorDate: Mon Jun 15 16:20:06 2026 +0200
[SYNCOPE-1976] Add configurable REST rate limiting (#1420)
---
core/idrepo/rest-cxf/pom.xml | 10 ++
.../core/rest/cxf/IdRepoRESTCXFContext.java | 38 +++++
.../syncope/core/rest/cxf/RESTProperties.java | 75 ++++++++++
.../syncope/core/rest/cxf/RateLimitFilter.java | 158 +++++++++++++++++++++
.../syncope/core/rest/cxf/RateLimitFilterTest.java | 120 ++++++++++++++++
.../security/AuthenticationAttemptThrottler.java | 4 +-
.../SyncopeBasicAuthenticationEntryPoint.java | 8 +-
.../SyncopeBasicAuthenticationEntryPointTest.java | 24 ++--
.../syncope/fit/core/AuthenticationITCase.java | 8 +-
src/main/asciidoc/reference-guide/usage/core.adoc | 62 ++++++++
10 files changed, 487 insertions(+), 20 deletions(-)
diff --git a/core/idrepo/rest-cxf/pom.xml b/core/idrepo/rest-cxf/pom.xml
index 135b62723a..8bfd9f3b05 100644
--- a/core/idrepo/rest-cxf/pom.xml
+++ b/core/idrepo/rest-cxf/pom.xml
@@ -49,6 +49,11 @@ under the License.
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
+ <dependency>
+ <groupId>javax.cache</groupId>
+ <artifactId>cache-api</artifactId>
+ </dependency>
+
<dependency>
<groupId>com.fasterxml.jackson.jakarta.rs</groupId>
<artifactId>jackson-jakarta-rs-json-provider</artifactId>
@@ -128,6 +133,11 @@ under the License.
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>com.github.ben-manes.caffeine</groupId>
+ <artifactId>jcache</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
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 f88cdae3c6..b76ab977b8 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
@@ -21,10 +21,17 @@ package org.apache.syncope.core.rest.cxf;
import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider;
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 class IdRepoRESTCXFContext {
return new AddETagFilter();
}
+ @ConditionalOnMissingBean
+ @Bean
+ public RateLimitFilter rateLimitFilter(
+ final RESTProperties props,
+ @Qualifier(RateLimitFilter.CACHE)
+ final Cache<String, RateLimitFilter.ClientWindow>
cxfRateLimitCache) {
+
+ return new RateLimitFilter(props, cxfRateLimitCache);
+ }
+
+ @ConditionalOnMissingBean(name = RateLimitFilter.CACHE)
+ @Bean(name = RateLimitFilter.CACHE)
+ public Cache<String, RateLimitFilter.ClientWindow> 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<String,
RateLimitFilter.ClientWindow>().
+ 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 class IdRepoRESTCXFContext {
final List<JAXRSService> services,
final AddETagFilter addETagFilter,
final AddDomainFilter addDomainFilter,
+ final RateLimitFilter rateLimitFilter,
final ContextProvider<SearchContext> searchContextProvider,
final JacksonJsonProvider jsonProvider,
final DateParamConverterProvider dateParamConverterProvider,
@@ -308,6 +345,7 @@ public class IdRepoRESTCXFContext {
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 5fc8473f15..a7ddd4d04f 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 @@ import
org.springframework.boot.context.properties.NestedConfigurationProperty;
@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<String> excludedAddresses = new HashSet<>();
+
+ private final Set<String> 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<String> getExcludedAddresses() {
+ return excludedAddresses;
+ }
+
+ public Set<String> 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 0000000000..fe334fa9aa
--- /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<String, ClientWindow> clients;
+
+ @Context
+ protected HttpServletRequest request;
+
+ public RateLimitFilter(final RESTProperties props, final Cache<String,
ClientWindow> 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<String> requestRemoteAddress() {
+ return
Optional.ofNullable(request).map(HttpServletRequest::getRemoteAddr);
+ }
+
+ protected static Optional<String> 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 0000000000..833aaaab61
--- /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<String, RateLimitFilter.ClientWindow> 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> 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 e552a70c3f..afafd89d29 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 @@ import org.apache.commons.lang3.StringUtils;
public class AuthenticationAttemptThrottler {
+ public static final String CACHE = "AuthenticationAttemptCache";
+
public record Attempts(Deque<Long> failures, long blockedUntil) implements
Serializable {
private static final long serialVersionUID = 8023582605543650484L;
@@ -41,8 +43,6 @@ public class AuthenticationAttemptThrottler {
}
}
- 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 9693780dbd..33cb97081f 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 @@ import
org.springframework.security.web.authentication.www.BasicAuthenticationEn
*/
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 class SyncopeBasicAuthenticationEntryPoint extends
BasicAuthenticationEnt
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 191b6b0028..04d9714e37 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 @@ class SyncopeBasicAuthenticationEntryPointTest {
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 @@ class SyncopeBasicAuthenticationEntryPointTest {
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 @@ class SyncopeBasicAuthenticationEntryPointTest {
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 @@ class SyncopeBasicAuthenticationEntryPointTest {
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 @@ class SyncopeBasicAuthenticationEntryPointTest {
}
@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 8b5bf35676..5c3e5ac9c1 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
@@ -336,19 +336,19 @@ public class AuthenticationITCase extends AbstractITCase {
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("[email protected]");
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 50b0dd0c68..9f866a4a16 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: <seconds>
+----
+
==== REST Authentication and Authorization
The <<core>> authentication and authorization is based on
https://spring.io/projects/spring-security[Spring Security^].