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