This is an automated email from the ASF dual-hosted git repository. coheigea pushed a commit to branch coheigea/TokenReplayCache in repository https://gitbox.apache.org/repos/asf/cxf.git
commit 0fe8ea23a0d4c8cd310951d85b01cd60acd75b5c Author: Colm O hEigeartaigh <[email protected]> AuthorDate: Tue Oct 27 10:35:23 2026 +0000 Support detecting the replay of SAML2 bearer assertions at the OAuth token endpoint --- .../oauth2/auth/saml/Saml2BearerAuthHandler.java | 6 + .../grants/saml/Saml2BearerGrantHandler.java | 5 + .../security/oauth2/saml/SamlOAuthValidator.java | 53 ++++++- .../oauth2/saml/SamlOAuthValidatorTest.java | 85 +++++++++++ .../cxf/rs/security/saml/sso/TokenReplayCache.java | 43 +----- .../saml/sso/jcache/JCacheTokenReplayCache.java | 134 +--------------- rt/rs/security/xml/pom.xml | 20 +++ .../cxf/rs/security/common}/TokenReplayCache.java | 13 +- .../common}/jcache/JCacheTokenReplayCache.java | 71 +++++---- .../resources/cxf-rs-security-replay-jcache.xml | 43 ++++++ .../rs/security/common/TokenReplayCacheTest.java | 169 +++++++++++++++++++++ .../security/oauth2/grants/JAXRSOAuth2Test.java | 80 ++++++++++ .../jaxrs/security/oauth2/grants/server-jcache.xml | 42 +++++ 13 files changed, 564 insertions(+), 200 deletions(-) diff --git a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/auth/saml/Saml2BearerAuthHandler.java b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/auth/saml/Saml2BearerAuthHandler.java index 3f9b36bfbe8..7897ced0695 100644 --- a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/auth/saml/Saml2BearerAuthHandler.java +++ b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/auth/saml/Saml2BearerAuthHandler.java @@ -50,6 +50,12 @@ public class Saml2BearerAuthHandler extends AbstractSamlInHandler { public Saml2BearerAuthHandler() { } + /** + * Set the SamlOAuthValidator. The replay of client authentication assertions is only detected if a + * replay cache is set on the validator, as clients may legitimately reuse an assertion until it + * expires. + * @param validator the SamlOAuthValidator + */ public void setSamlOAuthValidator(SamlOAuthValidator validator) { samlOAuthValidator = validator; } diff --git a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/grants/saml/Saml2BearerGrantHandler.java b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/grants/saml/Saml2BearerGrantHandler.java index f7a18c94287..7dfbd9d1fde 100644 --- a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/grants/saml/Saml2BearerGrantHandler.java +++ b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/grants/saml/Saml2BearerGrantHandler.java @@ -99,6 +99,11 @@ public class Saml2BearerGrantHandler extends AbstractGrantHandler { samlValidator = validator; } + /** + * Set the SamlOAuthValidator. The replay of SAML2 bearer grant assertions is only detected if a + * replay cache is set on the validator. + * @param validator the SamlOAuthValidator + */ public void setSamlOAuthValidator(SamlOAuthValidator validator) { samlOAuthValidator = validator; } diff --git a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java index 94a15e2a564..95f326cafa7 100644 --- a/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java +++ b/rt/rs/security/oauth-parent/oauth2-saml/src/main/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidator.java @@ -26,6 +26,7 @@ import jakarta.ws.rs.core.UriBuilder; import org.apache.cxf.jaxrs.impl.UriInfoImpl; import org.apache.cxf.jaxrs.utils.ExceptionUtils; import org.apache.cxf.message.Message; +import org.apache.cxf.rs.security.common.TokenReplayCache; import org.apache.cxf.rs.security.oauth2.utils.OAuthConstants; import org.apache.wss4j.common.saml.SamlAssertionWrapper; import org.apache.wss4j.common.saml.builder.SAML2Constants; @@ -41,6 +42,7 @@ public class SamlOAuthValidator { private String issuer; private String clientAddress; private boolean subjectConfirmationDataRequired; + private TokenReplayCache<String> replayCache; public SamlOAuthValidator() { } @@ -61,6 +63,19 @@ public class SamlOAuthValidator { clientAddress = value; } + /** + * Set the cache used to detect the replay of bearer assertions. If it is set, an assertion is + * rejected if its ID has already been seen, until the assertion expires. It is not set by default. + * @param replayCache the replay cache + */ + public void setReplayCache(TokenReplayCache<String> replayCache) { + this.replayCache = replayCache; + } + + public TokenReplayCache<String> getReplayCache() { + return replayCache; + } + public void validate(Message message, SamlAssertionWrapper wrapper) { validateSAMLVersion(wrapper); @@ -78,6 +93,42 @@ public class SamlOAuthValidator { if (!validateAuthenticationSubject(message, cs, wrapper.getSaml2().getSubject())) { throw ExceptionUtils.toNotAuthorizedException(null, null); } + + // This must be the last check, so that only an otherwise valid assertion is cached + checkReplay(wrapper, cs); + } + + /** + * Bearer assertion IDs are cached until the assertion expires, so that the same assertion cannot be + * used more than once. This is done once per assertion, rather than per Subject Confirmation. + */ + private void checkReplay(SamlAssertionWrapper wrapper, Conditions cs) { + if (replayCache == null) { + return; + } + Instant expiry = getReplayCacheExpiry(cs, wrapper.getSaml2().getSubject()); + if (!replayCache.putIdIfAbsent(wrapper.getId(), expiry)) { + throw ExceptionUtils.toNotAuthorizedException(null, null); + } + } + + /** + * The assertion ID must be cached for as long as the assertion can be accepted, so the latest of the + * NotOnOrAfter times of the Conditions and of the Bearer Subject Confirmations is used. + */ + private static Instant getReplayCacheExpiry(Conditions cs, org.opensaml.saml.saml2.core.Subject subject) { + Instant expiry = cs != null ? cs.getNotOnOrAfter() : null; + if (subject != null && subject.getSubjectConfirmations() != null) { + for (SubjectConfirmation subjectConf : subject.getSubjectConfirmations()) { + SubjectConfirmationData subjectConfData = subjectConf.getSubjectConfirmationData(); + if (SAML2Constants.CONF_BEARER.equals(subjectConf.getMethod()) && subjectConfData != null + && subjectConfData.getNotOnOrAfter() != null + && (expiry == null || subjectConfData.getNotOnOrAfter().isAfter(expiry))) { + expiry = subjectConfData.getNotOnOrAfter(); + } + } + } + return expiry; } private void validateSAMLVersion(SamlAssertionWrapper assertionW) { @@ -163,8 +214,6 @@ public class SamlOAuthValidator { throw ExceptionUtils.toNotAuthorizedException(null, null); } - //TODO: replay cache, same as with SAML SSO case - // Check address if (subjectConfData.getAddress() != null && (clientAddress == null || !subjectConfData.getAddress().equals(clientAddress))) { diff --git a/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java b/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java index a127ae9bfb2..46187b27935 100644 --- a/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java +++ b/rt/rs/security/oauth-parent/oauth2-saml/src/test/java/org/apache/cxf/rs/security/oauth2/saml/SamlOAuthValidatorTest.java @@ -22,12 +22,15 @@ import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.Collections; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import javax.security.auth.callback.Callback; import javax.security.auth.callback.CallbackHandler; import javax.security.auth.callback.UnsupportedCallbackException; import jakarta.ws.rs.NotAuthorizedException; +import org.apache.cxf.rs.security.common.TokenReplayCache; import org.apache.wss4j.common.saml.SAMLCallback; import org.apache.wss4j.common.saml.SAMLUtil; import org.apache.wss4j.common.saml.SamlAssertionWrapper; @@ -41,6 +44,9 @@ import org.apache.wss4j.dom.engine.WSSConfig; import org.junit.Test; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; + public class SamlOAuthValidatorTest { static { @@ -75,6 +81,61 @@ public class SamlOAuthValidatorTest { validator.validate(null, wrapper); } + @Test + public void testReplayedAssertionIsRejected() throws Exception { + String tokenServiceAddress = "https://service.example.org/token"; + + SamlOAuthValidator validator = new SamlOAuthValidator(); + validator.setAccessTokenServiceAddress(tokenServiceAddress); + validator.setReplayCache(new TestTokenReplayCache()); + + SamlAssertionWrapper wrapper = createAssertion(tokenServiceAddress, null); + validator.validate(null, wrapper); + + try { + validator.validate(null, wrapper); + fail("Expected failure on replayed assertion"); + } catch (NotAuthorizedException ex) { + // expected + } + + // A different assertion is still accepted + validator.validate(null, createAssertion(tokenServiceAddress, null)); + } + + // An assertion that fails validation is not cached, so it doesn't block the same ID later + @Test + public void testInvalidAssertionIsNotCached() throws Exception { + String tokenServiceAddress = "https://service.example.org/token"; + TestTokenReplayCache replayCache = new TestTokenReplayCache(); + + SamlOAuthValidator validator = new SamlOAuthValidator(); + validator.setAccessTokenServiceAddress(tokenServiceAddress); + validator.setClientAddress("192.0.2.10"); + validator.setReplayCache(replayCache); + + SamlAssertionWrapper wrapper = createAssertion(tokenServiceAddress, "192.0.2.20"); + try { + validator.validate(null, wrapper); + fail("Expected failure on mismatching client address"); + } catch (NotAuthorizedException ex) { + // expected + } + assertFalse(replayCache.contains(wrapper.getId())); + } + + @Test + public void testReplayIsNotCheckedWithoutReplayCache() throws Exception { + String tokenServiceAddress = "https://service.example.org/token"; + + SamlOAuthValidator validator = new SamlOAuthValidator(); + validator.setAccessTokenServiceAddress(tokenServiceAddress); + + SamlAssertionWrapper wrapper = createAssertion(tokenServiceAddress, null); + validator.validate(null, wrapper); + validator.validate(null, wrapper); + } + private SamlAssertionWrapper createAssertion(String recipient, String clientAddress) throws Exception { SubjectConfirmationDataBean confirmationData = new SubjectConfirmationDataBean(); confirmationData.setRecipient(recipient); @@ -93,6 +154,30 @@ public class SamlOAuthValidatorTest { return new SamlAssertionWrapper(samlCallback); } + private static final class TestTokenReplayCache implements TokenReplayCache<String> { + private final Set<String> ids = ConcurrentHashMap.newKeySet(); + + @Override + public boolean contains(String id) { + return ids.contains(id); + } + + @Override + public void putId(String id) { + ids.add(id); + } + + @Override + public void putId(String id, Instant expiry) { + ids.add(id); + } + + @Override + public void close() { + ids.clear(); + } + } + private static class TestSaml2CallbackHandler implements CallbackHandler { private final ConditionsBean conditions; private final SubjectConfirmationDataBean confirmationData; diff --git a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java index 51cb3dd0510..9af7602d0e9 100644 --- a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java +++ b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java @@ -19,42 +19,11 @@ package org.apache.cxf.rs.security.saml.sso; -import java.io.Closeable; -import java.io.IOException; -import java.time.Instant; - -public interface TokenReplayCache<T> extends Closeable { - - boolean contains(T id); - - void putId(T id); - - void putId(T id, Instant expiry); - - /** - * Add the given identifier to the cache if, and only if, it is not already contained in it, as a - * single atomic operation, so that concurrent requests carrying the same identifier cannot all - * pass a replay check before any of them has cached it. This default implementation only - * serializes calls within a single JVM; an implementation backed by a distributed cache should - * override it with the atomic put-if-absent operation of the underlying cache. - * @param id The identifier to be added - * @param expiry A custom expiry time for the identifier. Can be null in which case, the default - * expiry is used. - * @return true if the identifier was added, false if it was already contained in the cache or is - * null or empty - */ - default boolean putIdIfAbsent(T id, Instant expiry) { - if (id == null || "".equals(id)) { - return false; - } - synchronized (this) { - if (contains(id)) { - return false; - } - putId(id, expiry); - return true; - } - } +/** + * The replay cache type used by the SAML SSO module. It extends the common + * {@link org.apache.cxf.rs.security.common.TokenReplayCache} (which defines all of its methods), and is + * kept so that existing implementations and configurations continue to work unchanged. + */ +public interface TokenReplayCache<T> extends org.apache.cxf.rs.security.common.TokenReplayCache<T> { - void close() throws IOException; } \ No newline at end of file diff --git a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java index 853bb45c6ad..9c1a62ca026 100644 --- a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java +++ b/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java @@ -19,41 +19,24 @@ package org.apache.cxf.rs.security.saml.sso.jcache; -import java.io.IOException; import java.net.URISyntaxException; -import java.net.URL; -import java.time.Instant; - -import javax.cache.Cache; -import javax.cache.CacheManager; -import javax.cache.Caching; -import javax.cache.configuration.MutableConfiguration; -import javax.cache.spi.CachingProvider; import org.apache.cxf.Bus; -import org.apache.cxf.BusFactory; -import org.apache.cxf.jaxrs.utils.ResourceUtils; import org.apache.cxf.rs.security.saml.sso.TokenReplayCache; -import org.apache.wss4j.common.cache.EHCacheReplayCache; -import org.apache.wss4j.common.cache.EHCacheValue; -import org.apache.wss4j.common.util.Loader; /** - * An in-memory JCache based implementation of the TokenReplayCache interface. + * A JCache based implementation of the SSO TokenReplayCache interface, using the SSO replay cache + * name and default configuration file. The implementation is inherited from + * {@link org.apache.cxf.rs.security.common.jcache.JCacheTokenReplayCache}. * The default TTL is 60 minutes and the max TTL is 12 hours. */ - -public class JCacheTokenReplayCache implements TokenReplayCache<String> { +public class JCacheTokenReplayCache extends org.apache.cxf.rs.security.common.jcache.JCacheTokenReplayCache + implements TokenReplayCache<String> { public static final String CACHE_KEY = "cxf.samlp.replay.cache"; - private static final org.slf4j.Logger LOG = - org.slf4j.LoggerFactory.getLogger(EHCacheReplayCache.class); private static final String DEFAULT_CONFIG_URL = "/cxf-samlp-jcache.xml"; - private final Cache<String, EHCacheValue> cache; - private final CacheManager cacheManager; - public JCacheTokenReplayCache() throws URISyntaxException { this(DEFAULT_CONFIG_URL, null); } @@ -67,112 +50,7 @@ public class JCacheTokenReplayCache implements TokenReplayCache<String> { } public JCacheTokenReplayCache(String configFile, Bus bus) throws URISyntaxException { - if (bus == null) { - bus = BusFactory.getThreadDefaultBus(true); - } - URL configFileURL = null; - try { - configFileURL = - ResourceUtils.getClasspathResourceURL(configFile, JCacheTokenReplayCache.class, bus); - } catch (Exception ex) { - // ignore - } - - final CachingProvider cachingProvider = Caching.getCachingProvider(); - - cacheManager = cachingProvider.getCacheManager( - getConfigFileURL(configFileURL).toURI(), - getClass().getClassLoader()); - - cache = getOrCreate(cacheManager, CACHE_KEY, String.class, EHCacheValue.class); - } - - private static <K, V> Cache<K, V> getOrCreate(CacheManager cacheManager, String name, - Class<K> kclass, Class<V> vclass) { - - final Cache<K, V> cache = cacheManager.getCache(name, kclass, vclass); - if (cache != null) { - return cache; - } - - final MutableConfiguration<K, V> cacheConfiguration = new MutableConfiguration<>(); - cacheConfiguration.setTypes(kclass, vclass); - - return cacheManager.createCache(name, cacheConfiguration); - } - - private URL getConfigFileURL(URL suppliedConfigFileURL) { - if (suppliedConfigFileURL == null) { - //using the default - try { - URL configFileURL = Loader.getResource(DEFAULT_CONFIG_URL); - if (configFileURL == null) { - configFileURL = new URL(DEFAULT_CONFIG_URL); - } - return configFileURL; - } catch (IOException e) { - // Do nothing - LOG.debug(e.getMessage()); - } - } - return suppliedConfigFileURL; + super(CACHE_KEY, configFile, DEFAULT_CONFIG_URL, bus); } - /** - * Add the given identifier to the cache. It will be cached for a default amount of time. - * @param identifier The identifier to be added - */ - public void putId(String identifier) { - putId(identifier, null); - } - - /** - * Add the given identifier to the cache to be cached for the given time - * @param identifier The identifier to be added - * @param expiry A custom expiry time for the identifier. Can be null in which case, the default expiry is used. - */ - public void putId(String identifier, Instant expiry) { - if (identifier == null || "".equals(identifier)) { - return; - } - - cache.put(identifier, new EHCacheValue(identifier, expiry)); - } - - /** - * Add the given identifier to the cache if, and only if, it is not already contained in it, as a - * single atomic operation. JCache's putIfAbsent is atomic for a distributed provider as well. - * @param identifier The identifier to be added - * @param expiry A custom expiry time for the identifier. Can be null in which case, the default expiry is used. - * @return true if the identifier was added, false if it was already contained in the cache or is - * null or empty - */ - @Override - public boolean putIdIfAbsent(String identifier, Instant expiry) { - if (identifier == null || "".equals(identifier)) { - return false; - } - return cache.putIfAbsent(identifier, new EHCacheValue(identifier, expiry)); - } - - /** - * Return true if the given identifier is contained in the cache - * @param identifier The identifier to check - */ - public boolean contains(String identifier) { - if (cache == null) { - return false; - } - EHCacheValue element = cache.get(identifier); - return element != null; - } - - public synchronized void close() { - if (!cacheManager.isClosed()) { - cacheManager.destroyCache(CACHE_KEY); - cacheManager.close(); - } - } - - } diff --git a/rt/rs/security/xml/pom.xml b/rt/rs/security/xml/pom.xml index fb2bc646a75..7acfac93446 100644 --- a/rt/rs/security/xml/pom.xml +++ b/rt/rs/security/xml/pom.xml @@ -34,6 +34,7 @@ <cxf.module.name>org.apache.cxf.rs.security.xml</cxf.module.name> <cxf.osgi.import> org.opensaml*;version="${cxf.opensaml.osgi.version}", + javax.cache*;resolution:=optional, </cxf.osgi.import> </properties> <dependencies> @@ -71,5 +72,24 @@ </exclusion> </exclusions> </dependency> + <dependency> + <groupId>javax.cache</groupId> + <artifactId>cache-api</artifactId> + <version>${cxf.jcache.version}</version> + <scope>provided</scope> + <optional>true</optional> + </dependency> + <dependency> + <groupId>org.ehcache</groupId> + <artifactId>ehcache</artifactId> + <classifier>jakarta</classifier> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.ehcache.modules</groupId> + <artifactId>ehcache-107</artifactId> + <version>${cxf.ehcache3.version}</version> + <scope>test</scope> + </dependency> </dependencies> </project> diff --git a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java similarity index 83% copy from rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java copy to rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java index 51cb3dd0510..94c9cb80648 100644 --- a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/TokenReplayCache.java +++ b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/TokenReplayCache.java @@ -17,12 +17,15 @@ * under the License. */ -package org.apache.cxf.rs.security.saml.sso; +package org.apache.cxf.rs.security.common; import java.io.Closeable; import java.io.IOException; import java.time.Instant; +/** + * A cache of token identifiers, used to detect the replay of (bearer) tokens such as SAML assertions. + */ public interface TokenReplayCache<T> extends Closeable { boolean contains(T id); @@ -34,9 +37,9 @@ public interface TokenReplayCache<T> extends Closeable { /** * Add the given identifier to the cache if, and only if, it is not already contained in it, as a * single atomic operation, so that concurrent requests carrying the same identifier cannot all - * pass a replay check before any of them has cached it. This default implementation only - * serializes calls within a single JVM; an implementation backed by a distributed cache should - * override it with the atomic put-if-absent operation of the underlying cache. + * pass a replay check before any of them has cached it. This default implementation is only + * atomic with respect to other callers of this method on the same instance; an implementation + * should override it with the atomic put-if-absent operation of the underlying cache. * @param id The identifier to be added * @param expiry A custom expiry time for the identifier. Can be null in which case, the default * expiry is used. @@ -57,4 +60,4 @@ public interface TokenReplayCache<T> extends Closeable { } void close() throws IOException; -} \ No newline at end of file +} diff --git a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java similarity index 65% copy from rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java copy to rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java index 853bb45c6ad..12502eccd4c 100644 --- a/rt/rs/security/sso/saml/src/main/java/org/apache/cxf/rs/security/saml/sso/jcache/JCacheTokenReplayCache.java +++ b/rt/rs/security/xml/src/main/java/org/apache/cxf/rs/security/common/jcache/JCacheTokenReplayCache.java @@ -17,12 +17,15 @@ * under the License. */ -package org.apache.cxf.rs.security.saml.sso.jcache; +package org.apache.cxf.rs.security.common.jcache; import java.io.IOException; +import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.time.Instant; +import java.util.logging.Level; +import java.util.logging.Logger; import javax.cache.Cache; import javax.cache.CacheManager; @@ -32,25 +35,24 @@ import javax.cache.spi.CachingProvider; import org.apache.cxf.Bus; import org.apache.cxf.BusFactory; +import org.apache.cxf.common.logging.LogUtils; import org.apache.cxf.jaxrs.utils.ResourceUtils; -import org.apache.cxf.rs.security.saml.sso.TokenReplayCache; -import org.apache.wss4j.common.cache.EHCacheReplayCache; +import org.apache.cxf.rs.security.common.TokenReplayCache; import org.apache.wss4j.common.cache.EHCacheValue; import org.apache.wss4j.common.util.Loader; /** - * An in-memory JCache based implementation of the TokenReplayCache interface. - * The default TTL is 60 minutes and the max TTL is 12 hours. + * A JCache based implementation of the TokenReplayCache interface. The default configuration uses + * Ehcache as the JCache provider, with a default TTL of 60 minutes and a max TTL of 12 hours. */ - public class JCacheTokenReplayCache implements TokenReplayCache<String> { - public static final String CACHE_KEY = "cxf.samlp.replay.cache"; + public static final String CACHE_KEY = "cxf.rs.security.replay.cache"; - private static final org.slf4j.Logger LOG = - org.slf4j.LoggerFactory.getLogger(EHCacheReplayCache.class); - private static final String DEFAULT_CONFIG_URL = "/cxf-samlp-jcache.xml"; + private static final Logger LOG = LogUtils.getL7dLogger(JCacheTokenReplayCache.class); + private static final String DEFAULT_CONFIG_URL = "/cxf-rs-security-replay-jcache.xml"; + private final String cacheKey; private final Cache<String, EHCacheValue> cache; private final CacheManager cacheManager; @@ -67,24 +69,37 @@ public class JCacheTokenReplayCache implements TokenReplayCache<String> { } public JCacheTokenReplayCache(String configFile, Bus bus) throws URISyntaxException { + this(CACHE_KEY, configFile, DEFAULT_CONFIG_URL, bus); + } + + /** + * @param cacheKey The name of the cache + * @param configFile The JCache provider configuration file + * @param defaultConfigFile The configuration file to fall back to if configFile cannot be loaded + * @param bus The Bus used to load the configuration file. Can be null, in which case the thread + * default Bus is used. + */ + protected JCacheTokenReplayCache(String cacheKey, String configFile, String defaultConfigFile, Bus bus) + throws URISyntaxException { + this.cacheKey = cacheKey; if (bus == null) { bus = BusFactory.getThreadDefaultBus(true); } URL configFileURL = null; try { - configFileURL = - ResourceUtils.getClasspathResourceURL(configFile, JCacheTokenReplayCache.class, bus); + configFileURL = ResourceUtils.getClasspathResourceURL(configFile, getClass(), bus); } catch (Exception ex) { // ignore } final CachingProvider cachingProvider = Caching.getCachingProvider(); - cacheManager = cachingProvider.getCacheManager( - getConfigFileURL(configFileURL).toURI(), - getClass().getClassLoader()); + URL resolvedConfigFileURL = getConfigFileURL(configFileURL, defaultConfigFile); + URI configURI = resolvedConfigFileURL != null + ? resolvedConfigFileURL.toURI() : cachingProvider.getDefaultURI(); + cacheManager = cachingProvider.getCacheManager(configURI, getClass().getClassLoader()); - cache = getOrCreate(cacheManager, CACHE_KEY, String.class, EHCacheValue.class); + cache = getOrCreate(cacheManager, cacheKey, String.class, EHCacheValue.class); } private static <K, V> Cache<K, V> getOrCreate(CacheManager cacheManager, String name, @@ -101,18 +116,18 @@ public class JCacheTokenReplayCache implements TokenReplayCache<String> { return cacheManager.createCache(name, cacheConfiguration); } - private URL getConfigFileURL(URL suppliedConfigFileURL) { + private static URL getConfigFileURL(URL suppliedConfigFileURL, String defaultConfigFile) { if (suppliedConfigFileURL == null) { //using the default try { - URL configFileURL = Loader.getResource(DEFAULT_CONFIG_URL); + URL configFileURL = Loader.getResource(defaultConfigFile); if (configFileURL == null) { - configFileURL = new URL(DEFAULT_CONFIG_URL); + configFileURL = new URL(defaultConfigFile); } return configFileURL; } catch (IOException e) { // Do nothing - LOG.debug(e.getMessage()); + LOG.log(Level.FINE, e.getMessage(), e); } } return suppliedConfigFileURL; @@ -129,7 +144,8 @@ public class JCacheTokenReplayCache implements TokenReplayCache<String> { /** * Add the given identifier to the cache to be cached for the given time * @param identifier The identifier to be added - * @param expiry A custom expiry time for the identifier. Can be null in which case, the default expiry is used. + * @param expiry A custom expiry time for the identifier. Can be null in which case, the default + * expiry is used. */ public void putId(String identifier, Instant expiry) { if (identifier == null || "".equals(identifier)) { @@ -140,18 +156,18 @@ public class JCacheTokenReplayCache implements TokenReplayCache<String> { } /** - * Add the given identifier to the cache if, and only if, it is not already contained in it, as a - * single atomic operation. JCache's putIfAbsent is atomic for a distributed provider as well. + * Atomically add the given identifier to the cache if it is not already present * @param identifier The identifier to be added - * @param expiry A custom expiry time for the identifier. Can be null in which case, the default expiry is used. - * @return true if the identifier was added, false if it was already contained in the cache or is - * null or empty + * @param expiry A custom expiry time for the identifier. Can be null in which case, the default + * expiry is used. + * @return true if the identifier was added, false if it was already present (or is null or empty) */ @Override public boolean putIdIfAbsent(String identifier, Instant expiry) { if (identifier == null || "".equals(identifier)) { return false; } + return cache.putIfAbsent(identifier, new EHCacheValue(identifier, expiry)); } @@ -169,10 +185,9 @@ public class JCacheTokenReplayCache implements TokenReplayCache<String> { public synchronized void close() { if (!cacheManager.isClosed()) { - cacheManager.destroyCache(CACHE_KEY); + cacheManager.destroyCache(cacheKey); cacheManager.close(); } } - } diff --git a/rt/rs/security/xml/src/main/resources/cxf-rs-security-replay-jcache.xml b/rt/rs/security/xml/src/main/resources/cxf-rs-security-replay-jcache.xml new file mode 100644 index 00000000000..58be6e2f8fd --- /dev/null +++ b/rt/rs/security/xml/src/main/resources/cxf-rs-security-replay-jcache.xml @@ -0,0 +1,43 @@ +<?xml version="1.0"?> +<!-- + 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. +--> +<config + xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' + xmlns='http://www.ehcache.org/v3' + xmlns:jsr107='http://www.ehcache.org/v3/jsr107' + xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.8.xsd + http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd"> + <service> + <jsr107:defaults> + <jsr107:cache name="cxf.rs.security.replay.cache" template="cxf.rs.security.replay.cache.template"/> + </jsr107:defaults> + </service> + <persistence directory="${java.io.tmpdir}/ehcache-cxf-rs-security-replay/" /> + <cache-template name="cxf.rs.security.replay.cache.template"> + <key-type>java.lang.String</key-type> + <value-type>org.apache.wss4j.common.cache.EHCacheValue</value-type> + <expiry> + <class>org.apache.wss4j.common.cache.EHCacheExpiry</class> + </expiry> + <resources> + <heap unit="entries">5000</heap> + <disk unit="MB" persistent="false">10</disk> + </resources> + </cache-template> +</config> diff --git a/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java b/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java new file mode 100644 index 00000000000..f761ded111e --- /dev/null +++ b/rt/rs/security/xml/src/test/java/org/apache/cxf/rs/security/common/TokenReplayCacheTest.java @@ -0,0 +1,169 @@ +/** + * 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.cxf.rs.security.common; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicIntegerArray; + +import org.apache.cxf.rs.security.common.jcache.JCacheTokenReplayCache; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Some unit tests for the common TokenReplayCache implementations + */ +@RunWith(value = org.junit.runners.Parameterized.class) +public class TokenReplayCacheTest { + private final Class<? extends TokenReplayCache<String>> tokenReplayCacheClass; + + public TokenReplayCacheTest(Class<? extends TokenReplayCache<String>> tokenReplayCacheClass) { + this.tokenReplayCacheClass = tokenReplayCacheClass; + } + + @Parameterized.Parameters(name = "{0}") + public static Collection<Class<? extends TokenReplayCache<String>>> data() { + return Collections.singletonList(JCacheTokenReplayCache.class); + } + + @Test + public void testTokenReplayCache() throws Exception { + TokenReplayCache<String> replayCache = createCache(); + try { + // Test default TTL caches OK + String id = UUID.randomUUID().toString(); + replayCache.putId(id); + assertTrue(replayCache.contains(id)); + + // Test specifying TTL caches OK + id = UUID.randomUUID().toString(); + replayCache.putId(id, Instant.now().plusSeconds(100L)); + assertTrue(replayCache.contains(id)); + + // Test expiration + id = UUID.randomUUID().toString(); + replayCache.putId(id, Instant.now().plusSeconds(1L)); + Thread.sleep(1250L); + assertFalse(replayCache.contains(id)); + assertTrue(replayCache.putIdIfAbsent(id, null)); + } finally { + replayCache.close(); + } + } + + // The negative and huge expiries are rejected and fall back to the default + @Test + public void testTokenReplayCacheInvalidExpiry() throws Exception { + TokenReplayCache<String> replayCache = createCache(); + try { + String id = UUID.randomUUID().toString(); + replayCache.putId(id, Instant.now().minusSeconds(100L)); + assertTrue(replayCache.contains(id)); + + id = UUID.randomUUID().toString(); + replayCache.putId(id, Instant.now().plus(14, ChronoUnit.HOURS)); + assertTrue(replayCache.contains(id)); + } finally { + replayCache.close(); + } + } + + @Test + public void testTokenReplayCachePutIdIfAbsent() throws Exception { + TokenReplayCache<String> replayCache = createCache(); + try { + String id = UUID.randomUUID().toString(); + assertTrue(replayCache.putIdIfAbsent(id, null)); + assertTrue(replayCache.contains(id)); + assertFalse(replayCache.putIdIfAbsent(id, Instant.now().plusSeconds(100L))); + + id = UUID.randomUUID().toString(); + replayCache.putId(id); + assertFalse(replayCache.putIdIfAbsent(id, null)); + + assertFalse(replayCache.putIdIfAbsent(null, null)); + assertFalse(replayCache.putIdIfAbsent("", null)); + } finally { + replayCache.close(); + } + } + + // Exactly one putIdIfAbsent call per identifier may succeed, when called concurrently + @Test + public void testTokenReplayCacheConcurrentPutIdIfAbsent() throws Exception { + TokenReplayCache<String> replayCache = createCache(); + + final int threads = 8; + final int identifiers = 500; + List<String> ids = new ArrayList<>(identifiers); + for (int i = 0; i < identifiers; i++) { + ids.add(UUID.randomUUID().toString()); + } + AtomicIntegerArray accepted = new AtomicIntegerArray(identifiers); + CyclicBarrier barrier = new CyclicBarrier(threads); + Instant expiry = Instant.now().plusSeconds(100L); + + ExecutorService executor = Executors.newFixedThreadPool(threads); + try { + List<Future<?>> futures = new ArrayList<>(threads); + for (int t = 0; t < threads; t++) { + futures.add(executor.submit(() -> { + barrier.await(10, TimeUnit.SECONDS); + for (int i = 0; i < identifiers; i++) { + if (replayCache.putIdIfAbsent(ids.get(i), expiry)) { + accepted.incrementAndGet(i); + } + } + return null; + })); + } + for (Future<?> future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + executor.shutdownNow(); + replayCache.close(); + } + + for (int i = 0; i < identifiers; i++) { + assertEquals("Identifier " + i + " was accepted more than once", 1, accepted.get(i)); + } + } + + private TokenReplayCache<String> createCache() throws Exception { + return tokenReplayCacheClass.getDeclaredConstructor().newInstance(); + } +} diff --git a/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java b/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java index 855e8b24b5b..5299b9bfc9f 100644 --- a/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java +++ b/systests/rs-security/src/test/java/org/apache/cxf/systest/jaxrs/security/oauth2/grants/JAXRSOAuth2Test.java @@ -28,8 +28,10 @@ import java.util.Map; import org.w3c.dom.Document; import org.w3c.dom.Element; +import jakarta.ws.rs.core.Form; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; import org.apache.cxf.Bus; import org.apache.cxf.BusFactory; import org.apache.cxf.bus.spring.SpringBusFactory; @@ -65,6 +67,7 @@ import org.apache.wss4j.common.saml.SamlAssertionWrapper; import org.apache.wss4j.common.saml.builder.SAML2Constants; import org.apache.wss4j.common.util.DOM2Writer; +import org.junit.Assume; import org.junit.BeforeClass; import org.junit.Test; import org.junit.runner.RunWith; @@ -177,6 +180,83 @@ public class JAXRSOAuth2Test extends AbstractBusClientServerTestBase { assertNotNull(at.getTokenKey()); } + // The same SAML2 bearer grant assertion can only be used once, when a replay cache is configured + @Test + public void testSAML2BearerGrantReplay() throws Exception { + // The replay endpoints are only configured for the JCache server + Assume.assumeTrue(JCACHE_PORT.equals(port)); + + String address = "https://localhost:" + port + "/oauth2-replay/token"; + Saml2BearerGrant grant = new Saml2BearerGrant(createSignedAssertion(address, false)); + + ClientAccessToken at = OAuthClientUtils.getAccessToken(createWebClient(address), + new Consumer("alice", "alice"), + grant, + false); + assertNotNull(at.getTokenKey()); + + try { + OAuthClientUtils.getAccessToken(createWebClient(address), + new Consumer("alice", "alice"), + grant, + false); + fail("Failure expected on a replayed SAML2 bearer grant"); + } catch (OAuthServiceException ex) { + assertEquals(OAuthConstants.INVALID_GRANT, ex.getError().getError()); + } + + // A new assertion is still accepted + at = OAuthClientUtils.getAccessToken(createWebClient(address), + new Consumer("alice", "alice"), + new Saml2BearerGrant(createSignedAssertion(address, false)), + false); + assertNotNull(at.getTokenKey()); + } + + // The same SAML2 client authentication assertion can only be used once, when a replay cache is + // configured + @Test + public void testSAML2BearerAuthenticationReplay() throws Exception { + // The replay endpoints are only configured for the JCache server + Assume.assumeTrue(JCACHE_PORT.equals(port)); + + String address = "https://localhost:" + port + "/oauth2-auth-replay/token"; + String encodedAssertion = Base64UrlUtility.encode(createSignedAssertion(address, true)); + + Map<String, String> extraParams = new HashMap<>(); + extraParams.put(Constants.CLIENT_AUTH_ASSERTION_TYPE, Constants.CLIENT_AUTH_SAML2_BEARER); + extraParams.put(Constants.CLIENT_AUTH_ASSERTION_PARAM, encodedAssertion); + + ClientAccessToken at = OAuthClientUtils.getAccessToken(createWebClient(address), + new CustomGrant(), + extraParams); + assertNotNull(at.getTokenKey()); + + // The replayed assertion is rejected by the client authentication filter + Form form = new Form(); + form.param(OAuthConstants.GRANT_TYPE, "custom_grant"); + form.param(Constants.CLIENT_AUTH_ASSERTION_TYPE, Constants.CLIENT_AUTH_SAML2_BEARER); + form.param(Constants.CLIENT_AUTH_ASSERTION_PARAM, encodedAssertion); + try (Response response = createWebClient(address).form(form)) { + assertEquals(401, response.getStatus()); + } + } + + private String createSignedAssertion(String audienceURI, boolean clientAuthentication) throws Exception { + Crypto crypto = new CryptoLoader().loadCrypto(CRYPTO_RESOURCE_PROPERTIES); + SelfSignInfo signInfo = new SelfSignInfo(crypto, "alice", "password"); + + SamlCallbackHandler samlCallbackHandler = new SamlCallbackHandler(clientAuthentication); + if (clientAuthentication) { + samlCallbackHandler.setIssuer("alice"); + } + samlCallbackHandler.setAudience(audienceURI); + SamlAssertionWrapper assertionWrapper = SAMLUtils.createAssertion(samlCallbackHandler, signInfo); + Document doc = DOMUtils.newDocument(); + Element assertionElement = assertionWrapper.toDOM(doc); + return DOM2Writer.nodeToString(assertionElement); + } + @Test() public void testConfidentialClientIdOnly() throws Exception { String address = "https://localhost:" + port + "/oauth2/token"; diff --git a/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oauth2/grants/server-jcache.xml b/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oauth2/grants/server-jcache.xml index 6fefcb8fa5b..a50c17fe2a2 100644 --- a/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oauth2/grants/server-jcache.xml +++ b/systests/rs-security/src/test/resources/org/apache/cxf/systest/jaxrs/security/oauth2/grants/server-jcache.xml @@ -128,6 +128,48 @@ under the License. <entry key="security.signature.properties" value="org/apache/cxf/systest/jaxrs/security/alice.properties"/> </jaxrs:properties> </jaxrs:server> + <!-- SAML2 bearer grant and client authentication with replay detection, using a JCache replay cache --> + <bean id="replayCache" class="org.apache.cxf.rs.security.common.jcache.JCacheTokenReplayCache" + destroy-method="close"/> + <bean id="replaySamlOAuthValidator" class="org.apache.cxf.rs.security.oauth2.saml.SamlOAuthValidator"> + <property name="replayCache" ref="replayCache"/> + </bean> + <bean id="replaySamlGrantHandler" class="org.apache.cxf.rs.security.oauth2.grants.saml.Saml2BearerGrantHandler"> + <property name="dataProvider" ref="dataProvider"/> + <property name="samlOAuthValidator" ref="replaySamlOAuthValidator"/> + </bean> + <bean id="replaySamlAuthHandler" class="org.apache.cxf.rs.security.oauth2.auth.saml.Saml2BearerAuthHandler"> + <property name="samlOAuthValidator" ref="replaySamlOAuthValidator"/> + </bean> + <bean id="replayServiceBean" class="org.apache.cxf.rs.security.oauth2.services.AccessTokenService"> + <property name="dataProvider" ref="dataProvider"/> + <property name="grantHandlers"> + <list> + <ref bean="replaySamlGrantHandler"/> + <ref bean="customGrantHandler"/> + </list> + </property> + <property name="canSupportPublicClients" value="true"/> + </bean> + <jaxrs:server address="https://localhost:${testutil.ports.jaxrs-oauth2-jcache}/oauth2-replay"> + <jaxrs:serviceBeans> + <ref bean="replayServiceBean"/> + </jaxrs:serviceBeans> + <jaxrs:properties> + <entry key="security.signature.properties" value="org/apache/cxf/systest/jaxrs/security/alice.properties"/> + </jaxrs:properties> + </jaxrs:server> + <jaxrs:server address="https://localhost:${testutil.ports.jaxrs-oauth2-jcache}/oauth2-auth-replay"> + <jaxrs:serviceBeans> + <ref bean="replayServiceBean"/> + </jaxrs:serviceBeans> + <jaxrs:providers> + <ref bean="replaySamlAuthHandler"/> + </jaxrs:providers> + <jaxrs:properties> + <entry key="security.signature.properties" value="org/apache/cxf/systest/jaxrs/security/alice.properties"/> + </jaxrs:properties> + </jaxrs:server> <jaxrs:server address="https://localhost:${testutil.ports.jaxrs-oauth2-jcache}/oauth2-auth-jwt"> <jaxrs:serviceBeans> <ref bean="serviceBean"/>
