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 d4b78c8cf2 [SYNCOPE-1975] Throttle password reset requests (#1419)
d4b78c8cf2 is described below

commit d4b78c8cf291b929a1ec8908e99fb97e3ce233ad
Author: Massimiliano Perrone <[email protected]>
AuthorDate: Tue Jun 16 16:51:25 2026 +0200

    [SYNCOPE-1975] Throttle password reset requests (#1419)
---
 .../common/lib/types/ClientExceptionType.java      |   1 +
 .../syncope/core/logic/IdMLogicTestContext.java    |   9 ++
 .../syncope/core/logic/IdRepoLogicContext.java     |  13 ++-
 .../apache/syncope/core/logic/UserSelfLogic.java   |  23 ++++-
 .../syncope/core/logic/IdRepoLogicTestContext.java |   9 ++
 .../core/logic/UserSelfLogicPasswordResetTest.java | 111 +++++++++++++++++++++
 .../syncope/core/rest/cxf/RESTProperties.java      |   1 -
 .../core/rest/cxf/RestServiceExceptionMapper.java  |  10 ++
 .../core/rest/cxf/service/UserSelfServiceImpl.java |   2 +-
 .../rest/cxf/RestServiceExceptionMapperTest.java   |  43 ++++++++
 ...sterUsernamePasswordAuthenticationProvider.java |   4 +-
 .../syncope/core/starter/SelfKeymasterContext.java |   7 +-
 .../core/spring/security/SecurityProperties.java   |  12 ++-
 .../SyncopeBasicAuthenticationEntryPoint.java      |   5 +-
 .../UsernamePasswordAuthenticationProvider.java    |  15 +--
 .../core/spring/security/WebSecurityContext.java   |  37 +++++--
 .../security/throttle/AbstractThrottler.java       |  59 +++++++++++
 .../AuthenticationThrottleException.java}          |   6 +-
 .../AuthenticationThrottler.java}                  |  68 +++----------
 .../throttle/PasswordResetRequestThrottler.java    | 109 ++++++++++++++++++++
 .../PasswordResetThrottleException.java}           |  12 +--
 .../ThrottlerAttempts.java}                        |  21 ++--
 .../SyncopeBasicAuthenticationEntryPointTest.java  |   6 +-
 .../AuthenticationThrottlerTest.java}              |  30 +++---
 core/starter/src/main/resources/core.properties    |   5 +
 .../apache/syncope/fit/core/UserSelfITCase.java    |  31 ++++++
 .../concepts/provisioning/passwordreset.adoc       |  44 ++++++++
 src/main/asciidoc/reference-guide/usage/core.adoc  |   2 +-
 28 files changed, 569 insertions(+), 126 deletions(-)

diff --git 
a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/ClientExceptionType.java
 
b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/ClientExceptionType.java
index bf3bbca120..068b07ffce 100644
--- 
a/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/ClientExceptionType.java
+++ 
b/common/idrepo/lib/src/main/java/org/apache/syncope/common/lib/types/ClientExceptionType.java
@@ -68,6 +68,7 @@ public enum ClientExceptionType {
     RealmContains(Response.Status.BAD_REQUEST),
     RequiredValuesMissing(Response.Status.BAD_REQUEST),
     RESTValidation(Response.Status.BAD_REQUEST),
+    TooManyRequests(Response.Status.TOO_MANY_REQUESTS),
     Management(Response.Status.BAD_REQUEST),
     InUse(Response.Status.BAD_REQUEST),
     Scheduling(Response.Status.BAD_REQUEST),
diff --git 
a/core/idm/logic/src/test/java/org/apache/syncope/core/logic/IdMLogicTestContext.java
 
b/core/idm/logic/src/test/java/org/apache/syncope/core/logic/IdMLogicTestContext.java
index cffd6e7613..108d527052 100644
--- 
a/core/idm/logic/src/test/java/org/apache/syncope/core/logic/IdMLogicTestContext.java
+++ 
b/core/idm/logic/src/test/java/org/apache/syncope/core/logic/IdMLogicTestContext.java
@@ -20,8 +20,10 @@ package org.apache.syncope.core.logic;
 
 import static org.mockito.Mockito.mock;
 
+import javax.cache.Cache;
 import javax.cache.CacheManager;
 import javax.cache.Caching;
+import javax.cache.configuration.MutableConfiguration;
 import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
 import org.apache.syncope.common.keymaster.client.api.DomainOps;
 import org.apache.syncope.common.keymaster.client.api.ServiceOps;
@@ -37,6 +39,8 @@ import 
org.apache.syncope.core.persistence.jpa.StartupDomainLoader;
 import org.apache.syncope.core.provisioning.api.ImplementationLookup;
 import org.apache.syncope.core.provisioning.java.ProvisioningContext;
 import org.apache.syncope.core.spring.security.SecurityContext;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetRequestThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.apache.syncope.core.workflow.java.WorkflowContext;
 import org.springframework.boot.logging.LoggingSystem;
 import org.springframework.context.ConfigurableApplicationContext;
@@ -105,4 +109,9 @@ public class IdMLogicTestContext {
     public CacheManager cacheManager() {
         return Caching.getCachingProvider().getCacheManager();
     }
+
+    @Bean(name = PasswordResetRequestThrottler.CACHE)
+    public Cache<String, ThrottlerAttempts> 
passwordResetRequestThrottlerCache(final CacheManager cacheManager) {
+        return cacheManager.createCache(PasswordResetRequestThrottler.CACHE, 
new MutableConfiguration<>());
+    }
 }
diff --git 
a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
 
b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
index a2d6d38ae5..8e561f448a 100644
--- 
a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
+++ 
b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/IdRepoLogicContext.java
@@ -26,6 +26,7 @@ import dev.samstevens.totp.recovery.RecoveryCodeGenerator;
 import dev.samstevens.totp.secret.DefaultSecretGenerator;
 import dev.samstevens.totp.secret.SecretGenerator;
 import jakarta.validation.Validator;
+import javax.cache.Cache;
 import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
 import org.apache.syncope.common.keymaster.client.api.DomainOps;
 import org.apache.syncope.core.logic.init.ClassPathScanImplementationLookup;
@@ -101,8 +102,11 @@ import 
org.apache.syncope.core.provisioning.api.propagation.PropagationTaskExecu
 import org.apache.syncope.core.provisioning.api.rules.RuleProvider;
 import org.apache.syncope.core.provisioning.java.job.SyncopeTaskScheduler;
 import org.apache.syncope.core.spring.security.SecurityProperties;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetRequestThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.annotation.Bean;
@@ -523,7 +527,10 @@ public class IdRepoLogicContext {
             final DelegationDAO delegationDAO,
             final AccessTokenDAO accessTokenDAO,
             final ExternalResourceDAO resourceDAO,
-            final RuleProvider ruleProvider) {
+            final RuleProvider ruleProvider,
+            final SecurityProperties securityProperties,
+            @Qualifier(PasswordResetRequestThrottler.CACHE)
+            final Cache<String, ThrottlerAttempts> passwordResetRequestCache) {
 
         return new UserSelfLogic(
                 realmSearchDAO,
@@ -537,7 +544,9 @@ public class IdRepoLogicContext {
                 delegationDAO,
                 accessTokenDAO,
                 resourceDAO,
-                ruleProvider);
+                ruleProvider,
+                securityProperties,
+                passwordResetRequestCache);
     }
 
     @ConditionalOnMissingBean
diff --git 
a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/UserSelfLogic.java
 
b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/UserSelfLogic.java
index 2369054201..b5e1fb9512 100644
--- 
a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/UserSelfLogic.java
+++ 
b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/UserSelfLogic.java
@@ -24,6 +24,7 @@ import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 import java.util.stream.Collectors;
+import javax.cache.Cache;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
 import org.apache.syncope.common.keymaster.client.api.StandardConfParams;
@@ -62,6 +63,9 @@ import 
org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
 import org.apache.syncope.core.spring.policy.AccountPolicyException;
 import org.apache.syncope.core.spring.policy.PasswordPolicyException;
 import org.apache.syncope.core.spring.security.AuthContextUtils;
+import org.apache.syncope.core.spring.security.SecurityProperties;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetRequestThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -85,6 +89,8 @@ public class UserSelfLogic extends AbstractUserLogic {
 
     protected final RuleProvider ruleProvider;
 
+    protected final PasswordResetRequestThrottler 
passwordResetRequestThrottler;
+
     public UserSelfLogic(
             final RealmSearchDAO realmSearchDAO,
             final AnyTypeDAO anyTypeDAO,
@@ -97,7 +103,9 @@ public class UserSelfLogic extends AbstractUserLogic {
             final DelegationDAO delegationDAO,
             final AccessTokenDAO accessTokenDAO,
             final ExternalResourceDAO resourceDAO,
-            final RuleProvider ruleProvider) {
+            final RuleProvider ruleProvider,
+            final SecurityProperties securityProperties,
+            final Cache<String, ThrottlerAttempts> passwordResetRequestCache) {
 
         super(realmSearchDAO,
                 anyTypeDAO,
@@ -111,6 +119,9 @@ public class UserSelfLogic extends AbstractUserLogic {
         this.accessTokenDAO = accessTokenDAO;
         this.resourceDAO = resourceDAO;
         this.ruleProvider = ruleProvider;
+        this.passwordResetRequestThrottler = new PasswordResetRequestThrottler(
+                securityProperties,
+                passwordResetRequestCache);
     }
 
     @PreAuthorize("isAuthenticated() "
@@ -265,7 +276,11 @@ public class UserSelfLogic extends AbstractUserLogic {
     }
 
     @PreAuthorize("hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
-    public void requestPasswordReset(final String username, final String 
securityAnswer) {
+    public void requestPasswordReset(
+            final String username,
+            final String securityAnswer,
+            final String clientAddress) {
+
         if (!confParamOps.get(
                 AuthContextUtils.getDomain(), 
StandardConfParams.PASSWORD_RESET_ALLOWED, false, boolean.class)) {
 
@@ -274,8 +289,10 @@ public class UserSelfLogic extends AbstractUserLogic {
             throw sce;
         }
 
+        
passwordResetRequestThrottler.recordAndCheck(AuthContextUtils.getDomain(), 
username, clientAddress);
+
         String key = userDAO.findKey(username).
-                orElseThrow(() -> new NotFoundException("User " + username));
+                orElseThrow(() -> new NotFoundException("User"));
 
         if (confParamOps.get(
                 AuthContextUtils.getDomain(), 
StandardConfParams.PASSWORD_RESET_SECURITY_QUESTION, false, boolean.class)
diff --git 
a/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/IdRepoLogicTestContext.java
 
b/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/IdRepoLogicTestContext.java
index 8217a10ceb..70baa7f507 100644
--- 
a/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/IdRepoLogicTestContext.java
+++ 
b/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/IdRepoLogicTestContext.java
@@ -20,8 +20,10 @@ package org.apache.syncope.core.logic;
 
 import static org.mockito.Mockito.mock;
 
+import javax.cache.Cache;
 import javax.cache.CacheManager;
 import javax.cache.Caching;
+import javax.cache.configuration.MutableConfiguration;
 import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
 import org.apache.syncope.common.keymaster.client.api.DomainOps;
 import org.apache.syncope.common.keymaster.client.api.ServiceOps;
@@ -37,6 +39,8 @@ import 
org.apache.syncope.core.persistence.jpa.StartupDomainLoader;
 import org.apache.syncope.core.provisioning.api.ImplementationLookup;
 import org.apache.syncope.core.provisioning.java.ProvisioningContext;
 import org.apache.syncope.core.spring.security.SecurityContext;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetRequestThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.apache.syncope.core.workflow.java.WorkflowContext;
 import org.springframework.boot.logging.LoggingSystem;
 import org.springframework.context.ConfigurableApplicationContext;
@@ -104,4 +108,9 @@ public class IdRepoLogicTestContext {
     public CacheManager cacheManager() {
         return Caching.getCachingProvider().getCacheManager();
     }
+
+    @Bean(name = PasswordResetRequestThrottler.CACHE)
+    public Cache<String, ThrottlerAttempts> 
passwordResetRequestThrottlerCache(final CacheManager cacheManager) {
+        return cacheManager.createCache(PasswordResetRequestThrottler.CACHE, 
new MutableConfiguration<>());
+    }
 }
diff --git 
a/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/UserSelfLogicPasswordResetTest.java
 
b/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/UserSelfLogicPasswordResetTest.java
new file mode 100644
index 0000000000..78fadb0577
--- /dev/null
+++ 
b/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/UserSelfLogicPasswordResetTest.java
@@ -0,0 +1,111 @@
+/*
+ * 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.logic;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Optional;
+import javax.cache.Cache;
+import javax.cache.Caching;
+import javax.cache.configuration.MutableConfiguration;
+import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
+import org.apache.syncope.common.keymaster.client.api.StandardConfParams;
+import org.apache.syncope.core.persistence.api.EncryptorManager;
+import org.apache.syncope.core.persistence.api.dao.AccessTokenDAO;
+import org.apache.syncope.core.persistence.api.dao.AnyTypeDAO;
+import org.apache.syncope.core.persistence.api.dao.DelegationDAO;
+import org.apache.syncope.core.persistence.api.dao.ExternalResourceDAO;
+import org.apache.syncope.core.persistence.api.dao.NotFoundException;
+import org.apache.syncope.core.persistence.api.dao.RealmSearchDAO;
+import org.apache.syncope.core.persistence.api.dao.UserDAO;
+import org.apache.syncope.core.provisioning.api.UserProvisioningManager;
+import org.apache.syncope.core.provisioning.api.data.UserDataBinder;
+import org.apache.syncope.core.provisioning.api.jexl.TemplateUtils;
+import org.apache.syncope.core.provisioning.api.rules.RuleProvider;
+import org.apache.syncope.core.spring.security.SecurityProperties;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetThrottleException;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class UserSelfLogicPasswordResetTest {
+
+    private static final Cache<String, ThrottlerAttempts> CACHE =
+            Caching.getCachingProvider().getCacheManager().createCache(
+                    UserSelfLogicPasswordResetTest.class.getName(),
+                    new MutableConfiguration<>());
+
+    private UserDAO userDAO;
+
+    private SecurityProperties securityProperties;
+
+    private UserSelfLogic logic;
+
+    @BeforeEach
+    void setUp() {
+        ConfParamOps confParamOps = mock(ConfParamOps.class);
+        userDAO = mock(UserDAO.class);
+        securityProperties = new SecurityProperties();
+
+        when(confParamOps.get(any(), 
eq(StandardConfParams.PASSWORD_RESET_ALLOWED), eq(false), eq(boolean.class))).
+                thenReturn(true);
+
+        logic = new UserSelfLogic(
+                mock(RealmSearchDAO.class),
+                mock(AnyTypeDAO.class),
+                mock(TemplateUtils.class),
+                userDAO,
+                mock(UserDataBinder.class),
+                mock(UserProvisioningManager.class),
+                mock(EncryptorManager.class),
+                confParamOps,
+                mock(DelegationDAO.class),
+                mock(AccessTokenDAO.class),
+                mock(ExternalResourceDAO.class),
+                mock(RuleProvider.class),
+                securityProperties,
+                CACHE);
+    }
+
+    @Test
+    void passwordResetRequestsAreThrottled() {
+        securityProperties.getPasswordResetThrottle().setMaxAttempts(1);
+        when(userDAO.findKey("missing")).thenReturn(Optional.empty());
+
+        assertThrows(NotFoundException.class, () -> 
logic.requestPasswordReset("missing", "answer", "192.0.2.1"));
+        assertThrows(
+                PasswordResetThrottleException.class,
+                () -> logic.requestPasswordReset("missing", "answer", 
"192.0.2.1"));
+        assertThrows(NotFoundException.class, () -> 
logic.requestPasswordReset("missing", "answer", "192.0.2.2"));
+    }
+
+    @Test
+    void passwordResetThrottlingCanBeDisabled() {
+        securityProperties.getPasswordResetThrottle().setEnabled(false);
+        securityProperties.getPasswordResetThrottle().setMaxAttempts(1);
+        when(userDAO.findKey("missing")).thenReturn(Optional.empty());
+
+        assertThrows(NotFoundException.class, () -> 
logic.requestPasswordReset("missing", "answer", "192.0.2.1"));
+        assertThrows(NotFoundException.class, () -> 
logic.requestPasswordReset("missing", "answer", "192.0.2.1"));
+    }
+}
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 a7ddd4d04f..7445d5c656 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
@@ -96,7 +96,6 @@ public class RESTProperties {
     @NestedConfigurationProperty
     private final ExecutorProperties batchExecutor = new ExecutorProperties();
 
-    @NestedConfigurationProperty
     private final RateLimitProperties rateLimitProperties = new 
RateLimitProperties();
 
     public ExecutorProperties getBatchExecutor() {
diff --git 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapper.java
 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapper.java
index b147dc9dd0..a67a404656 100644
--- 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapper.java
+++ 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapper.java
@@ -50,6 +50,7 @@ import 
org.apache.syncope.core.persistence.api.dao.MalformedPathException;
 import org.apache.syncope.core.persistence.api.dao.NotFoundException;
 import org.apache.syncope.core.persistence.api.entity.PlainAttr;
 import 
org.apache.syncope.core.spring.security.DelegatedAdministrationException;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetThrottleException;
 import org.apache.syncope.core.workflow.api.WorkflowException;
 import 
org.identityconnectors.framework.common.exceptions.ConfigurationException;
 import org.identityconnectors.framework.common.exceptions.ConnectorException;
@@ -136,6 +137,15 @@ public class RestServiceExceptionMapper implements 
ExceptionMapper<Exception> {
             builder = sce.isComposite()
                     ? 
getSyncopeClientCompositeExceptionResponse(sce.asComposite())
                     : getSyncopeClientExceptionResponse(sce);
+        } else if (ex instanceof PasswordResetThrottleException
+                || ExceptionUtils.getRootCause(ex) instanceof 
PasswordResetThrottleException) {
+
+            PasswordResetThrottleException prte = ex instanceof 
PasswordResetThrottleException
+                    ? (PasswordResetThrottleException) ex
+                    : (PasswordResetThrottleException) 
ExceptionUtils.getRootCause(ex);
+
+            builder = builder(ClientExceptionType.TooManyRequests, 
prte.getMessage()).
+                    header(HttpHeaders.RETRY_AFTER, 
prte.getRetryAfterSeconds());
         } else if (ex instanceof DelegatedAdministrationException
                 || ExceptionUtils.getRootCause(ex) instanceof 
DelegatedAdministrationException) {
 
diff --git 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/UserSelfServiceImpl.java
 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/UserSelfServiceImpl.java
index 2aaa911e16..209300cc34 100644
--- 
a/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/UserSelfServiceImpl.java
+++ 
b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/UserSelfServiceImpl.java
@@ -93,7 +93,7 @@ public class UserSelfServiceImpl extends AbstractService 
implements UserSelfServ
 
     @Override
     public void requestPasswordReset(final String username, final String 
securityAnswer) {
-        logic.requestPasswordReset(username, securityAnswer);
+        logic.requestPasswordReset(username, securityAnswer, 
messageContext.getHttpServletRequest().getRemoteAddr());
     }
 
     @Override
diff --git 
a/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapperTest.java
 
b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapperTest.java
new file mode 100644
index 0000000000..5972fe69f4
--- /dev/null
+++ 
b/core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapperTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.Mockito.mock;
+
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Response;
+import org.apache.syncope.common.lib.types.ClientExceptionType;
+import org.apache.syncope.common.rest.api.RESTHeaders;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetThrottleException;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.env.Environment;
+
+public class RestServiceExceptionMapperTest {
+
+    @Test
+    public void passwordResetThrottleReturnsTooManyRequests() {
+        Response response = new 
RestServiceExceptionMapper(mock(Environment.class)).
+                toResponse(new PasswordResetThrottleException(42));
+
+        assertEquals(Response.Status.TOO_MANY_REQUESTS.getStatusCode(), 
response.getStatus());
+        assertEquals("42", response.getHeaderString(HttpHeaders.RETRY_AFTER));
+        assertEquals(ClientExceptionType.TooManyRequests.name(), 
response.getHeaderString(RESTHeaders.ERROR_CODE));
+    }
+}
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 52e1445464..b42612d3d6 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
@@ -24,10 +24,10 @@ 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;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.springframework.security.core.Authentication;
 
 public class SelfKeymasterUsernamePasswordAuthenticationProvider extends 
UsernamePasswordAuthenticationProvider {
@@ -40,7 +40,7 @@ public class 
SelfKeymasterUsernamePasswordAuthenticationProvider extends Usernam
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
             final EncryptorManager encryptorManager,
-            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache,
+            final Cache<String, ThrottlerAttempts> authenticationAttemptCache,
             final KeymasterProperties keymasterProperties) {
 
         super(
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 671dc8113b..24c5d44595 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
@@ -73,10 +73,11 @@ 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.spring.security.throttle.AuthenticationThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import 
org.apache.syncope.core.starter.SelfKeymasterContext.SelfKeymasterCondition;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -225,8 +226,8 @@ public class SelfKeymasterContext {
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
             final EncryptorManager encryptorManager,
-            @Qualifier(AuthenticationAttemptThrottler.CACHE)
-            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache,
+            @Qualifier(AuthenticationThrottler.CACHE)
+            final Cache<String, ThrottlerAttempts> authenticationAttemptCache,
             final KeymasterProperties keymasterProperties) {
 
         return new SelfKeymasterUsernamePasswordAuthenticationProvider(
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 296d870ebc..2470d84051 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,7 +25,7 @@ import 
org.springframework.boot.context.properties.ConfigurationProperties;
 @ConfigurationProperties("security")
 public class SecurityProperties {
 
-    public static class AuthenticationThrottleProperties {
+    public static class ThrottleProperties {
 
         private boolean enabled = true;
 
@@ -172,7 +172,9 @@ public class SecurityProperties {
 
     private String groovyBlacklist = "classpath:META-INF/groovy.blacklist";
 
-    private final AuthenticationThrottleProperties authenticationThrottle = 
new AuthenticationThrottleProperties();
+    private final ThrottleProperties authenticationThrottle = new 
ThrottleProperties();
+
+    private final ThrottleProperties passwordResetThrottle = new 
ThrottleProperties();
 
     private final AuthenticationErrorProperties authenticationError = new 
AuthenticationErrorProperties();
 
@@ -266,10 +268,14 @@ public class SecurityProperties {
         this.groovyBlacklist = groovyBlacklist;
     }
 
-    public AuthenticationThrottleProperties getAuthenticationThrottle() {
+    public SecurityProperties.ThrottleProperties getAuthenticationThrottle() {
         return authenticationThrottle;
     }
 
+    public ThrottleProperties getPasswordResetThrottle() {
+        return passwordResetThrottle;
+    }
+
     public AuthenticationErrorProperties getAuthenticationError() {
         return authenticationError;
     }
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 33cb97081f..48b758777e 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
@@ -23,6 +23,7 @@ import jakarta.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.syncope.common.rest.api.RESTHeaders;
+import 
org.apache.syncope.core.spring.security.throttle.AuthenticationThrottleException;
 import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
 import org.springframework.security.core.AuthenticationException;
@@ -50,8 +51,8 @@ public class SyncopeBasicAuthenticationEntryPoint extends 
BasicAuthenticationEnt
                 : StringUtils.defaultIfBlank(
                         props.getGenericMessage(),
                         
SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE));
-        if (authException instanceof RateLimitAuthenticationException 
rateLimit) {
-            response.addHeader(HttpHeaders.RETRY_AFTER, 
Long.toString(rateLimit.getRetryAfterSeconds()));
+        if (authException instanceof AuthenticationThrottleException ate) {
+            response.addHeader(HttpHeaders.RETRY_AFTER, 
Long.toString(ate.getRetryAfterSeconds()));
             response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(), 
authException.getMessage());
             return;
         }
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 0c73293f83..3f3a659ecd 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
@@ -31,6 +31,8 @@ import org.apache.syncope.common.lib.types.OpEvent;
 import org.apache.syncope.core.persistence.api.EncryptorManager;
 import org.apache.syncope.core.persistence.api.dao.NotFoundException;
 import org.apache.syncope.core.provisioning.api.UserProvisioningManager;
+import 
org.apache.syncope.core.spring.security.throttle.AuthenticationThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Configurable;
@@ -55,7 +57,7 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
 
     protected final EncryptorManager encryptorManager;
 
-    protected final AuthenticationAttemptThrottler 
authenticationAttemptThrottler;
+    protected final AuthenticationThrottler authenticationThrottler;
 
     public UsernamePasswordAuthenticationProvider(
             final DomainOps domainOps,
@@ -63,22 +65,21 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
             final EncryptorManager encryptorManager,
-            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache) {
+            final Cache<String, ThrottlerAttempts> 
authenticationThrottlerCache) {
 
         this.domainOps = domainOps;
         this.dataAccessor = dataAccessor;
         this.provisioningManager = provisioningManager;
         this.securityProperties = securityProperties;
         this.encryptorManager = encryptorManager;
-        this.authenticationAttemptThrottler =
-                new AuthenticationAttemptThrottler(securityProperties, 
authenticationAttemptCache);
+        this.authenticationThrottler = new 
AuthenticationThrottler(securityProperties, authenticationThrottlerCache);
     }
 
     @Override
     public Authentication authenticate(final Authentication authentication) {
         String domainKey = ((SyncopeAuthenticationDetails) 
authentication.getDetails()).getDomain();
         String authenticatingPrincipal = 
Objects.requireNonNull(authentication.getPrincipal()).toString();
-        authenticationAttemptThrottler.checkAllowed(domainKey, 
authenticatingPrincipal);
+        authenticationThrottler.checkAllowed(domainKey, 
authenticatingPrincipal);
 
         Optional<Domain> domain;
         if (SyncopeConstants.MASTER_DOMAIN.equals(domainKey)) {
@@ -125,7 +126,7 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
             final Authentication authentication) {
 
         if (authResult.isSuccess()) {
-            authenticationAttemptThrottler.clearFailures(domain, login);
+            authenticationThrottler.clearFailures(domain, login);
             UsernamePasswordAuthenticationToken token = 
AuthContextUtils.callAsAdmin(domain, () -> {
                 UsernamePasswordAuthenticationToken upat = new 
UsernamePasswordAuthenticationToken(
                         username,
@@ -158,7 +159,7 @@ public class UsernamePasswordAuthenticationProvider 
implements AuthenticationPro
 
         LOG.debug("User {} not authenticated", username);
 
-        authenticationAttemptThrottler.recordFailure(domain, login);
+        authenticationThrottler.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 c99fd6cc83..fbc2446651 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
@@ -42,6 +42,9 @@ 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.apache.syncope.core.spring.security.throttle.AuthenticationThrottler;
+import 
org.apache.syncope.core.spring.security.throttle.PasswordResetRequestThrottler;
+import org.apache.syncope.core.spring.security.throttle.ThrottlerAttempts;
 import org.springframework.beans.factory.annotation.Qualifier;
 import 
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.context.annotation.Bean;
@@ -135,8 +138,8 @@ public class WebSecurityContext {
             final UserProvisioningManager provisioningManager,
             final SecurityProperties securityProperties,
             final EncryptorManager encryptorManager,
-            @Qualifier(AuthenticationAttemptThrottler.CACHE)
-            final Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache) {
+            @Qualifier(AuthenticationThrottler.CACHE)
+            final Cache<String, ThrottlerAttempts> 
authenticationThrottlerCache) {
 
         return new UsernamePasswordAuthenticationProvider(
                 domainOps,
@@ -144,18 +147,18 @@ public class WebSecurityContext {
                 provisioningManager,
                 securityProperties,
                 encryptorManager,
-                authenticationAttemptCache);
+                authenticationThrottlerCache);
     }
 
-    @ConditionalOnMissingBean(name = AuthenticationAttemptThrottler.CACHE)
-    @Bean(name = AuthenticationAttemptThrottler.CACHE)
-    public Cache<String, AuthenticationAttemptThrottler.Attempts> 
authenticationAttemptCache(
+    @ConditionalOnMissingBean(name = AuthenticationThrottler.CACHE)
+    @Bean(name = AuthenticationThrottler.CACHE)
+    public Cache<String, ThrottlerAttempts> authenticationThrottlerCache(
             final CacheManager cacheManager,
             final SecurityProperties securityProperties) {
 
-        return cacheManager.createCache(AuthenticationAttemptThrottler.CACHE,
-                new MutableConfiguration<String, 
AuthenticationAttemptThrottler.Attempts>().
-                        setTypes(String.class, 
AuthenticationAttemptThrottler.Attempts.class).
+        return cacheManager.createCache(AuthenticationThrottler.CACHE,
+                new MutableConfiguration<String, ThrottlerAttempts>().
+                        setTypes(String.class, ThrottlerAttempts.class).
                         
setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(new Duration(
                                 TimeUnit.SECONDS,
                                 Math.max(1, Math.max(
@@ -163,6 +166,22 @@ public class WebSecurityContext {
                                         
securityProperties.getAuthenticationThrottle().getLockSeconds()))))));
     }
 
+    @ConditionalOnMissingBean(name = PasswordResetRequestThrottler.CACHE)
+    @Bean(name = PasswordResetRequestThrottler.CACHE)
+    public Cache<String, ThrottlerAttempts> passwordResetRequestThrottlerCache(
+            final CacheManager cacheManager,
+            final SecurityProperties securityProperties) {
+
+        return cacheManager.createCache(PasswordResetRequestThrottler.CACHE,
+                new MutableConfiguration<String, ThrottlerAttempts>().
+                        setTypes(String.class, ThrottlerAttempts.class).
+                        
setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(new Duration(
+                                TimeUnit.SECONDS,
+                                Math.max(1, Math.max(
+                                        
securityProperties.getPasswordResetThrottle().getWindowSeconds(),
+                                        
securityProperties.getPasswordResetThrottle().getLockSeconds()))))));
+    }
+
     @Bean
     public AccessDeniedHandler accessDeniedHandler() {
         return new SyncopeAccessDeniedHandler();
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AbstractThrottler.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AbstractThrottler.java
new file mode 100644
index 0000000000..711df6540b
--- /dev/null
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AbstractThrottler.java
@@ -0,0 +1,59 @@
+/*
+ * 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.throttle;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.concurrent.TimeUnit;
+import java.util.function.LongSupplier;
+import javax.cache.Cache;
+import org.apache.syncope.core.spring.security.SecurityProperties;
+
+abstract class AbstractThrottler {
+
+    protected final SecurityProperties.ThrottleProperties throttle;
+
+    protected final LongSupplier clock = System::currentTimeMillis;
+
+    protected final Cache<String, ThrottlerAttempts> attempts;
+
+    protected AbstractThrottler(
+            final SecurityProperties.ThrottleProperties throttle,
+            final Cache<String, ThrottlerAttempts> attempts) {
+
+        this.throttle = throttle;
+        this.attempts = attempts;
+    }
+
+    protected boolean isEnabled() {
+        return throttle.isEnabled()
+                && throttle.getMaxAttempts() > 0
+                && throttle.getWindowSeconds() > 0
+                && throttle.getLockSeconds() > 0;
+    }
+
+    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;
+    }
+}
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AuthenticationThrottleException.java
similarity index 84%
copy from 
core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
copy to 
core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AuthenticationThrottleException.java
index 1a69f9b0af..ab3f85ec82 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AuthenticationThrottleException.java
@@ -16,17 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.spring.security;
+package org.apache.syncope.core.spring.security.throttle;
 
 import org.springframework.security.core.AuthenticationException;
 
-public class RateLimitAuthenticationException extends AuthenticationException {
+public class AuthenticationThrottleException extends AuthenticationException {
 
     private static final long serialVersionUID = 3829921857697051591L;
 
     private final long retryAfterSeconds;
 
-    public RateLimitAuthenticationException(final long retryAfterSeconds) {
+    public AuthenticationThrottleException(final long retryAfterSeconds) {
         super("Too many authentication failures");
         this.retryAfterSeconds = retryAfterSeconds;
     }
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/throttle/AuthenticationThrottler.java
similarity index 58%
rename from 
core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java
rename to 
core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/AuthenticationThrottler.java
index afafd89d29..f725e0afba 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/throttle/AuthenticationThrottler.java
@@ -16,32 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.spring.security;
+package org.apache.syncope.core.spring.security.throttle;
 
-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 org.apache.commons.lang3.StringUtils;
+import org.apache.syncope.core.spring.security.SecurityProperties;
 
-public class AuthenticationAttemptThrottler {
+public class AuthenticationThrottler extends AbstractThrottler {
 
-    public static final String CACHE = "AuthenticationAttemptCache";
-
-    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);
-        }
-    }
+    public static final String CACHE = "AuthenticationThrottlerCache";
 
     protected static String key(final String domain, final String username) {
         return StringUtils.defaultString(domain) + ':' + 
StringUtils.defaultString(username);
@@ -51,18 +36,11 @@ public class AuthenticationAttemptThrottler {
         return Math.max(1, TimeUnit.MILLISECONDS.toSeconds(blockedUntil - 
now));
     }
 
-    protected final SecurityProperties.AuthenticationThrottleProperties 
throttle;
-
-    protected final LongSupplier clock = System::currentTimeMillis;
-
-    protected final Cache<String, Attempts> attempts;
-
-    public AuthenticationAttemptThrottler(
+    public AuthenticationThrottler(
             final SecurityProperties securityProperties,
-            final Cache<String, Attempts> attempts) {
+            final Cache<String, ThrottlerAttempts> attempts) {
 
-        this.throttle = securityProperties.getAuthenticationThrottle();
-        this.attempts = attempts;
+        super(securityProperties.getAuthenticationThrottle(), attempts);
     }
 
     public void checkAllowed(final String domain, final String username) {
@@ -77,7 +55,7 @@ public class AuthenticationAttemptThrottler {
                 return null;
             }
 
-            Attempts state = entry.getValue();
+            ThrottlerAttempts state = entry.getValue();
             if (state.blockedUntil() > now) {
                 return retryAfterSeconds(state.blockedUntil(), now);
             }
@@ -86,22 +64,15 @@ public class AuthenticationAttemptThrottler {
             if (failures.isEmpty()) {
                 entry.remove();
             } else {
-                entry.setValue(new Attempts(failures, state.blockedUntil()));
+                entry.setValue(new ThrottlerAttempts(failures, 
state.blockedUntil()));
             }
             return null;
         });
         if (retryAfter != null) {
-            throw new RateLimitAuthenticationException(retryAfter);
+            throw new AuthenticationThrottleException(retryAfter);
         }
     }
 
-    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));
     }
@@ -113,32 +84,23 @@ public class AuthenticationAttemptThrottler {
 
         long now = clock.getAsLong();
         Long retryAfter = attempts.invoke(key(domain, username), (entry, args) 
-> {
-            Attempts state = entry.exists()
+            ThrottlerAttempts state = entry.exists()
                     ? entry.getValue()
-                    : new Attempts();
+                    : new ThrottlerAttempts();
             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));
+                entry.setValue(new ThrottlerAttempts(failures, blockedUntil));
                 return retryAfterSeconds(blockedUntil, now);
             }
 
-            entry.setValue(new Attempts(failures, state.blockedUntil()));
+            entry.setValue(new ThrottlerAttempts(failures, 
state.blockedUntil()));
             return null;
         });
         if (retryAfter != null) {
-            throw new RateLimitAuthenticationException(retryAfter);
-        }
-    }
-
-    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();
+            throw new AuthenticationThrottleException(retryAfter);
         }
-        return failures;
     }
 }
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetRequestThrottler.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetRequestThrottler.java
new file mode 100644
index 0000000000..9795cfae42
--- /dev/null
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetRequestThrottler.java
@@ -0,0 +1,109 @@
+/*
+ * 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.throttle;
+
+import java.util.Deque;
+import java.util.concurrent.TimeUnit;
+import javax.cache.Cache;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.syncope.core.spring.security.SecurityProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PasswordResetRequestThrottler extends AbstractThrottler {
+
+    protected static final Logger LOG = 
LoggerFactory.getLogger(PasswordResetRequestThrottler.class);
+
+    public static final String CACHE = "PasswordResetRequestThrottlerCache";
+
+    protected static String key(final String domain, final String username, 
final String clientAddress) {
+        return StringUtils.defaultString(domain)
+                + ':' + StringUtils.defaultString(username)
+                + ':' + StringUtils.defaultString(clientAddress);
+    }
+
+    protected static String attemptKeyId(final String key) {
+        return Integer.toUnsignedString(key.hashCode(), 16);
+    }
+
+    protected static PasswordResetThrottleException blocked(final long 
blockedUntil, final long now) {
+        long retryAfter = Math.max(1, 
TimeUnit.MILLISECONDS.toSeconds(blockedUntil - now));
+        return new PasswordResetThrottleException(retryAfter);
+    }
+
+    public PasswordResetRequestThrottler(
+            final SecurityProperties securityProperties,
+            final Cache<String, ThrottlerAttempts> attempts) {
+
+        super(securityProperties.getPasswordResetThrottle(), attempts);
+    }
+
+    public void recordAndCheck(final String domain, final String username, 
final String clientAddress) {
+        if (!isEnabled()) {
+            LOG.debug("Password reset request throttling skipped because it is 
disabled or misconfigured");
+            return;
+        }
+
+        long now = clock.getAsLong();
+        String attemptKey = key(domain, username, clientAddress);
+        String attemptKeyId = attemptKeyId(attemptKey);
+        PasswordResetThrottleException blocked = attempts.invoke(
+                attemptKey,
+                (entry, args) -> {
+                    ThrottlerAttempts state = entry.exists()
+                    ? entry.getValue()
+                    : new ThrottlerAttempts();
+
+                    if (state.blockedUntil() > now) {
+                        PasswordResetThrottleException exception = 
blocked(state.blockedUntil(), now);
+                        LOG.debug(
+                                "Password reset request throttled for attempt 
key [{}]; retry after [{}] seconds",
+                                attemptKeyId,
+                                exception.getRetryAfterSeconds());
+                        return exception;
+                    }
+
+                    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 ThrottlerAttempts(failures, 
blockedUntil));
+                        LOG.warn(
+                                "Password reset request throttling activated 
for attempt key [{}]; "
+                                + "attempts [{}], max attempts [{}], lock 
seconds [{}]",
+                                attemptKeyId,
+                                failures.size(),
+                                throttle.getMaxAttempts(),
+                                throttle.getLockSeconds());
+                        return blocked(blockedUntil, now);
+                    }
+
+                    entry.setValue(new ThrottlerAttempts(failures, 
state.blockedUntil()));
+                    LOG.trace(
+                            "Password reset request failure recorded for 
attempt key [{}]; attempts [{}/{}]",
+                            attemptKeyId,
+                            failures.size(),
+                            throttle.getMaxAttempts());
+                    return null;
+                });
+        if (blocked != null) {
+            throw blocked;
+        }
+    }
+}
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetThrottleException.java
similarity index 71%
copy from 
core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
copy to 
core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetThrottleException.java
index 1a69f9b0af..04368f8296 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetThrottleException.java
@@ -16,18 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.spring.security;
+package org.apache.syncope.core.spring.security.throttle;
 
-import org.springframework.security.core.AuthenticationException;
+public class PasswordResetThrottleException extends RuntimeException {
 
-public class RateLimitAuthenticationException extends AuthenticationException {
-
-    private static final long serialVersionUID = 3829921857697051591L;
+    private static final long serialVersionUID = 7640084563260847773L;
 
     private final long retryAfterSeconds;
 
-    public RateLimitAuthenticationException(final long retryAfterSeconds) {
-        super("Too many authentication failures");
+    public PasswordResetThrottleException(final long retryAfterSeconds) {
+        super("Too many password reset requests");
         this.retryAfterSeconds = retryAfterSeconds;
     }
 
diff --git 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/ThrottlerAttempts.java
similarity index 59%
rename from 
core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
rename to 
core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/ThrottlerAttempts.java
index 1a69f9b0af..2465717470 100644
--- 
a/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java
+++ 
b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/ThrottlerAttempts.java
@@ -16,22 +16,21 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.spring.security;
+package org.apache.syncope.core.spring.security.throttle;
 
-import org.springframework.security.core.AuthenticationException;
+import java.io.Serializable;
+import java.util.ArrayDeque;
+import java.util.Deque;
 
-public class RateLimitAuthenticationException extends AuthenticationException {
+public record ThrottlerAttempts(Deque<Long> failures, long blockedUntil) 
implements Serializable {
 
-    private static final long serialVersionUID = 3829921857697051591L;
+    private static final long serialVersionUID = 8023582605543650484L;
 
-    private final long retryAfterSeconds;
-
-    public RateLimitAuthenticationException(final long retryAfterSeconds) {
-        super("Too many authentication failures");
-        this.retryAfterSeconds = retryAfterSeconds;
+    public ThrottlerAttempts {
+        failures = new ArrayDeque<>(failures);
     }
 
-    public long getRetryAfterSeconds() {
-        return retryAfterSeconds;
+    public ThrottlerAttempts() {
+        this(new ArrayDeque<>(), 0);
     }
 }
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 04d9714e37..b4fadc43c3 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
@@ -21,6 +21,7 @@ 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.apache.syncope.core.spring.security.throttle.AuthenticationThrottleException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.springframework.http.HttpHeaders;
@@ -49,10 +50,9 @@ class SyncopeBasicAuthenticationEntryPointTest {
     void rateLimitAuthenticationExceptionReturnsTooManyRequests() throws 
Exception {
         MockHttpServletResponse response = new MockHttpServletResponse();
 
-        entryPoint.commence(
-                new MockHttpServletRequest(),
+        entryPoint.commence(new MockHttpServletRequest(),
                 response,
-                new RateLimitAuthenticationException(30));
+                new AuthenticationThrottleException(30));
 
         assertEquals(HttpStatus.TOO_MANY_REQUESTS.value(), 
response.getStatus());
         assertEquals("30", response.getHeader(HttpHeaders.RETRY_AFTER));
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/throttle/AuthenticationThrottlerTest.java
similarity index 78%
rename from 
core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottlerTest.java
rename to 
core/spring/src/test/java/org/apache/syncope/core/spring/security/throttle/AuthenticationThrottlerTest.java
index 97eac45dd1..8f2a0c1795 100644
--- 
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/throttle/AuthenticationThrottlerTest.java
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.syncope.core.spring.security;
+package org.apache.syncope.core.spring.security.throttle;
 
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -30,10 +30,11 @@ import javax.cache.configuration.MutableConfiguration;
 import javax.cache.expiry.Duration;
 import javax.cache.expiry.TouchedExpiryPolicy;
 import org.apache.syncope.common.lib.SyncopeConstants;
+import org.apache.syncope.core.spring.security.SecurityProperties;
 import org.junit.jupiter.api.Test;
 import org.springframework.test.util.ReflectionTestUtils;
 
-class AuthenticationAttemptThrottlerTest {
+class AuthenticationThrottlerTest {
 
     private static SecurityProperties securityProperties() {
         SecurityProperties securityProperties = new SecurityProperties();
@@ -44,24 +45,23 @@ class AuthenticationAttemptThrottlerTest {
         return securityProperties;
     }
 
-    private static AuthenticationAttemptThrottler throttler(
+    private static AuthenticationThrottler throttler(
             final SecurityProperties securityProperties,
             final LongSupplier clock) {
 
-        Cache<String, AuthenticationAttemptThrottler.Attempts> cache =
-                
Caching.getCachingProvider().getCacheManager().getCache(AuthenticationAttemptThrottler.CACHE);
+        Cache<String, ThrottlerAttempts> cache =
+                
Caching.getCachingProvider().getCacheManager().getCache(AuthenticationThrottler.CACHE);
         if (cache == null) {
-            cache = Caching.getCachingProvider().getCacheManager().createCache(
-                    AuthenticationAttemptThrottler.CACHE,
-                    new MutableConfiguration<String, 
AuthenticationAttemptThrottler.Attempts>().
-                            setTypes(String.class, 
AuthenticationAttemptThrottler.Attempts.class).
+            cache = 
Caching.getCachingProvider().getCacheManager().createCache(AuthenticationThrottler.CACHE,
+                    new MutableConfiguration<String, ThrottlerAttempts>().
+                            setTypes(String.class, ThrottlerAttempts.class).
                             setExpiryPolicyFactory(
                                     TouchedExpiryPolicy.factoryOf(new 
Duration(TimeUnit.SECONDS, 30))));
         } else {
             cache.clear();
         }
 
-        AuthenticationAttemptThrottler throttler = new 
AuthenticationAttemptThrottler(securityProperties, cache);
+        AuthenticationThrottler throttler = new 
AuthenticationThrottler(securityProperties, cache);
         ReflectionTestUtils.setField(throttler, "clock", clock);
         return throttler;
     }
@@ -70,14 +70,14 @@ class AuthenticationAttemptThrottlerTest {
     void blocksAfterConfiguredFailures() {
         AtomicLong now = new AtomicLong();
         SecurityProperties securityProperties = securityProperties();
-        AuthenticationAttemptThrottler throttler = 
throttler(securityProperties, now::get);
+        AuthenticationThrottler throttler = throttler(securityProperties, 
now::get);
 
         for (int i = 1; i < 
securityProperties.getAuthenticationThrottle().getMaxAttempts(); i++) {
             assertDoesNotThrow(() -> 
throttler.recordFailure(SyncopeConstants.MASTER_DOMAIN, "rossini"));
         }
-        assertThrows(RateLimitAuthenticationException.class,
+        assertThrows(AuthenticationThrottleException.class,
                 () -> throttler.recordFailure(SyncopeConstants.MASTER_DOMAIN, 
"rossini"));
-        assertThrows(RateLimitAuthenticationException.class,
+        assertThrows(AuthenticationThrottleException.class,
                 () -> throttler.checkAllowed(SyncopeConstants.MASTER_DOMAIN, 
"rossini"));
 
         now.addAndGet(30_000);
@@ -87,7 +87,7 @@ class AuthenticationAttemptThrottlerTest {
     @Test
     void successResetsFailures() {
         AtomicLong now = new AtomicLong();
-        AuthenticationAttemptThrottler throttler = 
throttler(securityProperties(), now::get);
+        AuthenticationThrottler throttler = throttler(securityProperties(), 
now::get);
 
         throttler.recordFailure(SyncopeConstants.MASTER_DOMAIN, "rossini");
         throttler.clearFailures(SyncopeConstants.MASTER_DOMAIN, "rossini");
@@ -98,7 +98,7 @@ class AuthenticationAttemptThrottlerTest {
     @Test
     void expiredFailuresAreIgnored() {
         AtomicLong now = new AtomicLong();
-        AuthenticationAttemptThrottler throttler = 
throttler(securityProperties(), now::get);
+        AuthenticationThrottler throttler = throttler(securityProperties(), 
now::get);
 
         throttler.recordFailure(SyncopeConstants.MASTER_DOMAIN, "rossini");
         now.addAndGet(61_000);
diff --git a/core/starter/src/main/resources/core.properties 
b/core/starter/src/main/resources/core.properties
index 5a522b6fda..16210f3ba4 100644
--- a/core/starter/src/main/resources/core.properties
+++ b/core/starter/src/main/resources/core.properties
@@ -107,6 +107,11 @@ security.authenticationThrottle.maxAttempts=5
 security.authenticationThrottle.windowSeconds=60
 security.authenticationThrottle.lockSeconds=60
 
+security.passwordResetThrottle.enabled=true
+security.passwordResetThrottle.maxAttempts=5
+security.passwordResetThrottle.windowSeconds=300
+security.passwordResetThrottle.lockSeconds=300
+
 security.authenticationError.exposeDetails=false
 security.authenticationError.genericMessage=Authentication failed
 
diff --git 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/UserSelfITCase.java
 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/UserSelfITCase.java
index 6a5ba9d34e..67f4465d62 100644
--- 
a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/UserSelfITCase.java
+++ 
b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/UserSelfITCase.java
@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
+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.assumeFalse;
@@ -32,8 +33,11 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue;
 import jakarta.ws.rs.ForbiddenException;
 import jakarta.ws.rs.core.GenericType;
 import jakarta.ws.rs.core.Response;
+import java.io.IOException;
+import java.io.InputStream;
 import java.util.List;
 import java.util.Optional;
+import java.util.Properties;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.TimeUnit;
@@ -392,6 +396,33 @@ public class UserSelfITCase extends AbstractITCase {
         assertNotEquals(pwdOnResource, newPwdOnResource);
     }
 
+    @Test
+    public void passwordResetThrottle() {
+        String username = "missing-" + UUID.randomUUID() + 
"@syncope.apache.org";
+        UserSelfService userSelfService = 
ANONYMOUS_CLIENT.getService(UserSelfService.class);
+
+        int maxAttempts;
+        try (InputStream propStream = 
AbstractITCase.class.getResourceAsStream("/core.properties")) {
+            Properties props = new Properties();
+            props.load(propStream);
+            maxAttempts = 
Integer.parseInt(props.getProperty("security.passwordReset.throttle.maxAttempts",
 "5"));
+        } catch (IOException | NumberFormatException e) {
+            throw new IllegalStateException("Could not read password reset 
throttle max attempts from core.props", e);
+        }
+
+        for (int i = 0; i < maxAttempts; i++) {
+            SyncopeClientException e = assertThrows(
+                    SyncopeClientException.class,
+                    () -> userSelfService.requestPasswordReset(username, 
"answer"));
+            assertEquals(ClientExceptionType.NotFound, e.getType());
+        }
+
+        SyncopeClientException e = assertThrows(
+                SyncopeClientException.class,
+                () -> userSelfService.requestPasswordReset(username, 
"answer"));
+        assertEquals(Response.Status.TOO_MANY_REQUESTS, 
e.getType().getResponseStatus());
+    }
+
     @Test
     public void passwordResetWithoutSecurityQuestion() {
         // 0. disable security question for password reset
diff --git 
a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc 
b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc
index 9229e0112d..33de5bb6db 100644
--- a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc
+++ b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc
@@ -63,3 +63,47 @@ This to avoid any information disclosure which can 
potentially lead attackers to
 [NOTE]
 In addition to the password reset feature, administrators can set a flag on a 
given user so that he / she is forced to
 update their password value at next login.
+
+===== Password Reset Request Throttling
+This functionality can throttle repeated password reset requests to reduce the 
effectiveness of automated guessing
+against security answers and to limit password reset email abuse.
+
+This protection is configured in `core.properties` via the following 
properties:
+
+[cols="1,3"]
+|===
+|Property |Description
+|`security.passwordResetThrottle.enabled`
+|Whether password reset request throttling is enabled. When set to `false`, 
password reset requests are not
+rate-limited by this mechanism.
+|`security.passwordResetThrottle.maxAttempts`
+|Maximum number of password reset requests allowed for the same Domain, 
username and client address within the
+configured time window.
+|`security.passwordResetThrottle.windowSeconds`
+|Time window, in seconds, used to count password reset requests.
+|`security.passwordResetThrottle.lockSeconds`
+|Time, in seconds, during which further password reset requests for the same 
Domain, username and client address are
+rejected after the maximum number of requests has been exceeded.
+|===
+
+A possible configuration is:
+[source,properties]
+----
+security.passwordResetThrottle.enabled=true
+security.passwordResetThrottle.maxAttempts=5
+security.passwordResetThrottle.windowSeconds=300
+security.passwordResetThrottle.lockSeconds=300
+----
+
+When enabled, Core tracks password reset requests for each combination of 
Domain, username and client address.
+The client address is obtained from the servlet request remote address.
+
+Once more than `security.passwordResetThrottle.maxAttempts` requests are 
received within
+`security.passwordResetThrottle.windowSeconds`, further password reset 
requests for the same combination are rejected
+for `security.passwordResetThrottle.lockSeconds`.
+
+During the lock interval, the requester receives an HTTP `429 Too Many 
Requests` response with a `Retry-After` header.
+
+[NOTE]
+The client address is part of the throttling key to avoid allowing a client 
that knows a username or email address to
+globally block password reset for that account from other client addresses.
diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc 
b/src/main/asciidoc/reference-guide/usage/core.adoc
index da64d10609..c1176ef129 100644
--- a/src/main/asciidoc/reference-guide/usage/core.adoc
+++ b/src/main/asciidoc/reference-guide/usage/core.adoc
@@ -231,7 +231,7 @@ 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.
+When enabled, Core 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`.
 

Reply via email to