From 6aaa718d1b1c8996cd47fe7f3ba00698ee48df2a Mon Sep 17 00:00:00 2001 From: Massimiliano Perrone Date: Thu, 4 Jun 2026 06:05:37 +0200 Subject: [PATCH 1/9] Add authentication failure throttling --- ...sernamePasswordAuthenticationProvider.java | 12 +- .../core/starter/SelfKeymasterContext.java | 6 + .../AuthenticationAttemptThrottler.java | 170 ++++++++++++++++++ .../RateLimitAuthenticationException.java | 37 ++++ .../spring/security/SecurityProperties.java | 49 +++++ .../SyncopeBasicAuthenticationEntryPoint.java | 7 + ...sernamePasswordAuthenticationProvider.java | 21 ++- .../spring/security/WebSecurityContext.java | 21 ++- .../AuthenticationAttemptThrottlerTest.java | 97 ++++++++++ ...copeBasicAuthenticationEntryPointTest.java | 46 +++++ .../src/main/resources/core.properties | 5 + .../apache/syncope/fit/AbstractITCase.java | 4 + .../fit/core/AuthenticationITCase.java | 26 +++ .../asciidoc/reference-guide/usage/core.adoc | 38 ++++ 14 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 core/spring/src/main/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottler.java create mode 100644 core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java create mode 100644 core/spring/src/test/java/org/apache/syncope/core/spring/security/AuthenticationAttemptThrottlerTest.java create mode 100644 core/spring/src/test/java/org/apache/syncope/core/spring/security/SyncopeBasicAuthenticationEntryPointTest.java 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 SelfKeymasterUsernamePasswordAuthenticationProvider( final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, final EncryptorManager encryptorManager, + final Cache 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 Authentication authenticate(final Authentication authentication) { 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 55ff9525e9..c55b6a7782 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 @@ -29,6 +29,7 @@ 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; @@ -72,12 +73,14 @@ 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 UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProv final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, final EncryptorManager encryptorManager, + @Qualifier(AuthenticationAttemptThrottler.CACHE_NAME) + final Cache authenticationAttemptCache, final KeymasterProperties keymasterProperties) { return new SelfKeymasterUsernamePasswordAuthenticationProvider( @@ -230,6 +235,7 @@ public UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProv 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 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 attempts; + + public AuthenticationAttemptThrottler( + final SecurityProperties securityProperties, + final Cache attempts) { + + this(securityProperties, System::currentTimeMillis, attempts); + } + + protected AuthenticationAttemptThrottler( + final SecurityProperties securityProperties, + final LongSupplier clock, + final Cache attempts) { + + this.throttle = securityProperties.getAuthenticationThrottle(); + this.clock = clock; + this.attempts = attempts; + } + + public static MutableConfiguration cacheConfiguration( + final SecurityProperties.AuthenticationThrottleProperties throttle) { + + return new MutableConfiguration(). + 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 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 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 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; + } + + 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/RateLimitAuthenticationException.java b/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java new file mode 100644 index 0000000000..a78888b4a2 --- /dev/null +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/security/RateLimitAuthenticationException.java @@ -0,0 +1,37 @@ +/* + * 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 org.springframework.security.core.AuthenticationException; + +public class RateLimitAuthenticationException extends AuthenticationException { + + private static final long serialVersionUID = 3829921857697051591L; + + private final long retryAfterSeconds; + + public RateLimitAuthenticationException(final String msg, final long retryAfterSeconds) { + super(msg); + this.retryAfterSeconds = retryAfterSeconds; + } + + 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 @@ @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 void setUseLenientSaltSizeCheck(final boolean useLenientSaltSizeCheck) { 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 void setGroovyBlacklist(final String groovyBlacklist) { 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.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 void commence(final HttpServletRequest request, final HttpServletResponse 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 0fe113cef9..0c73293f83 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 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; if (SyncopeConstants.MASTER_DOMAIN.equals(domainKey)) { domain = Optional.empty(); @@ -96,11 +106,12 @@ public Authentication authenticate(final Authentication authentication) { } 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 Authentication authenticate(final Authentication authentication) { 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 @@ protected Authentication finalizeAuthentication( 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 @@ 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.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 UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProv final AuthDataAccessor dataAccessor, final UserProvisioningManager provisioningManager, final SecurityProperties securityProperties, - final EncryptorManager encryptorManager) { + final EncryptorManager encryptorManager, + @Qualifier(AuthenticationAttemptThrottler.CACHE_NAME) + final Cache authenticationAttemptCache) { return new UsernamePasswordAuthenticationProvider( domainOps, dataAccessor, provisioningManager, securityProperties, - encryptorManager); + encryptorManager, + authenticationAttemptCache); + } + + @ConditionalOnMissingBean(name = AuthenticationAttemptThrottler.CACHE_NAME) + @Bean(name = AuthenticationAttemptThrottler.CACHE_NAME) + public Cache 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 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 7d49610b82..fb4c232b99 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 d96821a350..f3992b5336 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 @@ -294,6 +294,8 @@ public void initialize(final ConfigurableApplicationContext ctx) { protected static String ANONYMOUS_KEY; + protected static int AUTHENTICATION_THROTTLE_MAX_ATTEMPTS; + protected static String JWS_KEY; protected static String JWT_ISSUER; @@ -487,6 +489,8 @@ public static void anonymousSetup() throws IOException { 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 9f61875091..009db1e5b8 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 @@ 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; @@ -387,6 +389,30 @@ public void checkUserSuspension() { assertEquals(0, goodPwdClient.self().user().getFailedLogins().intValue()); } + @Test + public void authenticationThrottle() { + UserCR userCR = UserITCase.getUniqueSample("authThrottle@syncope.apache.org"); + 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 09b80c440c..bddb9c92be 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/7.0/servlet/authentication/anon <>, handed over to users via <>. **** +===== 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 Account Policy suspension based on `maxAuthenticationAttempts`: throttling is a temporary rate-limiting measure, while Account Policy suspension changes the user status according to the configured policy. + ==== REST Headers Apache Syncope supports a number of HTTP headers as detailed below, in addition to the common HTTP headers such as From a9ddfb9bf5463d4cd88d99a3c33c2485a3ea5e42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:25:35 +0200 Subject: [PATCH 2/9] Bump org.openapitools:openapi-generator-maven-plugin from 7.22.0 to 7.23.0 (#1417) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7b3abbf8c8..3ccf8c8ed6 100644 --- a/pom.xml +++ b/pom.xml @@ -1756,7 +1756,7 @@ under the License. org.openapitools openapi-generator-maven-plugin - 7.22.0 + 7.23.0 From 0d7d99a037ac37fcaa409fba0cf5fe4a62d61feb Mon Sep 17 00:00:00 2001 From: Samuel Garofalo <72073457+SamuelGaro@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:50:38 +0200 Subject: [PATCH 3/9] [SYNCOPE-1972] set security answer in enduser and fix for password reset (#1415) --- .../ui/commons/wizards/any/PasswordPanel.java | 2 ++ .../console/status/ChangePasswordModal.java | 3 ++- .../console/wizards/any/UserDetails.java | 2 +- .../enduser/pages/EditSecurityQuestion.java | 17 +++++++------- .../pages/SelfConfirmPasswordReset.java | 3 ++- .../enduser/panels/any/UserDetails.java | 1 + .../rest/api/beans/ComplianceQuery.java | 19 +++++++++++++++- .../syncope/core/logic/UserSelfLogic.java | 22 ++++++++++++++----- 8 files changed, 51 insertions(+), 18 deletions(-) diff --git a/client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/wizards/any/PasswordPanel.java b/client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/wizards/any/PasswordPanel.java index f822414b2e..a252ed4807 100644 --- a/client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/wizards/any/PasswordPanel.java +++ b/client/idrepo/common-ui/src/main/java/org/apache/syncope/client/ui/commons/wizards/any/PasswordPanel.java @@ -44,6 +44,7 @@ public PasswordPanel( final UserWrapper wrapper, final Boolean storePasswordInSyncope, final boolean templateMode, + final String token, final AnonymousRestClient restClient) { super(id); @@ -95,6 +96,7 @@ public void validate(final Form form) { ComplianceQuery quey = new ComplianceQuery.Builder(). realm(wrapper.getInnerObject().getRealm()). password(password.getField().getInput()). + token(token). resources(wrapper.getInnerObject().getResources()). build(); try { diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/status/ChangePasswordModal.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/status/ChangePasswordModal.java index 31682cf793..49c42543db 100644 --- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/status/ChangePasswordModal.java +++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/status/ChangePasswordModal.java @@ -64,7 +64,8 @@ public ChangePasswordModal( super(baseModal, pageRefer); this.wrapper = wrapper; - PasswordPanel passwordPanel = new PasswordPanel("passwordPanel", wrapper, false, false, anonymousRestClient); + PasswordPanel passwordPanel = new PasswordPanel( + "passwordPanel", wrapper, false, false, null, anonymousRestClient); passwordPanel.setOutputMarkupId(true); add(passwordPanel); diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/any/UserDetails.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/any/UserDetails.java index 47493d2226..3492d5ff6d 100644 --- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/any/UserDetails.java +++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wizards/any/UserDetails.java @@ -176,7 +176,7 @@ public EditUserPasswordPanel(final String id, final UserWrapper wrapper, final b super(id); setOutputMarkupId(true); add(new Label("warning", new ResourceModel("password.change.warning"))); - add(new PasswordPanel("passwordPanel", wrapper, false, templateMode, anonymousRestClient)); + add(new PasswordPanel("passwordPanel", wrapper, false, templateMode, null, anonymousRestClient)); } } } diff --git a/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/EditSecurityQuestion.java b/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/EditSecurityQuestion.java index 08809c6284..db15c18002 100644 --- a/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/EditSecurityQuestion.java +++ b/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/EditSecurityQuestion.java @@ -70,10 +70,15 @@ public class EditSecurityQuestion extends BaseReauthPage { protected final UserTO userTO; + protected final UserUR userUR; + public EditSecurityQuestion(final PageParameters parameters) { super(parameters, EDIT_SECURITY_QUESTION); userTO = SyncopeEnduserSession.get().getSelfTO(true); + userUR = new UserUR.Builder(userTO.getKey()). + securityQuestion(new StringReplacePatchItem.Builder().value(userTO.getSecurityQuestion()).build()). + build(); WebMarkupContainer content = new WebMarkupContainer("content"); content.setOutputMarkupId(true); @@ -84,7 +89,7 @@ public EditSecurityQuestion(final PageParameters parameters) { content.add(form); securityQuestion = new AjaxDropDownChoicePanel<>("securityQuestion", - "securityQuestion", new PropertyModel<>(userTO, "securityQuestion")); + "securityQuestion", new PropertyModel<>(userUR, "securityQuestion.value")); securityQuestion.setNullValid(true); securityQuestion.setRequired(true); @@ -126,7 +131,7 @@ protected void onEvent(final AjaxRequestTarget target) { form.add(securityQuestion); securityAnswer = new AjaxTextFieldPanel("securityAnswer", "securityAnswer", - new PropertyModel<>(userTO, "securityAnswer"), false); + new PropertyModel<>(userUR, "securityAnswer.value"), false); form.add(securityAnswer.setOutputMarkupId(true).setOutputMarkupPlaceholderTag(true). setEnabled(StringUtils.isNotBlank(securityQuestion.getModelObject()))); securityAnswer.setRequired(true); @@ -151,13 +156,7 @@ protected void onSubmit(final AjaxRequestTarget target) { } else { try { ProvisioningResult provisioningResult = - userSelfRestClient.update( - userTO.getETagValue(), - new UserUR.Builder(userTO.getKey()) - .securityQuestion(new StringReplacePatchItem.Builder(). - value(securityQuestion.getModelObject()).build()) - .securityAnswer(new StringReplacePatchItem.Builder(). - value(securityAnswer.getModelObject()).build()).build()); + userSelfRestClient.update(userTO.getETagValue(), userUR); setResponsePage(new SelfResult(provisioningResult, ProvisioningUtils.managePageParams(EditSecurityQuestion.this, "securityquestion.change", diff --git a/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/SelfConfirmPasswordReset.java b/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/SelfConfirmPasswordReset.java index 1263a00b6c..5617fc7dc6 100644 --- a/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/SelfConfirmPasswordReset.java +++ b/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/pages/SelfConfirmPasswordReset.java @@ -49,7 +49,7 @@ public SelfConfirmPasswordReset(final PageParameters parameters) { setDomain(parameters); disableSidebarAndNavbar(); - if (parameters == null || parameters.get("token").isEmpty()) { + if (parameters.get("token").isEmpty()) { LOG.error("No token parameter found in the request url"); PageParameters homeParameters = new PageParameters(); @@ -72,6 +72,7 @@ public SelfConfirmPasswordReset(final PageParameters parameters) { new UserWrapper(fakeUserTO), false, false, + parameters.get("token").toString(), anonymousRestClient); passwordPanel.setOutputMarkupId(true); diff --git a/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/panels/any/UserDetails.java b/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/panels/any/UserDetails.java index 9261f8d300..a5f05a0cab 100644 --- a/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/panels/any/UserDetails.java +++ b/client/idrepo/enduser/src/main/java/org/apache/syncope/client/enduser/panels/any/UserDetails.java @@ -124,6 +124,7 @@ protected EditUserPasswordPanel(final String id, final UserWrapper wrapper) { wrapper, wrapper.getInnerObject().getKey() == null, false, + null, restClient)); } } diff --git a/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/ComplianceQuery.java b/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/ComplianceQuery.java index 15bf953482..69810d58c8 100644 --- a/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/ComplianceQuery.java +++ b/common/idrepo/rest-api/src/main/java/org/apache/syncope/common/rest/api/beans/ComplianceQuery.java @@ -46,6 +46,11 @@ public Builder password(final String password) { return this; } + public Builder token(final String token) { + instance.setToken(token); + return this; + } + public Builder realm(final String realm) { instance.setRealm(realm); return this; @@ -79,6 +84,8 @@ public Builder resources(final Collection resources) { private String password; + private String token; + private String realm; private Set resources = new HashSet<>(); @@ -99,6 +106,14 @@ public void setPassword(final String password) { this.password = password; } + public String getToken() { + return token; + } + + public void setToken(final String token) { + this.token = token; + } + public String getRealm() { return realm; } @@ -120,7 +135,7 @@ public boolean isEmpty() { if (StringUtils.isBlank(username) && StringUtils.isBlank(password)) { return true; } - return StringUtils.isEmpty(realm) && resources.isEmpty(); + return StringUtils.isEmpty(token) && StringUtils.isEmpty(realm) && resources.isEmpty(); } @Override @@ -138,6 +153,7 @@ public boolean equals(final Object obj) { return new EqualsBuilder(). append(username, other.username). append(password, other.password). + append(token, other.token). append(realm, other.realm). append(resources, other.resources). build(); @@ -148,6 +164,7 @@ public int hashCode() { return new HashCodeBuilder(). append(username). append(password). + append(token). append(realm). append(resources). build(); 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 af7ca062f0..2369054201 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 @@ -19,6 +19,7 @@ package org.apache.syncope.core.logic; import java.time.OffsetDateTime; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -212,12 +213,23 @@ public void compliance(final ComplianceQuery query) { } Realm realm = null; - if (StringUtils.isNotBlank(query.getRealm())) { - realm = realmSearchDAO.findByFullPath(query.getRealm()). - orElseThrow(() -> new NotFoundException("Realm " + query.getRealm())); + Set resources; + if (StringUtils.isNotBlank(query.getToken())) { + String key = userDAO.findByToken(query.getToken()). + orElseThrow(() -> new NotFoundException("User with token " + query.getToken())); + User user = userDAO.findById(key). + orElseThrow(() -> new NotFoundException("User with key " + key)); + realm = user.getRealm(); + resources = new HashSet<>(user.getResources()); + } else { + if (StringUtils.isNotBlank(query.getRealm())) { + realm = realmSearchDAO.findByFullPath(query.getRealm()). + orElseThrow(() -> new NotFoundException("Realm " + query.getRealm())); + } + resources = query.getResources().stream(). + map(resourceDAO::findById).flatMap(Optional::stream).collect(Collectors.toSet()); } - Set resources = query.getResources().stream(). - map(resourceDAO::findById).flatMap(Optional::stream).collect(Collectors.toSet()); + if (realm == null && resources.isEmpty()) { sce.getElements().add("Nothing to check"); throw sce; From 4e631e99c7dbe69d961e4fb9b86344cf88113704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Tue, 9 Jun 2026 13:33:10 +0200 Subject: [PATCH 4/9] Wrapping Flowable's Groovy scriptTasks with security sandbox (#1418) --- .../GroovySandboxScriptEngineImpl.java | 86 +++++++++++++++++++ .../flowable/FlowableWorkflowContext.java | 28 +++++- pom.xml | 2 +- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 core/spring/src/main/java/org/apache/syncope/core/spring/implementation/GroovySandboxScriptEngineImpl.java diff --git a/core/spring/src/main/java/org/apache/syncope/core/spring/implementation/GroovySandboxScriptEngineImpl.java b/core/spring/src/main/java/org/apache/syncope/core/spring/implementation/GroovySandboxScriptEngineImpl.java new file mode 100644 index 0000000000..9f8939133d --- /dev/null +++ b/core/spring/src/main/java/org/apache/syncope/core/spring/implementation/GroovySandboxScriptEngineImpl.java @@ -0,0 +1,86 @@ +/* + * 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.implementation; + +import groovy.grape.GrabAnnotationTransformation; +import groovy.lang.GroovyClassLoader; +import java.io.Reader; +import java.util.Set; +import javax.script.ScriptContext; +import javax.script.ScriptException; +import org.codehaus.groovy.control.CompilerConfiguration; +import org.codehaus.groovy.jsr223.GroovyScriptEngineImpl; +import org.jenkinsci.plugins.scriptsecurity.sandbox.blacklists.Blacklist; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.RejectASTTransformsCustomizer; +import org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SandboxInterceptor; +import org.kohsuke.groovy.sandbox.GroovyInterceptor; +import org.kohsuke.groovy.sandbox.SandboxTransformer; +import org.springframework.util.function.ThrowingSupplier; + +public class GroovySandboxScriptEngineImpl extends GroovyScriptEngineImpl { + + private static final GroovyClassLoader GROOVY_CLASSLOADER; + + static { + CompilerConfiguration cc = new CompilerConfiguration(); + cc.addCompilationCustomizers(new RejectASTTransformsCustomizer(), new SandboxTransformer()); + cc.setDisabledGlobalASTTransformations(Set.of(GrabAnnotationTransformation.class.getName())); + + GROOVY_CLASSLOADER = new GroovyClassLoader(Thread.currentThread().getContextClassLoader(), cc); + } + + protected final Blacklist blackList; + + public GroovySandboxScriptEngineImpl(final Blacklist blackList) { + super(GROOVY_CLASSLOADER); + this.blackList = blackList; + } + + protected Object sandboxEval(final ThrowingSupplier eval) { + GroovyInterceptor interceptor = null; + try { + interceptor = new SandboxInterceptor(blackList); + interceptor.register(); + } catch (NoClassDefFoundError noClassDefFound) { + // ignore + } + + try { + return eval.get(); + } finally { + if (interceptor != null) { + try { + interceptor.unregister(); + } catch (NoClassDefFoundError noClassDefFound) { + // ignore + } + } + } + } + + @Override + public Object eval(final Reader reader, final ScriptContext ctx) throws ScriptException { + return sandboxEval(() -> super.eval(reader, ctx)); + } + + @Override + public Object eval(final String script, final ScriptContext ctx) throws ScriptException { + return sandboxEval(() -> super.eval(script, ctx)); + } +} diff --git a/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/FlowableWorkflowContext.java b/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/FlowableWorkflowContext.java index 1b1a643cfa..d0fbecfa92 100644 --- a/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/FlowableWorkflowContext.java +++ b/ext/flowable/flowable-bpmn/src/main/java/org/apache/syncope/core/flowable/FlowableWorkflowContext.java @@ -19,6 +19,8 @@ package org.apache.syncope.core.flowable; import java.util.List; +import javax.script.ScriptContext; +import javax.script.ScriptEngine; import org.apache.syncope.common.keymaster.client.api.ConfParamOps; import org.apache.syncope.core.flowable.api.UserRequestHandler; import org.apache.syncope.core.flowable.impl.FlowableBpmnProcessManager; @@ -48,14 +50,19 @@ import org.apache.syncope.core.provisioning.api.data.UserDataBinder; import org.apache.syncope.core.provisioning.api.notification.NotificationManager; import org.apache.syncope.core.provisioning.api.rules.RuleProvider; +import org.apache.syncope.core.spring.implementation.GroovySandboxScriptEngineImpl; import org.apache.syncope.core.spring.security.SecurityProperties; import org.apache.syncope.core.workflow.api.UserWorkflowAdapter; +import org.flowable.common.engine.api.FlowableException; import org.flowable.common.engine.impl.AbstractEngineConfiguration; import org.flowable.common.engine.impl.cfg.IdGenerator; import org.flowable.common.engine.impl.persistence.StrongUuidGenerator; +import org.flowable.common.engine.impl.scripting.JSR223FlowableScriptEngine; +import org.flowable.common.engine.impl.scripting.ScriptingEngines; import org.flowable.idm.spring.SpringIdmEngineConfiguration; import org.flowable.idm.spring.configurator.SpringIdmEngineConfigurator; import org.flowable.spring.SpringProcessEngineConfiguration; +import org.jenkinsci.plugins.scriptsecurity.sandbox.blacklists.Blacklist; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.ApplicationEventPublisher; @@ -140,9 +147,10 @@ public IdGenerator idGenerator() { public SpringProcessEngineConfiguration processEngineConfiguration( final WorkflowFlowableProperties props, final SpringIdmEngineConfigurator syncopeIdmEngineConfigurator, - final IdGenerator idGenerator, final SyncopeEntitiesVariableType syncopeEntitiesVariableType, - final SyncopeFormHandlerHelper syncopeFormHandlerHelper) { + final SyncopeFormHandlerHelper syncopeFormHandlerHelper, + final IdGenerator idGenerator, + final Blacklist groovyBlackList) { SpringProcessEngineConfiguration conf = new SpringProcessEngineConfiguration(); conf.setDatabaseSchemaUpdate(AbstractEngineConfiguration.DB_SCHEMA_UPDATE_TRUE); @@ -154,6 +162,22 @@ public SpringProcessEngineConfiguration processEngineConfiguration( conf.setFormHandlerHelper(syncopeFormHandlerHelper); conf.setIdGenerator(idGenerator); conf.setPreBpmnParseHandlers(List.of(new ShellServiceTaskDisablingBpmnParseHandler())); + + ScriptEngine groovyScriptEngine = new GroovySandboxScriptEngineImpl(groovyBlackList); + groovyScriptEngine.getContext(). + setAttribute("#jsr223.groovy.engine.keep.globals", "weak", ScriptContext.ENGINE_SCOPE); + conf.setScriptEngine(new JSR223FlowableScriptEngine() { + + @Override + protected ScriptEngine getEngineByName(final String language) { + if (ScriptingEngines.GROOVY_SCRIPTING_LANGUAGE.equals(language)) { + return groovyScriptEngine; + } + + throw new FlowableException("Can't find scripting engine for '" + language + "'"); + } + }); + return conf; } diff --git a/pom.xml b/pom.xml index 3ccf8c8ed6..806bdc8c6c 100644 --- a/pom.xml +++ b/pom.xml @@ -415,7 +415,7 @@ under the License. 2025-11-25T06:53:52Z ${project.version} - 1.6.1.0 + 1.6.1.1-SNAPSHOT 1.5.0 1.1.1 2.4.1 From b99b112bf7bc490e9535066a5197ea2c6a1c53c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Tue, 9 Jun 2026 14:46:46 +0200 Subject: [PATCH 5/9] Upgrading woodstox-core --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 806bdc8c6c..9c14cab386 100644 --- a/pom.xml +++ b/pom.xml @@ -682,7 +682,7 @@ under the License. com.fasterxml.woodstox woodstox-core - 7.2.0 + 7.2.1 From 234ee17fe6c0795ff688a516e41fd1fe708e7cea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:09:05 +0000 Subject: [PATCH 6/9] Bump org.opensearch.client:opensearch-java from 3.8.0 to 3.9.0 Bumps [org.opensearch.client:opensearch-java](https://github.com/opensearch-project/opensearch-java) from 3.8.0 to 3.9.0. - [Release notes](https://github.com/opensearch-project/opensearch-java/releases) - [Changelog](https://github.com/opensearch-project/opensearch-java/blob/v3.9.0/CHANGELOG.md) - [Commits](https://github.com/opensearch-project/opensearch-java/compare/v3.8.0...v3.9.0) --- updated-dependencies: - dependency-name: org.opensearch.client:opensearch-java dependency-version: 3.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 9c14cab386..82e818b10c 100644 --- a/pom.xml +++ b/pom.xml @@ -453,7 +453,7 @@ under the License. 9.4.2 3.6.0 - 3.8.0 + 3.9.0 v1 From 7911782d62b6da5046edbd44b7521a7c26dd2bf6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:37:16 +0000 Subject: [PATCH 7/9] Bump hibernate.version from 7.4.0.Final to 7.4.1.Final Bumps `hibernate.version` from 7.4.0.Final to 7.4.1.Final. Updates `org.hibernate.orm:hibernate-core` from 7.4.0.Final to 7.4.1.Final - [Release notes](https://github.com/hibernate/hibernate-orm/releases) - [Changelog](https://github.com/hibernate/hibernate-orm/blob/7.4.1/changelog.txt) - [Commits](https://github.com/hibernate/hibernate-orm/compare/7.4.0...7.4.1) Updates `org.hibernate.orm:hibernate-jcache` from 7.4.0.Final to 7.4.1.Final - [Release notes](https://github.com/hibernate/hibernate-orm/releases) - [Changelog](https://github.com/hibernate/hibernate-orm/blob/7.4.1/changelog.txt) - [Commits](https://github.com/hibernate/hibernate-orm/compare/7.4.0...7.4.1) Updates `org.hibernate.orm:hibernate-maven-plugin` from 7.4.0.Final to 7.4.1.Final - [Release notes](https://github.com/hibernate/hibernate-orm/releases) - [Changelog](https://github.com/hibernate/hibernate-orm/blob/7.4.1/changelog.txt) - [Commits](https://github.com/hibernate/hibernate-orm/compare/7.4.0...7.4.1) --- updated-dependencies: - dependency-name: org.hibernate.orm:hibernate-core dependency-version: 7.4.1.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.hibernate.orm:hibernate-jcache dependency-version: 7.4.1.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.hibernate.orm:hibernate-maven-plugin dependency-version: 7.4.1.Final dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 82e818b10c..1a14f0caf8 100644 --- a/pom.xml +++ b/pom.xml @@ -438,7 +438,7 @@ under the License. 4.0.6 5.0.1 - 7.4.0.Final + 7.4.1.Final 1.9.3 From c313f42be80a99ed81bdb1f4cf2f8fffdddb185a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Wed, 10 Jun 2026 10:07:40 +0200 Subject: [PATCH 8/9] Upgrading OpenSearch --- ext/opensearch/client-opensearch/pom.xml | 10 ---------- .../opensearch/client/OpenSearchClientFactoryBean.java | 8 +++----- pom.xml | 2 +- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/ext/opensearch/client-opensearch/pom.xml b/ext/opensearch/client-opensearch/pom.xml index 7677d742c0..2a907a7be2 100644 --- a/ext/opensearch/client-opensearch/pom.xml +++ b/ext/opensearch/client-opensearch/pom.xml @@ -53,16 +53,6 @@ under the License. opensearch-java - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-databind - - org.springframework.boot spring-boot-health diff --git a/ext/opensearch/client-opensearch/src/main/java/org/apache/syncope/ext/opensearch/client/OpenSearchClientFactoryBean.java b/ext/opensearch/client-opensearch/src/main/java/org/apache/syncope/ext/opensearch/client/OpenSearchClientFactoryBean.java index d21cbe0f05..9a43eebb4c 100644 --- a/ext/opensearch/client-opensearch/src/main/java/org/apache/syncope/ext/opensearch/client/OpenSearchClientFactoryBean.java +++ b/ext/opensearch/client-opensearch/src/main/java/org/apache/syncope/ext/opensearch/client/OpenSearchClientFactoryBean.java @@ -18,8 +18,6 @@ */ package org.apache.syncope.ext.opensearch.client; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.json.JsonMapper; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; @@ -27,12 +25,13 @@ import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.HttpHost; import org.apache.hc.core5.http.message.BasicHeader; -import org.opensearch.client.json.jackson.JacksonJsonpMapper; +import org.opensearch.client.json.jackson3.JacksonJsonpMapper; import org.opensearch.client.opensearch.OpenSearchClient; import org.opensearch.client.transport.httpclient5.ApacheHttpClient5Transport; import org.opensearch.client.transport.httpclient5.ApacheHttpClient5TransportBuilder; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.FactoryBean; +import tools.jackson.databind.json.JsonMapper; /** * Spring {@link FactoryBean} for getting the {@link OpenSearchClient} singleton instance. @@ -97,8 +96,7 @@ public OpenSearchClient getObject() { if (client == null) { ApacheHttpClient5TransportBuilder builder = ApacheHttpClient5TransportBuilder. builder(hosts.toArray(HttpHost[]::new)). - setMapper(new JacksonJsonpMapper(JsonMapper.builder(). - findAndAddModules().disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).build())); + setMapper(new JacksonJsonpMapper(JsonMapper.builder().findAndAddModules().build())); if (username != null && password != null) { String encodedAuth = Base64.getEncoder(). encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8)); diff --git a/pom.xml b/pom.xml index 1a14f0caf8..5e655ddeff 100644 --- a/pom.xml +++ b/pom.xml @@ -452,7 +452,7 @@ under the License. 4.0.0 9.4.2 - 3.6.0 + 3.7.0 3.9.0 v1 From fc7d3af53ec8dbcfe6d06737ca27062ae43d8ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francesco=20Chicchiricc=C3=B2?= Date: Wed, 10 Jun 2026 11:51:55 +0200 Subject: [PATCH 9/9] Fix docs --- src/main/asciidoc/reference-guide/usage/core.adoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/asciidoc/reference-guide/usage/core.adoc b/src/main/asciidoc/reference-guide/usage/core.adoc index bddb9c92be..11f98d3a35 100644 --- a/src/main/asciidoc/reference-guide/usage/core.adoc +++ b/src/main/asciidoc/reference-guide/usage/core.adoc @@ -136,14 +136,14 @@ 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, 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 Account Policy suspension based on `maxAuthenticationAttempts`: throttling is a temporary rate-limiting measure, while Account Policy suspension changes the user status according to the configured policy. +This mechanism is independent from <>: 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