This is an automated email from the ASF dual-hosted git repository.

ilgrosso pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/syncope.git


The following commit(s) were added to refs/heads/master by this push:
     new 2b897c3328 [SYNCOPE-1971] Add authentication failure throttling to 
avoid brute-force attack (#1413)
2b897c3328 is described below

commit 2b897c3328d40d00ab359c6704b9fb82ad88da2e
Author: Massimiliano Perrone <[email protected]>
AuthorDate: Wed Jun 10 11:56:30 2026 +0200

    [SYNCOPE-1971] Add authentication failure throttling to avoid brute-force 
attack (#1413)
---
 ...sterUsernamePasswordAuthenticationProvider.java |  12 +-
 .../syncope/core/starter/SelfKeymasterContext.java |   6 +
 .../security/AuthenticationAttemptThrottler.java   | 170 +++++++++++++++++++++
 ....java => RateLimitAuthenticationException.java} |  24 ++-
 .../core/spring/security/SecurityProperties.java   |  49 ++++++
 .../SyncopeBasicAuthenticationEntryPoint.java      |   7 +
 .../UsernamePasswordAuthenticationProvider.java    |  21 ++-
 .../core/spring/security/WebSecurityContext.java   |  21 ++-
 .../AuthenticationAttemptThrottlerTest.java        |  97 ++++++++++++
 .../SyncopeBasicAuthenticationEntryPointTest.java  |  46 ++++++
 core/starter/src/main/resources/core.properties    |   5 +
 .../org/apache/syncope/fit/AbstractITCase.java     |   4 +
 .../syncope/fit/core/AuthenticationITCase.java     |  26 ++++
 src/main/asciidoc/reference-guide/usage/core.adoc  |  38 +++++
 14 files changed, 506 insertions(+), 20 deletions(-)

diff --git 
a/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/keymaster/rest/security/SelfKeymasterUsernamePasswordAuthenticationProvider.java
 
b/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/keymaster/rest/security/SelfKeymasterUsernamePasswordAuthenticationProvider.java
index 97db987438..52e1445464 100644
--- 
a/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/keymaster/rest/security/SelfKeymasterUsernamePasswordAuthenticationProvider.java
+++ 
b/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/keymaster/rest/security/SelfKeymasterUsernamePasswordAuthenticationProvider.java
@@ -18,11 +18,13 @@
  */
 package org.apache.syncope.core.keymaster.rest.security;
 
+import javax.cache.Cache;
 import org.apache.syncope.common.keymaster.client.api.DomainOps;
 import org.apache.syncope.common.keymaster.client.api.KeymasterProperties;
 import org.apache.syncope.core.persistence.api.EncryptorManager;
 import org.apache.syncope.core.provisioning.api.UserProvisioningManager;
 import org.apache.syncope.core.spring.security.AuthDataAccessor;
+import org.apache.syncope.core.spring.security.AuthenticationAttemptThrottler;
 import org.apache.syncope.core.spring.security.SecurityProperties;
 import org.apache.syncope.core.spring.security.SyncopeAuthenticationDetails;
 import 
org.apache.syncope.core.spring.security.UsernamePasswordAuthenticationProvider;
@@ -38,9 +40,16 @@ public class 
SelfKeymasterUsernamePasswordAuthenticationProvider extends Usernam
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
             final EncryptorManager encryptorManager,
+            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache,
             final KeymasterProperties keymasterProperties) {
 
-        super(domainOps, dataAccessor, provisioningManager, 
securityProperties, encryptorManager);
+        super(
+                domainOps,
+                dataAccessor,
+                provisioningManager,
+                securityProperties,
+                encryptorManager,
+                authenticationAttemptCache);
         this.keymasterProperties = keymasterProperties;
     }
 
@@ -50,6 +59,7 @@ public class 
SelfKeymasterUsernamePasswordAuthenticationProvider extends Usernam
             return finalizeAuthentication(
                     
SyncopeAuthenticationDetails.class.cast(authentication.getDetails()).getDomain(),
                     keymasterProperties.getUsername(),
+                    keymasterProperties.getUsername(),
                     new AuthDataAccessor.UsernamePasswordAuthResult(
                             null,
                             
authentication.getCredentials().toString().equals(keymasterProperties.getPassword()),
diff --git 
a/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/starter/SelfKeymasterContext.java
 
b/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/starter/SelfKeymasterContext.java
index 55ff9525e9..c55b6a7782 100644
--- 
a/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/starter/SelfKeymasterContext.java
+++ 
b/core/self-keymaster-starter/src/main/java/org/apache/syncope/core/starter/SelfKeymasterContext.java
@@ -29,6 +29,7 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
+import javax.cache.Cache;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.cxf.Bus;
 import org.apache.cxf.endpoint.Server;
@@ -72,12 +73,14 @@ import 
org.apache.syncope.core.provisioning.api.UserProvisioningManager;
 import org.apache.syncope.core.rest.cxf.JavaDocUtils;
 import org.apache.syncope.core.rest.cxf.RestServiceExceptionMapper;
 import org.apache.syncope.core.spring.security.AuthDataAccessor;
+import org.apache.syncope.core.spring.security.AuthenticationAttemptThrottler;
 import org.apache.syncope.core.spring.security.SecurityProperties;
 import 
org.apache.syncope.core.spring.security.UsernamePasswordAuthenticationProvider;
 import org.apache.syncope.core.spring.security.WebSecurityContext;
 import 
org.apache.syncope.core.starter.SelfKeymasterContext.SelfKeymasterCondition;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.boot.autoconfigure.AutoConfigureBefore;
 import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
 import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
@@ -222,6 +225,8 @@ public class SelfKeymasterContext {
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
             final EncryptorManager encryptorManager,
+            @Qualifier(AuthenticationAttemptThrottler.CACHE_NAME)
+            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache,
             final KeymasterProperties keymasterProperties) {
 
         return new SelfKeymasterUsernamePasswordAuthenticationProvider(
@@ -230,6 +235,7 @@ public class SelfKeymasterContext {
                 provisioningManager,
                 securityProperties,
                 encryptorManager,
+                authenticationAttemptCache,
                 keymasterProperties);
     }
 
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
new file mode 100644
index 0000000000..6527950643
--- /dev/null
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java
@@ -0,0 +1,170 @@
+/*
+ * 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.spring.security;
+
+import java.io.Serializable;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+import javax.cache.Cache;
+import javax.cache.configuration.MutableConfiguration;
+import javax.cache.expiry.TouchedExpiryPolicy;
+import org.apache.commons.lang3.StringUtils;
+
+public class AuthenticationAttemptThrottler {
+
+    public static final String CACHE_NAME =
+            
"org.apache.syncope.core.spring.security.AuthenticationAttemptThrottler";
+
+    public record Attempts(Deque<Long> failures, long blockedUntil) implements 
Serializable {
+
+        private static final long serialVersionUID = 8023582605543650484L;
+
+        public Attempts {
+            failures = new ArrayDeque<>(failures);
+        }
+
+        private Attempts() {
+            this(new ArrayDeque<>(), 0);
+        }
+    }
+
+    protected final SecurityProperties.AuthenticationThrottleProperties 
throttle;
+
+    protected final LongSupplier clock;
+
+    protected final Cache<String, Attempts> attempts;
+
+    public AuthenticationAttemptThrottler(
+            final SecurityProperties securityProperties,
+            final Cache<String, Attempts> attempts) {
+
+        this(securityProperties, System::currentTimeMillis, attempts);
+    }
+
+    protected AuthenticationAttemptThrottler(
+            final SecurityProperties securityProperties,
+            final LongSupplier clock,
+            final Cache<String, Attempts> attempts) {
+
+        this.throttle = securityProperties.getAuthenticationThrottle();
+        this.clock = clock;
+        this.attempts = attempts;
+    }
+
+    public static MutableConfiguration<String, Attempts> cacheConfiguration(
+            final SecurityProperties.AuthenticationThrottleProperties 
throttle) {
+
+        return new MutableConfiguration<String, Attempts>().
+                setTypes(String.class, Attempts.class).
+                setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(
+                        new javax.cache.expiry.Duration(TimeUnit.SECONDS, 
cacheExpirySeconds(throttle))));
+    }
+
+    protected static long cacheExpirySeconds(final 
SecurityProperties.AuthenticationThrottleProperties throttle) {
+        return Math.max(1, Math.max(throttle.getWindowSeconds(), 
throttle.getLockSeconds()));
+    }
+
+    protected static String key(final String domain, final String username) {
+        return StringUtils.defaultString(domain) + ':' + 
StringUtils.defaultString(username);
+    }
+
+    public void checkAllowed(final String domain, final String username) {
+        if (!isEnabled()) {
+            return;
+        }
+
+        String key = key(domain, username);
+        long now = clock.getAsLong();
+        RateLimitAuthenticationException blocked = attempts.invoke(key, 
(entry, args) -> {
+            if (!entry.exists()) {
+                return null;
+            }
+
+            Attempts state = entry.getValue();
+            if (state.blockedUntil() > now) {
+                return blocked(state.blockedUntil(), now);
+            }
+
+            Deque<Long> failures = prune(state.failures(), now);
+            if (failures.isEmpty()) {
+                entry.remove();
+            } else {
+                entry.setValue(new Attempts(failures, state.blockedUntil()));
+            }
+            return null;
+        });
+        if (blocked != null) {
+            throw blocked;
+        }
+    }
+
+    protected boolean isEnabled() {
+        return throttle.isEnabled()
+                && throttle.getMaxAttempts() > 0
+                && throttle.getWindowSeconds() > 0
+                && throttle.getLockSeconds() > 0;
+    }
+
+    public void clearFailures(final String domain, final String username) {
+        attempts.remove(key(domain, username));
+    }
+
+    public void recordFailure(final String domain, final String username) {
+        if (!isEnabled()) {
+            return;
+        }
+
+        long now = clock.getAsLong();
+        RateLimitAuthenticationException blocked = attempts.invoke(key(domain, 
username), (entry, args) -> {
+            Attempts state = entry.exists()
+                    ? entry.getValue()
+                    : new Attempts();
+            Deque<Long> failures = prune(state.failures(), now);
+            failures.addLast(now);
+
+            if (failures.size() >= throttle.getMaxAttempts()) {
+                long blockedUntil = now + 
TimeUnit.SECONDS.toMillis(throttle.getLockSeconds());
+                entry.setValue(new Attempts(failures, blockedUntil));
+                return blocked(blockedUntil, now);
+            }
+
+            entry.setValue(new Attempts(failures, state.blockedUntil()));
+            return null;
+        });
+        if (blocked != null) {
+            throw blocked;
+        }
+    }
+
+    protected Deque<Long> prune(final Deque<Long> attempts, final long now) {
+        Deque<Long> failures = new ArrayDeque<>(attempts);
+        long threshold = now - 
TimeUnit.SECONDS.toMillis(throttle.getWindowSeconds());
+        while (!failures.isEmpty() && failures.peekFirst() < threshold) {
+            failures.removeFirst();
+        }
+        return failures;
+    }
+
+    protected static RateLimitAuthenticationException blocked(final long 
blockedUntil, final long now) {
+        long retryAfter = Math.max(1, 
TimeUnit.MILLISECONDS.toSeconds(blockedUntil - now));
+        return new RateLimitAuthenticationException("Too many authentication 
failures", retryAfter);
+    }
+}
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/RateLimitAuthenticationException.java
similarity index 54%
copy from 
core/spring/src/main/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPoint.java
copy to 
core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
index 6e7f4520d1..a78888b4a2 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/RateLimitAuthenticationException.java
@@ -18,24 +18,20 @@
  */
 package org.apache.syncope.core.spring.security;
 
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import org.apache.syncope.common.rest.api.RESTHeaders;
 import org.springframework.security.core.AuthenticationException;
-import 
org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
 
-/**
- * Render Spring's {@link AuthenticationException} as other Syncope errors.
- */
-public class SyncopeBasicAuthenticationEntryPoint extends 
BasicAuthenticationEntryPoint {
+public class RateLimitAuthenticationException extends AuthenticationException {
+
+    private static final long serialVersionUID = 3829921857697051591L;
 
-    @Override
-    public void commence(final HttpServletRequest request, final 
HttpServletResponse response,
-            final AuthenticationException authException) throws IOException {
+    private final long retryAfterSeconds;
 
-        response.addHeader(RESTHeaders.ERROR_INFO, authException.getMessage());
+    public RateLimitAuthenticationException(final String msg, final long 
retryAfterSeconds) {
+        super(msg);
+        this.retryAfterSeconds = retryAfterSeconds;
+    }
 
-        super.commence(request, response, authException);
+    public long getRetryAfterSeconds() {
+        return retryAfterSeconds;
     }
 }
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
index e4bf37a056..fce444315f 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/SecurityProperties.java
@@ -25,6 +25,49 @@ import 
org.springframework.boot.context.properties.ConfigurationProperties;
 @ConfigurationProperties("security")
 public class SecurityProperties {
 
+    public static class AuthenticationThrottleProperties {
+
+        private boolean enabled = true;
+
+        private int maxAttempts = 5;
+
+        private long windowSeconds = 60;
+
+        private long lockSeconds = 60;
+
+        public boolean isEnabled() {
+            return enabled;
+        }
+
+        public void setEnabled(final boolean enabled) {
+            this.enabled = enabled;
+        }
+
+        public int getMaxAttempts() {
+            return maxAttempts;
+        }
+
+        public void setMaxAttempts(final int maxAttempts) {
+            this.maxAttempts = maxAttempts;
+        }
+
+        public long getWindowSeconds() {
+            return windowSeconds;
+        }
+
+        public void setWindowSeconds(final long windowSeconds) {
+            this.windowSeconds = windowSeconds;
+        }
+
+        public long getLockSeconds() {
+            return lockSeconds;
+        }
+
+        public void setLockSeconds(final long lockSeconds) {
+            this.lockSeconds = lockSeconds;
+        }
+    }
+
     public static class DigesterProperties {
 
         private int saltIterations = 1;
@@ -104,6 +147,8 @@ public class SecurityProperties {
 
     private String groovyBlacklist = "classpath:META-INF/groovy.blacklist";
 
+    private final AuthenticationThrottleProperties authenticationThrottle = 
new AuthenticationThrottleProperties();
+
     private final DigesterProperties digester = new DigesterProperties();
 
     public String getAdminUser() {
@@ -194,6 +239,10 @@ public class SecurityProperties {
         this.groovyBlacklist = groovyBlacklist;
     }
 
+    public AuthenticationThrottleProperties getAuthenticationThrottle() {
+        return authenticationThrottle;
+    }
+
     public DigesterProperties getDigester() {
         return digester;
     }
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 6e7f4520d1..24efdce799 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
@@ -22,6 +22,8 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
 import org.springframework.security.core.AuthenticationException;
 import 
org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
 
@@ -35,6 +37,11 @@ public class SyncopeBasicAuthenticationEntryPoint extends 
BasicAuthenticationEnt
             final AuthenticationException authException) throws IOException {
 
         response.addHeader(RESTHeaders.ERROR_INFO, authException.getMessage());
+        if (authException instanceof RateLimitAuthenticationException 
rateLimit) {
+            response.addHeader(HttpHeaders.RETRY_AFTER, 
Long.toString(rateLimit.getRetryAfterSeconds()));
+            response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), 
authException.getMessage());
+            return;
+        }
 
         super.commence(request, response, authException);
     }
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/UsernamePasswordAuthenticationProvider.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/UsernamePasswordAuthenticationProvider.java
index 0fe113cef9..0c73293f83 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/UsernamePasswordAuthenticationProvider.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/UsernamePasswordAuthenticationProvider.java
@@ -18,7 +18,9 @@
  */
 package org.apache.syncope.core.spring.security;
 
+import java.util.Objects;
 import java.util.Optional;
+import javax.cache.Cache;
 import org.apache.commons.lang3.mutable.Mutable;
 import org.apache.commons.lang3.mutable.MutableObject;
 import org.apache.syncope.common.keymaster.client.api.DomainOps;
@@ -53,23 +55,31 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
 
     protected final EncryptorManager encryptorManager;
 
+    protected final AuthenticationAttemptThrottler 
authenticationAttemptThrottler;
+
     public UsernamePasswordAuthenticationProvider(
             final DomainOps domainOps,
             final AuthDataAccessor dataAccessor,
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
-            final EncryptorManager encryptorManager) {
+            final EncryptorManager encryptorManager,
+            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache) {
 
         this.domainOps = domainOps;
         this.dataAccessor = dataAccessor;
         this.provisioningManager = provisioningManager;
         this.securityProperties = securityProperties;
         this.encryptorManager = encryptorManager;
+        this.authenticationAttemptThrottler =
+                new AuthenticationAttemptThrottler(securityProperties, 
authenticationAttemptCache);
     }
 
     @Override
     public Authentication authenticate(final Authentication authentication) {
-        String domainKey = 
SyncopeAuthenticationDetails.class.cast(authentication.getDetails()).getDomain();
+        String domainKey = ((SyncopeAuthenticationDetails) 
authentication.getDetails()).getDomain();
+        String authenticatingPrincipal = 
Objects.requireNonNull(authentication.getPrincipal()).toString();
+        authenticationAttemptThrottler.checkAllowed(domainKey, 
authenticatingPrincipal);
+
         Optional<Domain> domain;
         if (SyncopeConstants.MASTER_DOMAIN.equals(domainKey)) {
             domain = Optional.empty();
@@ -96,11 +106,12 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
         }
 
         if (username.get() == null) {
-            username.setValue(authentication.getPrincipal().toString());
+            username.setValue(authenticatingPrincipal);
         }
 
         return finalizeAuthentication(
                 domainKey,
+                authenticatingPrincipal,
                 username.get(),
                 authResult,
                 authentication);
@@ -108,11 +119,13 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
 
     protected Authentication finalizeAuthentication(
             final String domain,
+            final String login,
             final String username,
             final AuthDataAccessor.UsernamePasswordAuthResult authResult,
             final Authentication authentication) {
 
         if (authResult.isSuccess()) {
+            authenticationAttemptThrottler.clearFailures(domain, login);
             UsernamePasswordAuthenticationToken token = 
AuthContextUtils.callAsAdmin(domain, () -> {
                 UsernamePasswordAuthenticationToken upat = new 
UsernamePasswordAuthenticationToken(
                         username,
@@ -145,6 +158,8 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
 
         LOG.debug("User {} not authenticated", username);
 
+        authenticationAttemptThrottler.recordFailure(domain, login);
+
         if (!authResult.passwordVerified()) {
             throw new BadCredentialsException(username + ": invalid password 
provided");
         }
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
index cd9cd7d5ea..77bfdc8b66 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/WebSecurityContext.java
@@ -20,6 +20,8 @@ package org.apache.syncope.core.spring.security;
 
 import dev.samstevens.totp.code.CodeVerifier;
 import java.util.List;
+import javax.cache.Cache;
+import javax.cache.CacheManager;
 import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
 import org.apache.syncope.common.keymaster.client.api.DomainOps;
 import org.apache.syncope.common.lib.types.IdRepoEntitlement;
@@ -36,6 +38,7 @@ import org.apache.syncope.core.provisioning.api.AuditManager;
 import org.apache.syncope.core.provisioning.api.ConnectorManager;
 import org.apache.syncope.core.provisioning.api.MappingManager;
 import org.apache.syncope.core.provisioning.api.UserProvisioningManager;
+import org.springframework.beans.factory.annotation.Qualifier;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -126,14 +129,28 @@ public class WebSecurityContext {
             final AuthDataAccessor dataAccessor,
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
-            final EncryptorManager encryptorManager) {
+            final EncryptorManager encryptorManager,
+            @Qualifier(AuthenticationAttemptThrottler.CACHE_NAME)
+            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache) {
 
         return new UsernamePasswordAuthenticationProvider(
                 domainOps,
                 dataAccessor,
                 provisioningManager,
                 securityProperties,
-                encryptorManager);
+                encryptorManager,
+                authenticationAttemptCache);
+    }
+
+    @ConditionalOnMissingBean(name = AuthenticationAttemptThrottler.CACHE_NAME)
+    @Bean(name = AuthenticationAttemptThrottler.CACHE_NAME)
+    public Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache(
+            final CacheManager cacheManager,
+            final SecurityProperties securityProperties) {
+
+        return cacheManager.createCache(
+                AuthenticationAttemptThrottler.CACHE_NAME,
+                
AuthenticationAttemptThrottler.cacheConfiguration(securityProperties.getAuthenticationThrottle()));
     }
 
     @Bean
diff --git 
a/core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottlerTest.java
 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottlerTest.java
new file mode 100644
index 0000000000..3db75e9671
--- /dev/null
+++ 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottlerTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.spring.security;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongSupplier;
+import javax.cache.Cache;
+import javax.cache.Caching;
+import org.junit.jupiter.api.Test;
+
+public class AuthenticationAttemptThrottlerTest {
+
+    private static SecurityProperties securityProperties() {
+        SecurityProperties securityProperties = new SecurityProperties();
+        securityProperties.getAuthenticationThrottle().setEnabled(true);
+        securityProperties.getAuthenticationThrottle().setMaxAttempts(5);
+        securityProperties.getAuthenticationThrottle().setWindowSeconds(60);
+        securityProperties.getAuthenticationThrottle().setLockSeconds(30);
+        return securityProperties;
+    }
+
+    private static AuthenticationAttemptThrottler throttler(
+            final SecurityProperties securityProperties,
+            final LongSupplier clock) {
+
+        Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache =
+                Caching.getCachingProvider().getCacheManager().createCache(
+                        AuthenticationAttemptThrottlerTest.class.getName() + 
'-' + UUID.randomUUID(),
+                        AuthenticationAttemptThrottler.cacheConfiguration(
+                                
securityProperties.getAuthenticationThrottle()));
+
+        return new AuthenticationAttemptThrottler(securityProperties, clock, 
authenticationAttemptCache);
+    }
+
+    @Test
+    public void blocksAfterConfiguredFailures() {
+        AtomicLong now = new AtomicLong();
+        SecurityProperties securityProperties = securityProperties();
+        AuthenticationAttemptThrottler throttler =
+                throttler(securityProperties, now::get);
+
+        for (int i = 1; i < 
securityProperties.getAuthenticationThrottle().getMaxAttempts(); i++) {
+            assertDoesNotThrow(() -> throttler.recordFailure("Master", 
"rossini"));
+        }
+        assertThrows(RateLimitAuthenticationException.class,
+                () -> throttler.recordFailure("Master", "rossini"));
+        assertThrows(RateLimitAuthenticationException.class,
+                () -> throttler.checkAllowed("Master", "rossini"));
+
+        now.addAndGet(30_000);
+        assertDoesNotThrow(() -> throttler.checkAllowed("Master", "rossini"));
+    }
+
+    @Test
+    public void successResetsFailures() {
+        AtomicLong now = new AtomicLong();
+        AuthenticationAttemptThrottler throttler =
+                throttler(securityProperties(), now::get);
+
+        throttler.recordFailure("Master", "rossini");
+        throttler.clearFailures("Master", "rossini");
+
+        assertDoesNotThrow(() -> throttler.recordFailure("Master", "rossini"));
+    }
+
+    @Test
+    public void expiredFailuresAreIgnored() {
+        AtomicLong now = new AtomicLong();
+        AuthenticationAttemptThrottler throttler =
+                throttler(securityProperties(), now::get);
+
+        throttler.recordFailure("Master", "rossini");
+        now.addAndGet(61_000);
+
+        assertDoesNotThrow(() -> throttler.recordFailure("Master", "rossini"));
+    }
+}
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
new file mode 100644
index 0000000000..c7e6022069
--- /dev/null
+++ 
b/core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.spring.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+public class SyncopeBasicAuthenticationEntryPointTest {
+
+    @Test
+    public void rateLimitAuthenticationExceptionReturnsTooManyRequests() 
throws Exception {
+        SyncopeBasicAuthenticationEntryPoint entryPoint = new 
SyncopeBasicAuthenticationEntryPoint();
+        MockHttpServletResponse response = new MockHttpServletResponse();
+
+        entryPoint.commence(
+                new MockHttpServletRequest(),
+                response,
+                new RateLimitAuthenticationException("Too many authentication 
failures", 30));
+
+        assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), 
response.getStatus());
+        assertEquals("30", response.getHeader(HttpHeaders.RETRY_AFTER));
+        assertEquals("Too many authentication failures", 
response.getHeader(RESTHeaders.ERROR_INFO));
+    }
+}
diff --git a/core/starter/src/main/resources/core.properties 
b/core/starter/src/main/resources/core.properties
index 7d49610b82..fb4c232b99 100644
--- a/core/starter/src/main/resources/core.properties
+++ b/core/starter/src/main/resources/core.properties
@@ -102,6 +102,11 @@ security.aesSecretKey=${secretKey}
 
 security.groovyBlacklist=classpath:META-INF/groovy.blacklist
 
+security.authenticationThrottle.enabled=true
+security.authenticationThrottle.maxAttempts=5
+security.authenticationThrottle.windowSeconds=60
+security.authenticationThrottle.lockSeconds=60
+
 # default for LDAP / RFC2307 SSHA
 security.digester.saltIterations=1
 security.digester.saltSizeBytes=8
diff --git 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
index d96821a350..f3992b5336 100644
--- 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
+++ 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
@@ -294,6 +294,8 @@ public abstract class AbstractITCase {
 
     protected static String ANONYMOUS_KEY;
 
+    protected static int AUTHENTICATION_THROTTLE_MAX_ATTEMPTS;
+
     protected static String JWS_KEY;
 
     protected static String JWT_ISSUER;
@@ -487,6 +489,8 @@ public abstract class AbstractITCase {
             JWT_ISSUER = props.getProperty("security.jwtIssuer");
             JWS_ALGORITHM = 
JWSAlgorithm.parse(props.getProperty("security.jwsAlgorithm"));
             JWS_KEY = props.getProperty("security.jwsKey");
+            AUTHENTICATION_THROTTLE_MAX_ATTEMPTS = Integer.parseInt(
+                    
props.getProperty("security.authenticationThrottle.maxAttempts"));
         } catch (Exception e) {
             LOG.error("Could not read core.properties", e);
         }
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 9f61875091..009db1e5b8 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
@@ -21,12 +21,14 @@ package org.apache.syncope.fit.core;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 import static org.junit.jupiter.api.Assumptions.assumeTrue;
 
 import jakarta.ws.rs.ForbiddenException;
 import jakarta.ws.rs.NotAuthorizedException;
+import jakarta.ws.rs.WebApplicationException;
 import jakarta.ws.rs.core.GenericType;
 import jakarta.ws.rs.core.Response;
 import java.util.List;
@@ -387,6 +389,30 @@ public class AuthenticationITCase extends AbstractITCase {
         assertEquals(0, 
goodPwdClient.self().user().getFailedLogins().intValue());
     }
 
+    @Test
+    public void authenticationThrottle() {
+        UserCR userCR = 
UserITCase.getUniqueSample("[email protected]");
+        userCR.getRoles().add("User manager");
+
+        UserTO userTO = createUser(userCR).getEntity();
+        assertNotNull(userTO);
+
+        for (int i = 0; i < AUTHENTICATION_THROTTLE_MAX_ATTEMPTS - 1; i++) {
+            assertThrows(NotAuthorizedException.class,
+                    () -> CLIENT_FACTORY.create(userTO.getUsername(), 
"wrongpwd").self());
+        }
+
+        WebApplicationException e = assertThrows(
+                WebApplicationException.class,
+                () -> CLIENT_FACTORY.create(userTO.getUsername(), 
"wrongpwd").self());
+        assertEquals(Response.Status.TOO_MANY_REQUESTS.getStatusCode(), 
e.getResponse().getStatus());
+
+        e = assertThrows(
+                WebApplicationException.class,
+                () -> CLIENT_FACTORY.create(userTO.getUsername(), 
"password123").self());
+        assertEquals(Response.Status.TOO_MANY_REQUESTS.getStatusCode(), 
e.getResponse().getStatus());
+    }
+
     @Test
     public void anyTypeEntitlement() {
         String anyTypeKey = "FOLDER " + getUUIDString();
diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc 
b/src/main/asciidoc/reference-guide/usage/core.adoc
index 09b80c440c..11f98d3a35 100644
--- a/src/main/asciidoc/reference-guide/usage/core.adoc
+++ b/src/main/asciidoc/reference-guide/usage/core.adoc
@@ -107,6 +107,44 @@ 
https://docs.spring.io/spring-security/reference/7.0/servlet/authentication/anon
 <<entitlements,entitlements>>, handed over to users via <<roles,roles>>.
 ****
 
+===== Authentication Throttling
+This functionality can throttle repeated failed username / password 
authentication attempts, to reduce the effectiveness of automated guessing 
attacks.
+
+This protection is configured in `core.properties` via the following 
properties:
+
+[cols="1,3"]
+|===
+|Property |Description
+|`security.authenticationThrottle.enabled`
+|Whether authentication throttling is enabled. When set to `false`, failed 
authentication attempts are not rate-limited by this mechanism.
+|`security.authenticationThrottle.maxAttempts`
+|Maximum number of failed username / password authentication
+attempts allowed for the same authentication identifier within
+the configured time window.
+|`security.authenticationThrottle.windowSeconds`
+|Time window, in seconds, used to count failed authentication attempts.
+|`security.authenticationThrottle.lockSeconds`
+|Time, in seconds, during which further authentication attempts for the same 
identifier are rejected after the maximum number of failures has been reached.
+|===
+
+The default configuration is:
+[source,properties]
+----
+security.authenticationThrottle.enabled=true
+security.authenticationThrottle.maxAttempts=5
+security.authenticationThrottle.windowSeconds=60
+security.authenticationThrottle.lockSeconds=60
+----
+
+When enabled, Syncope tracks failed username / password authentication 
attempts for each authentication identifier in the current Domain.
+
+Once `security.authenticationThrottle.maxAttempts` failures are reached within 
`security.authenticationThrottle.windowSeconds`, further authentication 
attempts for the same identifier are rejected for 
`security.authenticationThrottle.lockSeconds`.
+
+During the lock interval, the requester receives an HTTP `429 Too Many 
Requests` response. Successful authentication clears the recorded failures for 
the related identifier.
+
+[NOTE]
+This mechanism is independent from <<policies-account,Account Policy>>: while 
throttling is a temporary rate-limiting measure, the latter is triggering user 
suspension in case one of the configured conditions are met.
+
 ==== REST Headers
 
 Apache Syncope supports a number of HTTP headers as detailed below, in 
addition to the common HTTP headers such as


Reply via email to