Persisting revoked access tokens

Closes #31296

Signed-off-by: Alexander Schwartz <aschwart@redhat.com>
This commit is contained in:
Alexander Schwartz 2024-07-26 11:46:14 +02:00 committed by GitHub
parent 90ff35bcd2
commit 227c71f7f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 631 additions and 15 deletions

View file

@ -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}]. 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 = Keycloak CR supports standard scheduling options

View file

@ -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]. 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 = 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`. 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`.

View file

@ -27,7 +27,9 @@ import org.jboss.logging.Logger;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.connections.infinispan.InfinispanUtil; import org.keycloak.connections.infinispan.InfinispanUtil;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ModelException;
import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.models.session.RevokedTokenPersisterProvider;
import org.keycloak.models.sessions.infinispan.entities.SingleUseObjectValueEntity; 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); public static final Logger logger = Logger.getLogger(InfinispanSingleUseObjectProvider.class);
private final KeycloakSession session;
private final Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache; private final Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache;
private final boolean persistRevokedTokens;
private final InfinispanKeycloakTransaction tx; private final InfinispanKeycloakTransaction tx;
public InfinispanSingleUseObjectProvider(KeycloakSession session, Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache) { public InfinispanSingleUseObjectProvider(KeycloakSession session, Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache, boolean persistRevokedTokens) {
this.session = session;
this.singleUseObjectCache = singleUseObjectCache; this.singleUseObjectCache = singleUseObjectCache;
this.persistRevokedTokens = persistRevokedTokens;
this.tx = new InfinispanKeycloakTransaction(); this.tx = new InfinispanKeycloakTransaction();
session.getTransactionManager().enlistAfterCompletion(tx); session.getTransactionManager().enlistAfterCompletion(tx);
} }
@ -61,13 +67,22 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debugf(re, "Failed when adding code %s", key); logger.debugf(re, "Failed when adding code %s", key);
} }
throw re; 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 @Override
public Map<String, String> get(String key) { public Map<String, String> get(String key) {
if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) {
throw new ModelException("Revoked tokens can't be retrieved");
}
SingleUseObjectValueEntity singleUseObjectValueEntity; SingleUseObjectValueEntity singleUseObjectValueEntity;
BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get(); BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();
@ -77,6 +92,10 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide
@Override @Override
public Map<String, String> remove(String key) { public Map<String, String> remove(String key) {
if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) {
throw new ModelException("Revoked tokens can't be removed");
}
try { try {
BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get(); BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();
SingleUseObjectValueEntity existing = cache.remove(key); SingleUseObjectValueEntity existing = cache.remove(key);
@ -94,12 +113,20 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide
@Override @Override
public boolean replace(String key, Map<String, String> notes) { public boolean replace(String key, Map<String, String> notes) {
if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) {
throw new ModelException("Revoked tokens can't be replaced");
}
BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get(); BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();
return cache.replace(key, new SingleUseObjectValueEntity(notes)) != null; return cache.replace(key, new SingleUseObjectValueEntity(notes)) != null;
} }
@Override @Override
public boolean putIfAbsent(String key, long lifespanInSeconds) { 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); SingleUseObjectValueEntity tokenValue = new SingleUseObjectValueEntity(null);
BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get(); BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();

View file

@ -18,6 +18,11 @@
package org.keycloak.models.sessions.infinispan; 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 java.util.function.Supplier;
import org.infinispan.Cache; import org.infinispan.Cache;
@ -26,30 +31,49 @@ import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.commons.api.BasicCache; import org.infinispan.commons.api.BasicCache;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.Config; import org.keycloak.Config;
import org.keycloak.common.util.Time;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider; import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.connections.infinispan.InfinispanUtil; import org.keycloak.connections.infinispan.InfinispanUtil;
import org.keycloak.infinispan.util.InfinispanUtils; import org.keycloak.infinispan.util.InfinispanUtils;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.models.SingleUseObjectProviderFactory; import org.keycloak.models.SingleUseObjectProviderFactory;
import org.keycloak.models.session.RevokedTokenPersisterProvider;
import org.keycloak.models.sessions.infinispan.entities.SingleUseObjectValueEntity; import org.keycloak.models.sessions.infinispan.entities.SingleUseObjectValueEntity;
import org.keycloak.models.utils.PostMigrationEvent;
import org.keycloak.provider.EnvironmentDependentProviderFactory; 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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a> * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/ */
public class InfinispanSingleUseObjectProviderFactory implements SingleUseObjectProviderFactory, EnvironmentDependentProviderFactory { public class InfinispanSingleUseObjectProviderFactory implements SingleUseObjectProviderFactory<InfinispanSingleUseObjectProvider>, 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 static final Logger LOG = Logger.getLogger(InfinispanSingleUseObjectProviderFactory.class);
private volatile Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache; protected volatile Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache;
private volatile boolean initialized;
private boolean persistRevokedTokens;
@Override @Override
public InfinispanSingleUseObjectProvider create(KeycloakSession session) { 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<BasicCache<String, SingleUseObjectValueEntity>> getSingleUseObjectCache(KeycloakSession session) {
InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class); InfinispanConnectionProvider connections = session.getProvider(InfinispanConnectionProvider.class);
Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE); Cache cache = connections.getCache(InfinispanConnectionProvider.ACTION_TOKEN_CACHE);
@ -66,16 +90,64 @@ public class InfinispanSingleUseObjectProviderFactory implements SingleUseObject
@Override @Override
public void init(Config.Scope config) { 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<String, SingleUseObjectValueEntity> 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 @Override
public void postInit(KeycloakSessionFactory factory) { public void postInit(KeycloakSessionFactory factory) {
// It is necessary to put the cache initialization here, otherwise the cache would be initialized lazily, that // 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) { if (singleUseObjectCache == null) {
this.singleUseObjectCache = getSingleUseObjectCache(factory.create()); 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 @Override
@ -97,4 +169,27 @@ public class InfinispanSingleUseObjectProviderFactory implements SingleUseObject
public boolean isSupported(Config.Scope config) { public boolean isSupported(Config.Scope config) {
return InfinispanUtils.isEmbeddedInfinispan(); return InfinispanUtils.isEmbeddedInfinispan();
} }
@Override
public Map<String, String> getOperationalInfo() {
Map<String, String> info = new HashMap<>();
info.put(CONFIG_PERSIST_REVOKED_TOKENS, Boolean.toString(persistRevokedTokens));
return info;
} }
@Override
public List<ProviderConfigProperty> 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();
}
}

View file

@ -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();
}
}

View file

@ -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<RevokedToken> getAllRevokedTokens() {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<RevokedTokenEntity> cq = cb.createQuery(RevokedTokenEntity.class);
Root<RevokedTokenEntity> rootEntry = cq.from(RevokedTokenEntity.class);
CriteriaQuery<RevokedTokenEntity> all = cq.select(rootEntry)
.where(cb.gt(rootEntry.get("expire"), Time.currentTime()));
TypedQuery<RevokedTokenEntity> allQuery = em.createQuery(all);
return allQuery.getResultStream().map(revokedTokenEntity -> new RevokedToken(revokedTokenEntity.getId(), revokedTokenEntity.getExpire()));
}
@Override
public void expireTokens() {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaDelete<RevokedTokenEntity> cd = cb.createCriteriaDelete(RevokedTokenEntity.class);
Root<RevokedTokenEntity> rootEntry = cd.from(RevokedTokenEntity.class);
cd.where(cb.lt(rootEntry.get("expire"), Time.currentTime()));
em.createQuery(cd).executeUpdate();
}
@Override
public void close() {
// noop
}
}

View file

@ -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 <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
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;
}
}

View file

@ -55,4 +55,16 @@
<addNotNullConstraint tableName="USER_GROUP_MEMBERSHIP" columnName="MEMBERSHIP_TYPE" columnDataType="VARCHAR(255)"/> <addNotNullConstraint tableName="USER_GROUP_MEMBERSHIP" columnName="MEMBERSHIP_TYPE" columnDataType="VARCHAR(255)"/>
</changeSet> </changeSet>
<changeSet author="keycloak" id="31296-persist-revoked-access-tokens">
<createTable tableName="REVOKED_TOKEN">
<column name="ID" type="VARCHAR(255)">
<constraints nullable="false" />
</column>
<column name="EXPIRE" type="BIGINT">
<constraints nullable="false" />
</column>
</createTable>
<addPrimaryKey columnNames="ID" constraintName="CONSTRAINT_RT" tableName="REVOKED_TOKEN"/>
</changeSet>
</databaseChangeLog> </databaseChangeLog>

View file

@ -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

View file

@ -50,6 +50,7 @@
<class>org.keycloak.models.jpa.entities.RequiredActionProviderEntity</class> <class>org.keycloak.models.jpa.entities.RequiredActionProviderEntity</class>
<class>org.keycloak.models.jpa.session.PersistentUserSessionEntity</class> <class>org.keycloak.models.jpa.session.PersistentUserSessionEntity</class>
<class>org.keycloak.models.jpa.session.PersistentClientSessionEntity</class> <class>org.keycloak.models.jpa.session.PersistentClientSessionEntity</class>
<class>org.keycloak.models.jpa.entities.RevokedTokenEntity</class>
<class>org.keycloak.models.jpa.entities.GroupEntity</class> <class>org.keycloak.models.jpa.entities.GroupEntity</class>
<class>org.keycloak.models.jpa.entities.GroupAttributeEntity</class> <class>org.keycloak.models.jpa.entities.GroupAttributeEntity</class>
<class>org.keycloak.models.jpa.entities.GroupRoleMappingEntity</class> <class>org.keycloak.models.jpa.entities.GroupRoleMappingEntity</class>

View file

@ -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) {
}

View file

@ -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<RevokedToken> getAllRevokedTokens();
void expireTokens();
}

View file

@ -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<? extends Provider> getProviderClass() {
return RevokedTokenPersisterProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return RevokedTokensPersisterProviderFactory.class;
}
}

View file

@ -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<RevokedTokenPersisterProvider> {
}

View file

@ -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;
}
}

View file

@ -23,4 +23,5 @@ org.keycloak.storage.client.ClientStorageProviderSpi
org.keycloak.storage.group.GroupStorageProviderSpi org.keycloak.storage.group.GroupStorageProviderSpi
org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi org.keycloak.storage.clientscope.ClientScopeStorageProviderSpi
org.keycloak.models.session.UserSessionPersisterSpi org.keycloak.models.session.UserSessionPersisterSpi
org.keycloak.models.session.RevokedTokenPersisterSpi
org.keycloak.cluster.ClusterSpi org.keycloak.cluster.ClusterSpi

View file

@ -28,14 +28,17 @@ import java.util.Map;
*/ */
public interface SingleUseObjectProvider extends Provider { 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"; 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 * 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 key String
* @param lifespanSeconds * @param lifespanSeconds Minimum lifespan for which successfully added key will be kept in the cache.
* @param notes * @param notes For revoked tokens, this must be an empty Map.
*/ */
void put(String key, long lifespanSeconds, Map<String, String> notes); void put(String key, long lifespanSeconds, Map<String, String> 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 * 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. * 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. * @return context data associated to the key. It returns {@code null} if there are no context data available.
*/ */
Map<String, String> remove(String key); Map<String, String> remove(String key);

View file

@ -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.connections.jpa.updater.liquibase.lock.LiquibaseDBLockProviderFactory;
import org.keycloak.events.jpa.JpaEventStoreProviderFactory; import org.keycloak.events.jpa.JpaEventStoreProviderFactory;
import org.keycloak.models.dblock.DBLockSpi; import org.keycloak.models.dblock.DBLockSpi;
import org.keycloak.models.jpa.session.JpaRevokedTokensPersisterProviderFactory;
import org.keycloak.models.jpa.session.JpaUserSessionPersisterProviderFactory; import org.keycloak.models.jpa.session.JpaUserSessionPersisterProviderFactory;
import org.keycloak.models.session.RevokedTokenPersisterSpi;
import org.keycloak.models.session.UserSessionPersisterSpi; import org.keycloak.models.session.UserSessionPersisterSpi;
import org.keycloak.migration.MigrationProviderFactory; import org.keycloak.migration.MigrationProviderFactory;
import org.keycloak.migration.MigrationSpi; import org.keycloak.migration.MigrationSpi;
@ -61,6 +63,7 @@ public class Jpa extends KeycloakModelParameters {
.add(JpaUpdaterSpi.class) .add(JpaUpdaterSpi.class)
.add(LiquibaseConnectionSpi.class) .add(LiquibaseConnectionSpi.class)
.add(UserSessionPersisterSpi.class) .add(UserSessionPersisterSpi.class)
.add(RevokedTokenPersisterSpi.class)
.add(DatastoreSpi.class) .add(DatastoreSpi.class)
@ -92,6 +95,7 @@ public class Jpa extends KeycloakModelParameters {
.add(LiquibaseConnectionProviderFactory.class) .add(LiquibaseConnectionProviderFactory.class)
.add(LiquibaseDBLockProviderFactory.class) .add(LiquibaseDBLockProviderFactory.class)
.add(JpaUserSessionPersisterProviderFactory.class) .add(JpaUserSessionPersisterProviderFactory.class)
.add(JpaRevokedTokensPersisterProviderFactory.class)
//required for migrateModel //required for migrateModel
.add(MigrationProviderFactory.class) .add(MigrationProviderFactory.class)

View file

@ -27,9 +27,11 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.SingleUseObjectProvider; import org.keycloak.models.SingleUseObjectProvider;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.services.scheduled.ClearExpiredRevokedTokens;
import org.keycloak.testsuite.model.KeycloakModelTest; import org.keycloak.testsuite.model.KeycloakModelTest;
import org.keycloak.testsuite.model.RequireProvider; import org.keycloak.testsuite.model.RequireProvider;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; 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 @Test
public void testCluster() throws InterruptedException { public void testCluster() throws InterruptedException {
AtomicInteger index = new AtomicInteger(); AtomicInteger index = new AtomicInteger();