From 227c71f7f0457d12f5130e03997b69823f50a5f7 Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Fri, 26 Jul 2024 11:46:14 +0200 Subject: [PATCH] Persisting revoked access tokens Closes #31296 Signed-off-by: Alexander Schwartz --- .../release_notes/topics/26_0_0.adoc | 5 + .../topics/changes/changes-26_0_0.adoc | 9 ++ .../InfinispanSingleUseObjectProvider.java | 31 +++++- ...inispanSingleUseObjectProviderFactory.java | 105 +++++++++++++++++- .../jpa/entities/RevokedTokenEntity.java | 75 +++++++++++++ .../JpaRevokedTokensPersisterProvider.java | 77 +++++++++++++ ...RevokedTokensPersisterProviderFactory.java | 65 +++++++++++ .../META-INF/jpa-changelog-26.0.0.xml | 12 ++ ...sion.RevokedTokensPersisterProviderFactory | 18 +++ .../main/resources/default-persistence.xml | 1 + .../keycloak/models/session/RevokedToken.java | 24 ++++ .../RevokedTokenPersisterProvider.java | 40 +++++++ .../session/RevokedTokenPersisterSpi.java | 45 ++++++++ ...RevokedTokensPersisterProviderFactory.java | 23 ++++ .../scheduled/ClearExpiredRevokedTokens.java | 49 ++++++++ .../services/org.keycloak.provider.Spi | 1 + .../models/SingleUseObjectProvider.java | 13 ++- .../testsuite/model/parameters/Jpa.java | 10 +- .../SingleUseObjectModelTest.java | 43 +++++++ 19 files changed, 631 insertions(+), 15 deletions(-) create mode 100755 model/jpa/src/main/java/org/keycloak/models/jpa/entities/RevokedTokenEntity.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProvider.java create mode 100644 model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProviderFactory.java create mode 100644 model/jpa/src/main/resources/META-INF/services/org.keycloak.models.session.RevokedTokensPersisterProviderFactory create mode 100644 model/storage-private/src/main/java/org/keycloak/models/session/RevokedToken.java create mode 100644 model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterProvider.java create mode 100644 model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterSpi.java create mode 100644 model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokensPersisterProviderFactory.java create mode 100755 model/storage-private/src/main/java/org/keycloak/services/scheduled/ClearExpiredRevokedTokens.java diff --git a/docs/documentation/release_notes/topics/26_0_0.adoc b/docs/documentation/release_notes/topics/26_0_0.adoc index 3d6cbeddd4..63e28c10b1 100644 --- a/docs/documentation/release_notes/topics/26_0_0.adoc +++ b/docs/documentation/release_notes/topics/26_0_0.adoc @@ -30,6 +30,11 @@ As a consequence, group-related events like the `GroupRemovedEvent` are no longe For information on how to migrate, see the link:{upgradingguide_link}[{upgradingguide_name}]. += Persisting revoked access tokens across restarts + +In this release, revoked access tokens are written to the database and reloaded when the cluster is restarted by default when using the embedded caches. + +For information on how to migrate, see the link:{upgradingguide_link}[{upgradingguide_name}]. = Keycloak CR supports standard scheduling options diff --git a/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc b/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc index 7d1e355747..10f5f3ab9b 100644 --- a/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc +++ b/docs/documentation/upgrading/topics/changes/changes-26_0_0.adoc @@ -83,6 +83,15 @@ In order to use a custom footer, create a `footer.ftl` file in your custom login For more details, see link:{developerguide_link}#_theme_custom_footer[Adding a custom footer to a login theme]. += Persisting revoked access tokens across restarts + +In this release, revoked access tokens are written to the database and reloaded when the cluster is restarted by default when using the embedded caches. + +To disable this behavior, use the SPI option `spi-single-use-object-infinispan-persist-revoked-tokens` as outlined in the https://www.keycloak.org/server/all-provider-config[All provider configuration] {section}. + +The SPI behavior of `SingleUseObjectProvider` has changed that for revoked tokens only the methods `put` and `contains` must be used. +This is enforced by default, and can be disabled using the SPI option `spi-single-use-object-infinispan-persist-revoked-tokens`. + = Admin Bootstrapping The environment variables `KEYCLOAK_ADMIN` and `KEYCLOAK_ADMIN_PASSWORD` have been deprecated. You should use `KC_BOOTSTRAP_ADMIN_USERNAME` and `KC_BOOTSTRAP_ADMIN_PASSWORD` instead. These are also general options, so they may be specified via the cli or other config sources, for example `--bootstrap-admin-username=admin`. diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProvider.java index 0108d39574..c9e3e9bbd2 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProvider.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProvider.java @@ -27,7 +27,9 @@ import org.jboss.logging.Logger; import org.keycloak.common.util.Time; import org.keycloak.connections.infinispan.InfinispanUtil; import org.keycloak.models.KeycloakSession; +import org.keycloak.models.ModelException; import org.keycloak.models.SingleUseObjectProvider; +import org.keycloak.models.session.RevokedTokenPersisterProvider; import org.keycloak.models.sessions.infinispan.entities.SingleUseObjectValueEntity; /** @@ -41,11 +43,15 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide public static final Logger logger = Logger.getLogger(InfinispanSingleUseObjectProvider.class); + private final KeycloakSession session; private final Supplier> singleUseObjectCache; + private final boolean persistRevokedTokens; private final InfinispanKeycloakTransaction tx; - public InfinispanSingleUseObjectProvider(KeycloakSession session, Supplier> singleUseObjectCache) { + public InfinispanSingleUseObjectProvider(KeycloakSession session, Supplier> singleUseObjectCache, boolean persistRevokedTokens) { + this.session = session; this.singleUseObjectCache = singleUseObjectCache; + this.persistRevokedTokens = persistRevokedTokens; this.tx = new InfinispanKeycloakTransaction(); session.getTransactionManager().enlistAfterCompletion(tx); } @@ -61,13 +67,22 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide if (logger.isDebugEnabled()) { logger.debugf(re, "Failed when adding code %s", key); } - throw re; } + if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) { + if (!notes.isEmpty()) { + throw new ModelException("Notes are not supported for revoked tokens"); + } + session.getProvider(RevokedTokenPersisterProvider.class).revokeToken(key.substring(0, key.length() - REVOKED_KEY.length()), lifespanSeconds); + } } @Override public Map get(String key) { + if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) { + throw new ModelException("Revoked tokens can't be retrieved"); + } + SingleUseObjectValueEntity singleUseObjectValueEntity; BasicCache cache = singleUseObjectCache.get(); @@ -77,6 +92,10 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide @Override public Map remove(String key) { + if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) { + throw new ModelException("Revoked tokens can't be removed"); + } + try { BasicCache cache = singleUseObjectCache.get(); SingleUseObjectValueEntity existing = cache.remove(key); @@ -94,12 +113,20 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide @Override public boolean replace(String key, Map notes) { + if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) { + throw new ModelException("Revoked tokens can't be replaced"); + } + BasicCache cache = singleUseObjectCache.get(); return cache.replace(key, new SingleUseObjectValueEntity(notes)) != null; } @Override public boolean putIfAbsent(String key, long lifespanInSeconds) { + if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) { + throw new ModelException("Revoked tokens can't be used in putIfAbsent"); + } + SingleUseObjectValueEntity tokenValue = new SingleUseObjectValueEntity(null); BasicCache cache = singleUseObjectCache.get(); diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProviderFactory.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProviderFactory.java index 3c09116a1b..f3f170af7e 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProviderFactory.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanSingleUseObjectProviderFactory.java @@ -18,6 +18,11 @@ package org.keycloak.models.sessions.infinispan; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import org.infinispan.Cache; @@ -26,30 +31,49 @@ import org.infinispan.client.hotrod.RemoteCache; import org.infinispan.commons.api.BasicCache; import org.jboss.logging.Logger; import org.keycloak.Config; +import org.keycloak.common.util.Time; import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.connections.infinispan.InfinispanUtil; import org.keycloak.infinispan.util.InfinispanUtils; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.SingleUseObjectProviderFactory; +import org.keycloak.models.session.RevokedTokenPersisterProvider; import org.keycloak.models.sessions.infinispan.entities.SingleUseObjectValueEntity; +import org.keycloak.models.utils.PostMigrationEvent; import org.keycloak.provider.EnvironmentDependentProviderFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.provider.ProviderEvent; +import org.keycloak.provider.ProviderEventListener; +import org.keycloak.provider.ServerInfoAwareProviderFactory; +import org.keycloak.services.scheduled.ClearExpiredRevokedTokens; +import org.keycloak.services.scheduled.ClusterAwareScheduledTaskRunner; +import org.keycloak.timer.TimerProvider; /** * @author Marek Posolda */ -public class InfinispanSingleUseObjectProviderFactory implements SingleUseObjectProviderFactory, EnvironmentDependentProviderFactory { +public class InfinispanSingleUseObjectProviderFactory implements SingleUseObjectProviderFactory, EnvironmentDependentProviderFactory, ServerInfoAwareProviderFactory { + + public static final String CONFIG_PERSIST_REVOKED_TOKENS = "persistRevokedTokens"; + private static final boolean DEFAULT_PERSIST_REVOKED_TOKENS = true; private static final Logger LOG = Logger.getLogger(InfinispanSingleUseObjectProviderFactory.class); - private volatile Supplier> singleUseObjectCache; + protected volatile Supplier> singleUseObjectCache; + + private volatile boolean initialized; + private boolean persistRevokedTokens; @Override public InfinispanSingleUseObjectProvider create(KeycloakSession session) { - return new InfinispanSingleUseObjectProvider(session, singleUseObjectCache); + initialize(session); + return new InfinispanSingleUseObjectProvider(session, singleUseObjectCache, persistRevokedTokens); } - static Supplier getSingleUseObjectCache(KeycloakSession session) { + static Supplier> getSingleUseObjectCache(KeycloakSession session) { InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE); @@ -66,16 +90,64 @@ public class InfinispanSingleUseObjectProviderFactory implements SingleUseObject @Override public void init(Config.Scope config) { + persistRevokedTokens = config.getBoolean(CONFIG_PERSIST_REVOKED_TOKENS, DEFAULT_PERSIST_REVOKED_TOKENS); + } + private final static String LOADED = "loaded" + SingleUseObjectProvider.REVOKED_KEY; + + private void initialize(KeycloakSession session) { + if (persistRevokedTokens && !initialized) { + synchronized (this) { + if (!initialized) { + RevokedTokenPersisterProvider provider = session.getProvider(RevokedTokenPersisterProvider.class); + BasicCache cache = singleUseObjectCache.get(); + if (cache.get(LOADED) == null) { + // in a cluster, multiple Keycloak instances might load the same data in parallel, but that wouldn't matter + provider.getAllRevokedTokens().forEach(revokedToken -> { + long lifespanSeconds = revokedToken.expiry() - Time.currentTime(); + if (lifespanSeconds > 0) { + cache.put(revokedToken.tokenId() + SingleUseObjectProvider.REVOKED_KEY, new SingleUseObjectValueEntity(Collections.emptyMap()), + InfinispanUtil.toHotrodTimeMs(cache, Time.toMillis(lifespanSeconds)), TimeUnit.MILLISECONDS); + } + }); + cache.put(LOADED, new SingleUseObjectValueEntity(Collections.emptyMap())); + } + initialized = true; + } + } + } } @Override public void postInit(KeycloakSessionFactory factory) { // It is necessary to put the cache initialization here, otherwise the cache would be initialized lazily, that - // means also listeners will start only after first cache initialization - that would be too late + // means also listeners will start only after first cache initialization - that would be too latedddd if (singleUseObjectCache == null) { this.singleUseObjectCache = getSingleUseObjectCache(factory.create()); } + + if (persistRevokedTokens) { + factory.register(new ProviderEventListener() { + public void onEvent(ProviderEvent event) { + if (event instanceof PostMigrationEvent) { + KeycloakSessionFactory sessionFactory = ((PostMigrationEvent) event).getFactory(); + try (KeycloakSession session = sessionFactory.create()) { + TimerProvider timer = session.getProvider(TimerProvider.class); + if (timer != null) { + long interval = Config.scope("scheduled").getLong("interval", 900L) * 1000; + scheduleTask(sessionFactory, timer, interval); + } + // load sessions during startup, not on first request to avoid congestion + initialize(session); + } + } + } + + private void scheduleTask(KeycloakSessionFactory sessionFactory, TimerProvider timer, long interval) { + timer.schedule(new ClusterAwareScheduledTaskRunner(sessionFactory, new ClearExpiredRevokedTokens(), interval), interval); + } + }); + } } @Override @@ -97,4 +169,27 @@ public class InfinispanSingleUseObjectProviderFactory implements SingleUseObject public boolean isSupported(Config.Scope config) { return InfinispanUtils.isEmbeddedInfinispan(); } + + @Override + public Map getOperationalInfo() { + Map info = new HashMap<>(); + info.put(CONFIG_PERSIST_REVOKED_TOKENS, Boolean.toString(persistRevokedTokens)); + return info; + } + + @Override + public List getConfigMetadata() { + ProviderConfigurationBuilder builder = ProviderConfigurationBuilder.create(); + + builder.property() + .name(CONFIG_PERSIST_REVOKED_TOKENS) + .type("boolean") + .helpText("If revoked tokens are stored persistently across restarts") + .defaultValue(DEFAULT_PERSIST_REVOKED_TOKENS) + .add(); + + return builder.build(); + } + } + diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RevokedTokenEntity.java b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RevokedTokenEntity.java new file mode 100755 index 0000000000..e6516ecf45 --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/entities/RevokedTokenEntity.java @@ -0,0 +1,75 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.jpa.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * Stores a list of revoked tokens in the database, so it is available after restarts. + * + * @author Alexander Schwartz + */ +@Table(name="REVOKED_TOKEN") +@Entity +public class RevokedTokenEntity { + @Id + @Column(name="ID", length = 36) + protected String id; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + /** + * Expire time in seconds. + */ + @Column(name="EXPIRE") + protected long expire; + + public long getExpire() { + return expire; + } + + public void setExpire(long expire) { + this.expire = expire; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + if (!(o instanceof RevokedTokenEntity that)) return false; + + if (!id.equals(that.getId())) return false; + + return true; + } + + @Override + public int hashCode() { + return id.hashCode(); + } + +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProvider.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProvider.java new file mode 100644 index 0000000000..c1fe892c9c --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProvider.java @@ -0,0 +1,77 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.jpa.session; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaDelete; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; +import org.keycloak.common.util.Time; +import org.keycloak.models.jpa.entities.RevokedTokenEntity; +import org.keycloak.models.session.RevokedToken; +import org.keycloak.models.session.RevokedTokenPersisterProvider; + +import java.util.stream.Stream; + +/** + * @author Alexander Schwartz + */ +public class JpaRevokedTokensPersisterProvider implements RevokedTokenPersisterProvider { + + private final EntityManager em; + + public JpaRevokedTokensPersisterProvider(EntityManager em) { + this.em = em; + } + + @Override + public void revokeToken(String tokenId, long lifetime) { + RevokedTokenEntity revokedTokenEntity = new RevokedTokenEntity(); + revokedTokenEntity.setId(tokenId); + revokedTokenEntity.setExpire(Time.currentTime() + lifetime); + em.persist(revokedTokenEntity); + } + + @Override + public Stream getAllRevokedTokens() { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(RevokedTokenEntity.class); + Root rootEntry = cq.from(RevokedTokenEntity.class); + CriteriaQuery all = cq.select(rootEntry) + .where(cb.gt(rootEntry.get("expire"), Time.currentTime())); + + TypedQuery allQuery = em.createQuery(all); + return allQuery.getResultStream().map(revokedTokenEntity -> new RevokedToken(revokedTokenEntity.getId(), revokedTokenEntity.getExpire())); + } + + @Override + public void expireTokens() { + CriteriaBuilder cb = em.getCriteriaBuilder(); + CriteriaDelete cd = cb.createCriteriaDelete(RevokedTokenEntity.class); + Root rootEntry = cd.from(RevokedTokenEntity.class); + cd.where(cb.lt(rootEntry.get("expire"), Time.currentTime())); + em.createQuery(cd).executeUpdate(); + } + + @Override + public void close() { + // noop + } +} diff --git a/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProviderFactory.java b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProviderFactory.java new file mode 100644 index 0000000000..ef6339f8bc --- /dev/null +++ b/model/jpa/src/main/java/org/keycloak/models/jpa/session/JpaRevokedTokensPersisterProviderFactory.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.jpa.session; + +import jakarta.persistence.EntityManager; +import org.keycloak.Config; +import org.keycloak.connections.jpa.JpaConnectionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.session.RevokedTokenPersisterProvider; +import org.keycloak.models.session.RevokedTokensPersisterProviderFactory; + +/** + * @author Marek Posolda + */ +public class JpaRevokedTokensPersisterProviderFactory implements RevokedTokensPersisterProviderFactory { + + public static final String ID = "jpa"; + + @Override + public RevokedTokenPersisterProvider create(KeycloakSession session) { + EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager(); + return new JpaRevokedTokensPersisterProvider(em); + } + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return ID; + } + + @Override + public int order() { + return 100; + } +} diff --git a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml index eb72cd06aa..50de3d6270 100644 --- a/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml +++ b/model/jpa/src/main/resources/META-INF/jpa-changelog-26.0.0.xml @@ -55,4 +55,16 @@ + + + + + + + + + + + + diff --git a/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.session.RevokedTokensPersisterProviderFactory b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.session.RevokedTokensPersisterProviderFactory new file mode 100644 index 0000000000..ededf8e15d --- /dev/null +++ b/model/jpa/src/main/resources/META-INF/services/org.keycloak.models.session.RevokedTokensPersisterProviderFactory @@ -0,0 +1,18 @@ +# +# Copyright 2024 Red Hat, Inc. and/or its affiliates +# and other contributors as indicated by the @author tags. +# +# Licensed 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. +# + +org.keycloak.models.jpa.session.JpaRevokedTokensPersisterProviderFactory \ No newline at end of file diff --git a/model/jpa/src/main/resources/default-persistence.xml b/model/jpa/src/main/resources/default-persistence.xml index 8cc13a02dc..a1fcb1ae66 100644 --- a/model/jpa/src/main/resources/default-persistence.xml +++ b/model/jpa/src/main/resources/default-persistence.xml @@ -50,6 +50,7 @@ org.keycloak.models.jpa.entities.RequiredActionProviderEntity org.keycloak.models.jpa.session.PersistentUserSessionEntity org.keycloak.models.jpa.session.PersistentClientSessionEntity + org.keycloak.models.jpa.entities.RevokedTokenEntity org.keycloak.models.jpa.entities.GroupEntity org.keycloak.models.jpa.entities.GroupAttributeEntity org.keycloak.models.jpa.entities.GroupRoleMappingEntity diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/RevokedToken.java b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedToken.java new file mode 100644 index 0000000000..0917543ae6 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedToken.java @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.session; + +/** + * @author Alexander Schwartz + */ +public record RevokedToken(String tokenId, long expiry) { +} diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterProvider.java b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterProvider.java new file mode 100644 index 0000000000..12dc537f8c --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.session; + +import org.keycloak.provider.Provider; + +import java.util.stream.Stream; + +/** + * Use this to revoke a token, so they will be available even after the restart of Keycloak. + * The store can be optimized in a way that entries are only added and are only removed by expiry. + * + * The first Keycloak instance starting up will re-load all expired tokens from it. + * + * @author Alexander Schwartz + */ +public interface RevokedTokenPersisterProvider extends Provider { + + /** Revoke a token with a given ID */ + void revokeToken(String tokenId, long lifetime); + + Stream getAllRevokedTokens(); + + void expireTokens(); +} diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterSpi.java b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterSpi.java new file mode 100644 index 0000000000..848e3392f7 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokenPersisterSpi.java @@ -0,0 +1,45 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.session; + +import org.keycloak.provider.Provider; +import org.keycloak.provider.ProviderFactory; +import org.keycloak.provider.Spi; + +public class RevokedTokenPersisterSpi implements Spi { + + @Override + public boolean isInternal() { + return true; + } + + @Override + public String getName() { + return "revokedTokenPersister"; + } + + @Override + public Class getProviderClass() { + return RevokedTokenPersisterProvider.class; + } + + @Override + public Class getProviderFactoryClass() { + return RevokedTokensPersisterProviderFactory.class; + } +} diff --git a/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokensPersisterProviderFactory.java b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokensPersisterProviderFactory.java new file mode 100644 index 0000000000..497aeaef81 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/models/session/RevokedTokensPersisterProviderFactory.java @@ -0,0 +1,23 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.models.session; + +import org.keycloak.provider.ProviderFactory; + +public interface RevokedTokensPersisterProviderFactory extends ProviderFactory { +} diff --git a/model/storage-private/src/main/java/org/keycloak/services/scheduled/ClearExpiredRevokedTokens.java b/model/storage-private/src/main/java/org/keycloak/services/scheduled/ClearExpiredRevokedTokens.java new file mode 100755 index 0000000000..64d3615ea8 --- /dev/null +++ b/model/storage-private/src/main/java/org/keycloak/services/scheduled/ClearExpiredRevokedTokens.java @@ -0,0 +1,49 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed 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.keycloak.services.scheduled; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.session.RevokedTokenPersisterProvider; +import org.keycloak.timer.ScheduledTask; + +/** + * Clear all expired revoked tokens. + */ +public class ClearExpiredRevokedTokens implements ScheduledTask { + + protected static final Logger logger = Logger.getLogger(ClearExpiredRevokedTokens.class); + + public static final String TASK_NAME = "ClearExpiredRevokedTokens"; + + @Override + public void run(KeycloakSession session) { + long currentTimeMillis = Time.currentTimeMillis(); + + session.getProvider(RevokedTokenPersisterProvider.class).expireTokens(); + + long took = Time.currentTimeMillis() - currentTimeMillis; + logger.debugf("%s finished in %d ms", getTaskName(), took); + } + + @Override + public String getTaskName() { + return TASK_NAME; + } +} diff --git a/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi b/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi index 4b01912bc6..465df451b4 100644 --- a/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi +++ b/model/storage-private/src/main/resources/META-INF/services/org.keycloak.provider.Spi @@ -23,4 +23,5 @@ org.keycloak.storage.client.ClientStorageProviderSpi org.keycloak.storage.group.GroupStorageProviderSpi org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi org.keycloak.models.session.UserSessionPersisterSpi +org.keycloak.models.session.RevokedTokenPersisterSpi org.keycloak.cluster.ClusterSpi diff --git a/server-spi/src/main/java/org/keycloak/models/SingleUseObjectProvider.java b/server-spi/src/main/java/org/keycloak/models/SingleUseObjectProvider.java index cc21eef36e..cebc878c32 100644 --- a/server-spi/src/main/java/org/keycloak/models/SingleUseObjectProvider.java +++ b/server-spi/src/main/java/org/keycloak/models/SingleUseObjectProvider.java @@ -28,14 +28,17 @@ import java.util.Map; */ public interface SingleUseObjectProvider extends Provider { - // suffix to a key to indicate that token is considered revoked + /** + * Suffix to a key to indicate that token is considered revoked. + * For revoked tokens, only the methods {@link #put} and {@link #contains} must be used. + */ String REVOKED_KEY = ".revoked"; /** * Stores the given data and guarantees that data should be available in the store for at least the time specified by {@param lifespanSeconds} parameter - * @param key - * @param lifespanSeconds - * @param notes + * @param key String + * @param lifespanSeconds Minimum lifespan for which successfully added key will be kept in the cache. + * @param notes For revoked tokens, this must be an empty Map. */ void put(String key, long lifespanSeconds, Map notes); @@ -51,7 +54,7 @@ public interface SingleUseObjectProvider extends Provider { * 2 threads (even on different cluster nodes or on different cross-dc nodes) calls "remove(123)" concurrently, then just one of them * is allowed to succeed and return data back. It can't happen that both will succeed. * - * @param key + * @param key String * @return context data associated to the key. It returns {@code null} if there are no context data available. */ Map remove(String key); diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Jpa.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Jpa.java index d1a2900574..2368a4837a 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Jpa.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/parameters/Jpa.java @@ -1,13 +1,13 @@ /* * Copyright 2020 Red Hat, Inc. and/or its affiliates * and other contributors as indicated by the @author tags. - * + * * Licensed 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. @@ -28,7 +28,9 @@ import org.keycloak.connections.jpa.updater.liquibase.conn.LiquibaseConnectionSp import org.keycloak.connections.jpa.updater.liquibase.lock.LiquibaseDBLockProviderFactory; import org.keycloak.events.jpa.JpaEventStoreProviderFactory; import org.keycloak.models.dblock.DBLockSpi; +import org.keycloak.models.jpa.session.JpaRevokedTokensPersisterProviderFactory; import org.keycloak.models.jpa.session.JpaUserSessionPersisterProviderFactory; +import org.keycloak.models.session.RevokedTokenPersisterSpi; import org.keycloak.models.session.UserSessionPersisterSpi; import org.keycloak.migration.MigrationProviderFactory; import org.keycloak.migration.MigrationSpi; @@ -61,6 +63,7 @@ public class Jpa extends KeycloakModelParameters { .add(JpaUpdaterSpi.class) .add(LiquibaseConnectionSpi.class) .add(UserSessionPersisterSpi.class) + .add(RevokedTokenPersisterSpi.class) .add(DatastoreSpi.class) @@ -92,6 +95,7 @@ public class Jpa extends KeycloakModelParameters { .add(LiquibaseConnectionProviderFactory.class) .add(LiquibaseDBLockProviderFactory.class) .add(JpaUserSessionPersisterProviderFactory.class) + .add(JpaRevokedTokensPersisterProviderFactory.class) //required for migrateModel .add(MigrationProviderFactory.class) diff --git a/testsuite/model/src/test/java/org/keycloak/testsuite/model/singleUseObject/SingleUseObjectModelTest.java b/testsuite/model/src/test/java/org/keycloak/testsuite/model/singleUseObject/SingleUseObjectModelTest.java index 3905ba906e..4013b1748b 100644 --- a/testsuite/model/src/test/java/org/keycloak/testsuite/model/singleUseObject/SingleUseObjectModelTest.java +++ b/testsuite/model/src/test/java/org/keycloak/testsuite/model/singleUseObject/SingleUseObjectModelTest.java @@ -27,9 +27,11 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.UserModel; +import org.keycloak.services.scheduled.ClearExpiredRevokedTokens; import org.keycloak.testsuite.model.KeycloakModelTest; import org.keycloak.testsuite.model.RequireProvider; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -163,6 +165,47 @@ public class SingleUseObjectModelTest extends KeycloakModelTest { }); } + @Test + public void testRevokedTokenIsPresentAfterRestartAndEventuallyExpires() { + String revokedKey = UUID.randomUUID() + SingleUseObjectProvider.REVOKED_KEY; + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + singleUseStore.put(revokedKey, 60, Collections.emptyMap()); + }); + + // simulate restart + reinitializeKeycloakSessionFactory(); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + assertThat(singleUseStore.contains(revokedKey), Matchers.is(true)); + }); + + setTimeOffset(120); + + // simulate restart + reinitializeKeycloakSessionFactory(); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + // not loaded as it is too old + assertThat(singleUseStore.contains(revokedKey), Matchers.is(false)); + + // remove it from the database + new ClearExpiredRevokedTokens().run(session); + }); + + setTimeOffset(0); + + inComittedTransaction(session -> { + SingleUseObjectProvider singleUseStore = session.singleUseObjects(); + // not loaded as it has been removed from the database + assertThat(singleUseStore.contains(revokedKey), Matchers.is(false)); + }); + + } + @Test public void testCluster() throws InterruptedException { AtomicInteger index = new AtomicInteger();