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 @@ 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.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 ServiceOps serviceOps() { public CacheManager cacheManager() { return Caching.getCachingProvider().getCacheManager(); } + + @Bean(name = PasswordResetRequestThrottler.CACHE) + public Cache 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.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.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 UserSelfLogic userSelfLogic( final DelegationDAO delegationDAO, final AccessTokenDAO accessTokenDAO, final ExternalResourceDAO resourceDAO, - final RuleProvider ruleProvider) { + final RuleProvider ruleProvider, + final SecurityProperties securityProperties, + @Qualifier(PasswordResetRequestThrottler.CACHE) + final Cache passwordResetRequestCache) { return new UserSelfLogic( realmSearchDAO, @@ -537,7 +544,9 @@ public UserSelfLogic userSelfLogic( 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.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.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 @@ 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 +103,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 +119,9 @@ public UserSelfLogic( this.accessTokenDAO = accessTokenDAO; this.resourceDAO = resourceDAO; this.ruleProvider = ruleProvider; + this.passwordResetRequestThrottler = new PasswordResetRequestThrottler( + securityProperties, + passwordResetRequestCache); } @PreAuthorize("isAuthenticated() " @@ -265,7 +276,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 +289,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/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 @@ 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.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 ServiceOps serviceOps() { public CacheManager cacheManager() { return Caching.getCachingProvider().getCacheManager(); } + + @Bean(name = PasswordResetRequestThrottler.CACHE) + public Cache 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 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 Set getTrustedProxies() { @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.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 Response toResponse(final Exception ex) { 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 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..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.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 SelfKeymasterUsernamePasswordAuthenticationProvider( final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, final EncryptorManager encryptorManager, - final Cache authenticationAttemptCache, + final Cache 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.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 UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProv final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, final EncryptorManager encryptorManager, - @Qualifier(AuthenticationAttemptThrottler.CACHE) - final Cache authenticationAttemptCache, + @Qualifier(AuthenticationThrottler.CACHE) + final Cache 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 @@ @ConfigurationProperties("security") public class SecurityProperties { - public static class AuthenticationThrottleProperties { + public static class ThrottleProperties { private boolean enabled = true; @@ -172,7 +172,9 @@ public void setUseLenientSaltSizeCheck(final boolean useLenientSaltSizeCheck) { 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 void setGroovyBlacklist(final String groovyBlacklist) { 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 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 void commence(final HttpServletRequest request, final HttpServletResponse : 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.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 UsernamePasswordAuthenticationProvider( final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, final EncryptorManager encryptorManager, - final Cache authenticationAttemptCache) { + final Cache 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; if (SyncopeConstants.MASTER_DOMAIN.equals(domainKey)) { @@ -125,7 +126,7 @@ protected Authentication finalizeAuthentication( 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 @@ protected Authentication finalizeAuthentication( 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.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 UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProv final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, final EncryptorManager encryptorManager, - @Qualifier(AuthenticationAttemptThrottler.CACHE) - final Cache authenticationAttemptCache) { + @Qualifier(AuthenticationThrottler.CACHE) + final Cache authenticationThrottlerCache) { return new UsernamePasswordAuthenticationProvider( domainOps, @@ -144,18 +147,18 @@ public UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProv provisioningManager, securityProperties, encryptorManager, - authenticationAttemptCache); + authenticationThrottlerCache); } - @ConditionalOnMissingBean(name = AuthenticationAttemptThrottler.CACHE) - @Bean(name = AuthenticationAttemptThrottler.CACHE) - public Cache authenticationAttemptCache( + @ConditionalOnMissingBean(name = AuthenticationThrottler.CACHE) + @Bean(name = AuthenticationThrottler.CACHE) + public Cache authenticationThrottlerCache( final CacheManager cacheManager, final SecurityProperties securityProperties) { - return cacheManager.createCache(AuthenticationAttemptThrottler.CACHE, - new MutableConfiguration(). - setTypes(String.class, AuthenticationAttemptThrottler.Attempts.class). + return cacheManager.createCache(AuthenticationThrottler.CACHE, + new MutableConfiguration(). + setTypes(String.class, ThrottlerAttempts.class). setExpiryPolicyFactory(TouchedExpiryPolicy.factoryOf(new Duration( TimeUnit.SECONDS, Math.max(1, Math.max( @@ -163,6 +166,22 @@ public Cache authenticationAtte securityProperties.getAuthenticationThrottle().getLockSeconds())))))); } + @ConditionalOnMissingBean(name = PasswordResetRequestThrottler.CACHE) + @Bean(name = PasswordResetRequestThrottler.CACHE) + public Cache passwordResetRequestThrottlerCache( + final CacheManager cacheManager, + final SecurityProperties securityProperties) { + + return cacheManager.createCache(PasswordResetRequestThrottler.CACHE, + new MutableConfiguration(). + 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 attempts; + + protected AbstractThrottler( + final SecurityProperties.ThrottleProperties throttle, + final Cache attempts) { + + this.throttle = throttle; + this.attempts = attempts; + } + + protected boolean isEnabled() { + return throttle.isEnabled() + && throttle.getMaxAttempts() > 0 + && throttle.getWindowSeconds() > 0 + && throttle.getLockSeconds() > 0; + } + + protected 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; + } +} 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% 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/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 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 @@ protected static long retryAfterSeconds(final long blockedUntil, final long now) return Math.max(1, TimeUnit.MILLISECONDS.toSeconds(blockedUntil - now)); } - protected final SecurityProperties.AuthenticationThrottleProperties throttle; - - protected final LongSupplier clock = System::currentTimeMillis; - - protected final Cache attempts; - - public AuthenticationAttemptThrottler( + public AuthenticationThrottler( final SecurityProperties securityProperties, - final Cache attempts) { + final Cache 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 void checkAllowed(final String domain, final String username) { return null; } - Attempts state = entry.getValue(); + ThrottlerAttempts state = entry.getValue(); if (state.blockedUntil() > now) { return retryAfterSeconds(state.blockedUntil(), now); } @@ -86,22 +64,15 @@ public void checkAllowed(final String domain, final String username) { 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 void recordFailure(final String domain, final String username) { 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 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 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(); + 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 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 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/throttle/PasswordResetThrottleException.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/PasswordResetThrottleException.java new file mode 100644 index 0000000000..04368f8296 --- /dev/null +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/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.spring.security.throttle; + +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/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/ThrottlerAttempts.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/ThrottlerAttempts.java new file mode 100644 index 0000000000..2465717470 --- /dev/null +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/throttle/ThrottlerAttempts.java @@ -0,0 +1,36 @@ +/* + * 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.io.Serializable; +import java.util.ArrayDeque; +import java.util.Deque; + +public record ThrottlerAttempts(Deque failures, long blockedUntil) implements Serializable { + + private static final long serialVersionUID = 8023582605543650484L; + + public ThrottlerAttempts { + failures = new ArrayDeque<>(failures); + } + + 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 @@ 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 @@ void setUp() throws Exception { 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.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 @@ private static SecurityProperties securityProperties() { return securityProperties; } - private static AuthenticationAttemptThrottler throttler( + private static AuthenticationThrottler throttler( final SecurityProperties securityProperties, final LongSupplier clock) { - Cache cache = - Caching.getCachingProvider().getCacheManager().getCache(AuthenticationAttemptThrottler.CACHE); + Cache cache = + Caching.getCachingProvider().getCacheManager().getCache(AuthenticationThrottler.CACHE); if (cache == null) { - cache = Caching.getCachingProvider().getCacheManager().createCache( - AuthenticationAttemptThrottler.CACHE, - new MutableConfiguration(). - setTypes(String.class, AuthenticationAttemptThrottler.Attempts.class). + cache = Caching.getCachingProvider().getCacheManager().createCache(AuthenticationThrottler.CACHE, + new MutableConfiguration(). + 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 @@ private static AuthenticationAttemptThrottler throttler( 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 @@ void blocksAfterConfiguredFailures() { @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 @@ void successResetsFailures() { @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.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 | 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`.