LocalJtiCacheManager.java
/*
* GovWay - A customizable API Gateway
* https://govway.org
*
* Copyright (c) 2005-2026 Link.it srl (https://link.it).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License version 3, as published by
* the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
package org.openspcoop2.pdd.core.token.dpop.jti;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.openspcoop2.pdd.config.OpenSPCoop2Properties;
import org.openspcoop2.pdd.logger.OpenSPCoop2Logger;
import org.openspcoop2.utils.date.DateManager;
import org.slf4j.Logger;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import com.github.benmanes.caffeine.cache.stats.CacheStats;
/**
* LocalJtiCacheManager - Gestisce registry di cache Caffeine dedicate per-policy
*
* Ogni policy ha la propria cache isolata per evitare interferenze tra policy diverse.
* Cache configurate con TTL per-entry (expireAfter) e maxSize dimensionato per policy.
*
* TTL per-entry: ogni JTI scade esattamente a (iat + tolerance), garantendo precisione
* come nel DistributedJtiValidator Redis.
*
* Thread-safe: usa ConcurrentHashMap per registry e computeIfAbsent per lazy initialization.
*
* @author Poli Andrea (apoli@link.it)
* @author $Author$
* @version $Rev$, $Date$
*/
public class LocalJtiCacheManager {
private LocalJtiCacheManager() {}
private static final Logger logger = OpenSPCoop2Logger.getLoggerOpenSPCoopCore();
/**
* Registry: policyName -> cache Caffeine dedicata
* ConcurrentHashMap garantisce thread-safety per accesso concorrente
*/
private static final Map<String, Cache<String, JtiEntry>> cacheRegistry = new ConcurrentHashMap<>();
/**
* Background scheduler per cleanup periodico delle entry scadute.
* Necessario perché Caffeine usa lazy expiration e estimatedSize() conta anche entry scadute.
* Un solo thread daemon per tutte le cache - overhead minimo.
*/
private static ScheduledExecutorService cleanupScheduler = null;
private static final AtomicBoolean schedulerInitialized = new AtomicBoolean(false);
private static final Object schedulerLock = new Object();
/**
* Inizializza lo scheduler di cleanup se configurato (intervallo > 0).
* Thread-safe tramite sincronizzazione su schedulerLock.
* Chiamato automaticamente alla prima creazione di cache.
*/
private static void initCleanupSchedulerIfNeeded() {
if (!schedulerInitialized.get()) {
synchronized (schedulerLock) {
if (schedulerInitialized.compareAndSet(false, true)) {
initCleanupScheduler();
}
}
}
}
private static void initCleanupScheduler() {
try {
int intervalSeconds = OpenSPCoop2Properties.getInstance().getGestioneTokenDPoPJtiLocalCacheCleanupIntervalSeconds();
if (intervalSeconds > 0) {
cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "JTI-Cache-Cleanup");
t.setDaemon(true);
return t;
});
cleanupScheduler.scheduleAtFixedRate(() -> {
try {
if (!cacheRegistry.isEmpty()) {
cacheRegistry.values().forEach(Cache::cleanUp);
}
} catch (Exception e) {
logger.error("Error during JTI cache cleanup: {}", e.getMessage(), e);
}
}, intervalSeconds, intervalSeconds, TimeUnit.SECONDS);
logger.info("JTI cache cleanup scheduler started with interval {} seconds", intervalSeconds);
} else {
logger.info("JTI cache cleanup scheduler disabled (interval=0)");
}
} catch (Exception e) {
logger.error("Failed to initialize JTI cache cleanup scheduler: {}", e.getMessage(), e);
schedulerInitialized.set(false);
}
}
/**
* Ottiene o crea cache dedicata per la policy specificata
*
* Thread-safe tramite computeIfAbsent: garantisce inizializzazione singola anche con accesso concorrente.
*
* Cache configurata con TTL per-entry: ogni JTI scade esattamente a (entry.iat + entry.toleranceMillis).
* Garantisce precisione temporale identica al DistributedJtiValidator Redis.
*
* @param policyName Nome della policy (usato come chiave registry)
* @param maxSize Dimensione massima cache (da validazioneDPoPJtiMaxSize)
* @return Cache Caffeine dedicata per la policy
*/
public static Cache<String, JtiEntry> getOrCreateCache(String policyName, long maxSize) {
initCleanupSchedulerIfNeeded();
return cacheRegistry.computeIfAbsent(policyName, key -> {
logger.info("Creating Caffeine JTI cache for policy [{}] with per-entry TTL, maxSize={}", policyName, maxSize);
return Caffeine.newBuilder()
.expireAfter(new Expiry<String, JtiEntry>() {
@Override
public long expireAfterCreate(String jti, JtiEntry entry, long currentTime) {
// Calcola TTL preciso per questa entry: (iat + tolerance - now)
long expirationMillis = entry.getExpirationMillis();
long currentMillis = DateManager.getTimeMillis();
long remainingMillis = expirationMillis - currentMillis;
// Converti in nanoseconds per Caffeine (minimo 0)
return TimeUnit.MILLISECONDS.toNanos(Math.max(0, remainingMillis));
}
@Override
public long expireAfterUpdate(String jti, JtiEntry entry, long currentTime, long currentDuration) {
// JTI non vengono mai aggiornati, solo creati
return currentDuration;
}
@Override
public long expireAfterRead(String jti, JtiEntry entry, long currentTime, long currentDuration) {
// TTL non cambia su lettura (non è LRU temporale)
return currentDuration;
}
})
.maximumSize(maxSize)
.recordStats() // Abilita statistiche per monitoring
.build();
});
}
/**
* Rimuove cache per policy specifica (es. durante riconfigurazione policy)
*
* @param policyName Nome della policy da rimuovere
*/
public static void removeCache(String policyName) {
Cache<String, JtiEntry> removed = cacheRegistry.remove(policyName);
if (removed != null) {
removed.invalidateAll();
removed.cleanUp();
logger.info("Removed and cleaned up JTI cache for policy [{}]", policyName);
}
}
/**
* Shutdown globale: ferma scheduler, pulisce tutte le cache e svuota registry
* Chiamato durante shutdown applicazione
*/
public static void shutdownAll() {
logger.info("Shutting down all JTI caches ({} policies)", cacheRegistry.size());
synchronized (schedulerLock) {
if (cleanupScheduler != null) {
cleanupScheduler.shutdownNow();
cleanupScheduler = null;
schedulerInitialized.set(false);
}
}
cacheRegistry.forEach((policyName, cache) -> {
cache.invalidateAll();
cache.cleanUp();
});
cacheRegistry.clear();
}
/**
* Ottiene statistiche aggregate di tutte le cache (per monitoring/diagnostics)
*
* @return Mappa policyName -> CacheStats
*/
public static Map<String, CacheStats> getAllStats() {
Map<String, CacheStats> stats = new ConcurrentHashMap<>();
cacheRegistry.forEach((policyName, cache) -> stats.put(policyName, cache.stats()));
return stats;
}
/**
* Verifica se esiste cache per policy specifica
*
* @param policyName Nome della policy
* @return true se cache già inizializzata
*/
public static boolean hasCacheForPolicy(String policyName) {
return cacheRegistry.containsKey(policyName);
}
/**
* Ottiene cache per policy specifica (per debug/monitoring)
*
* @param policyName Nome della policy
* @return Cache se esiste, null altrimenti
*/
public static Cache<String, JtiEntry> getCache(String policyName) {
return cacheRegistry.get(policyName);
}
/* ********** JMX Statistics Methods ********** */
/**
* Lista i nomi delle policy per cui è attiva una cache DPoP JTI
*
* @param separator Separatore tra i nomi delle policy
* @return Lista dei nomi delle policy, separati dal separatore specificato
*/
public static String listPolicyNames(String separator) {
if (cacheRegistry.isEmpty()) {
return "Nessuna cache DPoP JTI attiva";
}
StringBuilder sb = new StringBuilder();
boolean first = true;
for (String policyName : cacheRegistry.keySet()) {
if (!first) {
sb.append(separator);
}
sb.append(policyName);
first = false;
}
return sb.toString();
}
/**
* Stima della memoria RAM occupata per ogni entry nella cache DPoP JTI.
*
* La stima di ~200 bytes per entry è calcolata come segue:
*
* 1. Chiave (String JTI, tipicamente UUID di 36 caratteri):
* - char array: 36 * 2 bytes = 72 bytes
* - String object overhead: ~24 bytes
* - HashMap.Entry overhead: ~32 bytes
* Subtotale chiave: ~128 bytes
*
* 2. Valore (JtiEntry):
* - Date iat: ~24 bytes (object header + long internal)
* - long toleranceMillis: 8 bytes
* - Object overhead JtiEntry: ~16 bytes
* Subtotale valore: ~48 bytes
*
* 3. Overhead Caffeine (reference, metadata):
* - ~24 bytes per entry
*
* Totale stimato: ~200 bytes per entry
*/
private static final long ESTIMATED_BYTES_PER_JTI_ENTRY = 200L;
/**
* Genera report statistiche per una singola policy
*
* @param policyName Nome della policy
* @return Report formattato con statistiche cache
*/
public static String printStatsForPolicy(String policyName) {
if (policyName == null || policyName.isEmpty()) {
return "Nome policy non specificato";
}
Cache<String, JtiEntry> cache = cacheRegistry.get(policyName);
if (cache == null) {
return "Cache DPoP JTI per la policy '" + policyName + "' non trovata o non inizializzata";
}
return formatCacheStats(policyName, cache, false);
}
/**
* Genera report statistiche aggregate di tutte le policy
*
* @return Report formattato con statistiche di tutte le cache
*/
public static String printStatsAllPolicies() {
if (cacheRegistry.isEmpty()) {
return "Nessuna cache DPoP JTI attiva";
}
StringBuilder sb = new StringBuilder();
sb.append("=== Statistiche Cache DPoP JTI (anti-replay) ===\n");
sb.append("Numero policy attive: ").append(cacheRegistry.size()).append("\n\n");
long totalEntries = 0;
long totalEstimatedBytes = 0;
for (Map.Entry<String, Cache<String, JtiEntry>> entry : cacheRegistry.entrySet()) {
String name = entry.getKey();
Cache<String, JtiEntry> cache = entry.getValue();
sb.append(formatCacheStats(name, cache, true));
sb.append("\n");
long size = cache.estimatedSize();
totalEntries += size;
totalEstimatedBytes += size * ESTIMATED_BYTES_PER_JTI_ENTRY;
}
sb.append("=== Totale Globale ===\n");
sb.append("Entries totali: ").append(totalEntries).append("\n");
sb.append("RAM stimata totale: ").append(formatBytes(totalEstimatedBytes)).append("\n");
return sb.toString();
}
private static String formatCacheStats(String policyName, Cache<String, JtiEntry> cache, boolean appendPolicyName) {
CacheStats stats = cache.stats();
long estimatedSize = cache.estimatedSize();
long estimatedBytes = estimatedSize * ESTIMATED_BYTES_PER_JTI_ENTRY;
StringBuilder sb = new StringBuilder();
if(appendPolicyName) {
sb.append("--- Policy: ").append(policyName).append(" ---\n");
}
sb.append("Entries: ").append(estimatedSize).append("\n");
sb.append("RAM stimata: ").append(formatBytes(estimatedBytes));
sb.append(" (~").append(ESTIMATED_BYTES_PER_JTI_ENTRY).append(" bytes/entry)\n");
sb.append("Hit count: ").append(stats.hitCount()).append("\n");
sb.append("Miss count: ").append(stats.missCount()).append("\n");
sb.append("Hit rate: ").append(String.format("%.2f%%", stats.hitRate() * 100)).append("\n");
sb.append("Eviction count: ").append(stats.evictionCount()).append("\n");
sb.append("Load success count: ").append(stats.loadSuccessCount()).append("\n");
sb.append("Load failure count: ").append(stats.loadFailureCount()).append("\n");
return sb.toString();
}
private static String formatBytes(long bytes) {
if (bytes < 1024) {
return bytes + " B";
} else if (bytes < 1024 * 1024) {
return String.format("%.2f KB", bytes / 1024.0);
} else if (bytes < 1024L * 1024 * 1024) {
return String.format("%.2f MB", bytes / (1024.0 * 1024.0));
} else {
return String.format("%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
}
}
}