diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index f5421975d..1ff246049 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -1591,6 +1591,26 @@ public final class RegistryConfig { return CONFIG_SETTINGS.get().caching.eppResourceMaxCachedEntries; } + /** Returns if we have enabled caching for User Authentication */ + public static boolean getUserAuthCachingEnabled() { + return CONFIG_SETTINGS.get().caching.userAuthCachingEnabled; + } + + @VisibleForTesting + public static void overrideIsUserAuthCachingEnabledForTesting(boolean enabled) { + CONFIG_SETTINGS.get().caching.userAuthCachingEnabled = enabled; + } + + /** Returns the expiry duration for the user authentication cache. */ + public static java.time.Duration getUserAuthCachingDuration() { + return java.time.Duration.ofSeconds(CONFIG_SETTINGS.get().caching.userAuthCachingSeconds); + } + + /** Returns the maximum number of entries in user authentication cache. */ + public static int getUserAuthMaxCachedEntries() { + return CONFIG_SETTINGS.get().caching.userAuthMaxCachedEntries; + } + /** Returns the amount of time that a particular claims list should be cached. */ public static java.time.Duration getClaimsListCacheDuration() { return java.time.Duration.ofSeconds(CONFIG_SETTINGS.get().caching.claimsListCachingSeconds); diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 32dd08ee8..185f5106b 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -161,6 +161,9 @@ public class RegistryConfigSettings { public int eppResourceCachingSeconds; public int eppResourceMaxCachedEntries; public int claimsListCachingSeconds; + public boolean userAuthCachingEnabled; + public int userAuthCachingSeconds; + public int userAuthMaxCachedEntries; } /** Configuration for ICANN monthly reporting. */ diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 03828e34a..57e6bea62 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -326,6 +326,20 @@ caching: # long duration is acceptable because claims lists don't change frequently. claimsListCachingSeconds: 21600 # six hours + #-- User Authentication Cache Settings --# + + # Whether to cache User objects during OIDC token authentication to reduce database load. + # This helps mitigate high QPS from frequent hello commands and session-less requests. + userAuthCachingEnabled: true + + # The duration in seconds for which a User object is cached after being loaded. + # A short duration is recommended to avoid stale data. + userAuthCachingSeconds: 60 + + # The maximum number of User objects to store in the cache per pod. + # This helps limit the memory footprint of the cache. + userAuthMaxCachedEntries: 200 + # Note: Only allowedServiceAccountEmails and oauthClientId should be configured. # Other fields are related to OAuth-based authentication and will be removed. auth: diff --git a/core/src/main/java/google/registry/request/auth/OidcTokenAuthenticationMechanism.java b/core/src/main/java/google/registry/request/auth/OidcTokenAuthenticationMechanism.java index 53dea42f0..601f7dcbc 100644 --- a/core/src/main/java/google/registry/request/auth/OidcTokenAuthenticationMechanism.java +++ b/core/src/main/java/google/registry/request/auth/OidcTokenAuthenticationMechanism.java @@ -15,14 +15,20 @@ package google.registry.request.auth; import static com.google.common.base.Preconditions.checkState; +import static google.registry.config.RegistryConfig.getUserAuthCachingDuration; +import static google.registry.config.RegistryConfig.getUserAuthCachingEnabled; +import static google.registry.config.RegistryConfig.getUserAuthMaxCachedEntries; +import static google.registry.model.CacheUtils.newCacheBuilder; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import com.github.benmanes.caffeine.cache.LoadingCache; import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.auth.oauth2.TokenVerifier.VerificationException; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableSet; import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryConfig; import google.registry.config.RegistryConfig.Config; import google.registry.model.console.User; import google.registry.persistence.VKey; @@ -71,6 +77,40 @@ public abstract class OidcTokenAuthenticationMechanism implements Authentication this.tokenVerifier = tokenVerifier; } + /** + * An in-memory cache for User entities, built using the project's standard utility. + * + *

This cache reduces database load by temporarily storing User objects after they are fetched. + * It is configured to cache negative results (i.e., when a user is not found) to prevent repeated + * lookups for invalid users. The cache's behavior (enabled, expiry, size) is controlled by + * settings in {@link RegistryConfig}. + */ + @VisibleForTesting + static LoadingCache> userCache = + newCacheBuilder(getUserAuthCachingDuration()) + .maximumSize(getUserAuthMaxCachedEntries()) + .build(OidcTokenAuthenticationMechanism::loadUser); + + /** + * A loader function that defines how to fetch a User from the database on a cache miss. + * + *

This is the single point of entry to the database for this authentication flow. It will only + * be invoked by the cache when a requested user is not already in memory. + */ + @VisibleForTesting + static Optional loadUser(String email) { + VKey userVKey = VKey.create(User.class, email); + return tm().transact(() -> tm().loadByKeyIfPresent(userVKey)); + } + + @VisibleForTesting + public static void setCacheForTesting(LoadingCache> cache) { + checkState( + RegistryEnvironment.get() == RegistryEnvironment.UNITTEST, + "Cannot set cache outside of a test environment"); + OidcTokenAuthenticationMechanism.userCache = cache; + } + @Override public AuthResult authenticate(HttpServletRequest request) { if (RegistryEnvironment.get().equals(RegistryEnvironment.UNITTEST) @@ -112,8 +152,15 @@ public abstract class OidcTokenAuthenticationMechanism implements Authentication logger.atInfo().log("No email address from the OIDC token:\n%s", token.getPayload()); return AuthResult.NOT_AUTHENTICATED; } - Optional maybeUser = - tm().transact(() -> tm().loadByKeyIfPresent(VKey.create(User.class, email))); + Optional maybeUser; + if (getUserAuthCachingEnabled()) { + // If caching is ON, use the cache. + maybeUser = userCache.get(email); + } else { + // If caching is OFF, fall back to the original direct database call. + maybeUser = loadUser(email); + } + stopwatch.tick("OidcTokenAuthenticationMechanism maybeUser loaded"); if (maybeUser.isPresent()) { return AuthResult.createUser(maybeUser.get()); diff --git a/core/src/test/java/google/registry/request/auth/OidcTokenAuthenticationMechanismTest.java b/core/src/test/java/google/registry/request/auth/OidcTokenAuthenticationMechanismTest.java index 9be307d86..90d3416bb 100644 --- a/core/src/test/java/google/registry/request/auth/OidcTokenAuthenticationMechanismTest.java +++ b/core/src/test/java/google/registry/request/auth/OidcTokenAuthenticationMechanismTest.java @@ -16,13 +16,20 @@ package google.registry.request.auth; import static com.google.common.net.HttpHeaders.AUTHORIZATION; import static com.google.common.truth.Truth.assertThat; +import static google.registry.config.RegistryConfig.getUserAuthCachingDuration; +import static google.registry.config.RegistryConfig.getUserAuthMaxCachedEntries; import static google.registry.request.auth.AuthModule.BEARER_PREFIX; import static google.registry.request.auth.AuthModule.IAP_HEADER_NAME; import static google.registry.testing.DatabaseHelper.createAdminUser; import static google.registry.testing.DatabaseHelper.persistResource; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.github.benmanes.caffeine.cache.LoadingCache; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload; import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.api.client.json.webtoken.JsonWebSignature.Header; @@ -33,7 +40,9 @@ import dagger.Component; import dagger.Module; import dagger.Provides; import google.registry.config.CredentialModule.ApplicationDefaultCredential; +import google.registry.config.RegistryConfig; import google.registry.config.RegistryConfig.Config; +import google.registry.model.CacheUtils; import google.registry.model.console.GlobalRole; import google.registry.model.console.User; import google.registry.model.console.UserRoles; @@ -44,6 +53,7 @@ import google.registry.request.auth.OidcTokenAuthenticationMechanism.RegularOidc import google.registry.util.GoogleCredentialsBundle; import jakarta.inject.Singleton; import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -54,6 +64,9 @@ public class OidcTokenAuthenticationMechanismTest { private static final String rawToken = "this-token"; private static final String email = "user@email.test"; + + private static final String unknownEmail = "bad-guy@evil.real"; + private static final String gaiaId = "gaia-id"; private static final ImmutableSet serviceAccounts = ImmutableSet.of("service@email.test", "email@service.goog"); @@ -75,6 +88,12 @@ public class OidcTokenAuthenticationMechanismTest { @BeforeEach void beforeEach() throws Exception { + // 1. Create a brand new cache. + LoadingCache> testCache = + CacheUtils.newCacheBuilder(getUserAuthCachingDuration()) + .maximumSize(getUserAuthMaxCachedEntries()) + .build(OidcTokenAuthenticationMechanism::loadUser); + OidcTokenAuthenticationMechanism.setCacheForTesting(testCache); payload.setEmail(email); payload.setSubject(gaiaId); user = createAdminUser(email); @@ -154,7 +173,7 @@ public class OidcTokenAuthenticationMechanismTest { @Test void testAuthenticate_unknownEmailAddress() throws Exception { - payload.setEmail("bad-guy@evil.real"); + payload.setEmail(unknownEmail); authResult = authenticationMechanism.authenticate(request); assertThat(authResult).isEqualTo(AuthResult.NOT_AUTHENTICATED); } @@ -189,6 +208,62 @@ public class OidcTokenAuthenticationMechanismTest { authenticationMechanism = component.regularOidcAuthenticationMechanism(); } + @Test + void testAuthenticate_ExistentUser_isCached() { + // Arrange: Create a spy of the actual cache object. + // A spy calls the real methods of the object while allowing us to verify interactions. + LoadingCache> spiedCache = + spy(OidcTokenAuthenticationMechanism.userCache); + OidcTokenAuthenticationMechanism.setCacheForTesting(spiedCache); + + // Act: Call the authenticate method. + authenticationMechanism.authenticate(request); + + // Assert: Verify that the cache's "get" method was called exactly once. + // This confirms the cache is being used without checking its internal stats. + verify(spiedCache).get(email); + } + + @Test + void testAuthenticate_nonExistentUser_isCached() { + // Arrange: Use an email that is not in the test database. + + payload.setEmail(unknownEmail); + + LoadingCache> spiedCache = + spy(OidcTokenAuthenticationMechanism.userCache); + OidcTokenAuthenticationMechanism.setCacheForTesting(spiedCache); + // Act: Call the authenticate method. + authenticationMechanism.authenticate(request); + + // Assert: Verify that the cache's "get" method was called for the unverified email. + // This confirms that we attempted to look up the unknown user in the cache. + verify(spiedCache).get(unknownEmail); + } + + @Test + void testAuthenticate_whenCacheIsDisabled_cacheIsNotUsed() { + // Arrange: Explicitly disable the cache and create a spy. + RegistryConfig.overrideIsUserAuthCachingEnabledForTesting(false); + LoadingCache> spiedCache = + spy(OidcTokenAuthenticationMechanism.userCache); + OidcTokenAuthenticationMechanism.setCacheForTesting(spiedCache); + + // Act: Authenticate the user. + AuthResult authResult = authenticationMechanism.authenticate(request); + + // Assert: The authentication should still succeed because the code falls back + // to the direct database call. + assertThat(authResult.isAuthenticated()).isTrue(); + + // Assert: Crucially, verify that the cache's "get" method was NEVER called. + // This proves the cache was correctly bypassed. + verify(spiedCache, never()).get(any(String.class)); + + // Teardown: Restore the default setting for other tests. + RegistryConfig.overrideIsUserAuthCachingEnabledForTesting(true); + } + @Singleton @Component(modules = {AuthModule.class, TestModule.class}) interface TestComponent { @@ -234,4 +309,12 @@ public class OidcTokenAuthenticationMechanismTest { return GoogleCredentialsBundle.create(GoogleCredentials.newBuilder().build()); } } + + private void reinitializeCache() { + OidcTokenAuthenticationMechanism.userCache = + CacheUtils.newCacheBuilder(getUserAuthCachingDuration()) + .maximumSize(getUserAuthMaxCachedEntries()) + .recordStats() + .build(OidcTokenAuthenticationMechanism::loadUser); + } }