From bb54ace0c05c29ee3f556869e62f959da5380a0d Mon Sep 17 00:00:00 2001 From: gbrodman Date: Mon, 12 Dec 2022 13:51:33 -0500 Subject: [PATCH] Change the cookie auth mechanism to use IAP-provided JWTs (#1877) --- .../registry/config/RegistryConfig.java | 6 ++ .../config/RegistryConfigSettings.java | 1 + .../registry/config/files/default-config.yaml | 2 + .../registry/request/auth/AuthModule.java | 22 ++--- .../CookieOAuth2AuthenticationMechanism.java | 89 ------------------- .../IapHeaderAuthenticationMechanism.java | 87 ++++++++++++++++++ ...IapHeaderAuthenticationMechanismTest.java} | 28 +++--- .../server/RegistryTestServerMain.java | 14 +++ 8 files changed, 135 insertions(+), 114 deletions(-) delete mode 100644 core/src/main/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanism.java create mode 100644 core/src/main/java/google/registry/request/auth/IapHeaderAuthenticationMechanism.java rename core/src/test/java/google/registry/request/auth/{CookieOAuth2AuthenticationMechanismTest.java => IapHeaderAuthenticationMechanismTest.java} (79%) diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 390183133..6d7f288ab 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -106,6 +106,12 @@ public final class RegistryConfig { return config.gcpProject.projectId; } + @Provides + @Config("projectIdNumber") + public static long provideProjectIdNumber(RegistryConfigSettings config) { + return config.gcpProject.projectIdNumber; + } + @Provides @Config("locationId") public static String provideLocationId(RegistryConfigSettings config) { diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 7f04ae3ce..638443585 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -47,6 +47,7 @@ public class RegistryConfigSettings { /** Configuration options that apply to the entire GCP project. */ public static class GcpProject { public String projectId; + public long projectIdNumber; public String locationId; public boolean isLocal; public String defaultServiceUrl; 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 65c6fc6d6..57cb7b6c3 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 @@ -8,6 +8,8 @@ gcpProject: # Globally unique GCP project ID projectId: registry-project-id + # Corresponding project ID number + projectIdNumber: 123456789012 # Location of the GCP project, note that us-central1 and europe-west1 are special in that # they are used without the trailing number in GCP commands and Google Cloud Console. # See: https://cloud.google.com/appengine/docs/locations as an example diff --git a/core/src/main/java/google/registry/request/auth/AuthModule.java b/core/src/main/java/google/registry/request/auth/AuthModule.java index 9d5456c30..cb54f850f 100644 --- a/core/src/main/java/google/registry/request/auth/AuthModule.java +++ b/core/src/main/java/google/registry/request/auth/AuthModule.java @@ -14,13 +14,10 @@ package google.registry.request.auth; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; -import com.google.api.client.http.javanet.NetHttpTransport; -import com.google.api.client.json.JsonFactory; import com.google.appengine.api.oauth.OAuthService; import com.google.appengine.api.oauth.OAuthServiceFactory; +import com.google.auth.oauth2.TokenVerifier; import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; import dagger.Module; import dagger.Provides; import google.registry.config.RegistryConfig.Config; @@ -32,12 +29,14 @@ import javax.inject.Singleton; @Module public class AuthModule { + private static final String IAP_ISSUER_URL = "https://cloud.google.com/iap"; + /** Provides the custom authentication mechanisms (including OAuth). */ @Provides ImmutableList provideApiAuthenticationMechanisms( OAuthAuthenticationMechanism oauthAuthenticationMechanism, - CookieOAuth2AuthenticationMechanism cookieOAuth2AuthenticationMechanism) { - return ImmutableList.of(oauthAuthenticationMechanism, cookieOAuth2AuthenticationMechanism); + IapHeaderAuthenticationMechanism iapHeaderAuthenticationMechanism) { + return ImmutableList.of(oauthAuthenticationMechanism, iapHeaderAuthenticationMechanism); } /** Provides the OAuthService instance. */ @@ -48,12 +47,9 @@ public class AuthModule { @Provides @Singleton - GoogleIdTokenVerifier provideGoogleIdTokenVerifier( - @Config("allowedOauthClientIds") ImmutableSet allowedOauthClientIds, - NetHttpTransport httpTransport, - JsonFactory jsonFactory) { - return new GoogleIdTokenVerifier.Builder(httpTransport, jsonFactory) - .setAudience(allowedOauthClientIds) - .build(); + TokenVerifier provideTokenVerifier( + @Config("projectId") String projectId, @Config("projectIdNumber") long projectIdNumber) { + String audience = String.format("/projects/%d/apps/%s", projectIdNumber, projectId); + return TokenVerifier.newBuilder().setAudience(audience).setIssuer(IAP_ISSUER_URL).build(); } } diff --git a/core/src/main/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanism.java b/core/src/main/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanism.java deleted file mode 100644 index 4d367360d..000000000 --- a/core/src/main/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanism.java +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2022 The Nomulus Authors. All Rights Reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package google.registry.request.auth; - -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; -import com.google.common.flogger.FluentLogger; -import google.registry.model.console.User; -import google.registry.model.console.UserDao; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.util.Optional; -import javax.annotation.Nullable; -import javax.inject.Inject; -import javax.servlet.http.Cookie; -import javax.servlet.http.HttpServletRequest; - -/** - * A way to authenticate HTTP requests using OAuth2 ID tokens stored in cookies. - * - *

This is generic to Google Single-Sign-On and doesn't have any ties with Google App Engine. - */ -public class CookieOAuth2AuthenticationMechanism implements AuthenticationMechanism { - - private static final String ID_TOKEN_COOKIE_NAME = "idToken"; - - private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - - private final GoogleIdTokenVerifier googleIdTokenVerifier; - - @Inject - public CookieOAuth2AuthenticationMechanism(GoogleIdTokenVerifier googleIdTokenVerifier) { - this.googleIdTokenVerifier = googleIdTokenVerifier; - } - - @Override - public AuthResult authenticate(HttpServletRequest request) { - String rawIdToken = getRawIdTokenFromCookie(request); - if (rawIdToken == null) { - return AuthResult.NOT_AUTHENTICATED; - } - GoogleIdToken googleIdToken; - try { - googleIdToken = googleIdTokenVerifier.verify(rawIdToken); - } catch (IOException | GeneralSecurityException e) { - logger.atInfo().withCause(e).log("Error when verifying access token"); - return AuthResult.NOT_AUTHENTICATED; - } - // A null token means the provided ID token was invalid or expired - if (googleIdToken == null) { - logger.atInfo().log("Token %s failed validation", rawIdToken); - return AuthResult.NOT_AUTHENTICATED; - } - String emailAddress = googleIdToken.getPayload().getEmail(); - Optional maybeUser = UserDao.loadUser(emailAddress); - if (!maybeUser.isPresent()) { - logger.atInfo().log("No user found for email address %s", emailAddress); - return AuthResult.NOT_AUTHENTICATED; - } - return AuthResult.create(AuthLevel.USER, UserAuthInfo.create(maybeUser.get())); - } - - @Nullable - private String getRawIdTokenFromCookie(HttpServletRequest request) { - if (request.getCookies() == null) { - logger.atInfo().log("No cookies passed in request"); - return null; - } - for (Cookie cookie : request.getCookies()) { - if (cookie.getName().equals(ID_TOKEN_COOKIE_NAME)) { - return cookie.getValue(); - } - } - logger.atInfo().log("No ID token cookie"); - return null; - } -} diff --git a/core/src/main/java/google/registry/request/auth/IapHeaderAuthenticationMechanism.java b/core/src/main/java/google/registry/request/auth/IapHeaderAuthenticationMechanism.java new file mode 100644 index 000000000..3ec697f93 --- /dev/null +++ b/core/src/main/java/google/registry/request/auth/IapHeaderAuthenticationMechanism.java @@ -0,0 +1,87 @@ +// Copyright 2022 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.request.auth; + +import com.google.api.client.json.webtoken.JsonWebSignature; +import com.google.auth.oauth2.TokenVerifier; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.flogger.FluentLogger; +import google.registry.config.RegistryEnvironment; +import google.registry.model.console.User; +import google.registry.model.console.UserDao; +import java.util.Optional; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; + +/** + * A way to authenticate HTTP requests that have gone through the GCP Identity-Aware Proxy. + * + *

When the user logs in, IAP provides a JWT in the X-Goog-IAP-JWT-Assertion header. + * This header is included on all requests to IAP-enabled services (which should be all of them that + * receive requests from the front end). The token verification libraries ensure that the signed + * token has the proper audience and issuer. + * + * @see the documentation on GCP + * IAP's signed headers for more information. + */ +public class IapHeaderAuthenticationMechanism implements AuthenticationMechanism { + + private static final String ID_TOKEN_HEADER_NAME = "X-Goog-IAP-JWT-Assertion"; + + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + // A workaround that allows "use" of the IAP-based authenticator when running local testing, i.e. + // the RegistryTestServer + private static Optional userForTesting = Optional.empty(); + + private final TokenVerifier tokenVerifier; + + @Inject + public IapHeaderAuthenticationMechanism(TokenVerifier tokenVerifier) { + this.tokenVerifier = tokenVerifier; + } + + @Override + public AuthResult authenticate(HttpServletRequest request) { + if (RegistryEnvironment.get().equals(RegistryEnvironment.UNITTEST) + && userForTesting.isPresent()) { + return AuthResult.create(AuthLevel.USER, UserAuthInfo.create(userForTesting.get())); + } + String rawIdToken = request.getHeader(ID_TOKEN_HEADER_NAME); + if (rawIdToken == null) { + return AuthResult.NOT_AUTHENTICATED; + } + JsonWebSignature token; + try { + token = tokenVerifier.verify(rawIdToken); + } catch (TokenVerifier.VerificationException e) { + logger.atInfo().withCause(e).log("Error when verifying access token"); + return AuthResult.NOT_AUTHENTICATED; + } + String emailAddress = (String) token.getPayload().get("email"); + Optional maybeUser = UserDao.loadUser(emailAddress); + if (!maybeUser.isPresent()) { + logger.atInfo().log("No user found for email address %s", emailAddress); + return AuthResult.NOT_AUTHENTICATED; + } + return AuthResult.create(AuthLevel.USER, UserAuthInfo.create(maybeUser.get())); + } + + @VisibleForTesting + public static void setUserAuthInfoForTestServer(@Nullable User user) { + userForTesting = Optional.ofNullable(user); + } +} diff --git a/core/src/test/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanismTest.java b/core/src/test/java/google/registry/request/auth/IapHeaderAuthenticationMechanismTest.java similarity index 79% rename from core/src/test/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanismTest.java rename to core/src/test/java/google/registry/request/auth/IapHeaderAuthenticationMechanismTest.java index f09abbf40..de1603bb8 100644 --- a/core/src/test/java/google/registry/request/auth/CookieOAuth2AuthenticationMechanismTest.java +++ b/core/src/test/java/google/registry/request/auth/IapHeaderAuthenticationMechanismTest.java @@ -18,16 +18,15 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.testing.DatabaseHelper.insertInDb; import static org.mockito.Mockito.when; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload; -import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.api.client.json.webtoken.JsonWebSignature.Header; +import com.google.auth.oauth2.TokenVerifier; import com.google.common.truth.Truth8; import google.registry.model.console.GlobalRole; import google.registry.model.console.User; import google.registry.model.console.UserRoles; import google.registry.persistence.transaction.JpaTestExtensions; -import java.security.GeneralSecurityException; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import org.junit.jupiter.api.BeforeEach; @@ -36,28 +35,33 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.RegisterExtension; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; -/** Tests for {@link CookieOAuth2AuthenticationMechanism}. */ +/** Tests for {@link IapHeaderAuthenticationMechanism}. */ @ExtendWith(MockitoExtension.class) -public class CookieOAuth2AuthenticationMechanismTest { +@MockitoSettings(strictness = Strictness.LENIENT) +public class IapHeaderAuthenticationMechanismTest { @RegisterExtension public final JpaTestExtensions.JpaUnitTestExtension jpaExtension = new JpaTestExtensions.Builder().withEntityClass(User.class).buildUnitTestExtension(); - @Mock private GoogleIdTokenVerifier tokenVerifier; + @Mock private TokenVerifier tokenVerifier; @Mock private HttpServletRequest request; - private GoogleIdToken token; - private CookieOAuth2AuthenticationMechanism authenticationMechanism; + private JsonWebSignature token; + private IapHeaderAuthenticationMechanism authenticationMechanism; @BeforeEach - void beforeEach() { - authenticationMechanism = new CookieOAuth2AuthenticationMechanism(tokenVerifier); + void beforeEach() throws Exception { + authenticationMechanism = new IapHeaderAuthenticationMechanism(tokenVerifier); + when(request.getHeader("X-Goog-IAP-JWT-Assertion")).thenReturn("jwtValue"); Payload payload = new Payload(); payload.setEmail("email@email.com"); payload.setSubject("gaiaId"); - token = new GoogleIdToken(new Header(), payload, new byte[0], new byte[0]); + token = new JsonWebSignature(new Header(), payload, new byte[0], new byte[0]); + when(tokenVerifier.verify("jwtValue")).thenReturn(token); } @Test @@ -94,7 +98,7 @@ public class CookieOAuth2AuthenticationMechanismTest { @Test void testFailure_errorVerifyingToken() throws Exception { when(request.getCookies()).thenReturn(new Cookie[] {new Cookie("idToken", "asdf")}); - when(tokenVerifier.verify("asdf")).thenThrow(new GeneralSecurityException("hi")); + when(tokenVerifier.verify("asdf")).thenThrow(new TokenVerifier.VerificationException("hi")); assertThat(authenticationMechanism.authenticate(request).isAuthenticated()).isFalse(); } diff --git a/core/src/test/java/google/registry/server/RegistryTestServerMain.java b/core/src/test/java/google/registry/server/RegistryTestServerMain.java index 6fb2a5828..416e86a08 100644 --- a/core/src/test/java/google/registry/server/RegistryTestServerMain.java +++ b/core/src/test/java/google/registry/server/RegistryTestServerMain.java @@ -19,7 +19,11 @@ import com.beust.jcommander.Parameter; import com.beust.jcommander.Parameters; import com.google.common.collect.ImmutableList; import com.google.common.net.HostAndPort; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.auth.IapHeaderAuthenticationMechanism; import google.registry.testing.AppEngineExtension; import google.registry.testing.UserInfo; import google.registry.tools.params.HostAndPortParameter; @@ -138,6 +142,16 @@ public final class RegistryTestServerMain { loginIsAdmin ? UserInfo.createAdmin(loginEmail) : UserInfo.create(loginEmail)) .build(); appEngine.setUp(); + UserRoles userRoles = + new UserRoles.Builder().setIsAdmin(loginIsAdmin).setGlobalRole(GlobalRole.FTE).build(); + User user = + new User.Builder() + .setEmailAddress(loginEmail) + .setGaiaId("123457890") + .setUserRoles(userRoles) + .setRegistryLockPassword("registryLockPassword") + .build(); + IapHeaderAuthenticationMechanism.setUserAuthInfoForTestServer(user); new JpaTestExtensions.Builder().buildIntegrationTestExtension().beforeEach(null); AppEngineExtension.loadInitialData(); System.out.printf("%sLoading fixtures...%s\n", BLUE, RESET);