diff --git a/pom.xml b/pom.xml index 17fc39f56..9c1e4dea9 100644 --- a/pom.xml +++ b/pom.xml @@ -95,6 +95,14 @@ org.springframework.boot spring-boot-starter-oauth2-client + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + diff --git a/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java b/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java index 19dce67f3..80378c911 100644 --- a/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java +++ b/src/main/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverter.java @@ -1,6 +1,5 @@ package com.czertainly.core.auth.oauth2; -import com.czertainly.api.model.core.logging.enums.AuthMethod; import com.czertainly.api.model.core.logging.enums.Operation; import com.czertainly.api.model.core.logging.enums.OperationResult; import com.czertainly.api.model.core.settings.SettingsSection; @@ -62,7 +61,7 @@ public AbstractAuthenticationToken convert(@Nullable Jwt source) { throw e; } - AuthenticationInfo authInfo = authenticationClient.authenticate(AuthMethod.TOKEN, claims, false); + AuthenticationInfo authInfo = authenticationClient.authenticateByToken(claims); CzertainlyUserDetails userDetails = new CzertainlyUserDetails(authInfo); // Provider settings will not be null, otherwise converter would not have been reached from decoder logger.debug("User '{}' has been authenticated using JWT from OAuth2 Provider '{}'.", userDetails.getUsername(), providerSettings == null ? " " : providerSettings.getName()); diff --git a/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java b/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java index 040d1e819..f0fa77460 100644 --- a/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java +++ b/src/main/java/com/czertainly/core/auth/oauth2/OAuth2LoginFilter.java @@ -139,7 +139,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht private void authenticate(HttpServletRequest request, Map claims, ClientRegistration clientRegistration) { AuthenticationInfo authInfo; try { - authInfo = authenticationClient.authenticate(AuthMethod.TOKEN, claims, false); + authInfo = authenticationClient.authenticateByToken(claims); CzertainlyAuthenticationToken authenticationToken = new CzertainlyAuthenticationToken(new CzertainlyUserDetails(authInfo)); SecurityContextHolder.getContext().setAuthentication(authenticationToken); LOGGER.debug("Session of user '{}' logged using OAuth2 Provider '{}' has been successfully validated.", authenticationToken.getPrincipal().getUsername(), clientRegistration.getRegistrationId()); diff --git a/src/main/java/com/czertainly/core/config/AuthCacheProperties.java b/src/main/java/com/czertainly/core/config/AuthCacheProperties.java new file mode 100644 index 000000000..897a8a19c --- /dev/null +++ b/src/main/java/com/czertainly/core/config/AuthCacheProperties.java @@ -0,0 +1,15 @@ +package com.czertainly.core.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "caching.authentication") +public record AuthCacheProperties( + int ttlMinutes, + int maxSize +) { + + public AuthCacheProperties { + if (ttlMinutes <= 0) ttlMinutes = 5; + if (maxSize <= 0) maxSize = 500; + } +} diff --git a/src/main/java/com/czertainly/core/config/CacheConfig.java b/src/main/java/com/czertainly/core/config/CacheConfig.java new file mode 100644 index 000000000..51d9b1826 --- /dev/null +++ b/src/main/java/com/czertainly/core/config/CacheConfig.java @@ -0,0 +1,52 @@ +package com.czertainly.core.config; + +import com.czertainly.core.security.authn.client.TokenJtiIndex; +import com.czertainly.core.security.authn.client.UserCertificateIndex; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching(order = Ordered.HIGHEST_PRECEDENCE) +@EnableConfigurationProperties(AuthCacheProperties.class) +public class CacheConfig { + + public static final String SIGNING_PROFILES_CACHE = "signingProfiles"; + public static final String TSP_PROFILES_CACHE = "tspProfiles"; + public static final String CERTIFICATE_CHAIN_CACHE = "certificateChain"; + public static final String SYSTEM_USER_AUTH_CACHE = "systemUserAuth"; + public static final String USER_UUID_AUTH_CACHE = "userUuidAuth"; + public static final String CERTIFICATE_AUTH_CACHE = "certificateAuth"; + public static final String TOKEN_AUTH_CACHE = "tokenAuth"; + public static final String FORMATTER_CONNECTOR_CACHE = "formatterConnector"; + public static final String CRYPTOGRAPHIC_KEY_ITEM_CACHE = "cryptographicKeyItem"; + + @Bean + public CacheManager cacheManager(AuthCacheProperties cacheProperties, TokenJtiIndex tokenJtiIndex, UserCertificateIndex userCertificateIndex) { + CaffeineCacheManager mgr = new CaffeineCacheManager(SIGNING_PROFILES_CACHE, TSP_PROFILES_CACHE, CERTIFICATE_CHAIN_CACHE, SYSTEM_USER_AUTH_CACHE, USER_UUID_AUTH_CACHE, FORMATTER_CONNECTOR_CACHE, CRYPTOGRAPHIC_KEY_ITEM_CACHE); + mgr.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(cacheProperties.ttlMinutes(), TimeUnit.MINUTES) + .maximumSize(cacheProperties.maxSize()) + .recordStats()); + mgr.registerCustomCache(CERTIFICATE_AUTH_CACHE, Caffeine.newBuilder() + .expireAfterWrite(cacheProperties.ttlMinutes(), TimeUnit.MINUTES) + .maximumSize(cacheProperties.maxSize()) + .recordStats() + .removalListener(userCertificateIndex) + .build()); + mgr.registerCustomCache(TOKEN_AUTH_CACHE, Caffeine.newBuilder() + .expireAfterWrite(cacheProperties.ttlMinutes(), TimeUnit.MINUTES) + .maximumSize(cacheProperties.maxSize()) + .recordStats() + .removalListener(tokenJtiIndex) + .build()); + return mgr; + } +} diff --git a/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java b/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java index fb561e0cf..3a07f7926 100644 --- a/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java +++ b/src/main/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilter.java @@ -4,6 +4,7 @@ import com.czertainly.core.security.authn.client.AuthenticationInfo; import com.czertainly.core.security.authn.client.CzertainlyAuthenticationClient; import com.czertainly.core.util.AuthHelper; +import com.czertainly.core.util.CertificateUtil; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -19,7 +20,11 @@ import java.io.IOException; import java.net.InetAddress; +import java.net.URLDecoder; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; import java.util.UUID; public class CzertainlyAuthenticationFilter extends OncePerRequestFilter { @@ -44,17 +49,14 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht log.trace("Going to authenticate the '{}' request on '{}'.", request.getMethod(), request.getRequestURI()); try { - - AuthMethod authMethod = AuthMethod.NONE; - Object authData = null; - - if (request.getHeader(certificateHeaderName) != null) { - authMethod = AuthMethod.CERTIFICATE; - authData = request.getHeader(certificateHeaderName); + AuthenticationInfo authInfo; + String rawCertHeader = request.getHeader(certificateHeaderName); + if (rawCertHeader != null) { + authInfo = authenticateByCertificate(rawCertHeader); + } else { + authInfo = authClient.authenticate(AuthMethod.NONE, null, isLocalhostAddress(request)); } - AuthenticationInfo authInfo = authClient.authenticate(authMethod, authData, isLocalhostAddress(request)); - Authentication authentication; if (authInfo.isAnonymous()) { authentication = new CzertainlyAnonymousToken(UUID.randomUUID().toString(), new CzertainlyUserDetails(authInfo), authInfo.getAuthorities()); @@ -62,15 +64,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht authentication = new CzertainlyAuthenticationToken(new CzertainlyUserDetails(authInfo)); } - SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); - securityContext.setAuthentication(authentication); - SecurityContextHolder.setContext(securityContext); - CzertainlyUserDetails userDetails = (CzertainlyUserDetails) authentication.getPrincipal(); - if (userDetails.getAuthMethod() == AuthMethod.CERTIFICATE) { - log.debug("User with username '{}' has been successfully authenticated with certificate.", userDetails.getUsername()); - } else { - log.debug("User has not been identified, using anonymous user."); - } + updateSecurityContext(authentication); } catch (AuthenticationException e) { SecurityContextHolder.clearContext(); @@ -84,6 +78,33 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull Ht filterChain.doFilter(request, response); } + private static void updateSecurityContext(Authentication authentication) { + SecurityContext securityContext = SecurityContextHolder.createEmptyContext(); + securityContext.setAuthentication(authentication); + SecurityContextHolder.setContext(securityContext); + CzertainlyUserDetails userDetails = (CzertainlyUserDetails) authentication.getPrincipal(); + if (userDetails.getAuthMethod() == AuthMethod.CERTIFICATE) { + log.debug("User with username '{}' has been successfully authenticated with certificate.", userDetails.getUsername()); + } else { + log.debug("User has not been identified, using anonymous user."); + } + } + + private AuthenticationInfo authenticateByCertificate(String rawCertHeader) { + AuthenticationInfo authInfo; + try { + String decoded = URLDecoder.decode(rawCertHeader, StandardCharsets.UTF_8); + byte[] derBytes = Base64.getDecoder().decode(CertificateUtil.normalizeCertificateContent(decoded)); + String thumbprint = CertificateUtil.getThumbprint(derBytes); + authInfo = authClient.authenticateByCertificate(rawCertHeader, thumbprint); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm not available", e); + } catch (IllegalArgumentException e) { + throw new CzertainlyAuthenticationException("Invalid certificate header: " + e.getMessage(), e); + } + return authInfo; + } + private boolean isAuthenticationNeeded(final HttpServletRequest request) { SecurityContext securityContext = SecurityContextHolder.getContext(); diff --git a/src/main/java/com/czertainly/core/security/authn/client/AuthenticationCache.java b/src/main/java/com/czertainly/core/security/authn/client/AuthenticationCache.java new file mode 100644 index 000000000..90620eb13 --- /dev/null +++ b/src/main/java/com/czertainly/core/security/authn/client/AuthenticationCache.java @@ -0,0 +1,78 @@ +package com.czertainly.core.security.authn.client; + +import java.util.UUID; +import java.util.function.Supplier; + +public interface AuthenticationCache { + + /** + * Returns cached authentication for a system user, or invokes {@code loader} and caches the result. + * Cached by username. System usernames (superadmin, acme, scep, …) are stable identifiers + * that never change, so the cache entry remains valid for its full TTL. + * + * @param username system username used as the cache key + * @param loader called on a cache miss to produce the {@link AuthenticationInfo} + * @return the cached or freshly loaded {@link AuthenticationInfo} + */ + AuthenticationInfo getOrAuthenticateSystemUser(String username, Supplier loader); + + /** + * Returns cached authentication for a user identified by UUID, or invokes {@code loader} and caches the result. + * Effective for repeated internal impersonation calls within the TTL window. + * + * @param userUuid UUID of the user, used as the cache key + * @param loader called on a cache miss to produce the {@link AuthenticationInfo} + * @return the cached or freshly loaded {@link AuthenticationInfo} + */ + AuthenticationInfo getOrAuthenticateByUserUuid(UUID userUuid, Supplier loader); + + /** + * Returns cached authentication for a client-certificate request, or invokes {@code loader} and caches the result. + * Cached by SHA-256 of the DER-encoded certificate bytes (computed in CzertainlyAuthenticationFilter), + * matching the DB {@code certificate.fingerprint} field. All requests carrying the same client certificate + * share one cache entry. The entry is evicted immediately on revocation via CertificateServiceImpl. + * + * @param thumbprint SHA-256 fingerprint of the client certificate, used as the cache key + * @param loader called on a cache miss to produce the {@link AuthenticationInfo} + * @return the cached or freshly loaded {@link AuthenticationInfo} + */ + AuthenticationInfo getOrAuthenticateByCertificate(String thumbprint, Supplier loader); + + /** + * Returns cached authentication for a bearer-token request, or invokes {@code loader} and caches the result. + * Cached by the {@code jti} claim, which is unique per token issuance. All requests that carry the same + * access token share one cache entry for its lifetime. When the token is refreshed, a new {@code jti} + * causes a cache miss, triggering a fresh auth-service call. Tokens without a {@code jti} are never cached. + * + * @param jti the {@code jti} claim of the access token, used as the cache key; {@code null} skips caching + * @param loader called on a cache miss to produce the {@link AuthenticationInfo} + * @return the cached or freshly loaded {@link AuthenticationInfo} + */ + AuthenticationInfo getOrAuthenticateByToken(String jti, Supplier loader); + + /** + * Evicts all auth cache entries for a single user: their UUID entry, all token entries tracked + * in the jti index, and their certificate entry tracked in the certificate index. + * Use this for user-scoped changes (role assignment, disable, delete, certificate revocation) + * where only one user is affected and the system-user cache can be left untouched. + * + * @param userUuid UUID of the user whose cache entries should be evicted + */ + void evictByUserUuid(UUID userUuid); + + /** + * Evicts only the certificate-based auth cache entry for the given fingerprint. + * Use this when a certificate is disassociated from a user but the user's identity and + * roles are unchanged — their UUID and token cache entries remain valid. + * + * @param certFingerprint SHA-256 fingerprint of the certificate to evict + */ + void evictByCertificateFingerprint(String certFingerprint); + + /** + * Clears all four auth caches and the jti index (the per-user map of {@code jti} claims used to + * find and evict token cache entries when a user is modified). Use this for role-level mutations + * (permission changes, role deletion) that may affect any user, including system accounts. + */ + void evictAll(); +} diff --git a/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCache.java b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCache.java new file mode 100644 index 000000000..451986a7d --- /dev/null +++ b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCache.java @@ -0,0 +1,117 @@ +package com.czertainly.core.security.authn.client; + +import com.czertainly.core.config.CacheConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Supplier; + +@Component +public class CzertainlyAuthenticationCache implements AuthenticationCache { + + private final CacheManager cacheManager; + private final TokenJtiIndex tokenJtiIndex; + private final UserCertificateIndex userCertificateIndex; + private final Cache certCache; + private final Cache tokenCache; + + @Autowired + public CzertainlyAuthenticationCache(CacheManager cacheManager, TokenJtiIndex tokenJtiIndex, UserCertificateIndex userCertificateIndex) { + this.cacheManager = cacheManager; + this.tokenJtiIndex = tokenJtiIndex; + this.userCertificateIndex = userCertificateIndex; + this.certCache = Objects.requireNonNull(cacheManager.getCache(CacheConfig.CERTIFICATE_AUTH_CACHE)); + this.tokenCache = Objects.requireNonNull(cacheManager.getCache(CacheConfig.TOKEN_AUTH_CACHE)); + } + + @Override + @Cacheable(value = CacheConfig.SYSTEM_USER_AUTH_CACHE, key = "#username", unless = "#result.anonymous") + public AuthenticationInfo getOrAuthenticateSystemUser(String username, Supplier loader) { + return loader.get(); + } + + @Override + @Cacheable(value = CacheConfig.USER_UUID_AUTH_CACHE, key = "#userUuid", unless = "#result.anonymous") + public AuthenticationInfo getOrAuthenticateByUserUuid(UUID userUuid, Supplier loader) { + return loader.get(); + } + + // Manual caching (instead of @Cacheable) keeps userCertificateIndex in sync, enabling targeted + // per-user certificate eviction via evictByUserUuid(). + @Override + public AuthenticationInfo getOrAuthenticateByCertificate(String thumbprint, Supplier loader) { + Cache.ValueWrapper cached = certCache.get(thumbprint); + if (cached != null) { + return (AuthenticationInfo) cached.get(); + } + AuthenticationInfo result = loader.get(); + if (!result.isAnonymous()) { + certCache.put(thumbprint, result); + userCertificateIndex.add(UUID.fromString(result.getUserUuid()), thumbprint); + } + return result; + } + + // Manual caching (instead of @Cacheable) keeps tokenJtiIndex in sync, enabling targeted + // per-user eviction via evictTokensByUserUuid(). + @Override + public AuthenticationInfo getOrAuthenticateByToken(String jti, Supplier loader) { + if (jti == null) { + return loader.get(); + } + Cache.ValueWrapper cached = tokenCache.get(jti); + if (cached != null) { + return (AuthenticationInfo) cached.get(); + } + AuthenticationInfo result = loader.get(); + if (!result.isAnonymous()) { + tokenCache.put(jti, result); + tokenJtiIndex.add(UUID.fromString(result.getUserUuid()), jti); + } + return result; + } + + @Override + public void evictByUserUuid(UUID userUuid) { + Objects.requireNonNull(cacheManager.getCache(CacheConfig.USER_UUID_AUTH_CACHE)).evict(userUuid); + evictTokensByUserUuid(userUuid); + evictCertificateByUserUuid(userUuid); + } + + @Override + public void evictAll() { + tokenJtiIndex.clear(); + userCertificateIndex.clear(); + Objects.requireNonNull(cacheManager.getCache(CacheConfig.SYSTEM_USER_AUTH_CACHE)).clear(); + Objects.requireNonNull(cacheManager.getCache(CacheConfig.USER_UUID_AUTH_CACHE)).clear(); + certCache.clear(); + tokenCache.clear(); + } + + // Looks up jtis for the user in the index and evicts each one from the token cache. + // No-op if the user has no cached tokens. + private void evictTokensByUserUuid(UUID userUuid) { + Set jtis = tokenJtiIndex.removeUser(userUuid); + if (jtis == null) return; + jtis.forEach(tokenCache::evict); + } + + // Looks up the fingerprint for the user in the index and evicts it from the certificate cache. + // No-op if the user has no cached certificate. + private void evictCertificateByUserUuid(UUID userUuid) { + String fingerprint = userCertificateIndex.removeUser(userUuid); + if (fingerprint == null) return; + certCache.evict(fingerprint); + } + + @Override + public void evictByCertificateFingerprint(String certFingerprint) { + certCache.evict(certFingerprint); + } +} diff --git a/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java index 32bf2217c..f0742a63c 100644 --- a/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java +++ b/src/main/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClient.java @@ -41,18 +41,45 @@ public class CzertainlyAuthenticationClient extends CzertainlyBaseAuthentication private final ObjectMapper objectMapper; private final String customAuthServiceBaseUrl; + private final AuthenticationCache authenticationCache; @Value("${server.ssl.certificate-header-name}") private String certificateHeaderName; private final AuditLogService auditLogService; - public CzertainlyAuthenticationClient(@Autowired AuditLogService auditLogService, @Autowired ObjectMapper objectMapper, @Value("${auth-service.base-url}") String customAuthServiceBaseUrl) { + public CzertainlyAuthenticationClient( + @Autowired AuditLogService auditLogService, + @Autowired ObjectMapper objectMapper, + @Autowired AuthenticationCache authenticationCache, + @Value("${auth-service.base-url}") String customAuthServiceBaseUrl) { this.objectMapper = objectMapper; this.auditLogService = auditLogService; + this.authenticationCache = authenticationCache; this.customAuthServiceBaseUrl = customAuthServiceBaseUrl; } + public AuthenticationInfo authenticateSystemUser(String username) { + return authenticationCache.getOrAuthenticateSystemUser( + username, () -> authenticate(AuthMethod.USER_PROXY, username, false)); + } + + public AuthenticationInfo authenticateByUserUuid(UUID userUuid) { + return authenticationCache.getOrAuthenticateByUserUuid( + userUuid, () -> authenticate(AuthMethod.USER_PROXY, userUuid, false)); + } + + public AuthenticationInfo authenticateByCertificate(String rawCertHeader, String certificateThumbprint) { + return authenticationCache.getOrAuthenticateByCertificate( + certificateThumbprint, () -> authenticate(AuthMethod.CERTIFICATE, rawCertHeader, false)); + } + + public AuthenticationInfo authenticateByToken(Map claims) { + String jti = (String) claims.get("jti"); + return authenticationCache.getOrAuthenticateByToken( + jti, () -> authenticate(AuthMethod.TOKEN, claims, false)); + } + public AuthenticationInfo authenticate(AuthMethod authMethod, Object authData, boolean isLocalhostRequest) throws AuthenticationException { AuthenticationRequestDto authRequest = getAuthPayload(authMethod, authData, isLocalhostRequest); @@ -80,7 +107,7 @@ public AuthenticationInfo authenticate(AuthMethod authMethod, Object authData, b throw new CzertainlyAuthenticationException(message); } return createAuthenticationInfo(authRequest.getAuthMethod(), response); - } catch (WebClientResponseException.InternalServerError | WebClientRequestException e) { + } catch (WebClientResponseException.InternalServerError | WebClientRequestException | IllegalStateException e) { String message = "An error occurred when calling authentication service: " + e.getMessage(); AuthHelper.logAndAuditAuthFailure(logger, auditLogService, message, authRequest.getAuthData(false)); throw new CzertainlyAuthenticationException(message, e); diff --git a/src/main/java/com/czertainly/core/security/authn/client/TokenJtiIndex.java b/src/main/java/com/czertainly/core/security/authn/client/TokenJtiIndex.java new file mode 100644 index 000000000..54b36b788 --- /dev/null +++ b/src/main/java/com/czertainly/core/security/authn/client/TokenJtiIndex.java @@ -0,0 +1,46 @@ +package com.czertainly.core.security.authn.client; + +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.RemovalListener; +import org.springframework.stereotype.Component; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Secondary index: userUuid → set of JTI claims cached for that user. + * Enables per-user token eviction when only the userUuid is known. + * Kept in sync automatically via the Caffeine removal listener registered in CacheConfig. + */ +@Component +public class TokenJtiIndex implements RemovalListener { + + private final ConcurrentHashMap> index = new ConcurrentHashMap<>(); + + /** Called by Caffeine on every token cache eviction (TTL, size pressure, explicit, replace). */ + @Override + public void onRemoval(Object key, Object value, RemovalCause cause) { + if (!(key instanceof String jti) || !(value instanceof AuthenticationInfo info) || info.getUserUuid() == null) return; + index.computeIfPresent(UUID.fromString(info.getUserUuid()), (uuid, jtis) -> { + jtis.remove(jti); + return jtis.isEmpty() ? null : jtis; + }); + } + + public void add(UUID userUuid, String jti) { + if (userUuid == null) { + throw new IllegalStateException("Authenticated result must contain a non-null userUuid"); + } + index.computeIfAbsent(userUuid, k -> ConcurrentHashMap.newKeySet()).add(jti); + } + + /** Removes and returns all JTIs for the given user, or null if none. */ + public Set removeUser(UUID userUuid) { + return index.remove(userUuid); + } + + public void clear() { + index.clear(); + } +} diff --git a/src/main/java/com/czertainly/core/security/authn/client/UserCertificateIndex.java b/src/main/java/com/czertainly/core/security/authn/client/UserCertificateIndex.java new file mode 100644 index 000000000..c8c858d65 --- /dev/null +++ b/src/main/java/com/czertainly/core/security/authn/client/UserCertificateIndex.java @@ -0,0 +1,42 @@ +package com.czertainly.core.security.authn.client; + +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.RemovalListener; +import org.springframework.stereotype.Component; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Secondary index: userUuid → certificate fingerprint cached for that user. + * Enables per-user certificate eviction when only the userUuid is known. + * Kept in sync automatically via the Caffeine removal listener registered in CacheConfig. + */ +@Component +public class UserCertificateIndex implements RemovalListener { + + private final ConcurrentHashMap index = new ConcurrentHashMap<>(); + + /** Called by Caffeine on every certificate cache eviction (TTL, size pressure, explicit, replace). */ + @Override + public void onRemoval(Object key, Object value, RemovalCause cause) { + if (!(key instanceof String fingerprint) || !(value instanceof AuthenticationInfo info) || info.getUserUuid() == null) return; + index.computeIfPresent(UUID.fromString(info.getUserUuid()), (uuid, fp) -> fp.equals(fingerprint) ? null : fp); + } + + public void add(UUID userUuid, String fingerprint) { + if (userUuid == null) { + throw new IllegalStateException("Authenticated result must contain a non-null userUuid"); + } + index.put(userUuid, fingerprint); + } + + /** Removes and returns the fingerprint for the given user, or null if none. */ + public String removeUser(UUID userUuid) { + return index.remove(userUuid); + } + + public void clear() { + index.clear(); + } +} diff --git a/src/main/java/com/czertainly/core/service/CertificateService.java b/src/main/java/com/czertainly/core/service/CertificateService.java index 5018209bb..660c48d68 100644 --- a/src/main/java/com/czertainly/core/service/CertificateService.java +++ b/src/main/java/com/czertainly/core/service/CertificateService.java @@ -22,6 +22,7 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -39,6 +40,8 @@ public interface CertificateService extends ResourceExtensionService { Certificate getCertificateEntityByIssuerDnNormalizedAndSerialNumber(String issuerDn, String serialNumber) throws NotFoundException; + Optional findCertificateEntityByUserUuid(UUID userUuid); + boolean checkCertificateExistsByFingerprint(String fingerprint); void deleteCertificate(SecuredUUID uuid) throws NotFoundException; diff --git a/src/main/java/com/czertainly/core/service/impl/CertificateServiceImpl.java b/src/main/java/com/czertainly/core/service/impl/CertificateServiceImpl.java index 470beeb45..99faf3e60 100644 --- a/src/main/java/com/czertainly/core/service/impl/CertificateServiceImpl.java +++ b/src/main/java/com/czertainly/core/service/impl/CertificateServiceImpl.java @@ -36,6 +36,7 @@ import com.czertainly.core.attribute.engine.AttributeOperation; import com.czertainly.core.attribute.engine.records.ObjectAttributeContentInfo; import com.czertainly.core.comparator.SearchFieldDataComparator; +import com.czertainly.core.config.CacheConfig; import com.czertainly.core.dao.entity.*; import com.czertainly.core.dao.entity.Certificate; import com.czertainly.core.dao.entity.acme.AcmeAccount; @@ -61,6 +62,7 @@ import com.czertainly.core.model.request.CertificateRequest; import com.czertainly.core.oid.OidHandler; import com.czertainly.core.oid.OidRecord; +import com.czertainly.core.security.authn.client.AuthenticationCache; import com.czertainly.core.security.authn.client.UserManagementApiClient; import com.czertainly.core.security.authz.ExternalAuthorization; import com.czertainly.core.security.authz.SecuredParentUUID; @@ -169,6 +171,7 @@ public class CertificateServiceImpl implements CertificateService, AttributeReso private CertificateProtocolAssociationRepository certificateProtocolAssociationRepository; private ApplicationEventPublisher applicationEventPublisher; private ValidationProducer validationProducer; + private AuthenticationCache authenticationCache; /** * A map that contains ICertificateValidator implementations mapped to their corresponding certificate type code @@ -342,6 +345,11 @@ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEv this.applicationEventPublisher = applicationEventPublisher; } + @Autowired + public void setAuthenticationCache(AuthenticationCache authenticationCache) { + this.authenticationCache = authenticationCache; + } + @Override @ExternalAuthorization(resource = Resource.CERTIFICATE, action = ResourceAction.LIST, parentResource = Resource.RA_PROFILE, parentAction = ResourceAction.MEMBERS) public CertificateResponseDto listCertificates(SecurityFilter filter, CertificateSearchRequestDto request) { @@ -469,6 +477,11 @@ public Certificate getCertificateEntityByIssuerDnNormalizedAndSerialNumber(Strin return certificateRepository.findByIssuerDnNormalizedAndSerialNumber(issuerDn, serialNumber).orElseThrow(() -> new NotFoundException(Certificate.class, issuerDn + " " + serialNumber)); } + @Override + public Optional findCertificateEntityByUserUuid(UUID userUuid) { + return certificateRepository.findByUserUuid(userUuid); + } + @Override public boolean checkCertificateExistsByFingerprint(String fingerprint) { try { @@ -1288,6 +1301,9 @@ public void revokeCertificate(String serialNumber) { logger.warn("Unable to find the certificate with serialNumber {}", serialNumber); } if (certificate != null) { + if (certificate.getUserUuid() != null) { + authenticationCache.evictByCertificateFingerprint(certificate.getFingerprint()); + } eventProducer.produceMessage(CertificateStatusChangedEventHandler.constructEventMessage(certificate.getUuid(), oldStatus, CertificateValidationStatus.REVOKED)); } } @@ -1373,12 +1389,13 @@ public void updateCertificateUser(UUID certificateUuid, String userUuid) throws if (certificate.isArchived()) { throw new ValidationException("Certificate with UUID %s is archived and user with UUID %s cannot be set.".formatted(certificateUuid, userUuid)); } - if (userUuid == null) { - certificate.setUserUuid(null); - } else { - certificate.setUserUuid(UUID.fromString(userUuid)); - } + UUID oldUserUuid = certificate.getUserUuid(); + UUID newUserUuid = userUuid == null ? null : UUID.fromString(userUuid); + certificate.setUserUuid(newUserUuid); certificateRepository.save(certificate); + if (oldUserUuid != null && !Objects.equals(oldUserUuid, newUserUuid)) { + authenticationCache.evictByCertificateFingerprint(certificate.getFingerprint()); + } } @Override @@ -1388,6 +1405,7 @@ public void removeCertificateUser(UUID userUuid) { Certificate certificate = certificateRepository.findByUserUuid(userUuid).orElseThrow(() -> new NotFoundException(Certificate.class, userUuid)); certificate.setUserUuid(null); certificateRepository.save(certificate); + authenticationCache.evictByCertificateFingerprint(certificate.getFingerprint()); } catch (NotFoundException e) { logger.warn("No Certificate found for the user with UUID {}", userUuid); } diff --git a/src/main/java/com/czertainly/core/service/impl/RoleManagementServiceImpl.java b/src/main/java/com/czertainly/core/service/impl/RoleManagementServiceImpl.java index 061e2a35f..cfffb1dd9 100644 --- a/src/main/java/com/czertainly/core/service/impl/RoleManagementServiceImpl.java +++ b/src/main/java/com/czertainly/core/service/impl/RoleManagementServiceImpl.java @@ -8,6 +8,7 @@ import com.czertainly.api.model.core.scheduler.PaginationRequestDto; import com.czertainly.core.attribute.engine.AttributeEngine; import com.czertainly.core.model.auth.ResourceAction; +import com.czertainly.core.security.authn.client.AuthenticationCache; import com.czertainly.core.security.authn.client.RoleManagementApiClient; import com.czertainly.core.security.authz.ExternalAuthorization; import com.czertainly.core.security.authz.SecuredUUID; @@ -26,6 +27,7 @@ public class RoleManagementServiceImpl implements RoleManagementService { private RoleManagementApiClient roleManagementApiClient; private AttributeEngine attributeEngine; + private AuthenticationCache authenticationCache; @Autowired public void setRoleManagementApiClient(RoleManagementApiClient roleManagementApiClient) { @@ -37,6 +39,11 @@ public void setAttributeEngine(AttributeEngine attributeEngine) { this.attributeEngine = attributeEngine; } + @Autowired + public void setAuthenticationCache(AuthenticationCache authenticationCache) { + this.authenticationCache = authenticationCache; + } + @Override @ExternalAuthorization(resource = Resource.ROLE, action = ResourceAction.LIST) public List listRoles() { @@ -76,7 +83,7 @@ public RoleDetailDto updateRole(String roleUuid, RoleRequestDto request) throws requestDto.setSystemRole(false); RoleDetailDto dto = roleManagementApiClient.updateRole(roleUuid, requestDto); dto.setCustomAttributes(attributeEngine.updateObjectCustomAttributesContent(Resource.ROLE, UUID.fromString(dto.getUuid()), request.getCustomAttributes())); - + authenticationCache.evictAll(); return dto; } @@ -85,6 +92,7 @@ public RoleDetailDto updateRole(String roleUuid, RoleRequestDto request) throws public void deleteRole(String roleUuid) { roleManagementApiClient.deleteRole(roleUuid); attributeEngine.deleteObjectAttributeContent(Resource.ROLE, UUID.fromString(roleUuid)); + authenticationCache.evictAll(); } @Override @@ -97,8 +105,9 @@ public SubjectPermissionsDto getRolePermissions(String roleUuid) { @ExternalAuthorization(resource = Resource.ROLE, action = ResourceAction.UPDATE) public SubjectPermissionsDto addPermissions(String roleUuid, RolePermissionsRequestDto request) { checkSystemRole(roleUuid); - - return roleManagementApiClient.savePermissions(roleUuid, request); + SubjectPermissionsDto result = roleManagementApiClient.savePermissions(roleUuid, request); + authenticationCache.evictAll(); + return result; } @Override @@ -117,24 +126,24 @@ public List getResourcePermissionObjects(String roleUuid, @ExternalAuthorization(resource = Resource.ROLE, action = ResourceAction.UPDATE) public void addResourcePermissionObjects(String roleUuid, String resourceUuid, List request) { checkSystemRole(roleUuid); - roleManagementApiClient.addResourcePermissionObjects(roleUuid, resourceUuid, request); + authenticationCache.evictAll(); } @Override @ExternalAuthorization(resource = Resource.ROLE, action = ResourceAction.UPDATE) public void updateResourcePermissionObjects(String roleUuid, String resourceUuid, String objectUuid, ObjectPermissionsRequestDto request) { checkSystemRole(roleUuid); - roleManagementApiClient.updateResourcePermissionObjects(roleUuid, resourceUuid, objectUuid, request); + authenticationCache.evictAll(); } @Override @ExternalAuthorization(resource = Resource.ROLE, action = ResourceAction.UPDATE) public void removeResourcePermissionObjects(String roleUuid, String resourceUuid, String objectUuid) { checkSystemRole(roleUuid); - roleManagementApiClient.removeResourcePermissionObjects(roleUuid, resourceUuid, objectUuid); + authenticationCache.evictAll(); } @Override @@ -146,7 +155,9 @@ public List getRoleUsers(String roleUuid) { @Override @ExternalAuthorization(resource = Resource.ROLE, action = ResourceAction.UPDATE) public RoleDetailDto updateUsers(String roleUuid, List userUuids) { - return roleManagementApiClient.updateUsers(roleUuid, userUuids); + RoleDetailDto result = roleManagementApiClient.updateUsers(roleUuid, userUuids); + authenticationCache.evictAll(); + return result; } @Override diff --git a/src/main/java/com/czertainly/core/service/impl/UserManagementServiceImpl.java b/src/main/java/com/czertainly/core/service/impl/UserManagementServiceImpl.java index 55906a130..1b7356236 100644 --- a/src/main/java/com/czertainly/core/service/impl/UserManagementServiceImpl.java +++ b/src/main/java/com/czertainly/core/service/impl/UserManagementServiceImpl.java @@ -29,6 +29,7 @@ import com.czertainly.core.messaging.model.AuditLogMessage; import com.czertainly.core.model.auth.AuthenticationRequestDto; import com.czertainly.core.model.auth.ResourceAction; +import com.czertainly.core.security.authn.client.AuthenticationCache; import com.czertainly.core.security.authn.client.UserManagementApiClient; import com.czertainly.core.security.authz.ExternalAuthorization; import com.czertainly.core.security.authz.SecuredUUID; @@ -72,6 +73,13 @@ public class UserManagementServiceImpl implements UserManagementService { private FindByIndexNameSessionRepository sessionRepository; + private AuthenticationCache authenticationCache; + + @Autowired + public void setAuthenticationCache(AuthenticationCache authenticationCache) { + this.authenticationCache = authenticationCache; + } + @Autowired public void setAuditLogsProducer(AuditLogsProducer auditLogsProducer) { this.auditLogsProducer = auditLogsProducer; @@ -166,20 +174,22 @@ public UserDetailDto updateUser(String userUuid, UpdateUserRequestDto request) t attributeEngine.validateCustomAttributesContent(Resource.USER, request.getCustomAttributes()); UserDetailDto dto = getUserUpdateRequestPayload(userUuid, request, "", ""); dto.setCustomAttributes(attributeEngine.updateObjectCustomAttributesContent(Resource.USER, UUID.fromString(userUuid), request.getCustomAttributes())); + authenticationCache.evictByUserUuid(UUID.fromString(userUuid)); return dto; } @Override //Internal Use Only -- For Auth Profile Update API public UserDetailDto updateUserInternal(String userUuid, UpdateUserRequestDto request, String certificateUuid, String certificateFingerprint) throws NotFoundException, CertificateException { - return getUserUpdateRequestPayload(userUuid, request, certificateUuid, certificateFingerprint); + UserDetailDto dto = getUserUpdateRequestPayload(userUuid, request, certificateUuid, certificateFingerprint); + authenticationCache.evictByUserUuid(UUID.fromString(userUuid)); + return dto; } @Override @ExternalAuthorization(resource = Resource.USER, action = ResourceAction.DELETE) public void deleteUser(String userUuid) { userManagementApiClient.removeUser(userUuid); - UUID uuid = UUID.fromString(userUuid); certificateService.removeCertificateUser(uuid); objectAssociationService.removeOwnerAssociations(uuid); @@ -188,6 +198,8 @@ public void deleteUser(String userUuid) { } private void clearAuthenticationData(String userUuid, String actionName) { + authenticationCache.evictByUserUuid(UUID.fromString(userUuid)); + Map userSessions = sessionRepository.findByPrincipalName(userUuid); @@ -215,13 +227,17 @@ private void clearAuthenticationData(String userUuid, String actionName) { @Override @ExternalAuthorization(resource = Resource.USER, action = ResourceAction.UPDATE) public UserDetailDto updateRoles(String userUuid, List roleUuids) { - return userManagementApiClient.updateRoles(userUuid, roleUuids); + UserDetailDto result = userManagementApiClient.updateRoles(userUuid, roleUuids); + authenticationCache.evictByUserUuid(UUID.fromString(userUuid)); + return result; } @Override @ExternalAuthorization(resource = Resource.USER, action = ResourceAction.UPDATE) public UserDetailDto updateRole(String userUuid, String roleUuid) { - return userManagementApiClient.updateRole(userUuid, roleUuid); + UserDetailDto result = userManagementApiClient.updateRole(userUuid, roleUuid); + authenticationCache.evictByUserUuid(UUID.fromString(userUuid)); + return result; } @Override @@ -253,7 +269,9 @@ public List getUserRoles(String userUuid) { @Override @ExternalAuthorization(resource = Resource.USER, action = ResourceAction.UPDATE) public UserDetailDto removeRole(String userUuid, String roleUuid) { - return userManagementApiClient.removeRole(userUuid, roleUuid); + UserDetailDto result = userManagementApiClient.removeRole(userUuid, roleUuid); + authenticationCache.evictByUserUuid(UUID.fromString(userUuid)); + return result; } @Override diff --git a/src/main/java/com/czertainly/core/util/AuthHelper.java b/src/main/java/com/czertainly/core/util/AuthHelper.java index e5be765ad..6f9c5b24d 100644 --- a/src/main/java/com/czertainly/core/util/AuthHelper.java +++ b/src/main/java/com/czertainly/core/util/AuthHelper.java @@ -7,7 +7,6 @@ import com.czertainly.api.model.core.auth.UserDetailDto; import com.czertainly.api.model.core.auth.UserProfileDto; import com.czertainly.api.model.core.logging.enums.ActorType; -import com.czertainly.api.model.core.logging.enums.AuthMethod; import com.czertainly.api.model.core.logging.enums.Operation; import com.czertainly.api.model.core.logging.enums.OperationResult; import com.czertainly.core.logging.LoggingHelper; @@ -90,7 +89,7 @@ public void authenticateAsSystemUser(String username) { ActorType actorType = protocolUsers.contains(username) ? ActorType.PROTOCOL : ActorType.CORE; LoggingHelper.putActorInfoWhenNull(actorType, null, username); - AuthenticationInfo authUserInfo = czertainlyAuthenticationClient.authenticate(AuthMethod.USER_PROXY, username,false); + AuthenticationInfo authUserInfo = czertainlyAuthenticationClient.authenticateSystemUser(username); CzertainlyUserDetails userDetails = new CzertainlyUserDetails(authUserInfo); SecurityContext securityContext = SecurityContextHolder.getContext(); securityContext.setAuthentication(new CzertainlyAuthenticationToken(userDetails)); @@ -101,7 +100,7 @@ public void authenticateAsUser(UUID userUuid) { // update MDC for actor logging LoggingHelper.putActorInfoWhenNull(ActorType.USER, userUuid.toString(), null); - AuthenticationInfo authUserInfo = czertainlyAuthenticationClient.authenticate(AuthMethod.USER_PROXY, userUuid, false); + AuthenticationInfo authUserInfo = czertainlyAuthenticationClient.authenticateByUserUuid(userUuid); SecurityContext securityContext = SecurityContextHolder.getContext(); securityContext.setAuthentication(new CzertainlyAuthenticationToken(new CzertainlyUserDetails(authUserInfo))); logger.debug("User with username '{}' has been successfully authenticated as user proxy.", authUserInfo.getUsername()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5d8592cc9..46eec6b50 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,13 @@ app: version: '@project.version@' + +caching: + # Authentication cache caches resolved user identities to avoid repeated auth-service lookups + authentication: + ttl-minutes: ${AUTH_CACHE_TTL_MINUTES:5} # How long a cached identity is considered valid + max-size: ${AUTH_CACHE_MAX_SIZE:500} # Maximum number of cached identities + certificate: chain: max-depth: 20 diff --git a/src/test/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverterTest.java b/src/test/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverterTest.java new file mode 100644 index 000000000..a3ec8780f --- /dev/null +++ b/src/test/java/com/czertainly/core/auth/oauth2/CzertainlyJwtAuthenticationConverterTest.java @@ -0,0 +1,180 @@ +package com.czertainly.core.auth.oauth2; + +import com.czertainly.api.model.core.logging.enums.AuthMethod; +import com.czertainly.api.model.core.logging.enums.Operation; +import com.czertainly.api.model.core.logging.enums.OperationResult; +import com.czertainly.api.model.core.settings.SettingsSection; +import com.czertainly.api.model.core.settings.authentication.AuthenticationSettingsDto; +import com.czertainly.api.model.core.settings.authentication.OAuth2ProviderSettingsDto; +import com.czertainly.core.security.authn.CzertainlyAuthenticationException; +import com.czertainly.core.security.authn.CzertainlyAuthenticationToken; +import com.czertainly.core.security.authn.client.AuthenticationInfo; +import com.czertainly.core.security.authn.client.CzertainlyAuthenticationClient; +import com.czertainly.core.service.AuditLogService; +import com.czertainly.core.settings.SettingsCache; +import com.czertainly.core.util.OAuth2Util; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CzertainlyJwtAuthenticationConverterTest { + + private static final String ISSUER_URL = "https://issuer.example.com"; + private static final String TOKEN_VALUE = "header.payload.signature"; + + @Mock + private CzertainlyAuthenticationClient authenticationClient; + + @Mock + private AuditLogService auditLogService; + + private CzertainlyJwtAuthenticationConverter converter; + + @BeforeEach + void setUp() { + converter = new CzertainlyJwtAuthenticationConverter(); + converter.setAuthenticationClient(authenticationClient); + converter.setAuditLogService(auditLogService); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void nullJwt_returnsCurrentSecurityContextAuthentication() { + // given - a prior filter already placed an authenticated token in the context + AbstractAuthenticationToken existingAuth = mock(AbstractAuthenticationToken.class); + var ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(existingAuth); + SecurityContextHolder.setContext(ctx); + + // when + AbstractAuthenticationToken result = converter.convert(null); + + // then + assertSame(existingAuth, result); + } + + @Test + void nullJwt_withEmptySecurityContext_returnsNull() { + // given - no prior authentication in the context (SecurityContextHolder cleared in @BeforeEach) + + // when + AbstractAuthenticationToken result = converter.convert(null); + + // then + assertNull(result); + } + + @Test + void validJwt_callsAuthClientWithExtractedClaimsAndReturnsAuthToken() throws MalformedURLException { + // given + Jwt jwt = mockJwt(ISSUER_URL); + Map claims = Map.of("username", "alice", "jti", "jti-123"); + when(authenticationClient.authenticateByToken(claims)).thenReturn(authenticatedInfo()); + + try (MockedStatic settingsMock = mockStatic(SettingsCache.class); + MockedStatic oauth2Mock = mockStatic(OAuth2Util.class)) { + settingsMock.when(() -> SettingsCache.getSettings(SettingsSection.AUTHENTICATION)) + .thenReturn(authSettingsWithProvider(ISSUER_URL)); + oauth2Mock.when(() -> OAuth2Util.getAllClaimsAvailable( + argThat(p -> p != null && ISSUER_URL.equals(p.getIssuerUrl())), + eq(TOKEN_VALUE), isNull())) + .thenReturn(claims); + + // when + AbstractAuthenticationToken result = converter.convert(jwt); + + // then + verify(authenticationClient).authenticateByToken(claims); + assertInstanceOf(CzertainlyAuthenticationToken.class, result); + } + } + + @Test + void claimsExtractionFailure_logsAuditAndRethrows() throws MalformedURLException { + // given + Jwt jwt = mockJwt(ISSUER_URL); + CzertainlyAuthenticationException cause = new CzertainlyAuthenticationException("claims extraction failed"); + + try (MockedStatic settingsMock = mockStatic(SettingsCache.class); + MockedStatic oauth2Mock = mockStatic(OAuth2Util.class)) { + settingsMock.when(() -> SettingsCache.getSettings(SettingsSection.AUTHENTICATION)) + .thenReturn(authSettingsWithProvider(ISSUER_URL)); + oauth2Mock.when(() -> OAuth2Util.getAllClaimsAvailable(any(), anyString(), isNull())) + .thenThrow(cause); + + // when / then + CzertainlyAuthenticationException thrown = assertThrows( + CzertainlyAuthenticationException.class, () -> converter.convert(jwt)); + assertSame(cause, thrown); + verify(auditLogService).logAuthentication(Operation.AUTHENTICATION, OperationResult.FAILURE, cause.getMessage(), TOKEN_VALUE); + } + } + + @Test + void issuerNotMatchingAnyProvider_passesNullProviderSettingsToGetClaims() throws MalformedURLException { + // given - JWT issuer does not match the single configured provider + Jwt jwt = mockJwt("https://unknown-issuer.example.com"); + Map claims = Map.of("username", "alice"); + when(authenticationClient.authenticateByToken(any())).thenReturn(authenticatedInfo()); + + try (MockedStatic settingsMock = mockStatic(SettingsCache.class); + MockedStatic oauth2Mock = mockStatic(OAuth2Util.class)) { + settingsMock.when(() -> SettingsCache.getSettings(SettingsSection.AUTHENTICATION)) + .thenReturn(authSettingsWithProvider(ISSUER_URL)); + oauth2Mock.when(() -> OAuth2Util.getAllClaimsAvailable(isNull(), eq(TOKEN_VALUE), isNull())) + .thenReturn(claims); + + // when + converter.convert(jwt); + + // then - null provider settings forwarded because no configured issuer matched the JWT + oauth2Mock.verify(() -> OAuth2Util.getAllClaimsAvailable(isNull(), eq(TOKEN_VALUE), isNull())); + } + } + + // --- helpers --- + + private static Jwt mockJwt(String issuerUrl) throws MalformedURLException { + Jwt jwt = mock(Jwt.class); + when(jwt.getTokenValue()).thenReturn(TOKEN_VALUE); + when(jwt.getIssuer()).thenReturn(new URL(issuerUrl)); + return jwt; + } + + private static AuthenticationSettingsDto authSettingsWithProvider(String issuerUrl) { + OAuth2ProviderSettingsDto provider = new OAuth2ProviderSettingsDto(); + provider.setName("test-provider"); + provider.setIssuerUrl(issuerUrl); + AuthenticationSettingsDto settings = new AuthenticationSettingsDto(); + settings.setOAuth2Providers(Map.of("test-provider", provider)); + return settings; + } + + private static AuthenticationInfo authenticatedInfo() { + return new AuthenticationInfo(AuthMethod.TOKEN, "uuid-1", "alice", + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } +} diff --git a/src/test/java/com/czertainly/core/config/SecurityConfigTest.java b/src/test/java/com/czertainly/core/config/SecurityConfigTest.java index 2eaa9dc69..d25c076ca 100644 --- a/src/test/java/com/czertainly/core/config/SecurityConfigTest.java +++ b/src/test/java/com/czertainly/core/config/SecurityConfigTest.java @@ -2,6 +2,7 @@ import com.czertainly.api.model.core.settings.SettingsSection; import com.czertainly.api.model.core.settings.authentication.AuthenticationSettingsDto; +import com.czertainly.core.security.authn.client.AuthenticationCache; import com.czertainly.core.security.oauth2.OAuth2TestUtil; import com.czertainly.core.settings.SettingsCache; import com.czertainly.core.util.*; @@ -61,6 +62,9 @@ class SecurityConfigTest extends BaseSpringBootTestNoAuth { @Autowired SettingsCache settingsCache; + @Autowired + AuthenticationCache authenticationCache; + @MockitoBean private JdbcIndexedSessionRepository sessionRepository; @@ -89,6 +93,8 @@ static void authServiceProperties(DynamicPropertyRegistry registry) { @BeforeEach void setUp() throws NoSuchAlgorithmException, JOSEException, ServletException, IOException { + authenticationCache.evictAll(); + Mockito.doAnswer(invocation -> { ServletRequest request = invocation.getArgument(0); ServletResponse response = invocation.getArgument(1); diff --git a/src/test/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilterTest.java b/src/test/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilterTest.java new file mode 100644 index 000000000..a6f68fc73 --- /dev/null +++ b/src/test/java/com/czertainly/core/security/authn/CzertainlyAuthenticationFilterTest.java @@ -0,0 +1,227 @@ +package com.czertainly.core.security.authn; + +import com.czertainly.api.model.core.logging.enums.AuthMethod; +import com.czertainly.core.security.authn.client.AuthenticationInfo; +import com.czertainly.core.security.authn.client.CzertainlyAuthenticationClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CzertainlyAuthenticationFilterTest { + + private static final String CERT_HEADER_NAME = "ssl-client-cert"; + + // PEM wrapping base64("test") — after normalize+decode yields DER bytes [0x74, 0x65, 0x73, 0x74] + // expected thumbprint = SHA-256("test") = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + private static final String CERT_HEADER = "-----BEGIN CERTIFICATE-----\ndGVzdA==\n-----END CERTIFICATE-----\n"; + private static final String CERT_THUMBPRINT = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + + @Mock + private CzertainlyAuthenticationClient authClient; + + private CzertainlyAuthenticationFilter filter; + + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private MockFilterChain filterChain; + + @BeforeEach + void setUp() { + filter = new CzertainlyAuthenticationFilter(authClient, CERT_HEADER_NAME, ""); + request = new MockHttpServletRequest(); + request.setRemoteAddr("10.0.0.1"); // non-loopback so isLocalhostAddress = false + response = new MockHttpServletResponse(); + filterChain = new MockFilterChain(); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + // --- authentication routing --- + + @Test + void noCertHeader_callsNoneAuth() throws Exception { + // given + when(authClient.authenticate(eq(AuthMethod.NONE), isNull(), eq(false))) + .thenReturn(authenticatedInfo()); + + // when + filter.doFilter(request, response, filterChain); + + // then + verify(authClient).authenticate(AuthMethod.NONE, null, false); + verify(authClient, never()).authenticateByCertificate(any(), any()); + } + + @Test + void certHeader_present_callsCertificateAuthWithComputedThumbprint() throws Exception { + // given + request.addHeader(CERT_HEADER_NAME, CERT_HEADER); + when(authClient.authenticateByCertificate(CERT_HEADER, CERT_THUMBPRINT)) + .thenReturn(authenticatedInfo()); + + // when + filter.doFilter(request, response, filterChain); + + // then - thumbprint is the SHA-256 of the DER bytes derived from the PEM content + verify(authClient).authenticateByCertificate(CERT_HEADER, CERT_THUMBPRINT); + verify(authClient, never()).authenticate(any(), any(), anyBoolean()); + } + + // --- SecurityContext outcome --- + + @Test + void authenticatedResult_setsAuthenticationToken() throws Exception { + // given + when(authClient.authenticate(any(), any(), anyBoolean())).thenReturn(authenticatedInfo()); + + // when + filter.doFilter(request, response, filterChain); + + // then + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertInstanceOf(CzertainlyAuthenticationToken.class, auth); + assertTrue(auth.isAuthenticated()); + } + + @Test + void anonymousResult_setsAnonymousToken() throws Exception { + // given + when(authClient.authenticate(any(), any(), anyBoolean())) + .thenReturn(AuthenticationInfo.getAnonymousAuthenticationInfo()); + + // when + filter.doFilter(request, response, filterChain); + + // then + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertInstanceOf(CzertainlyAnonymousToken.class, auth); + } + + // --- exception handling --- + + @Test + void czertainlyAuthException_clearsContextAndContinuesChain() { + // given + when(authClient.authenticate(any(), any(), anyBoolean())) + .thenThrow(new CzertainlyAuthenticationException("auth service unreachable")); + + // when - must not propagate + assertDoesNotThrow(() -> filter.doFilter(request, response, filterChain)); + + // then - context must be cleared after the failure + assertNull(SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void malformedCertHeader_clearsContextAndContinuesChain() { + // given - cert header present but Base64 content is garbage (not valid DER) + request.addHeader(CERT_HEADER_NAME, "-----BEGIN CERTIFICATE-----\n!!!not-base64!!!\n-----END CERTIFICATE-----\n"); + + // when - must not propagate a 500; the filter should swallow the malformed-header error + assertDoesNotThrow(() -> filter.doFilter(request, response, filterChain)); + + // then - no auth was stored and the auth client was never called + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(authClient, never()).authenticateByCertificate(any(), any()); + } + + @Test + void nonCzertainlyAuthException_isRethrown() { + // given + when(authClient.authenticate(any(), any(), anyBoolean())) + .thenThrow(new BadCredentialsException("unexpected")); + + // when / then + assertThrows(BadCredentialsException.class, () -> filter.doFilter(request, response, filterChain)); + } + + // --- isAuthenticationNeeded guard cases --- + + @Test + void permitAllEndpoint_setsAnonymousTokenWithPermitAllFlagAndSkipsAuth() throws Exception { + // given - /v1/health/live matches the /v?/health/** permit-all pattern + request.setRequestURI("/v1/health/live"); + + // when + filter.doFilter(request, response, filterChain); + + // then + verifyNoInteractions(authClient); + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertInstanceOf(CzertainlyAnonymousToken.class, auth); + assertTrue(((CzertainlyAnonymousToken) auth).isAccessingPermitAllEndpoint()); + } + + @Test + void alreadyAuthenticated_skipsAuth() throws Exception { + // given - a prior filter already placed an authenticated token in the context + CzertainlyAuthenticationToken existingToken = + new CzertainlyAuthenticationToken(new CzertainlyUserDetails(authenticatedInfo())); + var ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(existingToken); + SecurityContextHolder.setContext(ctx); + + // when + filter.doFilter(request, response, filterChain); + + // then + verifyNoInteractions(authClient); + assertSame(existingToken, SecurityContextHolder.getContext().getAuthentication()); + } + + @Test + void authorizationHeaderOnly_noCertHeader_skipsAuth() throws Exception { + // given - bearer token present but no cert header; OAuth2 filter handles this path + request.addHeader("Authorization", "Bearer some.jwt.token"); + + // when + filter.doFilter(request, response, filterChain); + + // then + verifyNoInteractions(authClient); + } + + @Test + void authorizationAndCertHeaderPresent_certTakesPrecedence() throws Exception { + // given + request.addHeader("Authorization", "Bearer some.jwt.token"); + request.addHeader(CERT_HEADER_NAME, CERT_HEADER); + when(authClient.authenticateByCertificate(CERT_HEADER, CERT_THUMBPRINT)) + .thenReturn(authenticatedInfo()); + + // when + filter.doFilter(request, response, filterChain); + + // then - cert path is chosen even when an Authorization header is present + verify(authClient).authenticateByCertificate(CERT_HEADER, CERT_THUMBPRINT); + verify(authClient, never()).authenticate(any(), any(), anyBoolean()); + } + + // --- helpers --- + + private static AuthenticationInfo authenticatedInfo() { + return new AuthenticationInfo(AuthMethod.CERTIFICATE, "uuid-1", "alice", + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } +} diff --git a/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCacheTest.java b/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCacheTest.java new file mode 100644 index 000000000..6c2d945a6 --- /dev/null +++ b/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationCacheTest.java @@ -0,0 +1,358 @@ +package com.czertainly.core.security.authn.client; + +import com.czertainly.api.model.core.logging.enums.AuthMethod; +import com.czertainly.core.util.BaseSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class CzertainlyAuthenticationCacheTest extends BaseSpringBootTest { + + @Autowired + private AuthenticationCache authenticationCache; + + @BeforeEach + void clearCache() { + authenticationCache.evictAll(); + } + + // --- getOrAuthenticateSystemUser --- + + @Test + void getOrAuthenticateSystemUser_cacheMiss_callsLoader() { + // given - the cache is empty + AuthenticationInfo expected = authenticatedInfo(UUID.randomUUID().toString(), "superadmin"); + Supplier loader = loaderReturning(expected); + + // when - first call to authenticate the user + AuthenticationInfo result = authenticationCache.getOrAuthenticateSystemUser("superadmin", loader); + + // then - verify that the loader was called and the result is as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateSystemUser_cacheHit_loaderNotCalledAgain() { + // given + AuthenticationInfo expected = authenticatedInfo(UUID.randomUUID().toString(), "superadmin"); + Supplier loader = loaderReturning(expected); + // first call to authenticate the user - this will populate the cache + authenticationCache.getOrAuthenticateSystemUser("superadmin", loader); + + // when - second call to authenticate the user - the cache should be used + AuthenticationInfo result = authenticationCache.getOrAuthenticateSystemUser("superadmin", loader); + + // then - verify that the loader was not called again and the result is the same as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateSystemUser_anonymousResult_notCached() { + // given - anonymous results must not be stored, so the user can authenticate on the next request + Supplier loader = loaderReturning(AuthenticationInfo.getAnonymousAuthenticationInfo()); + + // when - call twice to verify that the loader is called both times due to the non-caching of anonymous results + authenticationCache.getOrAuthenticateSystemUser("superadmin", loader); + authenticationCache.getOrAuthenticateSystemUser("superadmin", loader); + + // then - verify that the loader was called twice and the result is anonymous + verify(loader, times(2)).get(); + } + + // --- getOrAuthenticateByUserUuid --- + + @Test + void getOrAuthenticateByUserUuid_cacheMiss_callsLoader() { + // given - the cache is empty + UUID userUuid = UUID.randomUUID(); + AuthenticationInfo expected = authenticatedInfo(userUuid.toString(), "user1"); + Supplier loader = loaderReturning(expected); + + // when - first call to authenticate the user by UUID + AuthenticationInfo result = authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + + // then - verify that the loader was called and the result is as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateByUserUuid_cacheHit_loaderNotCalledAgain() { + // given + UUID userUuid = UUID.randomUUID(); + AuthenticationInfo expected = authenticatedInfo(userUuid.toString(), "user1"); + Supplier loader = loaderReturning(expected); + // first call to authenticate the user - this will populate the cache + authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + + // when - second call to authenticate the same user UUID - the cache should be used + AuthenticationInfo result = authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + + // then - verify that the loader was not called again and the result is the same as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateByUserUuid_anonymousResult_notCached() { + // given - anonymous results must not be stored, so the user can authenticate on the next request + UUID userUuid = UUID.randomUUID(); + Supplier loader = loaderReturning(AuthenticationInfo.getAnonymousAuthenticationInfo()); + + // when - call twice to verify that the loader is called both times due to the non-caching of anonymous results + authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + + // then - verify that the loader was called twice because anonymous results bypass the cache + verify(loader, times(2)).get(); + } + + // --- getOrAuthenticateByCertificate --- + + @Test + void getOrAuthenticateByCertificate_cacheMiss_callsLoader() { + // given - the cache is empty + AuthenticationInfo expected = authenticatedInfo(UUID.randomUUID().toString(), "certUser"); + Supplier loader = loaderReturning(expected); + + // when - first call to authenticate by certificate fingerprint + AuthenticationInfo result = authenticationCache.getOrAuthenticateByCertificate("fingerprint-abc", loader); + + // then - verify that the loader was called and the result is as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateByCertificate_cacheHit_loaderNotCalledAgain() { + // given + AuthenticationInfo expected = authenticatedInfo(UUID.randomUUID().toString(), "certUser"); + Supplier loader = loaderReturning(expected); + // first call to authenticate the user - this will populate the cache + authenticationCache.getOrAuthenticateByCertificate("fingerprint-abc", loader); + + // when - second call with the same certificate fingerprint - the cache should be used + AuthenticationInfo result = authenticationCache.getOrAuthenticateByCertificate("fingerprint-abc", loader); + + // then - verify that the loader was not called again and the result is the same as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateByCertificate_anonymousResult_notCached() { + // given - anonymous results must not be stored, so the user can authenticate on the next request + Supplier loader = loaderReturning(AuthenticationInfo.getAnonymousAuthenticationInfo()); + + // when - call twice to verify that the loader is called both times due to the non-caching of anonymous results + authenticationCache.getOrAuthenticateByCertificate("fingerprint-abc", loader); + authenticationCache.getOrAuthenticateByCertificate("fingerprint-abc", loader); + + // then - verify that the loader was called twice because anonymous results bypass the cache + verify(loader, times(2)).get(); + } + + // --- getOrAuthenticateByToken --- + + @Test + void getOrAuthenticateByToken_nullJti_loaderAlwaysCalled() { + // given - tokens without a jti cannot be uniquely identified, so they are never cached + Supplier loader = loaderReturning(authenticatedInfo(UUID.randomUUID().toString(), "tokenUser")); + + // when - call twice with a null jti to verify that caching is always skipped + authenticationCache.getOrAuthenticateByToken(null, loader); + authenticationCache.getOrAuthenticateByToken(null, loader); + + // then - verify that the loader was called both times because null-jti tokens bypass the cache + verify(loader, times(2)).get(); + } + + @Test + void getOrAuthenticateByToken_cacheMiss_callsLoader() { + // given - the cache is empty + AuthenticationInfo expected = authenticatedInfo(UUID.randomUUID().toString(), "tokenUser"); + Supplier loader = loaderReturning(expected); + + // when - first call to authenticate by token jti + AuthenticationInfo result = authenticationCache.getOrAuthenticateByToken("jti-123", loader); + + // then - verify that the loader was called and the result is as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateByToken_cacheHit_loaderNotCalledAgain() { + // given + AuthenticationInfo expected = authenticatedInfo(UUID.randomUUID().toString(), "tokenUser"); + Supplier loader = loaderReturning(expected); + // first call to authenticate the user - this will populate the cache + authenticationCache.getOrAuthenticateByToken("jti-123", loader); + + // when - second call with the same jti - the cache should be used + AuthenticationInfo result = authenticationCache.getOrAuthenticateByToken("jti-123", loader); + + // then - verify that the loader was not called again and the result is the same as expected + assertEquals(expected, result); + verify(loader, times(1)).get(); + } + + @Test + void getOrAuthenticateByToken_anonymousResult_notCached() { + // given - anonymous results must not be stored, so the user can authenticate on the next request + Supplier loader = loaderReturning(AuthenticationInfo.getAnonymousAuthenticationInfo()); + + // when - call twice to verify that the loader is called both times due to the non-caching of anonymous results + authenticationCache.getOrAuthenticateByToken("jti-anon", loader); + authenticationCache.getOrAuthenticateByToken("jti-anon", loader); + + // then - verify that the loader was called twice because anonymous results bypass the cache + verify(loader, times(2)).get(); + } + + // --- evictByUserUuid --- + + @Test + void evictByUserUuid_evictsUserUuidEntry() { + // given - the user UUID cache is populated + UUID userUuid = UUID.randomUUID(); + Supplier loader = loaderReturning(authenticatedInfo(userUuid.toString(), "user")); + authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + + // when - evict the cache entry for this user + authenticationCache.evictByUserUuid(userUuid); + + // then - verify that the next call reaches the loader again because the entry was evicted + authenticationCache.getOrAuthenticateByUserUuid(userUuid, loader); + verify(loader, times(2)).get(); + } + + @Test + void evictByUserUuid_evictsAllTokensForUser() { + // given - two separate tokens for the same user are both tracked in the jti index + UUID userUuid = UUID.randomUUID(); + AuthenticationInfo info = authenticatedInfo(userUuid.toString(), "user"); + Supplier loaderA = loaderReturning(info); + Supplier loaderB = loaderReturning(info); + authenticationCache.getOrAuthenticateByToken("jti-A", loaderA); + authenticationCache.getOrAuthenticateByToken("jti-B", loaderB); + + // when - evict all cache entries for this user + authenticationCache.evictByUserUuid(userUuid); + + // then - verify that both token entries were evicted via the jti index and the loaders are called again + authenticationCache.getOrAuthenticateByToken("jti-A", loaderA); + authenticationCache.getOrAuthenticateByToken("jti-B", loaderB); + verify(loaderA, times(2)).get(); + verify(loaderB, times(2)).get(); + } + + @Test + void evictByUserUuid_evictsCertificateEntry() { + // given - the certificate cache is populated, so the user-certificate index has recorded the mapping + UUID userUuid = UUID.randomUUID(); + String fingerprint = "cert-fingerprint"; + Supplier loader = loaderReturning(authenticatedInfo(userUuid.toString(), "certUser")); + authenticationCache.getOrAuthenticateByCertificate(fingerprint, loader); + + // when - evict by user UUID; the cache resolves the fingerprint from the index internally + authenticationCache.evictByUserUuid(userUuid); + + // then - verify that the certificate entry was evicted and the loader is called again + authenticationCache.getOrAuthenticateByCertificate(fingerprint, loader); + verify(loader, times(2)).get(); + } + + @Test + void evictByUserUuid_doesNotEvictCertificateWhenNoneWasCached() { + // given - no certificate has been cached for this user, so the index has no entry + UUID userUuid = UUID.randomUUID(); + String fingerprint = "cert-fingerprint"; + // populate with a different user so the fingerprint is in the cache but not mapped to this user + Supplier loader = loaderReturning(authenticatedInfo(UUID.randomUUID().toString(), "certUser")); + authenticationCache.getOrAuthenticateByCertificate(fingerprint, loader); + + // when - evict a user who has no certificate cache entry + authenticationCache.evictByUserUuid(userUuid); + + // then - the certificate entry for the other user is unaffected + authenticationCache.getOrAuthenticateByCertificate(fingerprint, loader); + verify(loader, times(1)).get(); + } + + @Test + void evictByUserUuid_doesNotEvictOtherUsersTokens() { + // given - two different users each have a token cached + UUID userUuidA = UUID.randomUUID(); + UUID userUuidB = UUID.randomUUID(); + Supplier loaderA = loaderReturning(authenticatedInfo(userUuidA.toString(), "userA")); + Supplier loaderB = loaderReturning(authenticatedInfo(userUuidB.toString(), "userB")); + authenticationCache.getOrAuthenticateByToken("jti-userA", loaderA); + authenticationCache.getOrAuthenticateByToken("jti-userB", loaderB); + + // when - evict only userA + authenticationCache.evictByUserUuid(userUuidA); + + // then - verify that userA token was evicted and userB token is still cached + authenticationCache.getOrAuthenticateByToken("jti-userA", loaderA); + authenticationCache.getOrAuthenticateByToken("jti-userB", loaderB); + verify(loaderA, times(2)).get(); + verify(loaderB, times(1)).get(); + } + + // --- evictAll --- + + @Test + void evictAll_clearsAllCaches() { + // given - all four cache types are populated + String userUuid = UUID.randomUUID().toString(); + AuthenticationInfo info = authenticatedInfo(userUuid, "user"); + Supplier systemUserLoader = loaderReturning(info); + Supplier uuidLoader = loaderReturning(info); + Supplier certLoader = loaderReturning(info); + Supplier tokenLoader = loaderReturning(info); + + authenticationCache.getOrAuthenticateSystemUser("superadmin", systemUserLoader); + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(userUuid), uuidLoader); + authenticationCache.getOrAuthenticateByCertificate("fingerprint", certLoader); + authenticationCache.getOrAuthenticateByToken("jti-all", tokenLoader); + + // when - evict all entries across all caches + authenticationCache.evictAll(); + + // then - verify that every loader is called again because all entries were evicted + authenticationCache.getOrAuthenticateSystemUser("superadmin", systemUserLoader); + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(userUuid), uuidLoader); + authenticationCache.getOrAuthenticateByCertificate("fingerprint", certLoader); + authenticationCache.getOrAuthenticateByToken("jti-all", tokenLoader); + + verify(systemUserLoader, times(2)).get(); + verify(uuidLoader, times(2)).get(); + verify(certLoader, times(2)).get(); + verify(tokenLoader, times(2)).get(); + } + + // --- helpers --- + + private static AuthenticationInfo authenticatedInfo(String userUuid, String username) { + return new AuthenticationInfo(AuthMethod.CERTIFICATE, userUuid, username, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @SuppressWarnings("unchecked") + private static Supplier loaderReturning(AuthenticationInfo info) { + Supplier loader = mock(Supplier.class); + when(loader.get()).thenReturn(info); + return loader; + } +} diff --git a/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClientTest.java b/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClientTest.java index f2b3ca5bb..1b81c849f 100644 --- a/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClientTest.java +++ b/src/test/java/com/czertainly/core/security/authn/client/CzertainlyAuthenticationClientTest.java @@ -17,6 +17,8 @@ import java.io.IOException; import java.util.List; +import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -31,11 +33,15 @@ class CzertainlyAuthenticationClientTest extends BaseSpringBootTest { @Autowired private AuditLogService auditLogService; + @Autowired + private AuthenticationCache authenticationCache; + // @formatter:off String RAW_DATA = "{" + "\"authenticated\": true," + "\"data\": {" + "\"user\": {" + + "\"uuid\": \"a1b2c3d4-0000-0000-0000-000000000001\"," + "\"username\": \"FrantisekJednicka\"," + "\"enabled\": true" + "}," + @@ -54,7 +60,8 @@ void setup() throws IOException { String authServiceBaseUrl = "http://%s:%d".formatted(authServiceMock.getHostName(), authServiceMock.getPort()); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - czertainlyAuthenticationClient = new CzertainlyAuthenticationClient(auditLogService, objectMapper, authServiceBaseUrl); + czertainlyAuthenticationClient = new CzertainlyAuthenticationClient(auditLogService, objectMapper, authenticationCache, authServiceBaseUrl); + authenticationCache.evictAll(); } @AfterAll @@ -86,6 +93,7 @@ void extractAuthenticationInfoFromResponse() { // @formatter:off assertEquals("{" + "\"user\":{" + + "\"uuid\":\"a1b2c3d4-0000-0000-0000-000000000001\"," + "\"username\":\"FrantisekJednicka\"," + "\"enabled\":true" + "}," + @@ -119,6 +127,149 @@ void throwsAuthenticationExceptionWhenEmptyBodyIsReturned() { assertThrows(CzertainlyAuthenticationException.class, willThrow); } + @Test + void throwsAuthenticationExceptionWhenServiceReturns500() { + // given + setUpFaultyResponse(); + + // when + Executable willThrow = () -> czertainlyAuthenticationClient.authenticate(AuthMethod.NONE, null, false); + + // then + assertThrows(CzertainlyAuthenticationException.class, willThrow); + } + + // --- authenticateSystemUser --- + + @Test + void authenticateSystemUser_cacheMiss_callsAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + + // when + AuthenticationInfo result = czertainlyAuthenticationClient.authenticateSystemUser("superadmin"); + + // then + assertEquals("FrantisekJednicka", result.getUsername()); + assertEquals(1, authServiceMock.getRequestCount()); + } + + @Test + void authenticateSystemUser_cacheHit_doesNotCallAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + czertainlyAuthenticationClient.authenticateSystemUser("superadmin"); // prime the cache + + // when + czertainlyAuthenticationClient.authenticateSystemUser("superadmin"); + + // then + assertEquals(1, authServiceMock.getRequestCount()); + } + + // --- authenticateByUserUuid --- + + @Test + void authenticateByUserUuid_cacheMiss_callsAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + UUID userUuid = UUID.randomUUID(); + + // when + AuthenticationInfo result = czertainlyAuthenticationClient.authenticateByUserUuid(userUuid); + + // then + assertEquals("FrantisekJednicka", result.getUsername()); + assertEquals(1, authServiceMock.getRequestCount()); + } + + @Test + void authenticateByUserUuid_cacheHit_doesNotCallAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + UUID userUuid = UUID.randomUUID(); + czertainlyAuthenticationClient.authenticateByUserUuid(userUuid); // prime the cache + + // when + czertainlyAuthenticationClient.authenticateByUserUuid(userUuid); + + // then + assertEquals(1, authServiceMock.getRequestCount()); + } + + // --- authenticateByCertificate --- + + @Test + void authenticateByCertificate_cacheMiss_callsAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + + // when + AuthenticationInfo result = czertainlyAuthenticationClient.authenticateByCertificate("TEST_CERT_CONTENT", "sha256-fingerprint-abc"); + + // then + assertEquals("FrantisekJednicka", result.getUsername()); + assertEquals(1, authServiceMock.getRequestCount()); + } + + @Test + void authenticateByCertificate_cacheHit_doesNotCallAuthService() { + // given - cache key is the fingerprint, not the raw cert content + setUpSuccessfulAuthenticationResponse(); + czertainlyAuthenticationClient.authenticateByCertificate("TEST_CERT_CONTENT", "sha256-fingerprint-abc"); // prime the cache + + // when - same fingerprint, different raw content; the cache should serve the result + czertainlyAuthenticationClient.authenticateByCertificate("OTHER_CERT_CONTENT", "sha256-fingerprint-abc"); + + // then + assertEquals(1, authServiceMock.getRequestCount()); + } + + // --- authenticateByToken --- + + @Test + void authenticateByToken_cacheMiss_callsAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + Map claims = Map.of("jti", "jti-test-123"); + + // when + AuthenticationInfo result = czertainlyAuthenticationClient.authenticateByToken(claims); + + // then + assertEquals("FrantisekJednicka", result.getUsername()); + assertEquals(1, authServiceMock.getRequestCount()); + } + + @Test + void authenticateByToken_cacheHit_doesNotCallAuthService() { + // given + setUpSuccessfulAuthenticationResponse(); + Map claims = Map.of("jti", "jti-test-123"); + czertainlyAuthenticationClient.authenticateByToken(claims); // prime the cache + + // when + czertainlyAuthenticationClient.authenticateByToken(claims); + + // then + assertEquals(1, authServiceMock.getRequestCount()); + } + + @Test + void authenticateByToken_nullJti_alwaysCallsAuthService() { + // given - tokens without a jti claim cannot be uniquely identified, so caching is always skipped + setUpSuccessfulAuthenticationResponse(); + setUpSuccessfulAuthenticationResponse(); + Map claimsWithoutJti = Map.of("sub", "user-123"); + + // when + czertainlyAuthenticationClient.authenticateByToken(claimsWithoutJti); + czertainlyAuthenticationClient.authenticateByToken(claimsWithoutJti); + + // then + assertEquals(2, authServiceMock.getRequestCount()); + } + RecordedRequest getLastRequest() throws InterruptedException { return authServiceMock.takeRequest(500, TimeUnit.MILLISECONDS); } diff --git a/src/test/java/com/czertainly/core/service/AuthenticationCacheIntegrationTest.java b/src/test/java/com/czertainly/core/service/AuthenticationCacheIntegrationTest.java new file mode 100644 index 000000000..0f03d1c27 --- /dev/null +++ b/src/test/java/com/czertainly/core/service/AuthenticationCacheIntegrationTest.java @@ -0,0 +1,208 @@ +package com.czertainly.core.service; + +import com.czertainly.api.model.core.logging.enums.AuthMethod; +import com.czertainly.core.security.authn.client.AuthenticationCache; +import com.czertainly.core.security.authn.client.AuthenticationInfo; +import com.czertainly.core.security.authn.client.RoleManagementApiClient; +import com.czertainly.core.security.authn.client.UserManagementApiClient; +import com.czertainly.core.util.BaseSpringBootTest; +import com.czertainly.core.util.SessionTableHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; +import java.util.UUID; +import java.util.function.Supplier; + +import static org.mockito.Mockito.*; + +/** + * Integration tests verifying that service mutations correctly invalidate the authentication cache. + * Each test populates the real Caffeine cache, triggers a service operation, then asserts that + * the affected entries are evicted and the loader is re-invoked on the next authentication request. + */ +class AuthenticationCacheIntegrationTest extends BaseSpringBootTest { + + @Autowired + private AuthenticationCache authenticationCache; + + @Autowired + private UserManagementService userManagementService; + + @Autowired + private RoleManagementService roleManagementService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private UserManagementApiClient userManagementApiClient; + + @MockitoBean + private RoleManagementApiClient roleManagementApiClient; + + @BeforeEach + void setup() { + authenticationCache.evictAll(); + SessionTableHelper.createSessionTables(jdbcTemplate); + } + + // ------------------------------------------------------------------------- + // Per-user eviction — deleteUser as a representative mutation + // ------------------------------------------------------------------------- + + @Nested + class PerUserEviction { + + @Test + void evictsUserUuidEntry() { + // given - UUID cache is warm for a user + String userUuid = UUID.randomUUID().toString(); + Supplier loader = loaderReturning(authenticatedInfo(userUuid, "user")); + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(userUuid), loader); + + // when + userManagementService.deleteUser(userUuid); + + // then - the next lookup for this user misses the cache and calls the loader again + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(userUuid), loader); + verify(loader, times(2)).get(); + } + + @Test + void evictsAllTokensForUser() { + // given - two tokens issued to the same user are both cached + String userUuid = UUID.randomUUID().toString(); + Supplier loaderA = loaderReturning(authenticatedInfo(userUuid, "user")); + Supplier loaderB = loaderReturning(authenticatedInfo(userUuid, "user")); + authenticationCache.getOrAuthenticateByToken("jti-A", loaderA); + authenticationCache.getOrAuthenticateByToken("jti-B", loaderB); + + // when + userManagementService.deleteUser(userUuid); + + // then - both token entries are evicted via the JTI index + authenticationCache.getOrAuthenticateByToken("jti-A", loaderA); + authenticationCache.getOrAuthenticateByToken("jti-B", loaderB); + verify(loaderA, times(2)).get(); + verify(loaderB, times(2)).get(); + } + + @Test + void evictsCertificateEntryForUser() { + // given - certificate cache is warm; the user-certificate index tracks the mapping + String userUuid = UUID.randomUUID().toString(); + String fingerprint = "fp-" + userUuid; + Supplier loader = loaderReturning(authenticatedInfo(userUuid, "certUser")); + authenticationCache.getOrAuthenticateByCertificate(fingerprint, loader); + + // when + userManagementService.deleteUser(userUuid); + + // then - the certificate entry is evicted via the user-certificate index + authenticationCache.getOrAuthenticateByCertificate(fingerprint, loader); + verify(loader, times(2)).get(); + } + + @Test + void doesNotEvictOtherUsersEntries() { + // given - two users are independently cached + String evictedUserUuid = UUID.randomUUID().toString(); + String survivingUserUuid = UUID.randomUUID().toString(); + Supplier evictedLoader = loaderReturning(authenticatedInfo(evictedUserUuid, "evicted")); + Supplier survivingLoader = loaderReturning(authenticatedInfo(survivingUserUuid, "surviving")); + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(evictedUserUuid), evictedLoader); + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(survivingUserUuid), survivingLoader); + + // when - only one user is deleted + userManagementService.deleteUser(evictedUserUuid); + + // then - the surviving user's entry is still cached; its loader is not called again + authenticationCache.getOrAuthenticateByUserUuid(UUID.fromString(survivingUserUuid), survivingLoader); + verify(survivingLoader, times(1)).get(); + } + } + + // ------------------------------------------------------------------------- + // Global eviction — deleteRole as a representative mutation + // ------------------------------------------------------------------------- + + @Nested + class GlobalEviction { + + @Test + void evictsAllUsersFromAllCaches() { + // given - cache is warm with entries across all three auth methods + UUID userUuidA = UUID.randomUUID(); + String userUuidB = UUID.randomUUID().toString(); + String userUuidC = UUID.randomUUID().toString(); + Supplier uuidLoader = loaderReturning(authenticatedInfo(userUuidA.toString(), "uuidUser")); + Supplier certLoader = loaderReturning(authenticatedInfo(userUuidB, "certUser")); + Supplier tokenLoader = loaderReturning(authenticatedInfo(userUuidC, "tokenUser")); + authenticationCache.getOrAuthenticateByUserUuid(userUuidA, uuidLoader); + authenticationCache.getOrAuthenticateByCertificate("fp-B", certLoader); + authenticationCache.getOrAuthenticateByToken("jti-C", tokenLoader); + + // when - a role is deleted, which may affect the permissions of any user that held it + roleManagementService.deleteRole(UUID.randomUUID().toString()); + + // then - all entries are gone; every loader is invoked again + authenticationCache.getOrAuthenticateByUserUuid(userUuidA, uuidLoader); + authenticationCache.getOrAuthenticateByCertificate("fp-B", certLoader); + authenticationCache.getOrAuthenticateByToken("jti-C", tokenLoader); + verify(uuidLoader, times(2)).get(); + verify(certLoader, times(2)).get(); + verify(tokenLoader, times(2)).get(); + } + } + + // ------------------------------------------------------------------------- + // Certificate fingerprint eviction — selective eviction by fingerprint + // ------------------------------------------------------------------------- + + @Nested + class CertificateFingerprintEviction { + + @Test + void evictsOnlyTheTargetedFingerprint() { + // given - two certificates belonging to different users are both cached + String userUuidA = UUID.randomUUID().toString(); + String userUuidB = UUID.randomUUID().toString(); + Supplier loaderA = loaderReturning(authenticatedInfo(userUuidA, "userA")); + Supplier loaderB = loaderReturning(authenticatedInfo(userUuidB, "userB")); + authenticationCache.getOrAuthenticateByCertificate("fp-revoked", loaderA); + authenticationCache.getOrAuthenticateByCertificate("fp-valid", loaderB); + + // when - only the revoked certificate's fingerprint is invalidated + authenticationCache.evictByCertificateFingerprint("fp-revoked"); + + // then - the revoked entry is gone; the other certificate's entry survives + authenticationCache.getOrAuthenticateByCertificate("fp-revoked", loaderA); + authenticationCache.getOrAuthenticateByCertificate("fp-valid", loaderB); + verify(loaderA, times(2)).get(); + verify(loaderB, times(1)).get(); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static AuthenticationInfo authenticatedInfo(String userUuid, String username) { + return new AuthenticationInfo(AuthMethod.CERTIFICATE, userUuid, username, + List.of(new SimpleGrantedAuthority("ROLE_USER"))); + } + + @SuppressWarnings("unchecked") + private static Supplier loaderReturning(AuthenticationInfo info) { + Supplier loader = mock(Supplier.class); + when(loader.get()).thenReturn(info); + return loader; + } + +} diff --git a/src/test/java/com/czertainly/core/service/CertificateServiceTest.java b/src/test/java/com/czertainly/core/service/CertificateServiceTest.java index 1a6c82755..9e8510dc3 100644 --- a/src/test/java/com/czertainly/core/service/CertificateServiceTest.java +++ b/src/test/java/com/czertainly/core/service/CertificateServiceTest.java @@ -34,6 +34,9 @@ import com.czertainly.core.messaging.jms.producers.NotificationProducer; import com.czertainly.core.model.auth.CertificateProtocolInfo; import com.czertainly.core.model.auth.ResourceAction; +import com.czertainly.api.model.core.logging.enums.AuthMethod; +import com.czertainly.core.security.authn.client.AuthenticationCache; +import com.czertainly.core.security.authn.client.AuthenticationInfo; import com.czertainly.core.security.authz.SecuredUUID; import com.czertainly.core.security.authz.SecurityFilter; import com.czertainly.core.security.authz.opa.dto.OpaObjectAccessResult; @@ -68,8 +71,11 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + class CertificateServiceTest extends BaseSpringBootTest { public static final String CA_BASE64_CONTENT = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSVlEVENDRnZXZ0F3SUJBZ0lVSWhGc3h0YjVESHFOZzNMNkpKZHlVV0lKTGdJd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0lERWVNQndHQTFVRUF3d1ZTSGxpY21sa1pVTmxjblJwWm1sallYUmxJRU5CTUI0WERUSTFNRFV4TmpFdwpNRFEwTkZvWERUTTFNRFV4TkRFd01EUTBNMW93SURFZU1Cd0dBMVVFQXd3VlNIbGljbWxrWlVObGNuUnBabWxqCllYUmxJRU5CTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUEwWENhakNkWDZ6SUsKL3lHY0IxYkVhb2RBZjFPWVZmSFFVVy9iUlpGdy9VeTFjdUhicFlZTnFXNEcvU1QvUlJJdERVajkyUFM0YjBPSApaSFRqR0FCWGExL1oyaFpOSVpFcE5MMzFkNDNLKzJMemJNLzhCZkdQT1RFbmMzTWdoWnFtVnlqUWdKV2FFV3FMClVOMjZIRzFsL0QzN0R1MElFRm9nV1lsaVBrWU5HcTVOcU9Hd2trWGVUTkZiK3JPay9SUDJDS2NYclZOV2Vpcm4KV2lXMXZ2WEJlZ2pqK2pFRmZNUHJtN2Y3RXB2ZU5jNm1FVUZlNkxkcHRXaUFNVklxdlo2MUNSZWhEZ0lMWHRnWApQNEZpOFErMlR5djNzd1lMenRCcm1EMENJbE9lbFluUkhVVkMxakVTL05pSVRLR1N6c3lZZmhvbmk2TlJIbnlKCjdOeUsxRWFyQXdJREFRQUJvNElWUFRDQ0ZUa3dEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWZCZ05WSFNNRUdEQVcKZ0JTV28xV2k0Snh6S0VXYks4N1lYNEE4T0ZvY3RqQWRCZ05WSFE0RUZnUVVscU5Wb3VDY2N5aEZteXZPMkYrQQpQRGhhSExZd0RnWURWUjBQQVFIL0JBUURBZ0dHTUlJSHZ3WURWUjFJQklJSHRqQ0NCN0l3Q3dZSllJWklBV1VECkJBTVNBNElIb1FBc2ZNUExWTTFHRnB1ZElma2hPdnRseloxdUxrZ1JUSGJLd1dYU2dMKzFVOTM4bnI1MlVVNWMKaG1qK1RKMGZrdEZFZzJPbndMeDJ2ekY2ZnRQT0c3alYva21ydDJlTVhwS1FMa01PL2ZrbW5xQ1dxTkVpYU9YZwovY3pPNXlUNkRIWlg5RVNpZlJZTGdacXdUY0M0SVd5WC8zWWlseUlwUW03T2hTd2hONlU0aHJBR1hxb0VVTWN3CjlZL0lNdWRTK0lNcFo3T29YdjA1aWloTUdDZ0c4QWEzYWtIcnRUQ3BXVU1MUzM4TFZJenU1UVhOd0Rmdjg2bW8KMnBjRjltQzdTSnNTRE9Ja1VYTjlIbDc2amkrQUduU3BTa3BLSlB2aGllSUJ2czMwbERBd3BmMXlXd3MrUWZrcApCOFVEN2MzRjZDcXNvRTFPbjA3MUpkb1A3Q2tBWlpmUEVKZ1ZMVjNHRStod1lnMHFESVM4enRtNGVxTzc3RnUwCjdOeWJQOFJPWDIrQVpQNWJhSklydTdYR0oxTVJrWG9jOVJIWmxPdWNjdW9aWXFrU0VLM3JtNk1JcGdYbSt3M3YKanJ1c1hybnZWSnZTdGNZRTErNlcrNWt1VzBCMnVBTXJabkZ5cWtJUkRQMU5kaktPWStvZG4wQlA3R2hkRkxOOAowRndvditkV0t2QTRRUmhjbWdUUEIvUW9tNnZlVzdNcHdJZ25OWlZxVHovakRKRmluSnlOSU92bFZaWUtEQ0dDCjlOWmZocWNrUkhlTFN5ZnUxc3E5aXMyWmRSbTc2eEZmcFJnU2hHdmFHcGVYMzcvc09MbzdzeU1OdHAyMERiQUcKdk85LzhYRnRwc25TL0doVC9UOVBsb0FEOXZoK2FxSjBJdkltVTZGOXY1a0VhcnY4em5MeENLOUliMm95SGpNSQpRYzhPWGVVaWt3WEpLejZZTklXMUdFOFJwUFhRNUF2NlJDWUxabnpLNlpPZlBjZXZKdFZhZ3lSNmZpMUVWT010Ckp4RHpaMHVXVjJRZG9kUDdUZ3Z2OVdIYlBER1Q1eHFsTkZHWFA0QmFwNDl3OXpQLy9LZit4VG1ZRmJyUWVCaUUKTjcxMkd0MUE4OWFkM0FYTUhFSmNhaDBtWmI4ZFlTZHJKNUhLVDFQMDhKL2Ryd3FuREd6V2tOQlQxei8yK2NybAozb3Q1WGRjSWRoa2dsUzZJbzRROHZqckRkU24yY1M0VExRWjFmYzZQc2t0YlJLVU1PMG1QWXJxVzhUajFSREVSCmFSY1lEcFZxTDg1V0s3RzdJZUJ1Rll4REtRSktJOUM3VTFES3A1TmJrN2s1WTdLMENPeDB2YUlFOUN5OW5RWFoKdVBZQjZYM3BZUzlzdml1VmtSdGNvc0pheHdGM0E3d3owZTI1T2tyTmVocW5ad0FDTnZRZEd4aGxUYS8xdDFxQQpkK2ZXOGtMaEtSQnNpVW9aREpSdDB2N3ZPRk84aFlUT2VqU2lZWU1jNnNYcUhpdjVOUThQZldMOFhCcW5JNXh2CkhacCtvT2dEU2hIRlFvWi9kM3ZKOUlZN3MwaEtENTBjOWorZ0F6VWY2cFFyaGlqbkRITVNSaGtWRzB4cHNEbGcKUFg3Nm8vK0NOMnZtUFRFbHdwTFV5UmFtVHd0bDU0eW1YcVlsTXU1dDU3T2wyeUZVZzNXcE1XMmNSM2dvU0hFegpSTDRvZzFhY2U5NzBUdnprZHZ3ajJCRGhVL0tZaU0rMnNrT1pHaENvM29mSk9kNlRtVjJXbnJNMXpaaWhOVi9CClY3QlQ2Tm83emdIZVkxQnFabG1WNWw4NEowRk9PR0ZKY3RjMlVBK3YrNkxsakJDMjI2WVVKblg4ck1vb3UxbkIKSWxBQzcySG84R2JhSzNGUUNWMHVGanZjQ0Q1UDNqRkNxTThxMTFXWUdOWTNwR0gwZzhVQlNaamhoM1VHZWxtMQpFUEU3TlNPTmdSMGhnRGZPT25jUzBkVUdyQkZTWGJqcndlNGFreHoxRzAvb1ZBeXVnUnNESm1la3RtNkREUEpPCnltQUpyd2NNdzl0QWF5OXgrdDBXY05ZWVVuZGFNbkVkQjl1RGN1S0dyZk9vRVJDeDFDdm92Y1l1bit1OVpxaXAKYk9YUUZlaEIvSjJOajA1MVRVaktwZ1ZsR2k5MHhDSjNEY0JsNTZiNWh0THk5dUFKVnVoL2tFSzZWOGRvbm5BeAorK2NGYlYyL0M4MUwxcDFVQUc1UUJKeGJEOUdndjBrSVRIWFRLRVFQMEVjSmNEVzhhbXprMnc0RGZTZCtyY0gvCkVLaXpqN0R2WmVvRENiSEVaa2NGQk16ZVV2cG9oMXZLN1paMFVBZjgxVGRHMS91ejBHMGk1c1c5ZjRSRDU3SjcKRzd0RDZXMnZEQ2Nic1lmZ1pEOFZoOFhJMXFhRzZOSVdDVHhsMHZPWmdzUDY1b3B6WUZhNWx1MXNvZFBXVHBYcApKcy9EWkRJbG1QUHlqNUdjREZNRWZ0KzZ6LzRUSzBnQzhyZ3RZbzF4OVErU0ZvN1l1eGwxcUNlS2Q0RWd5UnlmCndnOERxZDhGWStlWDM1QmtLbEZURGdHOWVuZFJqd0t6SjFjMlUvZHMzS1Z1TDRLNHBtbTR4ajUyaUhEcGZhZzYKaWZUeFNTaW9ycFVyZEdVNGhjTFpTMVJNT1pab3NXYnVuSGo1NzdpNTViYzV2d0piMDRyMWh1eWtBZmpsVTg0Mwo1cTFaQzA4K0tBaGdnZkVyOFM4UmxBWXhHQWpXWm14TEFnSGhxazB5OFdNQlpXak9nd3BsVmIydkJFUFR5QmluClRrM202c1R4bGZaQm54U0xTWTFYcnJsZE9Hak9sRnFnNnQ4ZmFxWmFtWGRsUFcwZFpIL2tsRlB1VjRnUWE2UTMKN3hHWTJ3M3JSL3ZPS1psd05yU1FWMmZtMnVsSERHcnpQemo5TXQxSWRrZUZ3NXJCRFBuZndwZjhzYmd5b2tnSQpOdkRaVzdpM0ovTllpNVZqWit4RERMeVVtZ3UxcEVoUklMTEZnVGlIbkhzZEJTNkdhVEhLRlR6WkM5QnE1aFlHCjhCUlVuajYyTW9NQmV4ZEV6bWJKWlpPMUs3cUlwS1NORVhxZm44QlJBZmJXMDl2OGRHK3M2YlRoWHY2WVYrY04KZ0VQQ0hmWEdDNEdpSmlQakxBRjE1M3VhYlZrZEJmKzVLZG93R0RqRlJMRVFlSlRnZW5FOFVwLzI1NXdkK0xrVwo1VG9NMDY3eXFybXAxTlFMMkt0dFNUOFZNRHBlNUUyTEZ0Mi9JTEUyNmVKQ1NPNUFuZm8yY05BU3BDY1puZGllCmFJSXl6ZUVhUmpsNHRvV1doeVBGeUxYMTdkZk84cnZiUzFWQk5SZHI5azlnNXAvZ2h4RUw5TWlYKytkZlArc1MKTHAwbDBQQ2ZSSSs5Qm1CVEFVbUU1U1BHYXlpUGRTeU5oTUo1dzRid3h5a2NXRCt3S08zK1JEQVVCZ05WSFVrRQpEVEFMQmdsZ2hrZ0JaUU1FQXhJd2dnejdCZ05WSFVvRWdnenlBNElNN2dCMHBkVVlseDdXQUJETGgyQWlEZGRFCjdGTU94cDZoVkxDNk9BTUxvREpaQTl1SjJaa3ZjbVppejV3b2RpaXRnRmJsKzUyUmZHd2JWUm9FeU9QOU5iRUEKQ08xRTRiVlZJYjBiSjc2dm9hMEVvVjNjTVFZcWpmNks5bkhBTTExZ3VNOVpRS2JucEJDckIxbEVaaWlTa3lrRQpYQkZaZDArQUVCb0N3Qno3TlY2akpVTDJvcmJrV3F6WWRHQlg5MnpDS2dCWnZVbms1NnBFMWhBU1lhZXYvenNXCnBTNHdLQi81U3c4S2lGaGhjemlBWHU4MmEvM0xnZDNiNXd3dGRnUjJtb2FERU1VUmpqU2Y5b1JLdER4ZUZKcDYKS2VnaURydzdmTTZrMEdtZExyemdlUzdRdUNkdXlIODFQNWY4MXpRM0cvakJDeXE0d1QzRjNESXl4ajVPTDFxbgozaytPY3Z1VWxwNk5oZzZIV0pOY3VCNW1mM2JEcFFVYlRmR0kxeHJhTVVIRGRqVHRmYVFvbktBeWRMN1pVK3NXCktGc043U0tZS0dBNEVnVFU3SGs5WkhzTTFoTzRlVVRQUG1EYVhkekVORkhqWlpkeCtJK2gzODVHVWplN3VPUjAKckw4VjJWeE9aMnd6b2U2ZXZQRWtsUTcreUE0bWNXRmtXcExMU0ZIejRxSnljUUxkcTNDUFUrMUNxbjUyRFNiawpVTFNjcFBNd1VaNFkvUGhtelFUZ2JwMWNPVHp0RzV5STVTNVNGbDMrc1FLZDBCaE1Eb0QyelVSWWpVT0plR3BDCmRaanROQloreDZYSG1mYWdydjVLSThTY2lyZ3VOb1ZZYTIxbnN1d3YvdUI5VStXMkJQeEIvbGQvYlhXS3EraXkKZ25VL1B6cjlPbERvQjhZNkMvckFyVUJmdTVlS285b1pKZm1iSFRDOW50dFgyZWFRVWhHVU9WT253cXJnVXVKbgowMGNnWGZ5b3U4TENKaHRzcEdVRW5tQUlQVTlscEdLSElRVzRJWVc2eTNmVUtkWk5OUVZRVitsV1NBejNOM2pMCkdLczVCVVEreVQybzdrQmhLZ2l2UVczaEdqY1RIZzlsVUdUQXlXMkpIbnQxY0VZamlLbjVJK0MvN3pRRkkwaFoKMGdaKytBb2xXK2J6SVdGQU9kcnVraForME1qNXdsMnVnTUVIZ3BKcTk2eFA4dnc5Vkt2b3JPMDNqbnZiV3BQZwpwV3phMmFMSndWOUlhYjErYzdGbU05SjZOclBzMzQyaVVLVXhlL3pMcWMrcjY4UVRHKzd6Z3F2U1dib0swOERBCk5sZU1vMUl5NGZTWTB1Rmk3NEVlbVhxZHZhcmhuQ1BZRmJpRk1kcHFPOTFUMkJobEd0cG1PTDdiVHQ0NXJubUkKbU9sbytGbmlWY1NHZHdWMlY3bVdVN2dPTS94VmR1enZsZVc1VjQrbko5UXhUQmNWQXROMXhLdXVINWRNL0IyTQpVRUlmd2E4NG9JWFJYS2pFTkU4aWExWGJPOU1tQXZoSDlNN2lWMm9SdVF1ckM3N0xhb3RLNWtQTXhnZUM1dkdhCkdyTkRWeVRLaFN2TVdZMXRwLzdWZzM3OXZHbWJnOVNVUUFXK2Z0b05PSmNMQVg3bWg5TnpnZTBMQXpyeCsyNXgKaXBCT2dPUDd5NTBEekE3eEVjOFR4TENsYmhSMjZNY1BaVUpaZis3NlpSMlhQbU5CQVJYY3VwZmNLOUIvMENxQQp2OVVsdEZEV3lpazRmdnRNZXp6ZGtwQWhhK2QyUVVoRi9mVUs4WDFRRjFnbnliUDhEb3U5cmEwN0hLaU5BN2NGCkM1dlJrV0NNRVAxYW9FbFR4b1VPcm0zTmdqTjM4UmFFcHhiaHlCLzZMNlUrNDhMNlpRckN0c21MeTZublFVSVcKaWRTdEFRQXNuNTNqQU5oSll1TllHdndnbXdKbnBtRFFyVjhiUGNOcTBpZFFtQVdRMXVsU1RBVklFM2tuQnVzZQpZTXpOVFNtMG5QR2xrY2F5cDBJK1l3amZSR0dOQk9MMmE5cDFSeFdrc1l6Y2VpOGdjZFZXQkxKQVlHVFdCM2JVCkVLR3pBcjdRVGJxNy9YUFVJMUVBaUVaV2UzSFgzMFlBMWJrRUZKNDJGVnJNMHkvQmNZbi8rSzZRSmJaVFdZVWsKbHY3dTF0bDI2WnliRG5ibDdiZjlJbmVtUkZZQ1BBbTUwdDJiOEVpQVQwSCsvTkxmZXNDdmpBdEVRREhUL1pndgp6QUM2V3orZmE2WnIwOXFrMUtkSlVmNUpXa3hMKzdndERWd09DUVpmNjNjVnJjeG5NYVl1Q3o0d3ZzNGRsbk5JClhMZTgvODQyMHdsY3dtejdUVmdOblUrN2IxdGxnNHNTanlNMGhxelVNWldaZ29rZmwxUm1xQStVbFNDN1pDeXUKd0EwVVdNOFVHWlREdjNoK1ovY01VSklaVENuQ3dvekxTb3hLUmgrZ1dnWWZGcVBCTXMraDNoNDB2WEEreUhJegpMNlVyWTBqbnZhYU5lVjBmM0x3NWNJc2lWSzh2TmNIUTYwWkVBeTVCK0dZNTN0d2Iyb2tJMG0ycVAyOExUZEg2CjdRNlpLNlpSKzVQQ0d3THg3L2NYMXpEMUtxQnFrTGx0NitmVFZFa053OW9lcWZXQWZLVUdENVlHSXNnVUZXRXoKTmt3Y0R2YkUwS3lFY09vbjdPS3U1SXhkSm5YUmxyVmlraUFxN3Jac3pQN24ybGJLZmh4VEF5eVJYQXl4aG5zRgpUWE1QeFZzeWJsUjRQaFBXeDQwVHdYdlZ1VWxCa1dDV0cySVovTHdEbTQrQkRXMjdwTlB5eW12K1YzYmVsWEJ4CjFOVjhMQnY3Q0lzdmh1aFFOYWVpTm1mcWR0eGsxeWdKbEhON25XUCtiT3E5TUxSYW1mM3g3VFNmYzdBVWNkODUKNE9ORHpDeXVxTHF4bXpZaGd4TTF3dVh5V1RlUktQU3FwVFV6OWZ5ZUhZWlBLazRaQlBwL0ZJYUhkZGdSQUgrawpmem5lMGgzOXdVb2YxQyt3L3RVTGg1RFo1UlVyWkM1WloweS9sSXVVclZFeFdZWkx1M0RQQkV6TmhQR0huQU1jCnJZS1VTZ3BsU2J3TWJOMFU3M0hnQTNqNDc3RHJOWm5aNHpSRU96Qy8wT0NyTE5RTGpQdkVkRDFodUo5VURZV1YKWXNaSWt2c0l2MEtHUGZscDlKeSs5SUFhc2ZyODIvNUFUdEZmMHRGc1ROYWI3SU0xTmxZb1NRRCtvMEw5d0REUQpZcmo3cEFVZGRLVlgvOFFBSmQ2T1dTOEF2WmI3bkpGcVl5dUFnaEw1THExcGpoTkNqL244TVFSWjhDTDE3UlBOClQzOUp4NlZhN3VYSmRaN1hIYjBQTFBnQUxoeGZUOXZRY3VHTFMrcmJiblBWcGpxVGQ3ZERKZk1NVzgxUDJwaVYKcDRaRXAxMURER1dyM1licWRzU1c2UG5YU1I4ZDF0Nm5Qc2trdXlBZFFVN3J3RjhwRUxxalF6R2Rpb3E0aVJ0TgpsK2E4MmhuZVRWald1a3d3ZnZGa3FpWjRiaXRKdkNZMlFUN2pDZ0VlcUhCS2RFWi90OEYwc0NsRlVlSTdDbVRHCkFjcVVvMHdmTDViZ0lTbmhydjJZR1JrR3BWVGt6QUNBU2kzNHhkckVicDl6VW5RYWJRQXFLdXlTcGN4RzhhT0QKU2tZVHh2bHhZWldoWlJDRUFyNGxmd0U4cys5RC9NKzlBNGNCd29BTE5wM0g3Z1gzeDRKSmtEMTdZZVNQSkg0cAo4QTQrQzRIOXBwNy9qVFhpc3AwTGJtODgvSWl6Nk9BN1E2blBUSUdtTFk5WFQrcUlFRHJua1ZwWWV4ejNEZ0RoCis2eDYzelQzOGJZT0duSEpNa3gzY1lpTS9YQWRRaTZGR2VhK3R1MFZJVktoenplNVNUMDBZZStTOGFzbGt1UEUKakthZXN0Y0N2RjBvQWQrY01nRm1YZUl6TTRvK0pJL3lQTDdEM0RwcnUxUkVqK1BjK2NXQTEvQmFvQ2Q2cHFnaApQNWwzUFh2MjYyZXhvanZGa0pTRWQ4L3dEVnNLYmhGb1pYOENSZjJZTFVEUWc2bDZ5WnBiSFJ6ZGFObXl0ZmFSCjYwMCtuaXYyNDc3NWwwQ1ZoUmVIWlBBS2pFNlFQcTR4S3FTQm43SHNNNjdOUGRFMDhxSVNIbUdWOWNKNURiTEkKZHFoVjRMOEdrQXV3Ymo2U1hzaUFBeFh5cjloclA0eTFqbUtaQmVZRHBzenZoUUFqbWpnVUwrTC9tSXU3bS9ZVwpjUTQwYmN2TjFLb0pWMkhOQVR0NjluZ1FjYmIxZzBkanhpVVNNTHJOVUJXb1ljckgxMW5lQko4aFY3VHM4T3JhCk9MSUF5WnBYZ0lEUWEwdFNpSEtvK3FCaTVNZmUwcEQ2NVJuQWNPRFFrSHRXSHR3RUpaM3V2L2paUm5SV3lrVWcKOTBJYis3bUlSNkliajBPZHJZRXBjNmVIeXR4UVQ1MFM1VFZoSlNaVWwxQlRiU1IzTVlmRjdaSkN6T1hXWGxaVApOOEJBZk9EQzREdFk0NnFvTUFtYnNQTXljV0V6VUxVQ2thcGtoRGpKNDBUOXo4ZnQ4WDJLQXV6REkzek03cXVICkdNSWRud1JCdWRCRy9vUjZXVHlUcWVFZ1Nvb1hKa2ZiL1V6NHJnWlphYlhlbjNiMXpURWp5Y0t2dWNjR3FVbksKY1NhTnMvNkhMK3BjZXJ4cHh4em80QlIvWGpiSHFiNlU4bDgzaFljdWw4cFRCbFlyb2hwRjNncm9qb01pWW5DUAo0ZU15dGpnTXBFS0NtSWxrbis5ZjU1RVZpTUljMml5QzVCaFF2VGkxWW9SL3VFSlhncDFTV2NaeEU3eGJvYk0xCmVmU0FYSlk3aWhqK2cwaEdHWjdsVy9UMVlIdXJHZWZ0VWNORFNobjZ0Z2JVTzQreW1yc1JtdnpGUUtLYWZsd0YKcTRaVHQrWlJ4dFhSUG5YdzJkOGhib2ZYWFVBTDMvcWNPc0ZyWjRtT1BYZDlSdDV2RmVYNjRYMCsrb2hBR210dgo0bTB3QjhOL0tjclUrekszK1k5RW9yZHRlN0NrZTkzTG4xeXpZZTNLOU5leDZ2M1luVTdsbXNxMlg5cndWVGowCm5HK3dzREhWU1NFQmhBWmtVcDZzVnBUbGtsS0V6RVdwSVJTK0UrU2FsMkQzcUhzSEZyWkVMRmhnalBwWEUvdTgKQTM3a25OMURxYjRVNkoxeFA1cEx1dWlGbWdSRVNxN1JPYXE2cGFZYUwvNndCTnQ3UDNlcjRqaGJyOHdmclhZdwpmQjBqWTd2QkFhZTl1MnVFZVZsb2NUN3lkRjJmV0g2RzFsTlJaUGtUUTF1R0hERHFJeVFTNTBhdHJkb3JBb1hsCjdWL0Vna0Z1RCtvbTFHVjVkNDZqVEVRc3dxaGpJSWNYUDl6SmdQV1NOK2t0RnI4d2VLSXBMRUk3T1U5WXF4MXMKbU4xUDJhcHg1VmJxZFNyT1JUbTlFUGMzaEV2dEp2cFovVy9hT29sYVAyaUk0SlFkWFoyUklaYUtDU3prVzkrbgpOaDRraERoUXRDT1djcnZ6QWovQ0xzRDFDQk9GL1grYTZwMGNNek8yODJJY0duTDVMNXFzRnB6TkdxVnJ2Y3UrCmN2RE16cVV0ejliVUtkSzVuOWZwb3lsSFliWXNtL2ZnYUJOeFRLY3hwVUlNQzAvcHljYXhhdWg0ODAxZlRpZ20KMExLdlViSkUzSEp5MTZ3Rk40Zyt0bE4wZTRTRnFmNEhDeEVVUzRQRDQvb01WYVd6RHhOVWROYmhJVFpBUTRXTwpwNm5GSWorVG5xS2x3TWZPQUFBQUFBQUFBQUFBQUFBSEVCUWFJeXd3RFFZSktvWklodmNOQVFFTEJRQURnZ0VCCkFCWEtTbWIxalB0bHpEVEg4dFRUemRxVHRIQVBHeTRieFdINEJ0UkNPNGVoOUhMTFNnQXFmZmU5RnpWZDl5aHMKdzhnc0dHQUxUTlh5WEswc3FuelgzN0ZhSzNTS2ZhZ0gxL1NyUFIxZGVzaEo0M3VZTjRRSEl4QXFQNGczNDFTRgo0bThsWGdXcTZ3S3dBbzMwK08vMnRxWHlGUWwrU3BVcExUbnNBZ3NIdVhma1dDdk93MUtiN0tOdjQwVXhRUVE0CmVsbVdyNHJrbHUxTGdZbWdsd1FxWXhoQ2dGdldLZkw2YWdkcHdZOG5OQUVNK1VsSmZpbmxQc0UzUzRtNUN2elMKNE01K1dIaGtHQkNuTGdocmRUa0Znc0lmdEVyK0ttUFdYdGVQRWhWLzBmcjNFYmJsT0FDc3B5VjM0NC9iSUE0UQpmaXhCRVhzN290WmRXZGtJVTAvRlo0cz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="; @@ -121,6 +127,8 @@ static void authServiceProperties(DynamicPropertyRegistry registry) { private ProtocolCertificateAssociationsRepository protocolCertificateAssociationsRepository; @Autowired private CertificateRelationRepository certificateRelationRepository; + @Autowired + private AuthenticationCache authenticationCache; @MockitoBean private NotificationProducer notificationProducer; @@ -247,6 +255,7 @@ void setUp() throws GeneralSecurityException, IOException, AttributeException { @AfterEach void tearDown() { mockServer.stop(); + authenticationCache.evictAll(); } @Test @@ -448,6 +457,67 @@ void testRevokeCertificate() throws NotFoundException, CertificateException, IOE Assertions.assertEquals(CertificateState.REVOKED, dto.getState()); } + @Test + void revokeCertificate_evictsCertAuthCacheForAssociatedUser() { + // given + UUID userUuid = UUID.randomUUID(); + String fingerprint = "abcdef1234567890"; + certificate.setUserUuid(userUuid); + certificate.setFingerprint(fingerprint); + certificateRepository.save(certificate); + var uuidCache = primeUserUuidCache(userUuid); + var certCache = primeCertCache(fingerprint); + + // when + certificateService.revokeCertificate(certificate.getSerialNumber()); + + // then - both the user UUID entry and the certificate entry must be evicted + uuidCache.assertNotEvicted(); + certCache.assertEvicted(); + } + + @Test + void revokeCertificate_doesNotEvictCacheWhenNoUserAssociated() { + // given - certificate has no userUuid (default in setUp) + Assertions.assertNull(certificate.getUserUuid()); + var uuidCache = primeUserUuidCache(UUID.randomUUID()); + var certCache = primeCertCache("unrelated-fingerprint"); + + // when + certificateService.revokeCertificate(certificate.getSerialNumber()); + + // then - no user associated, nothing to evict + uuidCache.assertNotEvicted(); + certCache.assertNotEvicted(); + } + + @Test + void findCertificateEntityByUserUuid_returnsMatchingCertificate() { + // given + UUID userUuid = UUID.randomUUID(); + certificate.setUserUuid(userUuid); + certificateRepository.save(certificate); + + // when + Optional result = certificateService.findCertificateEntityByUserUuid(userUuid); + + // then + Assertions.assertTrue(result.isPresent()); + Assertions.assertEquals(certificate.getUuid(), result.get().getUuid()); + } + + @Test + void findCertificateEntityByUserUuid_returnsEmptyWhenNoCertificateForUser() { + // given - no certificate is associated with this user UUID + UUID unknownUserUuid = UUID.randomUUID(); + + // when + Optional result = certificateService.findCertificateEntityByUserUuid(unknownUserUuid); + + // then + Assertions.assertTrue(result.isEmpty()); + } + @Test void testUploadCertificate() throws CertificateException, AlreadyExistException, NoSuchAlgorithmException, NotFoundException, AttributeException { UploadCertificateRequestDto request = new UploadCertificateRequestDto(); @@ -657,6 +727,94 @@ void testUpdateCertificateUserArchived() { Assertions.assertDoesNotThrow(() -> certificateService.updateCertificateUser(certificateUuid, null)); } + @Test + void updateCertificateUser_evictsCertCacheWhenUserIsDisassociated() throws NotFoundException { + // given + UUID userUuid = UUID.randomUUID(); + String fingerprint = "abcdef1234567890"; + certificate.setUserUuid(userUuid); + certificate.setFingerprint(fingerprint); + certificateRepository.save(certificate); + var certCache = primeCertCache(fingerprint); + var uuidCache = primeUserUuidCache(UUID.randomUUID()); + + // when - disassociate user by passing null + certificateService.updateCertificateUser(certificate.getUuid(), null); + + // then - only the certificate cache entry needs eviction; user's UUID/token caches are still valid + certCache.assertEvicted(); + uuidCache.assertNotEvicted(); + } + + @Test + void updateCertificateUser_evictsCertCacheWhenUserIsReplacedWithAnotherUser() throws NotFoundException { + // given + UUID oldUserUuid = UUID.randomUUID(); + UUID newUserUuid = UUID.randomUUID(); + String fingerprint = "abcdef1234567890"; + certificate.setUserUuid(oldUserUuid); + certificate.setFingerprint(fingerprint); + certificateRepository.save(certificate); + var certCache = primeCertCache(fingerprint); + var uuidCache = primeUserUuidCache(UUID.randomUUID()); + + // when - replace with a different user + certificateService.updateCertificateUser(certificate.getUuid(), newUserUuid.toString()); + + // then - only the cert entry is stale; old user's UUID/token caches remain valid + certCache.assertEvicted(); + uuidCache.assertNotEvicted(); + } + + @Test + void updateCertificateUser_doesNotEvictCacheWhenCertificateHadNoUser() throws NotFoundException { + // given - certificate has no userUuid (default in setUp) + Assertions.assertNull(certificate.getUserUuid()); + var certCache = primeCertCache("unrelated-fingerprint"); + var uuidCache = primeUserUuidCache(UUID.randomUUID()); + + // when - associate a user for the first time + certificateService.updateCertificateUser(certificate.getUuid(), UUID.randomUUID().toString()); + + // then - nothing to evict for a previously unassociated certificate + certCache.assertNotEvicted(); + uuidCache.assertNotEvicted(); + } + + @Test + void removeCertificateUser_evictsCertCacheForUser() { + // given + UUID userUuid = UUID.randomUUID(); + String fingerprint = "abcdef1234567890"; + certificate.setUserUuid(userUuid); + certificate.setFingerprint(fingerprint); + certificateRepository.save(certificate); + var certCache = primeCertCache(fingerprint); + var uuidCache = primeUserUuidCache(UUID.randomUUID()); + + // when + certificateService.removeCertificateUser(userUuid); + + // then - user still exists, only the cert link is dropped; UUID/token caches remain valid + certCache.assertEvicted(); + uuidCache.assertNotEvicted(); + } + + @Test + void removeCertificateUser_doesNotEvictCacheWhenNoCertificateForUser() { + // given - no certificate is associated with this user UUID + UUID userWithoutCertUuid = UUID.randomUUID(); + var uuidCache = primeUserUuidCache(userWithoutCertUuid); + var certCache = primeCertCache("unrelated-fingerprint"); + + // when - no certificate found, method logs a warning and returns + certificateService.removeCertificateUser(userWithoutCertUuid); + + // then + uuidCache.assertNotEvicted(); + certCache.assertNotEvicted(); + } + @Test void testBulkRemove() throws NotFoundException { List uuids = new ArrayList<>(); @@ -1230,4 +1388,54 @@ private static boolean isObjectAccessRequestForResource(OpaRequestedResource res (resource.getProperties().containsKey("name") && resource.getProperties().get("name").equals(name)) && (resource.getProperties().containsKey("action") && resource.getProperties().get("action").equals(action)); } + + // --- Cache verification helpers --- + // + // Pattern: prime the cache with a loader that counts invocations, then re-access after the + // operation under test. If the entry was evicted the loader runs again (count becomes 2); + // if it survived the loader is skipped (count stays at 1). + // The same AtomicInteger is shared by both the priming and re-access loaders, so all + // increments land on the same counter regardless of which call triggers the load. + + private record CacheVerifier(AtomicInteger calls, Runnable reAccess) { + void assertEvicted() { + reAccess.run(); + assertThat(calls.get()).isEqualTo(2); + } + + void assertNotEvicted() { + reAccess.run(); + assertThat(calls.get()).isEqualTo(1); + } + } + + private CacheVerifier primeUserUuidCache(UUID userUuid) { + var calls = new AtomicInteger(); + authenticationCache.getOrAuthenticateByUserUuid(userUuid, () -> { + calls.incrementAndGet(); + return fakeAuth(); + }); + return new CacheVerifier(calls, + () -> authenticationCache.getOrAuthenticateByUserUuid(userUuid, () -> { + calls.incrementAndGet(); + return fakeAuth(); + })); + } + + private CacheVerifier primeCertCache(String fingerprint) { + var calls = new AtomicInteger(); + authenticationCache.getOrAuthenticateByCertificate(fingerprint, () -> { + calls.incrementAndGet(); + return fakeAuth(); + }); + return new CacheVerifier(calls, + () -> authenticationCache.getOrAuthenticateByCertificate(fingerprint, () -> { + calls.incrementAndGet(); + return fakeAuth(); + })); + } + + private AuthenticationInfo fakeAuth() { + return new AuthenticationInfo(AuthMethod.CERTIFICATE, UUID.randomUUID().toString(), "test-user", List.of()); + } } diff --git a/src/test/java/com/czertainly/core/service/RoleManagementServiceTest.java b/src/test/java/com/czertainly/core/service/RoleManagementServiceTest.java new file mode 100644 index 000000000..a10d49e23 --- /dev/null +++ b/src/test/java/com/czertainly/core/service/RoleManagementServiceTest.java @@ -0,0 +1,137 @@ +package com.czertainly.core.service; + +import com.czertainly.api.model.client.auth.RoleRequestDto; +import com.czertainly.api.model.core.auth.*; +import com.czertainly.core.security.authn.client.AuthenticationCache; +import com.czertainly.core.security.authn.client.RoleManagementApiClient; +import com.czertainly.core.util.BaseSpringBootTest; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; +import java.util.UUID; + +class RoleManagementServiceTest extends BaseSpringBootTest { + + @Autowired + private RoleManagementService roleManagementService; + + @MockitoBean + private RoleManagementApiClient roleManagementApiClient; + + @MockitoBean + private AuthenticationCache authenticationCache; + + @Test + void updateRole_evictsEntireCache() throws Exception { + // given + String roleUuid = UUID.randomUUID().toString(); + RoleDetailDto roleDetailDto = roleDetailDto(roleUuid, false); + Mockito.when(roleManagementApiClient.updateRole(Mockito.eq(roleUuid), Mockito.any())).thenReturn(roleDetailDto); + + RoleRequestDto request = new RoleRequestDto(); + request.setName("test-role"); + request.setCustomAttributes(List.of()); + + // when + roleManagementService.updateRole(roleUuid, request); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + @Test + void deleteRole_evictsEntireCache() { + // given + String roleUuid = UUID.randomUUID().toString(); + + // when + roleManagementService.deleteRole(roleUuid); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + @Test + void addPermissions_evictsEntireCache() { + // given + String roleUuid = UUID.randomUUID().toString(); + Mockito.when(roleManagementApiClient.getRoleDetail(roleUuid)).thenReturn(roleDetailDto(roleUuid, false)); + Mockito.when(roleManagementApiClient.savePermissions(Mockito.eq(roleUuid), Mockito.any())) + .thenReturn(new SubjectPermissionsDto()); + + // when + roleManagementService.addPermissions(roleUuid, new RolePermissionsRequestDto()); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + @Test + void addResourcePermissionObjects_evictsEntireCache() { + // given + String roleUuid = UUID.randomUUID().toString(); + String resourceUuid = UUID.randomUUID().toString(); + Mockito.when(roleManagementApiClient.getRoleDetail(roleUuid)).thenReturn(roleDetailDto(roleUuid, false)); + + // when + roleManagementService.addResourcePermissionObjects(roleUuid, resourceUuid, List.of()); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + @Test + void updateResourcePermissionObjects_evictsEntireCache() { + // given + String roleUuid = UUID.randomUUID().toString(); + String resourceUuid = UUID.randomUUID().toString(); + String objectUuid = UUID.randomUUID().toString(); + Mockito.when(roleManagementApiClient.getRoleDetail(roleUuid)).thenReturn(roleDetailDto(roleUuid, false)); + + // when + roleManagementService.updateResourcePermissionObjects(roleUuid, resourceUuid, objectUuid, new ObjectPermissionsRequestDto()); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + @Test + void removeResourcePermissionObjects_evictsEntireCache() { + // given + String roleUuid = UUID.randomUUID().toString(); + String resourceUuid = UUID.randomUUID().toString(); + String objectUuid = UUID.randomUUID().toString(); + Mockito.when(roleManagementApiClient.getRoleDetail(roleUuid)).thenReturn(roleDetailDto(roleUuid, false)); + + // when + roleManagementService.removeResourcePermissionObjects(roleUuid, resourceUuid, objectUuid); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + @Test + void updateUsers_evictsEntireCache() { + // given + String roleUuid = UUID.randomUUID().toString(); + Mockito.when(roleManagementApiClient.updateUsers(Mockito.eq(roleUuid), Mockito.any())) + .thenReturn(roleDetailDto(roleUuid, false)); + + // when + roleManagementService.updateUsers(roleUuid, List.of()); + + // then + Mockito.verify(authenticationCache).evictAll(); + } + + private static RoleDetailDto roleDetailDto(String uuid, boolean systemRole) { + RoleDetailDto dto = new RoleDetailDto(); + dto.setUuid(uuid); + dto.setName("role-" + uuid); + dto.setSystemRole(systemRole); + return dto; + } +} diff --git a/src/test/java/com/czertainly/core/service/UserManagementServiceCacheEvictionTest.java b/src/test/java/com/czertainly/core/service/UserManagementServiceCacheEvictionTest.java new file mode 100644 index 000000000..2166d59e5 --- /dev/null +++ b/src/test/java/com/czertainly/core/service/UserManagementServiceCacheEvictionTest.java @@ -0,0 +1,142 @@ +package com.czertainly.core.service; + +import com.czertainly.api.model.client.auth.UpdateUserRequestDto; +import com.czertainly.api.model.core.auth.UserDetailDto; +import com.czertainly.core.security.authn.client.AuthenticationCache; +import com.czertainly.core.security.authn.client.UserManagementApiClient; +import com.czertainly.core.util.BaseSpringBootTest; +import com.czertainly.core.util.SessionTableHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; +import java.util.UUID; + +class UserManagementServiceCacheEvictionTest extends BaseSpringBootTest { + + @Autowired + private UserManagementService userManagementService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @MockitoBean + private UserManagementApiClient userManagementApiClient; + + @MockitoBean + private AuthenticationCache authenticationCache; + + @BeforeEach + void setupSessionTables() { + SessionTableHelper.createSessionTables(jdbcTemplate); + } + + @Test + void updateUser_evictsUserCache() throws Exception { + // given + UUID userUuid = UUID.randomUUID(); + Mockito.when(userManagementApiClient.updateUser(Mockito.eq(userUuid.toString()), Mockito.any())) + .thenReturn(userDetailDto(userUuid.toString())); + + UpdateUserRequestDto request = new UpdateUserRequestDto(); + request.setCustomAttributes(List.of()); + + // when + userManagementService.updateUser(userUuid.toString(), request); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + @Test + void updateUserInternal_evictsUserCache() throws Exception { + // given + UUID userUuid = UUID.randomUUID(); + Mockito.when(userManagementApiClient.updateUser(Mockito.eq(userUuid.toString()), Mockito.any())) + .thenReturn(userDetailDto(userUuid.toString())); + + // when + userManagementService.updateUserInternal(userUuid.toString(), new UpdateUserRequestDto(), "", ""); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + @Test + void deleteUser_evictsUserCache() { + // given + UUID userUuid = UUID.randomUUID(); + + // when + userManagementService.deleteUser(userUuid.toString()); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + @Test + void disableUser_evictsUserCache() { + // given + UUID userUuid = UUID.randomUUID(); + Mockito.when(userManagementApiClient.disableUser(userUuid.toString())).thenReturn(userDetailDto(userUuid.toString())); + + // when + userManagementService.disableUser(userUuid.toString()); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + @Test + void updateRoles_evictsUserCache() { + // given + UUID userUuid = UUID.randomUUID(); + Mockito.when(userManagementApiClient.updateRoles(Mockito.eq(userUuid.toString()), Mockito.any())) + .thenReturn(userDetailDto(userUuid.toString())); + + // when + userManagementService.updateRoles(userUuid.toString(), List.of()); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + @Test + void updateRole_evictsUserCache() { + // given + UUID userUuid = UUID.randomUUID(); + UUID roleUuid = UUID.randomUUID(); + Mockito.when(userManagementApiClient.updateRole(userUuid.toString(), roleUuid.toString())).thenReturn(userDetailDto(userUuid.toString())); + + // when + userManagementService.updateRole(userUuid.toString(), roleUuid.toString()); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + @Test + void removeRole_evictsUserCache() { + // given + UUID userUuid = UUID.randomUUID(); + UUID roleUuid = UUID.randomUUID(); + Mockito.when(userManagementApiClient.removeRole(userUuid.toString(), roleUuid.toString())).thenReturn(userDetailDto(userUuid.toString())); + + // when + userManagementService.removeRole(userUuid.toString(), roleUuid.toString()); + + // then + Mockito.verify(authenticationCache).evictByUserUuid(userUuid); + } + + private static UserDetailDto userDetailDto(String uuid) { + UserDetailDto dto = new UserDetailDto(); + dto.setUuid(uuid); + dto.setUsername("user-" + uuid); + return dto; + } +} diff --git a/src/test/java/com/czertainly/core/service/UserManagementServiceTest.java b/src/test/java/com/czertainly/core/service/UserManagementServiceTest.java index 11cc776e0..59d23fb30 100644 --- a/src/test/java/com/czertainly/core/service/UserManagementServiceTest.java +++ b/src/test/java/com/czertainly/core/service/UserManagementServiceTest.java @@ -8,6 +8,7 @@ import com.czertainly.core.dao.repository.CertificateRepository; import com.czertainly.core.security.authn.client.UserManagementApiClient; import com.czertainly.core.util.BaseSpringBootTest; +import com.czertainly.core.util.SessionTableHelper; import org.springframework.jdbc.core.JdbcTemplate; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -79,29 +80,6 @@ private void createSession(UUID userUuid) { } private void setupSessionTables() { - // Create spring_session table - jdbcTemplate.execute(""" - CREATE TABLE IF NOT EXISTS spring_session ( - PRIMARY_ID CHAR(36) NOT NULL, - SESSION_ID CHAR(36) NOT NULL, - CREATION_TIME BIGINT NOT NULL, - LAST_ACCESS_TIME BIGINT NOT NULL, - MAX_INACTIVE_INTERVAL INT NOT NULL, - EXPIRY_TIME BIGINT NOT NULL, - PRINCIPAL_NAME VARCHAR(100), - CONSTRAINT spring_session_pkey PRIMARY KEY(PRIMARY_ID) - ); - """); - - // Create spring_session_attributes table (your JSON setup) - jdbcTemplate.execute(""" - CREATE TABLE IF NOT EXISTS spring_session_attributes ( - SESSION_PRIMARY_ID CHAR(36) NOT NULL, - ATTRIBUTE_NAME VARCHAR(200) NOT NULL, - ATTRIBUTE_BYTES TEXT, - CONSTRAINT spring_session_attributes_pkey PRIMARY KEY(SESSION_PRIMARY_ID, ATTRIBUTE_NAME), - CONSTRAINT fk_session FOREIGN KEY(SESSION_PRIMARY_ID) REFERENCES spring_session(PRIMARY_ID) ON DELETE CASCADE - ); - """); + SessionTableHelper.createSessionTables(jdbcTemplate); } } diff --git a/src/test/java/com/czertainly/core/util/SessionTableHelper.java b/src/test/java/com/czertainly/core/util/SessionTableHelper.java new file mode 100644 index 000000000..1c5f6d46e --- /dev/null +++ b/src/test/java/com/czertainly/core/util/SessionTableHelper.java @@ -0,0 +1,31 @@ +package com.czertainly.core.util; + +import org.springframework.jdbc.core.JdbcTemplate; + +public class SessionTableHelper { + + public static void createSessionTables(JdbcTemplate jdbcTemplate) { + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS spring_session ( + PRIMARY_ID CHAR(36) NOT NULL, + SESSION_ID CHAR(36) NOT NULL, + CREATION_TIME BIGINT NOT NULL, + LAST_ACCESS_TIME BIGINT NOT NULL, + MAX_INACTIVE_INTERVAL INT NOT NULL, + EXPIRY_TIME BIGINT NOT NULL, + PRINCIPAL_NAME VARCHAR(100), + CONSTRAINT spring_session_pkey PRIMARY KEY(PRIMARY_ID) + ); + """); + + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS spring_session_attributes ( + SESSION_PRIMARY_ID CHAR(36) NOT NULL, + ATTRIBUTE_NAME VARCHAR(200) NOT NULL, + ATTRIBUTE_BYTES TEXT, + CONSTRAINT spring_session_attributes_pkey PRIMARY KEY(SESSION_PRIMARY_ID, ATTRIBUTE_NAME), + CONSTRAINT fk_session FOREIGN KEY(SESSION_PRIMARY_ID) REFERENCES spring_session(PRIMARY_ID) ON DELETE CASCADE + ); + """); + } +}