Add crl cache to certificate validation

Closes #26473

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-12-23 20:49:00 +01:00 committed by Marek Posolda
parent f89be1813d
commit 6cf92d9dc7
42 changed files with 1054 additions and 46 deletions

View File

@ -8,3 +8,9 @@ A `direction` query parameter was also added, allowing controlling the order of
Finally, the returned event representations now also include the `id`, which provides a unique identifier for
an event.
= New cache for CRLs loaded for the X.509 authenticator
Now the Certificate Revocation Lists (CRL), that are used to validate certificates in the X.509 authenticator, are cached inside a new infinispan cache called `crl`. Caching improves the validation performance and decreases the memory consumption because just one CRL is maintained per source.
Check the `crl-storage` section in the link:https://www.keycloak.org/server/all-provider-config[All provider configuration] {section} to know the options for the new cache provider.

View File

@ -40,6 +40,7 @@ You configure these caches in `conf/cache-ispn.xml`:
|users|Local|Cache persisted user data
|authorization|Local|Cache persisted authorization data
|keys|Local|Cache external public keys
|crl|Local|Cache for X.509 authenticator CRLs
|work|Replicated|Propagate invalidation messages across nodes
|authenticationSessions|Distributed|Caches authentication sessions, created/destroyed/expired during the authentication process
|sessions|Distributed|Cache persisted user session data

View File

@ -293,6 +293,10 @@ public interface RealmResource {
@POST
void clearKeysCache();
@Path("clear-crl-cache")
@POST
void clearCrlCache();
@Path("push-revocation")
@POST
@Produces(MediaType.APPLICATION_JSON)

View File

@ -3314,10 +3314,12 @@ clearCachesTitle=Clear Caches
realmCache=Realm Cache
userCache=User Cache
keysCache=Keys Cache
crlCache=CRL Cache
clearButtonTitle=Clear
clearRealmCacheHelp=This will clear entries for all realms.
clearUserCacheHelp=This will clear entries for all realms.
clearKeysCacheHelp=Clears all entries from the cache of external public keys. These are keys of external clients or identity providers. This will clear all entries for all realms.
clearCrlCacheHelp=Clears all entries from the CRL cache. The CRL cache improves the performance of the X.509 authenticator when Certificate Revocation List (CRL) are enabled. This action will clear all the CRL entries for all the realms.
clearCacheSuccess=Cache cleared successfully
clearCacheError=Could not clear cache\: {{error}}
expandRow=Expand row

View File

@ -37,6 +37,7 @@ export const PageHeaderClearCachesModal = ({
const clearRealmCache = clearCache(adminClient.cache.clearRealmCache);
const clearUserCache = clearCache(adminClient.cache.clearUserCache);
const clearKeysCache = clearCache(adminClient.cache.clearKeysCache);
const clearCrlCache = clearCache(adminClient.cache.clearCrlCache);
return (
<Modal
@ -95,6 +96,22 @@ export const PageHeaderClearCachesModal = ({
</FlexItem>
</Flex>
</ListItem>
<ListItem>
<Flex justifyContent={{ default: "justifyContentSpaceBetween" }}>
<FlexItem>
{t("crlCache")}{" "}
<HelpItem
helpText={t("clearCrlCacheHelp")}
fieldLabelId="clearCrlCacheHelp"
/>
</FlexItem>
<FlexItem>
<Button onClick={() => clearCrlCache(realmName)}>
{t("clearButtonTitle")}
</Button>
</FlexItem>
</Flex>
</ListItem>
</List>
</Modal>
);

View File

@ -10,6 +10,10 @@ export class Cache extends Resource<{ realm?: string }> {
method: "POST",
path: "/clear-keys-cache",
});
public clearCrlCache = this.makeRequest<{}, void>({
method: "POST",
path: "/clear-crl-cache",
});
public clearRealmCache = this.makeRequest<{}, void>({
method: "POST",
path: "/clear-realm-cache",

View File

@ -68,6 +68,8 @@ import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.A
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.AUTHORIZATION_REVISIONS_CACHE_NAME;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLIENT_SESSION_CACHE_NAME;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CLUSTERED_CACHE_NAMES;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CRL_CACHE_DEFAULT_MAX;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.CRL_CACHE_NAME;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.JGROUPS_BIND_ADDR;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.JGROUPS_UDP_MCAST_ADDR;
import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.JMX_DOMAIN;
@ -264,6 +266,7 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon
defineRevisionCache(cacheManager, AUTHORIZATION_CACHE_NAME, AUTHORIZATION_REVISIONS_CACHE_NAME, AUTHORIZATION_REVISIONS_CACHE_DEFAULT_MAX);
cacheManager.getCache(KEYS_CACHE_NAME, true);
cacheManager.getCache(CRL_CACHE_NAME, true);
this.topologyInfo = new TopologyInfo(cacheManager, config, false, getId());
@ -318,6 +321,9 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon
cacheManager.defineConfiguration(KEYS_CACHE_NAME, getKeysCacheConfig());
cacheManager.getCache(KEYS_CACHE_NAME, true);
cacheManager.defineConfiguration(CRL_CACHE_NAME, getCrlCacheConfig());
cacheManager.getCache(CRL_CACHE_NAME, true);
var builder = createCacheConfigurationBuilder();
if (clustered) {
builder.simpleCache(false);
@ -473,6 +479,16 @@ public class DefaultInfinispanConnectionProviderFactory implements InfinispanCon
return cb.build();
}
protected Configuration getCrlCacheConfig() {
ConfigurationBuilder cb = createCacheConfigurationBuilder();
cb.memory()
.whenFull(EvictionStrategy.REMOVE)
.maxCount(CRL_CACHE_DEFAULT_MAX);
return cb.build();
}
private void registerSystemWideListeners(KeycloakSession session) {
KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory();
ClusterProvider cluster = session.getProvider(ClusterProvider.class);

View File

@ -64,6 +64,9 @@ public interface InfinispanConnectionProvider extends Provider {
int KEYS_CACHE_DEFAULT_MAX = 1000;
int KEYS_CACHE_MAX_IDLE_SECONDS = 3600;
String CRL_CACHE_NAME = "crl";
int CRL_CACHE_DEFAULT_MAX = 1000;
// System property used on Wildfly to identify distributedCache address and sticky session route
String JBOSS_NODE_NAME = "jboss.node.name";
String JGROUPS_UDP_MCAST_ADDR = "jgroups.mcast_addr";
@ -85,7 +88,8 @@ public interface InfinispanConnectionProvider extends Provider {
USER_REVISIONS_CACHE_NAME,
AUTHORIZATION_CACHE_NAME,
AUTHORIZATION_REVISIONS_CACHE_NAME,
KEYS_CACHE_NAME
KEYS_CACHE_NAME,
CRL_CACHE_NAME,
};
// list of cache name which could be defined as distributed or replicated

View File

@ -0,0 +1,48 @@
/*
* Copyright 2025 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.crl.infinispan;
import org.infinispan.Cache;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.cache.CacheCrlProvider;
import org.keycloak.models.cache.infinispan.ClearCacheEvent;
public class InfinispanCacheCrlProvider implements CacheCrlProvider {
private final KeycloakSession session;
private final Cache<String, X509CRLEntry> crlCache;
public InfinispanCacheCrlProvider(KeycloakSession session, Cache<String, X509CRLEntry> crlCache) {
this.session = session;
this.crlCache = crlCache;
}
@Override
public void clearCache() {
crlCache.clear();
ClusterProvider cluster = session.getProvider(ClusterProvider.class);
cluster.notify(InfinispanCacheCrlProviderFactory.CRL_CLEAR_CACHE_EVENTS, ClearCacheEvent.getInstance(), true, ClusterProvider.DCNotify.ALL_DCS);
}
@Override
public void close() {
}
}

View File

@ -0,0 +1,78 @@
/*
* Copyright 2025 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.crl.infinispan;
import org.infinispan.Cache;
import org.keycloak.Config;
import org.keycloak.cluster.ClusterEvent;
import org.keycloak.cluster.ClusterProvider;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.cache.CacheCrlProvider;
import org.keycloak.models.cache.CacheCrlProviderFactory;
public class InfinispanCacheCrlProviderFactory implements CacheCrlProviderFactory {
public static final String PROVIDER_ID = "infinispan";
public static final String CRL_CLEAR_CACHE_EVENTS = "CRL_CLEAR_CACHE_EVENTS";
private volatile Cache<String, X509CRLEntry> crlCache;
@Override
public CacheCrlProvider create(KeycloakSession session) {
lazyInit(session);
return new InfinispanCacheCrlProvider(session, crlCache);
}
private void lazyInit(KeycloakSession session) {
if (crlCache == null) {
synchronized (this) {
if (crlCache == null) {
crlCache = session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CRL_CACHE_NAME);
ClusterProvider cluster = session.getProvider(ClusterProvider.class);
cluster.registerListener(CRL_CLEAR_CACHE_EVENTS, (ClusterEvent event) -> {
crlCache.clear();
});
}
}
}
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View File

@ -0,0 +1,145 @@
/*
* Copyright 2025 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.crl.infinispan;
import java.security.GeneralSecurityException;
import java.security.cert.X509CRL;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import org.infinispan.Cache;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.crl.CrlStorageProvider;
/**
*
* @author rmartinc
*/
public class InfinispanCrlStorageProvider implements CrlStorageProvider {
private static final Logger log = Logger.getLogger(InfinispanCrlStorageProvider.class);
private final SharedData data;
public InfinispanCrlStorageProvider(SharedData data) {
this.data = data;
}
@Override
public X509CRL get(String key, Callable<X509CRL> loader) throws GeneralSecurityException {
final X509CRLEntry crlEntry = data.cache().get(key);
final long currentTime = Time.currentTimeMillis();
if (crlEntry != null && (crlEntry.crl().getNextUpdate() == null || crlEntry.crl().getNextUpdate().compareTo(new Date(currentTime)) > 0)) {
log.debugf("returning CRL '%s' from cache because it's cached OK", key);
return crlEntry.crl();
}
// refresh the crl entry in the cache
return reloadCrl(key, loader, currentTime, crlEntry);
}
@Override
public boolean refreshCache(String key, Callable<X509CRL> loader) throws GeneralSecurityException {
final X509CRLEntry entry = data.cache().get(key);
final X509CRL crl = reloadCrl(key, loader, Time.currentTimeMillis(), entry);
return crl != null && (entry == null || entry.crl() != crl);
}
@Override
public void close() {
// no-op
}
private X509CRL reloadCrl(String key, Callable<X509CRL> loader, long currentTime, X509CRLEntry crlEntry) {
if (crlEntry != null && currentTime < crlEntry.lastRequestTime()+ data.minTimeBetweenRequests()){
log.debugf("Avoiding loading crl with key '%s' again, last refreshed time %d", key, crlEntry.lastRequestTime());
return crlEntry.crl();
}
FutureTask<X509CRL> task = new FutureTask<>(() -> loadCrl(key, loader, currentTime));
final FutureTask<X509CRL> existing = data.tasksInProgress().putIfAbsent(key, task);
if (existing == null) {
log.debugf("Reloading crl for model key '%s'.", key);
task.run();
} else {
task = existing;
}
try {
return task.get();
} catch (ExecutionException ee) {
throw new RuntimeException("Error when loading crl " + key + " : " + ee.getMessage(), ee);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("Error. Interrupted when loading crl " + key, ie);
} finally {
// Our thread inserted the task. Let's clean
if (existing == null) {
data.tasksInProgress().remove(key);
}
}
}
private X509CRL loadCrl(String key, Callable<X509CRL> loader, long currentTime) throws Exception {
final X509CRL crl = loader.call();
if (crl == null) {
log.warnf("Loading crl with key '%s' returned null.", key);
return null;
}
long lifespan = getLifespan(crl, currentTime);
if (lifespan > 0) {
data.cache().put(key, new X509CRLEntry(crl, currentTime), lifespan, TimeUnit.MILLISECONDS);
log.debugf("The crl with key '%s' was retrieved successfully and cached for %d millis.", key, lifespan);
} else {
data.cache().put(key, new X509CRLEntry(crl, currentTime));
log.debugf("The crl with key '%s' was retrieved successfully and cached forever.", key);
}
return crl;
}
private long getLifespan(X509CRL crl, long currentTime) {
final long cacheTime = data.cacheTime();
if (crl.getNextUpdate() == null) {
return cacheTime;
}
final long nextUpdateTime = crl.getNextUpdate().getTime() - currentTime;
if (nextUpdateTime <= 0) {
// if the CRL is expired just cache the minimum time
return data.minTimeBetweenRequests();
} else if (cacheTime > 0) {
// get the minimum between cacheTime and nextUpdate
return Math.min(cacheTime, nextUpdateTime);
} else {
// just return the next update because default cache is infinite
return nextUpdateTime;
}
}
protected interface SharedData {
Cache<String, X509CRLEntry> cache();
Map<String, FutureTask<X509CRL>> tasksInProgress();
long cacheTime();
long minTimeBetweenRequests();
}
}

View File

@ -0,0 +1,138 @@
/*
* Copyright 2025 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.crl.infinispan;
import java.security.cert.X509CRL;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import org.infinispan.Cache;
import org.keycloak.Config;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.crl.CrlStorageProvider;
import org.keycloak.crl.CrlStorageProviderFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.provider.ProviderConfigurationBuilder;
/**
*
* @author rmartinc
*/
public class InfinispanCrlStorageProviderFactory implements CrlStorageProviderFactory, InfinispanCrlStorageProvider.SharedData {
public static final String PROVIDER_ID = "infinispan";
private volatile Cache<String, X509CRLEntry> crlCache;
private final Map<String, FutureTask<X509CRL>> tasksInProgress = new ConcurrentHashMap<>();
private volatile long cacheTime;
private volatile long minTimeBetweenRequests;
@Override
public CrlStorageProvider create(KeycloakSession session) {
lazyInit(session);
return new InfinispanCrlStorageProvider(this);
}
@Override
public List<ProviderConfigProperty> getConfigMetadata() {
return ProviderConfigurationBuilder.create()
.property()
.name("cacheTime")
.type("int")
.helpText(
"""
Interval in seconds that the CRL is cached. The next update time of the CRL is always a minimum if present.
Zero or a negative value means CRL is cached until the next update time specified in the CRL (or infinite if the
CRL does not contain the next update).
"""
)
.defaultValue(-1)
.add()
.property()
.name("minTimeBetweenRequests")
.type("int")
.helpText(
"""
Minimum interval in seconds between two requests to retrieve the CRL. The CRL is not updated
from the URL again until this minimum time has passed since the previous refresh. In theory
this option is never used if the CRL is refreshed correctly in the next update time.
The interval should be a positive number. Default 10 seconds.
"""
)
.defaultValue(10)
.add()
.build();
}
@Override
public void init(Config.Scope config) {
final long tmpCacheTime = config.getLong("cacheTime", -1L);
cacheTime = tmpCacheTime > 0? TimeUnit.SECONDS.toMillis(tmpCacheTime) : -1L;
final long tmpMinTimeBetweenRequests = config.getLong("minTimeBetweenRequests", 10L);
minTimeBetweenRequests = tmpMinTimeBetweenRequests > 0? TimeUnit.SECONDS.toMillis(tmpMinTimeBetweenRequests) : 10_000L;
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// no-op
}
@Override
public void close() {
// no-op
}
@Override
public String getId() {
return PROVIDER_ID;
}
private void lazyInit(KeycloakSession session) {
if (crlCache == null) {
synchronized (this) {
if (crlCache == null) {
this.crlCache = session.getProvider(InfinispanConnectionProvider.class).getCache(InfinispanConnectionProvider.CRL_CACHE_NAME);
}
}
}
}
@Override
public Cache<String, X509CRLEntry> cache() {
return crlCache;
}
@Override
public Map<String, FutureTask<X509CRL>> tasksInProgress() {
return tasksInProgress;
}
@Override
public long cacheTime() {
return cacheTime;
}
@Override
public long minTimeBetweenRequests() {
return minTimeBetweenRequests;
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2025 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.crl.infinispan;
import java.security.cert.X509CRL;
/**
*
* @author rmartinc
*/
public record X509CRLEntry(X509CRL crl, long lastRequestTime) {}

View File

@ -0,0 +1,18 @@
#
# Copyright 2025 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.crl.infinispan.InfinispanCrlStorageProviderFactory

View File

@ -0,0 +1,18 @@
#
# Copyright 2025 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.crl.infinispan.InfinispanCacheCrlProviderFactory

View File

@ -0,0 +1,29 @@
/*
* Copyright 2025 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.cache;
import org.keycloak.provider.Provider;
public interface CacheCrlProvider extends Provider {
/**
* Clears all the cached CRLs, so they need to be loaded again
*/
void clearCache();
}

View File

@ -0,0 +1,23 @@
/*
* Copyright 2025 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.cache;
import org.keycloak.provider.ProviderFactory;
public interface CacheCrlProviderFactory extends ProviderFactory<CacheCrlProvider> {
}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2025 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.cache;
import org.keycloak.provider.Spi;
public class CacheCrlProviderSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "crlCache";
}
@Override
public Class<CacheCrlProvider> getProviderClass() {
return CacheCrlProvider.class;
}
@Override
public Class<CacheCrlProviderFactory> getProviderFactoryClass() {
return CacheCrlProviderFactory.class;
}
}

View File

@ -18,6 +18,7 @@
org.keycloak.models.cache.CacheUserProviderSpi
org.keycloak.models.cache.CacheRealmProviderSpi
org.keycloak.models.cache.CachePublicKeyProviderSpi
org.keycloak.models.cache.CacheCrlProviderSpi
org.keycloak.models.dblock.DBLockSpi
org.keycloak.storage.client.ClientStorageProviderSpi
org.keycloak.storage.group.GroupStorageProviderSpi

View File

@ -0,0 +1,59 @@
/*
* Copyright 2025 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.resources.admin;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProvider;
import org.keycloak.services.resources.admin.ext.AdminRealmResourceProviderFactory;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
public class ClearCrlCacheRealmAdminProvider implements AdminRealmResourceProviderFactory, AdminRealmResourceProvider {
@Override
public AdminRealmResourceProvider create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "clear-crl-cache";
}
@Override
public Object getResource(KeycloakSession session, RealmModel realm, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
return new ClearCrlCacheResource(session, auth, adminEvent);
}
}

View File

@ -0,0 +1,58 @@
/*
* Copyright 2025 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.resources.admin;
import org.keycloak.events.admin.OperationType;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.cache.CacheCrlProvider;
import org.keycloak.services.resources.admin.permissions.AdminPermissionEvaluator;
import jakarta.ws.rs.POST;
public class ClearCrlCacheResource {
protected final AdminPermissionEvaluator auth;
protected final RealmModel realm;
private final AdminEventBuilder adminEvent;
protected final KeycloakSession session;
public ClearCrlCacheResource(KeycloakSession session, AdminPermissionEvaluator auth, AdminEventBuilder adminEvent) {
this.session = session;
this.auth = auth;
this.realm = session.getContext().getRealm();
this.adminEvent = adminEvent;
}
/**
* Clear the crl cache (CRLs loaded for X509 authentication)
*
*/
@POST
public void clearCrlCache() {
auth.realm().requireManageRealm();
CacheCrlProvider cache = session.getProvider(CacheCrlProvider.class);
if (cache != null) {
cache.clearCache();
}
adminEvent.operation(OperationType.ACTION).resourcePath(session.getContext().getUri()).success();
}
}

View File

@ -19,3 +19,4 @@ org.keycloak.services.resources.admin.UserStorageProviderRealmAdminProvider
org.keycloak.services.resources.admin.ClearUserCacheRealmAdminProvider
org.keycloak.services.resources.admin.ClearRealmCacheRealmAdminProvider
org.keycloak.services.resources.admin.ClearKeysCacheRealmAdminProvider
org.keycloak.services.resources.admin.ClearCrlCacheRealmAdminProvider

View File

@ -73,6 +73,14 @@
<expiration max-idle="3600000"/>
<memory max-count="1000"/>
</local-cache>
<local-cache name="crl" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration lifespan="-1"/>
<memory max-count="1000"/>
</local-cache>
<distributed-cache name="actionTokens" owners="2">
<encoding>
<key media-type="application/x-java-object"/>

View File

@ -26,7 +26,7 @@ public class CachingOptions {
private static final String CACHE_METRICS_PREFIX = "cache-metrics";
public static final String CACHE_METRICS_HISTOGRAMS_ENABLED_PROPERTY = CACHE_METRICS_PREFIX + "-histograms-enabled";
public static final String[] LOCAL_MAX_COUNT_CACHES = new String[]{"authorization", "keys", "realms", "users", };
public static final String[] LOCAL_MAX_COUNT_CACHES = new String[]{"authorization", "crl", "keys", "realms", "users", };
public static final String[] CLUSTERED_MAX_COUNT_CACHES = new String[]{"clientSessions", "offlineSessions", "offlineClientSessions", "sessions"};

View File

@ -77,6 +77,14 @@
<expiration max-idle="3600000"/>
<memory max-count="1000"/>
</local-cache>
<local-cache name="crl" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration lifespan="-1"/>
<memory max-count="1000"/>
</local-cache>
<distributed-cache name="actionTokens" owners="2">
<encoding>
<key media-type="application/x-java-object"/>

View File

@ -79,6 +79,14 @@
<expiration max-idle="3600000"/>
<memory max-count="1000"/>
</local-cache>
<local-cache name="crl" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration lifespan="-1"/>
<memory max-count="1000"/>
</local-cache>
<local-cache name="actionTokens">
<encoding>
<key media-type="application/x-java-object"/>

View File

@ -222,6 +222,14 @@
<expiration max-idle="3600000"/>
<memory max-count="1000"/>
</local-cache>
<local-cache name="crl" simple-cache="true" statistics="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration lifespan="-1"/>
<memory max-count="1000"/>
</local-cache>
<distributed-cache name="actionTokens" owners="2" statistics="true">
<encoding>
<key media-type="application/x-java-object"/>

View File

@ -85,6 +85,14 @@
<expiration max-idle="3600000"/>
<memory max-count="1000"/>
</local-cache>
<local-cache name="crl">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration lifespan="-1"/>
<memory max-count="1000"/>
</local-cache>
<distributed-cache name="actionTokens" owners="2">
<encoding>
<key media-type="application/x-java-object"/>

View File

@ -32,6 +32,8 @@ Cache:
The maximum number of entries that can be stored in-memory by the
clientSessions cache. Available only when embedded Infinispan clusters
configured.
--cache-embedded-crl-max-count <max-count>
The maximum number of entries that can be stored in-memory by the crl cache.
--cache-embedded-keys-max-count <max-count>
The maximum number of entries that can be stored in-memory by the keys cache.
--cache-embedded-mtls-enabled <true|false>
@ -361,4 +363,4 @@ Bootstrap Admin:
Do NOT start the server using this command when deploying to production.
Use 'kc.sh start-dev --help-all' to list all available options, including build
options.
options.

View File

@ -32,6 +32,8 @@ Cache:
The maximum number of entries that can be stored in-memory by the
clientSessions cache. Available only when embedded Infinispan clusters
configured.
--cache-embedded-crl-max-count <max-count>
The maximum number of entries that can be stored in-memory by the crl cache.
--cache-embedded-keys-max-count <max-count>
The maximum number of entries that can be stored in-memory by the keys cache.
--cache-embedded-mtls-enabled <true|false>
@ -502,4 +504,4 @@ Bootstrap Admin:
Do NOT start the server using this command when deploying to production.
Use 'kc.sh start-dev --help-all' to list all available options, including build
options.
options.

View File

@ -33,6 +33,8 @@ Cache:
The maximum number of entries that can be stored in-memory by the
clientSessions cache. Available only when embedded Infinispan clusters
configured.
--cache-embedded-crl-max-count <max-count>
The maximum number of entries that can be stored in-memory by the crl cache.
--cache-embedded-keys-max-count <max-count>
The maximum number of entries that can be stored in-memory by the keys cache.
--cache-embedded-mtls-enabled <true|false>
@ -372,4 +374,4 @@ By default, this command tries to update the server configuration by running a
$ kc.sh start '--optimized'
By doing that, the server should start faster based on any previous
configuration you have set when manually running the 'build' command.
configuration you have set when manually running the 'build' command.

View File

@ -33,6 +33,8 @@ Cache:
The maximum number of entries that can be stored in-memory by the
clientSessions cache. Available only when embedded Infinispan clusters
configured.
--cache-embedded-crl-max-count <max-count>
The maximum number of entries that can be stored in-memory by the crl cache.
--cache-embedded-keys-max-count <max-count>
The maximum number of entries that can be stored in-memory by the keys cache.
--cache-embedded-mtls-enabled <true|false>
@ -507,4 +509,4 @@ By default, this command tries to update the server configuration by running a
$ kc.sh start '--optimized'
By doing that, the server should start faster based on any previous
configuration you have set when manually running the 'build' command.
configuration you have set when manually running the 'build' command.

View File

@ -33,6 +33,8 @@ Cache:
The maximum number of entries that can be stored in-memory by the
clientSessions cache. Available only when embedded Infinispan clusters
configured.
--cache-embedded-crl-max-count <max-count>
The maximum number of entries that can be stored in-memory by the crl cache.
--cache-embedded-keys-max-count <max-count>
The maximum number of entries that can be stored in-memory by the keys cache.
--cache-embedded-mtls-enabled <true|false>
@ -306,4 +308,4 @@ By default, this command tries to update the server configuration by running a
$ kc.sh start '--optimized'
By doing that, the server should start faster based on any previous
configuration you have set when manually running the 'build' command.
configuration you have set when manually running the 'build' command.

View File

@ -33,6 +33,8 @@ Cache:
The maximum number of entries that can be stored in-memory by the
clientSessions cache. Available only when embedded Infinispan clusters
configured.
--cache-embedded-crl-max-count <max-count>
The maximum number of entries that can be stored in-memory by the crl cache.
--cache-embedded-keys-max-count <max-count>
The maximum number of entries that can be stored in-memory by the keys cache.
--cache-embedded-mtls-enabled <true|false>
@ -433,4 +435,4 @@ By default, this command tries to update the server configuration by running a
$ kc.sh start '--optimized'
By doing that, the server should start faster based on any previous
configuration you have set when manually running the 'build' command.
configuration you have set when manually running the 'build' command.

View File

@ -0,0 +1,50 @@
/*
* Copyright 2025 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.crl;
import org.keycloak.provider.Provider;
import java.security.GeneralSecurityException;
import java.security.cert.X509CRL;
import java.util.concurrent.Callable;
/**
* Crl Storage Provider interface
*
* @author rmartinc
*/
public interface CrlStorageProvider extends Provider {
/**
* Returns the CRL for this key from cache or loading from the loader.
* @param key The key for the CRL
* @param loader The loader to get if the CRL is not in cache
* @return The X509CRL placed in the cache
* @throws GeneralSecurityException
*/
X509CRL get(String key, Callable<X509CRL> loader) throws GeneralSecurityException;
/**
* Refreshes the CRL in the cache for this key.
* @param key The key for the CRL
* @param loader The loader to get the CRL
* @return true if refreshed or false if not
* @throws GeneralSecurityException
*/
boolean refreshCache(String key, Callable<X509CRL> loader) throws GeneralSecurityException;
}

View File

@ -0,0 +1,24 @@
/*
* Copyright 2025 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.crl;
import org.keycloak.provider.ProviderFactory;
public interface CrlStorageProviderFactory extends ProviderFactory<CrlStorageProvider> {
}

View File

@ -0,0 +1,44 @@
/*
* Copyright 2025 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.crl;
import org.keycloak.provider.Spi;
public class CrlStorageSpi implements Spi {
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return "crlStorage";
}
@Override
public Class<CrlStorageProvider> getProviderClass() {
return CrlStorageProvider.class;
}
@Override
public Class<CrlStorageProviderFactory> getProviderFactoryClass() {
return CrlStorageProviderFactory.class;
}
}

View File

@ -80,6 +80,7 @@ org.keycloak.transaction.TransactionManagerLookupSpi
org.keycloak.credential.hash.PasswordHashSpi
org.keycloak.credential.CredentialSpi
org.keycloak.keys.PublicKeyStorageSpi
org.keycloak.crl.CrlStorageSpi
org.keycloak.keys.KeySpi
org.keycloak.storage.DatastoreSpi
org.keycloak.storage.role.RoleStorageProviderSpi

View File

@ -21,7 +21,6 @@ package org.keycloak.authentication.authenticators.x509;
import static org.keycloak.authentication.authenticators.x509.AbstractX509ClientCertificateAuthenticator.CERTIFICATE_POLICY_MODE_ANY;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
@ -74,6 +73,7 @@ import org.keycloak.common.crypto.CryptoIntegration;
import org.keycloak.common.util.PemUtils;
import org.keycloak.common.util.Time;
import org.keycloak.connections.httpclient.HttpClientProvider;
import org.keycloak.crl.CrlStorageProvider;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.truststore.TruststoreProvider;
@ -279,7 +279,13 @@ public class CertificateValidator {
}
public Collection<X509CRL> getX509CRLs() throws GeneralSecurityException {
X509CRL crl = loadCRL();
if (cRLPath == null) {
throw new GeneralSecurityException("Unable to load CRL because no crl path is defined");
}
CrlStorageProvider crlCache = session.getProvider(CrlStorageProvider.class);
final X509CRL crl = crlCache.get(cRLPath, this::loadCRL);
if (crl == null) {
throw new GeneralSecurityException(String.format("Unable to load CRL from \"%s\"", cRLPath));
}
@ -298,25 +304,23 @@ public class CertificateValidator {
private X509CRL loadCRL() throws GeneralSecurityException {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509CRL crl = null;
if (cRLPath != null) {
if (cRLPath.startsWith("http") || cRLPath.startsWith("https")) {
// load CRL using remote URI
try {
crl = loadFromURI(cf, new URI(cRLPath));
} catch (URISyntaxException e) {
logger.error(e.getMessage());
}
} else if (cRLPath.startsWith("ldap")) {
// load CRL from LDAP
try {
crl = loadCRLFromLDAP(cf, new URI(cRLPath));
} catch(URISyntaxException e) {
logger.error(e.getMessage());
}
} else {
// load CRL from file
crl = loadCRLFromFile(cf, cRLPath);
if (cRLPath.startsWith("http") || cRLPath.startsWith("https")) {
// load CRL using remote URI
try {
crl = loadFromURI(cf, new URI(cRLPath));
} catch (URISyntaxException e) {
logger.error(e.getMessage());
}
} else if (cRLPath.startsWith("ldap")) {
// load CRL from LDAP
try {
crl = loadCRLFromLDAP(cf, new URI(cRLPath));
} catch (URISyntaxException e) {
logger.error(e.getMessage());
}
} else {
// load CRL from file
crl = loadCRLFromFile(cf, cRLPath);
}
return crl;
}
@ -330,10 +334,8 @@ public class CertificateValidator {
get.setHeader("Pragma", "no-cache");
get.setHeader("Cache-Control", "no-cache, no-store");
try (CloseableHttpResponse response = httpClient.execute(get)) {
try {
InputStream content = response.getEntity().getContent();
X509CRL crl = loadFromStream(cf, content);
return crl;
try (InputStream content = response.getEntity().getContent()) {
return loadFromStream(cf, content);
} finally {
EntityUtils.consumeQuietly(response.getEntity());
}
@ -360,8 +362,9 @@ public class CertificateValidator {
if (data == null || data.length == 0) {
throw new CertificateException(String.format("Failed to download CRL from \"%s\"", remoteURI.toString()));
}
X509CRL crl = loadFromStream(cf, new ByteArrayInputStream(data));
return crl;
try (InputStream is = new ByteArrayInputStream(data)) {
return loadFromStream(cf, is);
}
} finally {
ctx.close();
}
@ -386,8 +389,7 @@ public class CertificateValidator {
throw new IOException(String.format("Unable to read CRL from \"%s\"", f.getAbsolutePath()));
}
try (FileInputStream is = new FileInputStream(f.getAbsolutePath())) {
X509CRL crl = loadFromStream(cf, is);
return crl;
return loadFromStream(cf, is);
}
}
}
@ -397,11 +399,9 @@ public class CertificateValidator {
}
return null;
}
private X509CRL loadFromStream(CertificateFactory cf, InputStream is) throws IOException, CRLException {
DataInputStream dis = new DataInputStream(is);
X509CRL crl = (X509CRL)cf.generateCRL(dis);
dis.close();
return crl;
return (X509CRL) cf.generateCRL(is);
}
}

View File

@ -60,6 +60,14 @@
<expiration max-idle="3600000"/>
<memory storage="HEAP" max-count="1000"/>
</local-cache>
<local-cache name="crl" simple-cache="true">
<encoding>
<key media-type="application/x-java-object"/>
<value media-type="application/x-java-object"/>
</encoding>
<expiration lifespan="-1"/>
<memory max-count="1000"/>
</local-cache>
<distributed-cache name="actionTokens" owners="2">
<encoding>
<key media-type="application/x-java-object"/>

View File

@ -21,6 +21,8 @@ import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import io.undertow.Undertow;
import io.undertow.io.Sender;
@ -50,17 +52,21 @@ public class CRLRule extends ExternalResource {
public static final String CRL_RESPONDER_ORIGIN = "http://" + CRL_RESPONDER_HOST + ":" + CRL_RESPONDER_PORT;
private Undertow crlResponder;
private Map<String, CRLHandler> handlers;
private PathHandler pathHandler;
@Override
protected void before() throws Throwable {
log.info("Starting CRL Responder");
PathHandler pathHandler = new PathHandler();
pathHandler.addExactPath(AbstractX509AuthenticationTest.EMPTY_CRL_PATH, new CRLHandler(AbstractX509AuthenticationTest.EMPTY_CRL_PATH));
pathHandler.addExactPath(AbstractX509AuthenticationTest.INTERMEDIATE_CA_CRL_PATH, new CRLHandler(AbstractX509AuthenticationTest.INTERMEDIATE_CA_CRL_PATH));
pathHandler.addExactPath(AbstractX509AuthenticationTest.INTERMEDIATE_CA_INVALID_SIGNATURE_CRL_PATH, new CRLHandler(AbstractX509AuthenticationTest.INTERMEDIATE_CA_INVALID_SIGNATURE_CRL_PATH));
pathHandler.addExactPath(AbstractX509AuthenticationTest.INTERMEDIATE_CA_3_CRL_PATH, new CRLHandler(AbstractX509AuthenticationTest.INTERMEDIATE_CA_3_CRL_PATH));
pathHandler.addExactPath(AbstractX509AuthenticationTest.INVALID_CRL_PATH, new CRLHandler(AbstractX509AuthenticationTest.INVALID_CRL_PATH));
handlers = new HashMap<>();
pathHandler = new PathHandler();
addHandler(AbstractX509AuthenticationTest.EMPTY_CRL_PATH, AbstractX509AuthenticationTest.EMPTY_CRL_PATH);
addHandler(AbstractX509AuthenticationTest.EMPTY_EXPIRED_CRL_PATH, AbstractX509AuthenticationTest.EMPTY_EXPIRED_CRL_PATH);
addHandler(AbstractX509AuthenticationTest.INTERMEDIATE_CA_CRL_PATH, AbstractX509AuthenticationTest.INTERMEDIATE_CA_CRL_PATH);
addHandler(AbstractX509AuthenticationTest.INTERMEDIATE_CA_INVALID_SIGNATURE_CRL_PATH, AbstractX509AuthenticationTest.INTERMEDIATE_CA_INVALID_SIGNATURE_CRL_PATH);
addHandler(AbstractX509AuthenticationTest.INTERMEDIATE_CA_3_CRL_PATH, AbstractX509AuthenticationTest.INTERMEDIATE_CA_3_CRL_PATH);
addHandler(AbstractX509AuthenticationTest.INVALID_CRL_PATH, AbstractX509AuthenticationTest.INVALID_CRL_PATH);
crlResponder = Undertow.builder().addHttpListener(CRL_RESPONDER_PORT, CRL_RESPONDER_HOST)
.setHandler(
@ -76,15 +82,50 @@ public class CRLRule extends ExternalResource {
crlResponder.stop();
}
public void addHandler(String path, String crlFileName) {
CRLHandler handler = new CRLHandler(crlFileName);
handlers.put(path, handler);
pathHandler.addExactPath(path, handler);
}
public void removeHandler(String path) {
handlers.remove(path);
pathHandler.removeExactPath(path);
}
public void setCrlForHandler(String path, String crlFileName) {
handlers.get(path).SetFileName(crlFileName);
}
public int getCounter(String path) {
return handlers.get(path).getCounter();
}
public void resetCounter(String path) {
handlers.get(path).resetCounter();
}
private class CRLHandler implements HttpHandler {
private String crlFileName;
private int counter;
public CRLHandler(String crlFileName) {
this.crlFileName = crlFileName;
counter = 0;
}
public void SetFileName(String crlFileName) {
this.crlFileName = crlFileName;
}
public int getCounter() {
return counter;
}
public void resetCounter() {
counter = 0;
}
@Override
public void handleRequest(HttpServerExchange exchange) throws Exception {
@ -93,6 +134,7 @@ public class CRLRule extends ExternalResource {
return;
}
counter++;
String fullFile = AbstractX509AuthenticationTest.getAuthServerHome() + File.separator + crlFileName;
InputStream is = new FileInputStream(new File(fullFile));

View File

@ -28,6 +28,7 @@ import org.keycloak.events.Details;
import org.keycloak.models.Constants;
import org.keycloak.representations.idm.AuthenticatorConfigRepresentation;
import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.util.AccountHelper;
import org.keycloak.testsuite.util.ContainerAssume;
import org.keycloak.testsuite.util.HtmlUnitBrowser;
import org.openqa.selenium.WebDriver;
@ -143,6 +144,54 @@ public class X509BrowserCRLTest extends AbstractX509AuthenticationTest {
assertLoginFailedDueRevokedCertificate();
}
@Test
public void loginTestCRLCaching() {
X509AuthenticatorConfigModel config =
new X509AuthenticatorConfigModel()
.setCRLEnabled(true)
.setCRLRelativePath(CRLRule.CRL_RESPONDER_ORIGIN + "/cached-crl")
.setConfirmationPageAllowed(true)
.setMappingSourceType(SUBJECTDN_EMAIL)
.setUserIdentityMapperType(USERNAME_EMAIL);
AuthenticatorConfigRepresentation cfg = newConfig("x509-browser-config", config.getConfig());
String cfgId = createConfig(browserExecution.getId(), cfg);
Assert.assertNotNull(cfgId);
try {
// change CRL to the empty but expired, it should login OK
crlRule.addHandler("cached-crl", EMPTY_EXPIRED_CRL_PATH);
x509BrowserLogin(config, userId, "test-user@localhost", "test-user@localhost");
AccountHelper.logout(testRealm(), "test-user@localhost");
Assert.assertEquals(1, crlRule.getCounter("cached-crl"));
// change the CRL to the new one but it is cached the min time
crlRule.setCrlForHandler("cached-crl", INTERMEDIATE_CA_CRL_PATH);
x509BrowserLogin(config, userId, "test-user@localhost", "test-user@localhost");
AccountHelper.logout(testRealm(), "test-user@localhost");
Assert.assertEquals(1, crlRule.getCounter("cached-crl"));
// wait the min time and it should be refreshed now and fail
setTimeOffset(10);
assertLoginFailedDueRevokedCertificate();
AccountHelper.logout(testRealm(), "test-user@localhost");
Assert.assertEquals(2, crlRule.getCounter("cached-crl"));
// now it's cached until next update 50 years
setTimeOffset(3600);
assertLoginFailedDueRevokedCertificate();
AccountHelper.logout(testRealm(), "test-user@localhost");
Assert.assertEquals(2, crlRule.getCounter("cached-crl"));
// clear the cache
testRealm().clearCrlCache();
assertLoginFailedDueRevokedCertificate();
AccountHelper.logout(testRealm(), "test-user@localhost");
Assert.assertEquals(3, crlRule.getCounter("cached-crl"));
} finally {
crlRule.removeHandler("cached-crl");
}
}
@Test
public void loginFailedWithIntermediateRevocationListFromHttp() {
X509AuthenticatorConfigModel config =