/* * 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 com.addthis.hydra.query.web; import javax.annotation.Nullable; import java.io.Closeable; import java.io.IOException; import java.net.URI; import java.util.HashSet; import java.util.Iterator; import java.util.Set; import java.nio.CharBuffer; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import com.addthis.basis.kv.KVPair; import com.addthis.basis.kv.KVPairs; import com.addthis.basis.util.Parameter; import com.addthis.maljson.JSONObject; import org.apache.commons.io.output.StringBuilderWriter; import org.apache.http.NameValuePair; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.utils.URIBuilder; import org.apache.http.client.utils.URLEncodedUtils; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.apache.http.message.BasicNameValuePair; import org.apache.http.util.EntityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufUtil; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpHeaders; import static io.netty.handler.codec.http.HttpHeaders.Names.CONTENT_TYPE; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.QueryStringEncoder; import io.netty.util.CharsetUtil; public class GoogleDriveAuthentication { private static final String gdriveClientId = Parameter.value("qmaster.export.gdrive.clientId"); private static final String gdriveClientSecret = Parameter.value("qmaster.export.gdrive.clientSecret"); private static final boolean gdriveEnabled = Parameter.boolValue("qmaster.export.gdrive.enable", true); private static final String gdriveDomain = Parameter.value("qmaster.export.domain.suffix"); static final String autherror = "autherror"; static final String authtoken = "authtoken"; private static final String hostname = System.getenv("HOSTNAME"); private static final Logger log = LoggerFactory.getLogger(GoogleDriveAuthentication.class); /** * If a resource a non-null then close the resource. Catch any IOExceptions and log them. */ private static void closeResource(@Nullable Closeable resource) { try { if (resource != null) { resource.close(); } } catch (IOException ex) { log.error("Error", ex); } } /** * Send an HTML formatted error message. */ private static void sendErrorMessage(ChannelHandlerContext ctx, String message) throws IOException { HttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); response.headers().set(CONTENT_TYPE, "text/html; charset=utf-8"); StringBuilderWriter writer = new StringBuilderWriter(50); writer.append("<html><head><title>Hydra Query Master</title></head><body>"); writer.append("<h3>"); writer.append(message); writer.append("</h3></body></html>"); ByteBuf textResponse = ByteBufUtil.encodeString(ctx.alloc(), CharBuffer.wrap(writer.getBuilder()), CharsetUtil.UTF_8); HttpContent content = new DefaultHttpContent(textResponse); response.headers().set(HttpHeaders.Names.CONTENT_LENGTH, textResponse.readableBytes()); ctx.write(response); ctx.write(content); ChannelFuture lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); lastContentFuture.addListener(ChannelFutureListener.CLOSE); } /** * (1) If we cannot determine the hostname then return "localhost". * (2) If the result returned from the HOSTNAME environment variable * is a fully qualified name and the "qmaster.export.domain.suffix" * system property has been set then rewrite the hostname. * (3) Otherwise return the value from the HOSTNAME environment variable. * * @return hostname */ private static String generateTargetHostName() { String result; if (hostname == null) { result = "localhost"; } else { int index = hostname.indexOf('.'); if (index >= 0 && gdriveDomain != null) { result = hostname.substring(0, index) + gdriveDomain; } else { result = hostname; } } return result; } /** * Obtain a Google authorization token. This token is worthless by itself. It * is an intermediate step to obtain an access token. We need to do these two * steps because...reasons. */ static void gdriveAuthorization(KVPairs kv, ChannelHandlerContext ctx) throws Exception { if (gdriveClientId == null && gdriveClientSecret == null) { sendErrorMessage(ctx, "The system properties \"qmaster.export.gdrive.clientId\"" + " and \"qmaster.export.gdrive.clientSecret\" are both null."); return; } else if (gdriveClientId == null) { sendErrorMessage(ctx, "The system property \"qmaster.export.gdrive.clientId\"" + " is null."); return; } else if (gdriveClientSecret == null) { sendErrorMessage(ctx, "The system property \"qmaster.export.gdrive.clientSecret\"" + " is null."); return; } else if (!gdriveEnabled) { sendErrorMessage(ctx, "The system property \"qmaster.export.gdrive.enable\"" + " is false."); return; } QueryStringEncoder encoder = new QueryStringEncoder(""); Iterator<KVPair> iterator = kv.iterator(); while(iterator.hasNext()) { KVPair pair = iterator.next(); encoder.addParam(pair.getKey(), pair.getValue()); } String state = encoder.toString().substring(1); URI uri = new URIBuilder() .setScheme("https") .setHost("accounts.google.com") .setPath("/o/oauth2/auth") .setParameter("scope", "https://www.googleapis.com/auth/drive.file") .setParameter("state", state) .setParameter("redirect_uri", "http://" + generateTargetHostName() + ":2222/query/google/submit") .setParameter("response_type", "code") .setParameter("client_id", gdriveClientId) .build(); FullHttpResponse response = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.FOUND); response.headers().set(HttpHeaders.Names.LOCATION, uri); ctx.write(response); ChannelFuture lastContentFuture = ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT); log.trace("response pending"); log.trace("Setting close listener"); lastContentFuture.addListener(ChannelFutureListener.CLOSE); } /** * Use the Google authorization token to obtain a Google access token. * Google OAuth2 your documentation is sorely lacking. * * @param kv store the access token as a (key, value) pair * @return true if the access token was retrieved */ static boolean gdriveAccessToken(KVPairs kv, ChannelHandlerContext ctx) throws Exception { CloseableHttpClient httpClient = null; CloseableHttpResponse httpResponse = null; if (kv.hasKey(autherror)) { sendErrorMessage(ctx, "Error while attempting to authorize google drive access: " + kv.getValue(autherror)); return false; } else if (!kv.hasKey(authtoken)) { sendErrorMessage(ctx, "Error while attempting to authorize google drive access: " + "authorization token is missing."); return false; } try { String code = kv.getValue(authtoken); httpClient = HttpClients.createDefault(); HttpPost httpPost = new HttpPost("https://accounts.google.com/o/oauth2/token"); httpPost.setHeader(HttpHeaders.Names.CONTENT_TYPE, URLEncodedUtils.CONTENT_TYPE); Set<NameValuePair> parameters = new HashSet<>(); // Why is this redirect_uri required??? It appeared to be unused by the protocol. parameters.add(new BasicNameValuePair("redirect_uri", "http://" + generateTargetHostName() + ":2222/query/google/submit")); parameters.add(new BasicNameValuePair("code", code)); parameters.add(new BasicNameValuePair("client_id", gdriveClientId)); parameters.add(new BasicNameValuePair("client_secret", gdriveClientSecret)); parameters.add(new BasicNameValuePair("grant_type", "authorization_code")); httpPost.setEntity(new StringEntity(URLEncodedUtils.format(parameters, Charset.defaultCharset()), StandardCharsets.UTF_8)); httpResponse = httpClient.execute(httpPost); if (httpResponse.getStatusLine().getStatusCode() != HttpResponseStatus.OK.code()) { sendErrorMessage(ctx, "Error while attempting to exchange the authorization token " + "for the access token: " + httpResponse.getStatusLine().getReasonPhrase()); return false; } String responseEntity = EntityUtils.toString(httpResponse.getEntity()); JSONObject response = new JSONObject(responseEntity); if (response.has("error")) { sendErrorMessage(ctx, "Error while attempting to exchange the authorization token " + "for the access token: " + response.getString("error_description")); return false; } else if (!response.has("access_token")) { sendErrorMessage(ctx, "Error while attempting to exchange the authorization token " + "for the access token: No access token received."); return false; } kv.addValue("accesstoken", response.getString("access_token")); return true; } finally { closeResource(httpResponse); closeResource(httpClient); } } }