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`.