org.ctoolkit.restapi.client.adapter.GoogleApiProxyFactory Maven / Gradle / Ivy
/*
* Copyright (c) 2019 Comvai, s.r.o. All Rights Reserved.
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 2.1 of the License, or (at your option) any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this library; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
package org.ctoolkit.restapi.client.adapter;
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpExecuteInterceptor;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.http.HttpResponseInterceptor;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.PemReader;
import com.google.api.client.util.SecurityUtils;
import com.google.common.base.Charsets;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.eventbus.EventBus;
import org.ctoolkit.restapi.client.ApiCredential;
import org.ctoolkit.restapi.client.provider.AuthKeyProvider;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.MissingResourceException;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static org.ctoolkit.restapi.client.ApiCredential.CREDENTIAL_ATTR;
import static org.ctoolkit.restapi.client.ApiCredential.DEFAULT_CREDENTIAL_PREFIX;
import static org.ctoolkit.restapi.client.ApiCredential.DEFAULT_NUMBER_OF_RETRIES;
import static org.ctoolkit.restapi.client.ApiCredential.DEFAULT_READ_TIMEOUT;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_API_KEY;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_APPLICATION_NAME;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_CLIENT_ID;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_CREDENTIAL_ON;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_DISABLE_GZIP_CONTENT;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_ENDPOINT_URL;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_FILE_NAME;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_FILE_NAME_JSON;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_NUMBER_OF_RETRIES;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_PROJECT_ID;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_READ_TIMEOUT;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_SCOPES;
import static org.ctoolkit.restapi.client.ApiCredential.PROPERTY_SERVICE_ACCOUNT_EMAIL;
/**
* The factory to build proxy instance to allow authenticated calls to Google APIs on behalf of the application
* instead of an end-user by OAuth 2.0.
*
* OAuth 2.0 allows users to share specific data with application (for example, contact lists)
* while keeping their usernames, passwords, and other information private.
*
* @author Aurel Medvegy
*/
public abstract class GoogleApiProxyFactory
{
protected final EventBus eventBus;
private final Map credential;
private HttpTransport httpTransport;
private JsonFactory jsonFactory;
/**
* Optional authentication key provider (client responsibility).
*/
private AuthKeyProvider keyProvider;
/**
* Create factory instance.
*/
protected GoogleApiProxyFactory( @Nonnull Map credential, @Nonnull EventBus eventBus )
{
this.credential = credential;
this.eventBus = eventBus;
}
protected void setKeyProvider( AuthKeyProvider keyProvider )
{
this.keyProvider = keyProvider;
}
/**
* Returns singleton instance of the HTTP transport.
*
* @return the reusable {@link HttpTransport} instance
*/
public final HttpTransport getHttpTransport() throws GeneralSecurityException, IOException
{
if ( httpTransport == null )
{
httpTransport = GoogleNetHttpTransport.newTrustedTransport();
}
return httpTransport;
}
/**
* Returns singleton instance of the JSON factory.
*
* @return the reusable {@link JsonFactory} instance
*/
public final JsonFactory getJsonFactory()
{
if ( jsonFactory == null )
{
jsonFactory = JacksonFactory.getDefaultInstance();
}
return jsonFactory;
}
/**
* Returns value set by {@link ApiCredential#setProjectId(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the project ID
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public String getProjectId( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_PROJECT_ID );
}
/**
* Returns value set by {@link ApiCredential#setClientId(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the client ID
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public String getClientId( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_CLIENT_ID );
}
/**
* Returns value set by {@link ApiCredential#setDisableGZipContent(boolean)} (boolean)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return true to disable GZip compression. Otherwise HTTP content will be compressed.
*/
public final boolean isDisableGZipContent( @Nullable String prefix )
{
return getBoolean( PROPERTY_DISABLE_GZIP_CONTENT, prefix );
}
/**
* Returns value set by {@link ApiCredential#setScopes(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the unmodifiable list of API scopes
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public List getScopes( @Nullable String prefix )
{
String values = getStringValue( prefix, PROPERTY_SCOPES );
if ( values == null )
{
return Collections.emptyList();
}
List list = Splitter.on( "," ).trimResults().omitEmptyStrings().splitToList( values );
return Collections.unmodifiableList( list );
}
/**
* Returns value set by {@link ApiCredential#setServiceAccountEmail(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the service email
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public final String getServiceAccountEmail( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_SERVICE_ACCOUNT_EMAIL );
}
/**
* Returns value set by {@link ApiCredential#setApplicationName(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the application name
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public final String getApplicationName( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_APPLICATION_NAME );
}
/**
* Returns value set by {@link ApiCredential#setFileName(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the file name path
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public final String getFileName( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_FILE_NAME );
}
/**
* Returns value set by {@link ApiCredential#setFileNameJson(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the file name path
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public final String getFileNameJson( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_FILE_NAME_JSON );
}
/**
* Returns value set by {@link ApiCredential#setApiKey(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the API key
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public final String getApiKey( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_API_KEY );
}
/**
* Returns value set by {@link ApiCredential#setEndpointUrl(String)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the endpoint URL
* @throws MissingResourceException if default credential was requested and haven't been found
*/
public final String getEndpointUrl( @Nullable String prefix )
{
return getStringValue( prefix, PROPERTY_ENDPOINT_URL );
}
/**
* Returns value set by {@link ApiCredential#setNumberOfRetries(int)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the number of configured retries
*/
public int getNumberOfRetries( @Nullable String prefix )
{
return getInteger( PROPERTY_NUMBER_OF_RETRIES, DEFAULT_NUMBER_OF_RETRIES, prefix );
}
/**
* Returns value set by {@link ApiCredential#setRequestReadTimeout(int)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned 20000 (20 seconds).
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the request read timeout in milliseconds
*/
public int getReadTimeout( @Nullable String prefix )
{
return getInteger( PROPERTY_READ_TIMEOUT, DEFAULT_READ_TIMEOUT, prefix );
}
/**
* Returns the integer value for given property.
*
* @param property the name of the property to retrieve
* @param defaultValue the default value if no value will be found
* @param prefix the prefix used to identify specific credential or null for default
* @return the integer value
*/
private int getInteger( @Nonnull String property, @Nonnull String defaultValue, @Nullable String prefix )
{
if ( Strings.isNullOrEmpty( prefix ) )
{
prefix = DEFAULT_CREDENTIAL_PREFIX;
}
String errorMessage = "Property name is expected";
String value = credential.get( CREDENTIAL_ATTR + prefix + "." + checkNotNull( property, errorMessage ) );
if ( value == null )
{
value = credential.get( CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + checkNotNull( property ) );
}
if ( value == null )
{
value = checkNotNull( defaultValue, "Default value is expected" );
}
return Integer.parseInt( value );
}
/**
* Returns value set by {@link ApiCredential#setCredentialOn(boolean)}
* or defined by property file.
* If specific credential wouldn't not be found, default will be returned.
*
* @param prefix the prefix used to identify specific credential or null for default
* @return the true if credential will be used to authenticate
*/
public final boolean isCredentialOn( @Nullable String prefix )
{
return getBoolean( PROPERTY_CREDENTIAL_ON, prefix );
}
/**
* Returns the boolean value for given property.
*
* @param property the name of the property to retrieve
* @param prefix the prefix used to identify specific credential or null for default
* @return the boolean value
*/
private boolean getBoolean( @Nonnull String property, @Nullable String prefix )
{
if ( Strings.isNullOrEmpty( prefix ) )
{
prefix = DEFAULT_CREDENTIAL_PREFIX;
}
String fullProperty = CREDENTIAL_ATTR + prefix + "." + property;
String value = credential.get( fullProperty );
if ( value == null )
{
fullProperty = CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + property;
value = credential.get( fullProperty );
}
if ( value == null )
{
return false;
}
return Boolean.parseBoolean( value );
}
private String getStringValue( String prefix, String property )
{
checkArgument( !Strings.isNullOrEmpty( property ) );
if ( Strings.isNullOrEmpty( prefix ) )
{
prefix = DEFAULT_CREDENTIAL_PREFIX;
}
String value = credential.get( CREDENTIAL_ATTR + prefix + "." + property );
if ( value == null )
{
value = credential.get( CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + property );
}
defaultPropertyValueCheck( prefix, property, value );
return value;
}
private void defaultPropertyValueCheck( String prefix, String property, String value )
{
if ( value != null || !DEFAULT_CREDENTIAL_PREFIX.equals( prefix ) )
{
return;
}
String fullProperty = CREDENTIAL_ATTR + DEFAULT_CREDENTIAL_PREFIX + "." + property;
String className = ApiCredential.class.getName();
String message = "No value configured for default credential: '" + fullProperty + "'";
throw new MissingResourceException( message, className, fullProperty );
}
/**
* Creates the thread-safe Google-specific implementation of the OAuth 2.0.
*
* @param scopes the space-separated OAuth scopes to use with the service account flow
* or {@code null} for none.
* @param userAccount the email address. If you want to impersonate a user account, specify the email address.
* Useful for domain-wide delegation.
* @return the thread-safe credential instance
*/
public HttpRequestInitializer authorize( @Nonnull Collection scopes,
@Nullable String userAccount,
@Nonnull String prefix )
throws GeneralSecurityException, IOException
{
GoogleCredential googleCredential;
Collection checkedScopes = checkNotNull( scopes, "Scopes is mandatory" );
if ( isJsonConfiguration( checkNotNull( prefix, "API short name is mandatory" ) ) )
{
// json file load right before usage
InputStream json = getServiceAccountJson( prefix );
googleCredential = new ConfiguredByJsonGoogleCredential( json, prefix )
.setTransport( getHttpTransport() )
.setJsonFactory( getJsonFactory() )
.setServiceAccountScopes( checkedScopes )
.setServiceAccountUser( userAccount )
.build();
}
else
{
String serviceAccountEmail = getServiceAccountEmail( prefix );
if ( serviceAccountEmail == null )
{
throw new NullPointerException( "Missing service account email." );
}
// p12 file load right before usage
URL resource = getServiceAccountPrivateKeyP12Resource( prefix );
googleCredential = new ConfiguredGoogleCredential( prefix )
.setTransport( getHttpTransport() )
.setJsonFactory( getJsonFactory() )
.setServiceAccountId( serviceAccountEmail )
.setServiceAccountScopes( checkedScopes )
.setServiceAccountPrivateKeyFromP12File( new File( resource.getPath() ) )
.setServiceAccountUser( userAccount )
.build();
}
return googleCredential;
}
public boolean isJsonConfiguration( String prefix )
{
if ( keyProvider != null && keyProvider.isConfigured( prefix ) )
{
// if defined authentication key takes precedence
return true;
}
try
{
return getFileNameJson( prefix ) != null;
}
catch ( MissingResourceException e )
{
return false;
}
}
public URL getServiceAccountPrivateKeyP12Resource( String prefix )
{
String fileName = getFileName( prefix );
return GoogleApiProxyFactory.class.getResource( fileName );
}
/**
* Returns the Google APIs service account key as JSON.
*
* @param prefix the prefix used to identify specific credential
* @return the service account key as JSON
*/
public InputStream getServiceAccountJson( String prefix )
{
InputStream stream;
if ( keyProvider != null && keyProvider.isConfigured( prefix ) )
{
stream = keyProvider.get( prefix );
}
else
{
String fileName = getFileNameJson( prefix );
stream = GoogleApiProxyFactory.class.getResourceAsStream( fileName );
if ( stream == null )
{
throw new IllegalArgumentException( "No file has been found with name '" + fileName + "'" );
}
}
return stream;
}
public HttpRequestInitializer newRequestConfig( @Nullable String prefix,
@Nullable HttpResponseInterceptor interceptor )
{
return new RequestConfig( prefix, interceptor );
}
private PrivateKey privateKeyFromPkcs8( String privateKeyPem ) throws IOException
{
Reader reader = new StringReader( privateKeyPem );
PemReader.Section section = PemReader.readFirstSectionAndClose( reader, "PRIVATE KEY" );
if ( section == null )
{
throw new IOException( "Invalid PKCS8 data." );
}
byte[] bytes = section.getBase64DecodedBytes();
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec( bytes );
try
{
KeyFactory keyFactory = SecurityUtils.getRsaKeyFactory();
return keyFactory.generatePrivate( keySpec );
}
catch ( NoSuchAlgorithmException | InvalidKeySpecException e )
{
throw new IOException( "Unexpected exception reading PKCS data", e );
}
}
/**
* Configure HTTP request right before execution.
*
* @param request the HTTP request
* @param numberOfRetries the number of configured retries
* @param readTimeout the request read timeout in milliseconds
*/
protected final void configureHttpRequest( @Nonnull HttpRequest request, int numberOfRetries, int readTimeout )
{
request.setNumberOfRetries( numberOfRetries );
request.setReadTimeout( readTimeout );
}
private class RequestConfig
implements HttpRequestInitializer
{
private final int numberOfRetries;
private final int readTimeout;
private final HttpResponseInterceptor responseInterceptor;
HttpExecuteInterceptor interceptor = new HttpExecuteInterceptor()
{
@Override
public void intercept( HttpRequest request )
{
eventBus.post( new BeforeRequestEvent( request ) );
}
};
private RequestConfig( String prefix, HttpResponseInterceptor responseInterceptor )
{
this.numberOfRetries = getNumberOfRetries( prefix );
this.readTimeout = getReadTimeout( prefix );
this.responseInterceptor = responseInterceptor;
}
public void initialize( HttpRequest request )
{
configureHttpRequest( request, numberOfRetries, readTimeout );
request.setInterceptor( interceptor );
if ( responseInterceptor != null )
{
request.setResponseInterceptor( responseInterceptor );
}
}
}
/**
* Custom GoogleCredential implementation
*/
private class ConfiguredGoogleCredential
extends GoogleCredential.Builder
{
private final int numberOfRetries;
private final int readTimeout;
private ConfiguredGoogleCredential( String prefix )
{
this.numberOfRetries = getNumberOfRetries( prefix );
this.readTimeout = getReadTimeout( prefix );
}
@Override
public GoogleCredential build()
{
return new GoogleCredential( this )
{
@Override
public void intercept( HttpRequest request ) throws IOException
{
String authorization = request.getHeaders().getAuthorization();
// the authorization header set by facade client has a preference, see Request#authBy(String)
if ( authorization == null )
{
super.intercept( request );
}
eventBus.post( new BeforeRequestEvent( request ) );
}
@Override
public void initialize( HttpRequest request ) throws IOException
{
super.initialize( request );
configureHttpRequest( request, numberOfRetries, readTimeout );
}
};
}
}
private class ConfiguredByJsonGoogleCredential
extends ConfiguredGoogleCredential
{
ConfiguredByJsonGoogleCredential( InputStream jsonStream, String prefix ) throws IOException
{
super( prefix );
JsonObjectParser parser = new JsonObjectParser( GoogleApiProxyFactory.this.getJsonFactory() );
GenericJson fileContents = parser.parseAndClose( jsonStream, Charsets.UTF_8, GenericJson.class );
String clientId = ( String ) fileContents.get( "client_id" );
String clientEmail = ( String ) fileContents.get( "client_email" );
String privateKeyPem = ( String ) fileContents.get( "private_key" );
String privateKeyId = ( String ) fileContents.get( "private_key_id" );
if ( clientId == null || clientEmail == null || privateKeyPem == null || privateKeyId == null )
{
throw new IOException( "Error reading service account credential from stream, "
+ "expecting 'client_id', 'client_email', 'private_key' and 'private_key_id'." );
}
PrivateKey privateKey = privateKeyFromPkcs8( privateKeyPem );
// setup credential from json
setServiceAccountId( clientEmail );
setServiceAccountPrivateKey( privateKey );
setServiceAccountPrivateKeyId( privateKeyId );
String tokenUri = ( String ) fileContents.get( "token_uri" );
if ( tokenUri != null )
{
setTokenServerEncodedUrl( tokenUri );
}
String projectId = ( String ) fileContents.get( "project_id" );
if ( projectId != null )
{
setServiceAccountProjectId( projectId );
}
}
}
}