diff --git a/java/google/registry/tools/AppEngineConnectionFlags.java b/java/google/registry/tools/AppEngineConnectionFlags.java index e094396cb..020397883 100644 --- a/java/google/registry/tools/AppEngineConnectionFlags.java +++ b/java/google/registry/tools/AppEngineConnectionFlags.java @@ -16,6 +16,7 @@ package google.registry.tools; import com.beust.jcommander.Parameter; import com.beust.jcommander.Parameters; +import com.google.common.annotations.VisibleForTesting; import com.google.common.net.HostAndPort; import dagger.Module; import dagger.Provides; @@ -31,7 +32,15 @@ import google.registry.config.RegistryConfig; class AppEngineConnectionFlags { @Parameter(names = "--server", description = "HOST[:PORT] to which remote commands are sent.") - private static HostAndPort server = RegistryConfig.getServer(); + private HostAndPort server = RegistryConfig.getServer(); + + /** Provided for testing. */ + @VisibleForTesting + AppEngineConnectionFlags(HostAndPort server) { + this.server = server; + } + + AppEngineConnectionFlags() {} HostAndPort getServer() { return server; diff --git a/java/google/registry/tools/BUILD b/java/google/registry/tools/BUILD index c7d668e16..2c59312cc 100644 --- a/java/google/registry/tools/BUILD +++ b/java/google/registry/tools/BUILD @@ -26,6 +26,11 @@ java_library( resources = glob([ "*.properties", "sql/*.sql", + + # These are example client secret files. You'll need to obtain your + # own for every environment you use and install them in this + # directory. + "resources/client_secret*.json", ]), visibility = [":allowed-tools"], deps = [ @@ -65,6 +70,9 @@ java_library( "@com_google_guava", "@com_google_http_client", "@com_google_http_client_jackson2", + "@com_google_oauth_client", + "@com_google_oauth_client_java6", + "@com_google_oauth_client_jetty", "@com_google_re2j", "@com_googlecode_json_simple", "@io_bazel_rules_closure//closure/templates", diff --git a/java/google/registry/tools/DefaultRequestFactoryModule.java b/java/google/registry/tools/DefaultRequestFactoryModule.java index 32f53257e..9856202e0 100644 --- a/java/google/registry/tools/DefaultRequestFactoryModule.java +++ b/java/google/registry/tools/DefaultRequestFactoryModule.java @@ -14,14 +14,34 @@ package google.registry.tools; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.extensions.java6.auth.oauth2.AuthorizationCodeInstalledApp; +import com.google.api.client.extensions.jetty.auth.oauth2.LocalServerReceiver; +import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow; +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets; import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpRequestInitializer; import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.client.util.store.AbstractDataStoreFactory; +import com.google.api.client.util.store.FileDataStoreFactory; import dagger.Binds; import dagger.Module; import dagger.Provides; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.annotation.Documented; +import java.util.Collections; import javax.inject.Named; +import javax.inject.Provider; +import javax.inject.Qualifier; +import javax.inject.Singleton; /** * Module for providing the default HttpRequestFactory. @@ -40,9 +60,50 @@ import javax.inject.Named; @Module class DefaultRequestFactoryModule { + // TODO(mmuller): Use @Config("requiredOauthScopes") + private static final String DEFAULT_SCOPE = + "https://www.googleapis.com/auth/userinfo.email"; + + private static final File DATA_STORE_DIR = + new File(System.getProperty("user.home"), ".config/nomulus/credentials"); + + // TODO(mmuller): replace with a config parameter. + private static final String CLIENT_SECRET_FILENAME = + "/google/registry/tools/resources/client_secret.json"; + + @Provides + @ClientSecretFilename + String provideClientSecretFilename() { + return CLIENT_SECRET_FILENAME; + } + + /** Returns the credential object for the user. */ + @Provides + Credential provideCredential( + AbstractDataStoreFactory dataStoreFactory, + Authorizer authorizer, + @ClientSecretFilename String clientSecretFilename) { + try { + // Load the client secrets file. + JacksonFactory jsonFactory = new JacksonFactory(); + InputStream secretResourceStream = getClass().getResourceAsStream(clientSecretFilename); + if (secretResourceStream == null) { + throw new RuntimeException("No client secret file found: " + clientSecretFilename); + } + GoogleClientSecrets clientSecrets = GoogleClientSecrets.load(jsonFactory, + new InputStreamReader(secretResourceStream, UTF_8)); + + return authorizer.authorize(clientSecrets); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + @Provides @Named("default") - public HttpRequestFactory provideHttpRequestFactory(AppEngineConnectionFlags connectionFlags) { + public HttpRequestFactory provideHttpRequestFactory( + AppEngineConnectionFlags connectionFlags, + Provider credentialProvider) { if (connectionFlags.getServer().getHost().equals("localhost")) { return new NetHttpTransport() .createRequestFactory( @@ -55,7 +116,7 @@ class DefaultRequestFactoryModule { } }); } else { - return new NetHttpTransport().createRequestFactory(); + return new NetHttpTransport().createRequestFactory(credentialProvider.get()); } } @@ -63,14 +124,77 @@ class DefaultRequestFactoryModule { * Module for providing HttpRequestFactory. * *

Localhost connections go to the App Engine dev server. The dev server differs from most HTTP - * connections in that they don't require OAuth2 credentials, but instead require a special - * cookie. + * connections in that the types whose annotations affect the use of annotaty don't require + * OAuth2 credentials, but instead require a special cookie. */ @Module - abstract class RequestFactoryModule { + abstract static class RequestFactoryModule { @Binds public abstract HttpRequestFactory provideHttpRequestFactory( @Named("default") HttpRequestFactory requestFactory); } + + @Module + static class DataStoreFactoryModule { + @Provides + @Singleton + public AbstractDataStoreFactory provideDataStoreFactory() { + try { + return new FileDataStoreFactory(DATA_STORE_DIR); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + } + } + + /** + * Module to create the Authorizer used by DefaultRequestFactoryModule. + */ + @Module + static class AuthorizerModule { + @Provides + public Authorizer provideAuthorizer( + final JsonFactory jsonFactory, final AbstractDataStoreFactory dataStoreFactory) { + return new Authorizer() { + @Override + public Credential authorize(GoogleClientSecrets clientSecrets) { + try { + // Run a new auth flow. + GoogleAuthorizationCodeFlow flow = new GoogleAuthorizationCodeFlow.Builder( + new NetHttpTransport(), jsonFactory, clientSecrets, + Collections.singleton(DEFAULT_SCOPE)) + .setDataStoreFactory(dataStoreFactory) + .build(); + + // TODO(mmuller): "credentials" directory needs to be qualified with the scopes and + // client id. + + // We pass client id to the authorize method so we can safely persist credentials for + // multiple client ids. + return new AuthorizationCodeInstalledApp(flow, new LocalServerReceiver()) + .authorize(clientSecrets.getDetails().getClientId()); + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + }; + } + } + + /** + * Interface that encapsulates the authorization logic to produce a credential for the user, + * allowing us to override the behavior for unit tests. + */ + interface Authorizer { + Credential authorize(GoogleClientSecrets clientSecrets); + } + + /** Dagger qualifier for the client secret filename. + * + *

TODO(mmuller): move this to config. + */ + @Qualifier + @Documented + public @interface ClientSecretFilename {} } diff --git a/java/google/registry/tools/RegistryToolComponent.java b/java/google/registry/tools/RegistryToolComponent.java index 23bf71156..d325e452b 100644 --- a/java/google/registry/tools/RegistryToolComponent.java +++ b/java/google/registry/tools/RegistryToolComponent.java @@ -43,6 +43,8 @@ import javax.inject.Singleton; DatastoreServiceModule.class, CloudDnsWriterModule.class, DefaultRequestFactoryModule.class, + DefaultRequestFactoryModule.AuthorizerModule.class, + DefaultRequestFactoryModule.DataStoreFactoryModule.class, DefaultRequestFactoryModule.RequestFactoryModule.class, DnsUpdateWriterModule.class, DummyKeyringModule.class, diff --git a/java/google/registry/tools/resources/README.md b/java/google/registry/tools/resources/README.md new file mode 100644 index 000000000..f3ff09100 --- /dev/null +++ b/java/google/registry/tools/resources/README.md @@ -0,0 +1,18 @@ + +# Adding Client Secrets + +To use the nomulus tool to administer a nomulus instance, you will need to +obtain OAuth client ids for each of your environment. There's no reason you +can't use the same client id for all of your environments. + +To obtain a client id, go to your project's ["credentials" +page](https://console.developers.google.com/apis/credentials) in the Developer's +Console. Click "Create credentials" and select "OAuth client Id" from the +dropdown. In the create credentials window, select an application type of +"Other." + +When you return to the main credentials page, click the download icon to the +right of the client id that you just created. This will download a json file +that you should copy to this directory for all of the environments that you +want to use. Don't copy over the "UNITTEST" secret, otherwise your unit tests +will break. diff --git a/java/google/registry/tools/resources/client_secret.json b/java/google/registry/tools/resources/client_secret.json new file mode 100644 index 000000000..b111b0279 --- /dev/null +++ b/java/google/registry/tools/resources/client_secret.json @@ -0,0 +1,11 @@ +{ + "installed": { + "client_id":"SEE-README.md-IN_THIS_DIRECTORY.apps.googleusercontent.com", + "project_id":"your-registry-server", + "auth_uri":"https://accounts.google.com/o/oauth2/auth", + "token_uri":"https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", + "client_secret":"YOUR-CLIENT-SECRET", + "redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"] + } +} diff --git a/java/google/registry/tools/resources/client_secret_UNITTEST.json b/java/google/registry/tools/resources/client_secret_UNITTEST.json new file mode 100644 index 000000000..4df327a21 --- /dev/null +++ b/java/google/registry/tools/resources/client_secret_UNITTEST.json @@ -0,0 +1,11 @@ +{ + "installed": { + "client_id":"UNITTEST-CLIENT-ID", + "project_id":"DO NOT CHANGE", + "auth_uri":"https://accounts.google.com/o/oauth2/auth", + "token_uri":"https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs", + "client_secret":"TBj4EcP5c0609ojiy2DIG6wE", + "redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"] + } +} diff --git a/javatests/google/registry/tools/BUILD b/javatests/google/registry/tools/BUILD index e63ab0956..1a6813cf4 100644 --- a/javatests/google/registry/tools/BUILD +++ b/javatests/google/registry/tools/BUILD @@ -35,13 +35,18 @@ java_library( "//javatests/google/registry/xml", "//third_party/java/objectify:objectify-v4_1", "@com_beust_jcommander", + "@com_google_api_client", "@com_google_appengine_api_1_0_sdk//:testonly", "@com_google_appengine_remote_api//:link", "@com_google_code_findbugs_jsr305", "@com_google_guava", + "@com_google_http_client", + "@com_google_oauth_client", + "@com_google_oauth_client_java6", "@com_google_re2j", "@com_google_truth", "@com_googlecode_json_simple", + "@javax_inject", "@joda_time", "@junit", "@org_joda_money", diff --git a/javatests/google/registry/tools/DefaultRequestFactoryModuleTest.java b/javatests/google/registry/tools/DefaultRequestFactoryModuleTest.java new file mode 100644 index 000000000..8ab9c6f0e --- /dev/null +++ b/javatests/google/registry/tools/DefaultRequestFactoryModuleTest.java @@ -0,0 +1,109 @@ +// Copyright 2017 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.tools; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +import com.google.api.client.auth.oauth2.Credential; +import com.google.api.client.googleapis.auth.oauth2.GoogleClientSecrets; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpRequestInitializer; +import com.google.api.client.util.store.AbstractDataStoreFactory; +import com.google.common.net.HostAndPort; +import google.registry.testing.Providers; +import java.io.IOException; +import javax.inject.Provider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; + +@RunWith(JUnit4.class) +public class DefaultRequestFactoryModuleTest { + + private static final Credential FAKE_CREDENTIAL = new Credential( + new Credential.AccessMethod() { + @Override + public void intercept(HttpRequest request, String accessToken) throws IOException {} + + @Override + public String getAccessTokenFromRequest(HttpRequest request) { + return "MockAccessToken"; + } + }); + + private static final String TEST_CLIENT_SECRET_FILENAME = + "/google/registry/tools/resources/client_secret_UNITTEST.json"; + + // Mocks. + AbstractDataStoreFactory dataStoreFactory = mock(AbstractDataStoreFactory.class); + DefaultRequestFactoryModule.Authorizer authorizer = + mock(DefaultRequestFactoryModule.Authorizer.class); + Provider credentialProvider = Providers.of(FAKE_CREDENTIAL); + + // Captor for client secrets. + ArgumentCaptor secrets = ArgumentCaptor.forClass(GoogleClientSecrets.class); + + DefaultRequestFactoryModule module = new DefaultRequestFactoryModule(); + + @Before + public void setUp() { + RegistryToolEnvironment.UNITTEST.setup(); + } + + @Test + public void test_getCredential() throws Exception { + when(authorizer.authorize(any(GoogleClientSecrets.class))).thenReturn(FAKE_CREDENTIAL); + Credential cred = module.provideCredential( + dataStoreFactory, + authorizer, + TEST_CLIENT_SECRET_FILENAME); + assertThat(cred).isSameAs(FAKE_CREDENTIAL); + verify(authorizer).authorize(secrets.capture()); + assertThat(secrets.getValue().getDetails().getClientId()).isEqualTo( + "UNITTEST-CLIENT-ID"); + } + + @Test + public void test_provideHttpRequestFactory_localhost() throws Exception { + // Make sure that localhost creates a request factory with an initializer. + HttpRequestFactory factory = + module.provideHttpRequestFactory(new AppEngineConnectionFlags( + HostAndPort.fromParts("localhost", 1000)), + credentialProvider); + HttpRequestInitializer initializer = factory.getInitializer(); + assertThat(initializer).isNotNull(); + assertThat(initializer).isNotSameAs(FAKE_CREDENTIAL); + verifyZeroInteractions(authorizer); + } + + @Test + public void test_provideHttpRequestFactory_remote() throws Exception { + // Make sure that example.com creates a request factory with the UNITTEST client id but no + // initializer. + HttpRequestFactory factory = + module.provideHttpRequestFactory(new AppEngineConnectionFlags( + HostAndPort.fromParts("example.com", 1000)), + credentialProvider); + assertThat(factory.getInitializer()).isSameAs(FAKE_CREDENTIAL); + } +}