From 279efd149b7473e7936749f8d03cb304faab356f Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Thu, 4 Jun 2026 07:30:16 +0200 Subject: [PATCH 1/7] [SYNCOPE-1975] - Throttle password reset requests --- .../common/lib/types/ClientExceptionType.java | 1 + .../core/logic/IdRepoLogicContext.java | 24 ++- .../logic/PasswordResetRequestThrottler.java | 175 ++++++++++++++++++ .../logic/PasswordResetThrottleException.java | 35 ++++ .../syncope/core/logic/UserSelfLogic.java | 21 ++- .../logic/UserSelfLogicPasswordResetTest.java | 110 +++++++++++ .../rest/cxf/RestServiceExceptionMapper.java | 4 + .../rest/cxf/service/UserSelfServiceImpl.java | 2 +- .../cxf/RestServiceExceptionMapperTest.java | 43 +++++ .../spring/security/SecurityProperties.java | 58 ++++++ .../src/main/resources/core.properties | 5 + .../syncope/fit/core/UserSelfITCase.java | 31 ++++ 12 files changed, 503 insertions(+), 6 deletions(-) create mode 100644 core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetRequestThrottler.java create mode 100644 core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetThrottleException.java create mode 100644 core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/UserSelfLogicPasswordResetTest.java create mode 100644 core/idrepo/rest-cxf/src/test/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapperTest.java 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/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..88162ea9ad 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,8 @@ import dev.samstevens.totp.secret.DefaultSecretGenerator; import dev.samstevens.totp.secret.SecretGenerator; import jakarta.validation.Validator; +import javax.cache.Cache; +import javax.cache.CacheManager; import org.apache.syncope.common.keymaster.client.api.ConfParamOps; import org.apache.syncope.common.keymaster.client.api.DomainOps; import org.apache.syncope.core.logic.init.ClassPathScanImplementationLookup; @@ -103,6 +105,7 @@ import org.apache.syncope.core.spring.security.SecurityProperties; 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 +526,10 @@ public UserSelfLogic userSelfLogic( final DelegationDAO delegationDAO, final AccessTokenDAO accessTokenDAO, final ExternalResourceDAO resourceDAO, - final RuleProvider ruleProvider) { + final RuleProvider ruleProvider, + final SecurityProperties securityProperties, + @Qualifier(PasswordResetRequestThrottler.CACHE_NAME) + final Cache passwordResetRequestCache) { return new UserSelfLogic( realmSearchDAO, @@ -537,7 +543,21 @@ public UserSelfLogic userSelfLogic( delegationDAO, accessTokenDAO, resourceDAO, - ruleProvider); + ruleProvider, + securityProperties, + passwordResetRequestCache); + } + + @ConditionalOnMissingBean(name = PasswordResetRequestThrottler.CACHE_NAME) + @Bean(name = PasswordResetRequestThrottler.CACHE_NAME) + public Cache passwordResetRequestCache( + final CacheManager cacheManager, + final SecurityProperties securityProperties) { + + return cacheManager.createCache( + PasswordResetRequestThrottler.CACHE_NAME, + PasswordResetRequestThrottler.cacheConfiguration( + securityProperties.getPasswordReset().getThrottle())); } @ConditionalOnMissingBean diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetRequestThrottler.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetRequestThrottler.java new file mode 100644 index 0000000000..a158999e7c --- /dev/null +++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetRequestThrottler.java @@ -0,0 +1,175 @@ +/* + * 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 java.io.Serializable; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; +import javax.cache.Cache; +import javax.cache.configuration.MutableConfiguration; +import javax.cache.expiry.TouchedExpiryPolicy; +import org.apache.commons.lang3.StringUtils; +import org.apache.syncope.core.spring.security.SecurityProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PasswordResetRequestThrottler { + + protected static final Logger LOG = LoggerFactory.getLogger(PasswordResetRequestThrottler.class); + + public static final String CACHE_NAME = + "org.apache.syncope.core.logic.PasswordResetRequestThrottler"; + + public record Attempts(Deque failures, long blockedUntil) implements Serializable { + + private static final long serialVersionUID = -4276590495962149303L; + + public Attempts { + failures = new ArrayDeque<>(failures); + } + + private Attempts() { + this(new ArrayDeque<>(), 0); + } + } + + protected final SecurityProperties.PasswordResetProperties.ThrottleProperties throttle; + + protected final LongSupplier clock; + + protected final Cache attempts; + + public PasswordResetRequestThrottler( + final SecurityProperties securityProperties, + final Cache attempts) { + + this(securityProperties, System::currentTimeMillis, attempts); + } + + PasswordResetRequestThrottler( + final SecurityProperties securityProperties, + final LongSupplier clock, + final Cache attempts) { + + this.throttle = securityProperties.getPasswordReset().getThrottle(); + this.clock = clock; + this.attempts = attempts; + } + + public static MutableConfiguration cacheConfiguration( + final SecurityProperties.PasswordResetProperties.ThrottleProperties throttle) { + + return new MutableConfiguration(). + setTypes(String.class, Attempts.class). + setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf( + new javax.cache.expiry.Duration(TimeUnit.SECONDS, cacheExpirySeconds(throttle)))); + } + + private static long cacheExpirySeconds( + final SecurityProperties.PasswordResetProperties.ThrottleProperties throttle) { + + return Math.max(1, Math.max(throttle.getWindowSeconds(), throttle.getLockSeconds())); + } + + 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) -> { + Attempts state = entry.exists() + ? entry.getValue() + : new Attempts(); + + 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 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)); + 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 Attempts(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; + } + } + + private static String key(final String domain, final String username, final String clientAddress) { + return StringUtils.defaultString(domain) + + ':' + StringUtils.defaultString(username) + + ':' + StringUtils.defaultString(clientAddress); + } + + private static String attemptKeyId(final String key) { + return Integer.toUnsignedString(key.hashCode(), 16); + } + + private boolean isEnabled() { + return throttle.isEnabled() + && throttle.getMaxAttempts() > 0 + && throttle.getWindowSeconds() > 0 + && throttle.getLockSeconds() > 0; + } + + private Deque prune(final Deque attempts, final long now) { + Deque failures = new ArrayDeque<>(attempts); + long threshold = now - TimeUnit.SECONDS.toMillis(throttle.getWindowSeconds()); + while (!failures.isEmpty() && failures.peekFirst() < threshold) { + failures.removeFirst(); + } + return failures; + } + + private static PasswordResetThrottleException blocked(final long blockedUntil, final long now) { + long retryAfter = Math.max(1, TimeUnit.MILLISECONDS.toSeconds(blockedUntil - now)); + return new PasswordResetThrottleException(retryAfter); + } +} diff --git a/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetThrottleException.java b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetThrottleException.java new file mode 100644 index 0000000000..36ff261c62 --- /dev/null +++ b/core/idrepo/logic/src/main/java/org/apache/syncope/core/logic/PasswordResetThrottleException.java @@ -0,0 +1,35 @@ +/* + * 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; + +public class PasswordResetThrottleException extends RuntimeException { + + private static final long serialVersionUID = 7640084563260847773L; + + private final long retryAfterSeconds; + + public PasswordResetThrottleException(final long retryAfterSeconds) { + super("Too many password reset requests"); + this.retryAfterSeconds = retryAfterSeconds; + } + + public long getRetryAfterSeconds() { + return retryAfterSeconds; + } +} 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..6469124463 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.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,7 @@ 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.springframework.security.access.prepost.PreAuthorize; import org.springframework.transaction.annotation.Transactional; @@ -85,6 +87,8 @@ protected static void throwMfaWasSet(final String username) { protected final RuleProvider ruleProvider; + protected final PasswordResetRequestThrottler passwordResetRequestThrottler; + public UserSelfLogic( final RealmSearchDAO realmSearchDAO, final AnyTypeDAO anyTypeDAO, @@ -97,7 +101,9 @@ public UserSelfLogic( final DelegationDAO delegationDAO, final AccessTokenDAO accessTokenDAO, final ExternalResourceDAO resourceDAO, - final RuleProvider ruleProvider) { + final RuleProvider ruleProvider, + final SecurityProperties securityProperties, + final Cache passwordResetRequestCache) { super(realmSearchDAO, anyTypeDAO, @@ -111,6 +117,9 @@ public UserSelfLogic( this.accessTokenDAO = accessTokenDAO; this.resourceDAO = resourceDAO; this.ruleProvider = ruleProvider; + this.passwordResetRequestThrottler = new PasswordResetRequestThrottler( + securityProperties, + passwordResetRequestCache); } @PreAuthorize("isAuthenticated() " @@ -265,7 +274,11 @@ public void compliance(final ComplianceQuery query) { } @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 +287,10 @@ public void requestPasswordReset(final String username, final String securityAns 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/UserSelfLogicPasswordResetTest.java b/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/UserSelfLogicPasswordResetTest.java new file mode 100644 index 0000000000..e2b45afc7c --- /dev/null +++ b/core/idrepo/logic/src/test/java/org/apache/syncope/core/logic/UserSelfLogicPasswordResetTest.java @@ -0,0 +1,110 @@ +/* + * 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 java.util.UUID; +import javax.cache.Cache; +import javax.cache.Caching; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class UserSelfLogicPasswordResetTest { + + private UserDAO userDAO; + + private SecurityProperties securityProperties; + + private UserSelfLogic logic; + + @BeforeEach + public 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); + + Cache passwordResetRequestCache = + Caching.getCachingProvider().getCacheManager().createCache( + UserSelfLogicPasswordResetTest.class.getName() + '-' + UUID.randomUUID(), + PasswordResetRequestThrottler.cacheConfiguration( + securityProperties.getPasswordReset().getThrottle())); + + 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, + passwordResetRequestCache); + } + + @Test + public void passwordResetRequestsAreThrottled() { + securityProperties.getPasswordReset().getThrottle().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 + public void passwordResetThrottlingCanBeDisabled() { + securityProperties.getPasswordReset().getThrottle().setEnabled(false); + securityProperties.getPasswordReset().getThrottle().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/RestServiceExceptionMapper.java b/core/idrepo/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/RestServiceExceptionMapper.java index b147dc9dd0..5fb1983ec0 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 @@ -43,6 +43,7 @@ import org.apache.syncope.common.lib.types.ClientExceptionType; import org.apache.syncope.common.lib.types.EntityViolationType; import org.apache.syncope.common.rest.api.RESTHeaders; +import org.apache.syncope.core.logic.PasswordResetThrottleException; import org.apache.syncope.core.persistence.api.attrvalue.InvalidEntityException; import org.apache.syncope.core.persistence.api.attrvalue.ParsingValidationException; import org.apache.syncope.core.persistence.api.dao.DuplicateException; @@ -136,6 +137,9 @@ public Response toResponse(final Exception ex) { builder = sce.isComposite() ? getSyncopeClientCompositeExceptionResponse(sce.asComposite()) : getSyncopeClientExceptionResponse(sce); + } else if (ex instanceof PasswordResetThrottleException prte) { + 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 void compliance(final ComplianceQuery query) { @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..0db82e574d --- /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.logic.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/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 fce444315f..1467d01680 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 @@ -68,6 +68,58 @@ public void setLockSeconds(final long lockSeconds) { } } + public static class PasswordResetProperties { + + public static class ThrottleProperties { + + private boolean enabled = true; + + private int maxAttempts = 5; + + private int windowSeconds = 300; + + private int lockSeconds = 300; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(final boolean enabled) { + this.enabled = enabled; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public void setMaxAttempts(final int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + public int getWindowSeconds() { + return windowSeconds; + } + + public void setWindowSeconds(final int windowSeconds) { + this.windowSeconds = windowSeconds; + } + + public int getLockSeconds() { + return lockSeconds; + } + + public void setLockSeconds(final int lockSeconds) { + this.lockSeconds = lockSeconds; + } + } + + private final ThrottleProperties throttle = new ThrottleProperties(); + + public ThrottleProperties getThrottle() { + return throttle; + } + } + public static class DigesterProperties { private int saltIterations = 1; @@ -149,6 +201,8 @@ public void setUseLenientSaltSizeCheck(final boolean useLenientSaltSizeCheck) { private final AuthenticationThrottleProperties authenticationThrottle = new AuthenticationThrottleProperties(); + private final PasswordResetProperties passwordReset = new PasswordResetProperties(); + private final DigesterProperties digester = new DigesterProperties(); public String getAdminUser() { @@ -243,6 +297,10 @@ public AuthenticationThrottleProperties getAuthenticationThrottle() { return authenticationThrottle; } + public PasswordResetProperties getPasswordReset() { + return passwordReset; + } + public DigesterProperties getDigester() { return digester; } diff --git a/core/starter/src/main/resources/core.properties b/core/starter/src/main/resources/core.properties index fb4c232b99..92988b493d 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.passwordReset.throttle.enabled=true +security.passwordReset.throttle.maxAttempts=5 +security.passwordReset.throttle.windowSeconds=300 +security.passwordReset.throttle.lockSeconds=300 + # default for LDAP / RFC2307 SSHA security.digester.saltIterations=1 security.digester.saltSizeBytes=8 diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/UserSelfITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/UserSelfITCase.java index 6a5ba9d34e..28b8c8f96a 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.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 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 void passwordReset() throws Exception { 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 e) { + throw new IllegalStateException("Could not read core.properties", 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(ClientExceptionType.TooManyRequests, e.getType()); + } + @Test public void passwordResetWithoutSecurityQuestion() { // 0. disable security question for password reset From 9fcaa3e0e4f3d097f6cc10daff343cb4415bd05b Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Thu, 4 Jun 2026 07:30:16 +0200 Subject: [PATCH 2/7] [SYNCOPE-1975] - Throttle password reset requests --- .../java/org/apache/syncope/fit/core/UserSelfITCase.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 28b8c8f96a..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 @@ -406,8 +406,8 @@ public void passwordResetThrottle() { Properties props = new Properties(); props.load(propStream); maxAttempts = Integer.parseInt(props.getProperty("security.passwordReset.throttle.maxAttempts", "5")); - } catch (IOException e) { - throw new IllegalStateException("Could not read core.properties", e); + } 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++) { @@ -420,7 +420,7 @@ public void passwordResetThrottle() { SyncopeClientException e = assertThrows( SyncopeClientException.class, () -> userSelfService.requestPasswordReset(username, "answer")); - assertEquals(ClientExceptionType.TooManyRequests, e.getType()); + assertEquals(Response.Status.TOO_MANY_REQUESTS, e.getType().getResponseStatus()); } @Test From bdf9799b62475ad9eae2e6dbfc2947ac7c581759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Tue, 16 Jun 2026 12:48:24 +0200 Subject: [PATCH 3/7] Fix typo --- .../spring/security/SyncopeBasicAuthenticationEntryPoint.java | 4 ++-- core/starter/src/main/resources/core.properties | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 bccf12c740..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 @@ -51,8 +51,8 @@ public void commence(final HttpServletRequest request, final HttpServletResponse : StringUtils.defaultIfBlank( props.getGenericMessage(), SecurityProperties.AuthenticationErrorProperties.DEFAULT_GENERIC_MESSAGE)); - if (authException instanceof AuthenticationThrottleException 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/starter/src/main/resources/core.properties b/core/starter/src/main/resources/core.properties index 42b9167d23..16210f3ba4 100644 --- a/core/starter/src/main/resources/core.properties +++ b/core/starter/src/main/resources/core.properties @@ -110,7 +110,7 @@ security.authenticationThrottle.lockSeconds=60 security.passwordResetThrottle.enabled=true security.passwordResetThrottle.maxAttempts=5 security.passwordResetThrottle.windowSeconds=300 -security.ppasswordResetThrottle.lockSeconds=300 +security.passwordResetThrottle.lockSeconds=300 security.authenticationError.exposeDetails=false security.authenticationError.genericMessage=Authentication failed From 21ddd1877b48eac0f25f6c2d62290b23a0286f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Tue, 16 Jun 2026 14:12:19 +0200 Subject: [PATCH 4/7] Fixing Payara --- .../syncope/core/rest/cxf/RestServiceExceptionMapper.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 5d40cf2e82..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 @@ -137,7 +137,13 @@ public Response toResponse(final Exception ex) { builder = sce.isComposite() ? getSyncopeClientCompositeExceptionResponse(sce.asComposite()) : getSyncopeClientExceptionResponse(sce); - } else if (ex instanceof PasswordResetThrottleException prte) { + } 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 From bf928aae90adf0a7a35ba9815adc742900abf7da Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Tue, 16 Jun 2026 14:45:06 +0200 Subject: [PATCH 5/7] [SYNCOPE-1975] Document password reset request throttling --- .../concepts/provisioning/passwordreset.adoc | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc index 9229e0112d..1eaff214b4 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.passwordReset.throttle.enabled` +|Whether password reset request throttling is enabled. When set to `false`, password reset requests are not +rate-limited by this mechanism. +|`security.passwordReset.throttle.maxAttempts` +|Maximum number of password reset requests allowed for the same Domain, username and client address within the +configured time window. +|`security.passwordReset.throttle.windowSeconds` +|Time window, in seconds, used to count password reset requests. +|`security.passwordReset.throttle.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.passwordReset.throttle.enabled=true +security.passwordReset.throttle.maxAttempts=5 +security.passwordReset.throttle.windowSeconds=300 +security.passwordReset.throttle.lockSeconds=300 +---- + +When enabled, Syncope 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.passwordReset.throttle.maxAttempts` requests are received within +`security.passwordReset.throttle.windowSeconds`, further password reset requests for the same combination are rejected +for `security.passwordReset.throttle.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. From e15fa9c4420feadec10c851b4f3a22a83f32dd08 Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Tue, 16 Jun 2026 14:45:06 +0200 Subject: [PATCH 6/7] [SYNCOPE-1975] Document password reset request throttling --- .../concepts/provisioning/passwordreset.adoc | 26 +++++++++---------- .../asciidoc/reference-guide/usage/core.adoc | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc index 1eaff214b4..22c3aebc93 100644 --- a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc +++ b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc @@ -65,7 +65,7 @@ In addition to the password reset feature, administrators can set a flag on a gi 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 +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: @@ -73,15 +73,15 @@ This protection is configured in `core.properties` via the following properties: [cols="1,3"] |=== |Property |Description -|`security.passwordReset.throttle.enabled` +|`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.passwordReset.throttle.maxAttempts` +|`security.passwordResetThrottle.maxAttempts` |Maximum number of password reset requests allowed for the same Domain, username and client address within the configured time window. -|`security.passwordReset.throttle.windowSeconds` +|`security.passwordResetThrottle.windowSeconds` |Time window, in seconds, used to count password reset requests. -|`security.passwordReset.throttle.lockSeconds` +|`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. |=== @@ -89,18 +89,18 @@ rejected after the maximum number of requests has been exceeded. A possible configuration is: [source,properties] ---- -security.passwordReset.throttle.enabled=true -security.passwordReset.throttle.maxAttempts=5 -security.passwordReset.throttle.windowSeconds=300 -security.passwordReset.throttle.lockSeconds=300 +security.passwordResetThrottle.enabled=true +security.passwordResetThrottle.maxAttempts=5 +security.passwordResetThrottle.windowSeconds=300 +security.passwordResetThrottle.lockSeconds=300 ---- -When enabled, Syncope tracks password reset requests for each combination of Domain, username and client address. +When enabled, the 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.passwordReset.throttle.maxAttempts` requests are received within -`security.passwordReset.throttle.windowSeconds`, further password reset requests for the same combination are rejected -for `security.passwordReset.throttle.lockSeconds`. +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. diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index da64d10609..f937f5e336 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, the 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`. From 0cb9e7394fe2379f8155ece9abeb9ba8342aba03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Tue, 16 Jun 2026 16:49:24 +0200 Subject: [PATCH 7/7] Fix typo --- .../reference-guide/concepts/provisioning/passwordreset.adoc | 2 +- src/main/asciidoc/reference-guide/usage/core.adoc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc index 22c3aebc93..33de5bb6db 100644 --- a/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc +++ b/src/main/asciidoc/reference-guide/concepts/provisioning/passwordreset.adoc @@ -95,7 +95,7 @@ security.passwordResetThrottle.windowSeconds=300 security.passwordResetThrottle.lockSeconds=300 ---- -When enabled, the core tracks password reset requests for each combination of Domain, username and client address. +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 diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index f937f5e336..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, the core 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`.