Persisting revoked access tokens
Closes #31296 Signed-off-by: Alexander Schwartz <aschwart@redhat.com>
This commit is contained in:
parent
90ff35bcd2
commit
227c71f7f0
19 changed files with 631 additions and 15 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache;
|
||||
private final boolean persistRevokedTokens;
|
||||
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.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<String, String> get(String key) {
|
||||
if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) {
|
||||
throw new ModelException("Revoked tokens can't be retrieved");
|
||||
}
|
||||
|
||||
SingleUseObjectValueEntity singleUseObjectValueEntity;
|
||||
|
||||
BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();
|
||||
|
@ -77,6 +92,10 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide
|
|||
|
||||
@Override
|
||||
public Map<String, String> remove(String key) {
|
||||
if (persistRevokedTokens && key.endsWith(REVOKED_KEY)) {
|
||||
throw new ModelException("Revoked tokens can't be removed");
|
||||
}
|
||||
|
||||
try {
|
||||
BasicCache<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();
|
||||
SingleUseObjectValueEntity existing = cache.remove(key);
|
||||
|
@ -94,12 +113,20 @@ public class InfinispanSingleUseObjectProvider implements SingleUseObjectProvide
|
|||
|
||||
@Override
|
||||
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();
|
||||
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<String, SingleUseObjectValueEntity> cache = singleUseObjectCache.get();
|
||||
|
||||
|
|
|
@ -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 <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 volatile Supplier<BasicCache<String, SingleUseObjectValueEntity>> singleUseObjectCache;
|
||||
protected volatile Supplier<BasicCache<String, SingleUseObjectValueEntity>> 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<BasicCache<String, SingleUseObjectValueEntity>> 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<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
|
||||
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<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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -55,4 +55,16 @@
|
|||
<addNotNullConstraint tableName="USER_GROUP_MEMBERSHIP" columnName="MEMBERSHIP_TYPE" columnDataType="VARCHAR(255)"/>
|
||||
</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>
|
||||
|
|
|
@ -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
|
|
@ -50,6 +50,7 @@
|
|||
<class>org.keycloak.models.jpa.entities.RequiredActionProviderEntity</class>
|
||||
<class>org.keycloak.models.jpa.session.PersistentUserSessionEntity</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.GroupAttributeEntity</class>
|
||||
<class>org.keycloak.models.jpa.entities.GroupRoleMappingEntity</class>
|
||||
|
|
|
@ -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) {
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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> {
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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<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
|
||||
* 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<String, String> remove(String key);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue