Add Client-side OAuth2 to HTTP connections

Implement client-side OAuth in non-local HTTP connections.  Also add tests to
verify that the different modes of connection are set up correctly.

-------------
Created by MOE: https://github.com/google/moe
MOE_MIGRATED_REVID=147636222
This commit is contained in:
mmuller 2017-02-15 13:22:54 -08:00 committed by Ben McIlwain
parent 32b236e940
commit 177bf4a5f1
9 changed files with 303 additions and 6 deletions

View file

@ -16,6 +16,7 @@ package google.registry.tools;
import com.beust.jcommander.Parameter; import com.beust.jcommander.Parameter;
import com.beust.jcommander.Parameters; import com.beust.jcommander.Parameters;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.net.HostAndPort; import com.google.common.net.HostAndPort;
import dagger.Module; import dagger.Module;
import dagger.Provides; import dagger.Provides;
@ -31,7 +32,15 @@ import google.registry.config.RegistryConfig;
class AppEngineConnectionFlags { class AppEngineConnectionFlags {
@Parameter(names = "--server", description = "HOST[:PORT] to which remote commands are sent.") @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() { HostAndPort getServer() {
return server; return server;

View file

@ -26,6 +26,11 @@ java_library(
resources = glob([ resources = glob([
"*.properties", "*.properties",
"sql/*.sql", "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"], visibility = [":allowed-tools"],
deps = [ deps = [
@ -65,6 +70,9 @@ java_library(
"@com_google_guava", "@com_google_guava",
"@com_google_http_client", "@com_google_http_client",
"@com_google_http_client_jackson2", "@com_google_http_client_jackson2",
"@com_google_oauth_client",
"@com_google_oauth_client_java6",
"@com_google_oauth_client_jetty",
"@com_google_re2j", "@com_google_re2j",
"@com_googlecode_json_simple", "@com_googlecode_json_simple",
"@io_bazel_rules_closure//closure/templates", "@io_bazel_rules_closure//closure/templates",

View file

@ -14,14 +14,34 @@
package google.registry.tools; 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.HttpRequest;
import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpRequestFactory;
import com.google.api.client.http.HttpRequestInitializer; import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.http.javanet.NetHttpTransport; 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.Binds;
import dagger.Module; import dagger.Module;
import dagger.Provides; 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.Named;
import javax.inject.Provider;
import javax.inject.Qualifier;
import javax.inject.Singleton;
/** /**
* Module for providing the default HttpRequestFactory. * Module for providing the default HttpRequestFactory.
@ -40,9 +60,50 @@ import javax.inject.Named;
@Module @Module
class DefaultRequestFactoryModule { 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 @Provides
@Named("default") @Named("default")
public HttpRequestFactory provideHttpRequestFactory(AppEngineConnectionFlags connectionFlags) { public HttpRequestFactory provideHttpRequestFactory(
AppEngineConnectionFlags connectionFlags,
Provider<Credential> credentialProvider) {
if (connectionFlags.getServer().getHost().equals("localhost")) { if (connectionFlags.getServer().getHost().equals("localhost")) {
return new NetHttpTransport() return new NetHttpTransport()
.createRequestFactory( .createRequestFactory(
@ -55,7 +116,7 @@ class DefaultRequestFactoryModule {
} }
}); });
} else { } else {
return new NetHttpTransport().createRequestFactory(); return new NetHttpTransport().createRequestFactory(credentialProvider.get());
} }
} }
@ -63,14 +124,77 @@ class DefaultRequestFactoryModule {
* Module for providing HttpRequestFactory. * Module for providing HttpRequestFactory.
* *
* <p>Localhost connections go to the App Engine dev server. The dev server differs from most HTTP * <p>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 * connections in that the types whose annotations affect the use of annotaty don't require
* cookie. * OAuth2 credentials, but instead require a special cookie.
*/ */
@Module @Module
abstract class RequestFactoryModule { abstract static class RequestFactoryModule {
@Binds @Binds
public abstract HttpRequestFactory provideHttpRequestFactory( public abstract HttpRequestFactory provideHttpRequestFactory(
@Named("default") HttpRequestFactory requestFactory); @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.
*
* <p>TODO(mmuller): move this to config.
*/
@Qualifier
@Documented
public @interface ClientSecretFilename {}
} }

View file

@ -43,6 +43,8 @@ import javax.inject.Singleton;
DatastoreServiceModule.class, DatastoreServiceModule.class,
CloudDnsWriterModule.class, CloudDnsWriterModule.class,
DefaultRequestFactoryModule.class, DefaultRequestFactoryModule.class,
DefaultRequestFactoryModule.AuthorizerModule.class,
DefaultRequestFactoryModule.DataStoreFactoryModule.class,
DefaultRequestFactoryModule.RequestFactoryModule.class, DefaultRequestFactoryModule.RequestFactoryModule.class,
DnsUpdateWriterModule.class, DnsUpdateWriterModule.class,
DummyKeyringModule.class, DummyKeyringModule.class,

View file

@ -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.

View file

@ -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"]
}
}

View file

@ -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"]
}
}

View file

@ -35,13 +35,18 @@ java_library(
"//javatests/google/registry/xml", "//javatests/google/registry/xml",
"//third_party/java/objectify:objectify-v4_1", "//third_party/java/objectify:objectify-v4_1",
"@com_beust_jcommander", "@com_beust_jcommander",
"@com_google_api_client",
"@com_google_appengine_api_1_0_sdk//:testonly", "@com_google_appengine_api_1_0_sdk//:testonly",
"@com_google_appengine_remote_api//:link", "@com_google_appengine_remote_api//:link",
"@com_google_code_findbugs_jsr305", "@com_google_code_findbugs_jsr305",
"@com_google_guava", "@com_google_guava",
"@com_google_http_client",
"@com_google_oauth_client",
"@com_google_oauth_client_java6",
"@com_google_re2j", "@com_google_re2j",
"@com_google_truth", "@com_google_truth",
"@com_googlecode_json_simple", "@com_googlecode_json_simple",
"@javax_inject",
"@joda_time", "@joda_time",
"@junit", "@junit",
"@org_joda_money", "@org_joda_money",

View file

@ -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<Credential> credentialProvider = Providers.of(FAKE_CREDENTIAL);
// Captor for client secrets.
ArgumentCaptor<GoogleClientSecrets> 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);
}
}