com.urswolfer.gerrit.client.rest.http.GerritRestClient.java Source code

Java tutorial

Introduction

Here is the source code for com.urswolfer.gerrit.client.rest.http.GerritRestClient.java

Source

/*
 * Copyright 2013-2015 Urs Wolfer
 *
 * 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.urswolfer.gerrit.client.rest.http;

import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.io.CharStreams;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
import com.urswolfer.gerrit.client.rest.GerritAuthData;
import com.urswolfer.gerrit.client.rest.RestClient;
import com.urswolfer.gerrit.client.rest.Version;
import com.urswolfer.gerrit.client.rest.gson.GsonFactory;
import org.apache.http.*;
import org.apache.http.auth.*;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.*;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.cookie.Cookie;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.*;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.BasicHttpContext;
import org.apache.http.protocol.HttpContext;
import org.apache.http.util.EntityUtils;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URI;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * @author Urs Wolfer
 */
public class GerritRestClient implements RestClient {

    private static final String JSON_MIME_TYPE = ContentType.APPLICATION_JSON.getMimeType();
    private static final Pattern GERRIT_AUTH_PATTERN = Pattern.compile(".*?xGerritAuth=\"(.+?)\"");
    private static final int CONNECTION_TIMEOUT_MS = 30000;
    private static final String PREEMPTIVE_AUTH = "preemptive-auth";
    private static final Gson GSON = GsonFactory.create();

    private final GerritAuthData authData;
    private final HttpRequestExecutor httpRequestExecutor;
    private final List<HttpClientBuilderExtension> httpClientBuilderExtensions;

    private final BasicCookieStore cookieStore;
    private final LoginCache loginCache;

    public GerritRestClient(GerritAuthData authData, HttpRequestExecutor httpRequestExecutor,
            HttpClientBuilderExtension... httpClientBuilderExtensions) {
        this.authData = authData;
        this.httpRequestExecutor = httpRequestExecutor;
        this.httpClientBuilderExtensions = Arrays.asList(httpClientBuilderExtensions);

        cookieStore = new BasicCookieStore();
        loginCache = new LoginCache(authData, cookieStore);
    }

    @Override
    public Gson getGson() {
        return GSON;
    }

    @Override
    public JsonElement getRequest(String path) throws RestApiException {
        return requestJson(path, null, HttpVerb.GET);
    }

    @Override
    public JsonElement postRequest(String path) throws RestApiException {
        return postRequest(path, null);
    }

    @Override
    public JsonElement postRequest(String path, String requestBody) throws RestApiException {
        return requestJson(path, requestBody, HttpVerb.POST);
    }

    @Override
    public JsonElement putRequest(String path) throws RestApiException {
        return putRequest(path, null);
    }

    @Override
    public JsonElement putRequest(String path, String requestBody) throws RestApiException {
        return requestJson(path, requestBody, HttpVerb.PUT);
    }

    @Override
    public JsonElement deleteRequest(String path) throws RestApiException {
        return requestJson(path, null, HttpVerb.DELETE);
    }

    @Override
    public JsonElement requestJson(String path, String requestBody, HttpVerb verb) throws RestApiException {
        try {
            HttpResponse response = requestRest(path, requestBody, verb);

            HttpEntity entity = response.getEntity();
            if (entity == null) {
                return null;
            }

            checkContentType(entity);

            JsonElement ret = parseResponse(entity.getContent());
            if (ret.isJsonNull()) {
                throw new RestApiException("Unexpectedly empty response.");
            }
            return ret;
        } catch (IOException e) {
            throw new RestApiException("Request failed.", e);
        }
    }

    @Override
    public HttpResponse requestRest(String path, String requestBody, HttpVerb verb)
            throws IOException, HttpStatusException {
        return requestRest(path, requestBody, verb, false);
    }

    private HttpResponse requestRest(String path, String requestBody, HttpVerb verb, boolean isRetry)
            throws IOException, HttpStatusException {
        BasicHeader acceptHeader = new BasicHeader("Accept", JSON_MIME_TYPE);
        return request(path, requestBody, verb, isRetry, acceptHeader);
    }

    @Override
    public HttpResponse request(String path, String requestBody, HttpVerb verb, Header... headers)
            throws IOException, HttpStatusException {
        return request(path, requestBody, verb, false, headers);
    }

    private HttpResponse request(String path, String requestBody, HttpVerb verb, boolean isRetry, Header... headers)
            throws IOException, HttpStatusException {
        HttpContext httpContext = new BasicHttpContext();
        HttpClientBuilder client = getHttpClient(httpContext);

        Optional<String> gerritAuthOptional = updateGerritAuthWhenRequired(httpContext, client);

        String uri = authData.getHost();
        // only use /a when http login is required (i.e. we haven't got a gerrit-auth cookie)
        // it would work in most cases also with /a, but it breaks with HTTP digest auth ("Forbidden" returned)
        if (authData.isLoginAndPasswordAvailable() && !gerritAuthOptional.isPresent()) {
            uri += "/a";
        }
        uri += path;

        HttpRequestBase method;
        switch (verb) {
        case POST:
            method = new HttpPost(uri);
            setRequestBody(requestBody, method);
            break;
        case GET:
            method = new HttpGet(uri);
            break;
        case DELETE:
            method = new HttpDelete(uri);
            break;
        case PUT:
            method = new HttpPut(uri);
            setRequestBody(requestBody, method);
            break;
        default:
            throw new IllegalStateException("Unknown or unsupported HttpVerb method: " + verb.toString());
        }
        if (gerritAuthOptional.isPresent()) {
            method.addHeader("X-Gerrit-Auth", gerritAuthOptional.get());
        }

        for (Header header : headers) {
            method.addHeader(header);
        }

        HttpResponse response = httpRequestExecutor.execute(client, method, httpContext);

        if (!isRetry && response.getStatusLine().getStatusCode() == 403
                && loginCache.getGerritAuthOptional().isPresent()) {
            // handle expired sessions: try again with a fresh login
            loginCache.invalidate();
            response = requestRest(path, requestBody, verb, true);
        }

        checkStatusCode(response);

        return response;
    }

    private void setRequestBody(String requestBody, HttpRequestBase method) {
        if (requestBody != null) {
            ((HttpEntityEnclosingRequestBase) method)
                    .setEntity(new StringEntity(requestBody, ContentType.APPLICATION_JSON));
        }
    }

    private Optional<String> updateGerritAuthWhenRequired(HttpContext httpContext, HttpClientBuilder client)
            throws IOException, HttpStatusException {
        if (!loginCache.getHostSupportsGerritAuth()) {
            // We do not not need a cookie here since we are sending credentials as HTTP basic / digest header again.
            // In fact cookies could hurt: googlesource.com Gerrit instances block requests which send a magic cookie
            // named "gi" with a 400 HTTP status (as of 01/29/15).
            cookieStore.clear();
            return Optional.absent();
        } else if (authData.isHttpPassword()) {
            // Do not use a Gerrit HTTP password token to authenticate against the
            // login page.  This will cause Gerrit to use the password to authenticate
            // against the configured authentication source (LDAP, etc) and potentially
            // lock the account.
            return Optional.absent();
        }

        Optional<Cookie> gerritAccountCookie = findGerritAccountCookie();
        if (!gerritAccountCookie.isPresent() || gerritAccountCookie.get().isExpired(new Date())) {
            return updateGerritAuth(httpContext, client);
        }
        return loginCache.getGerritAuthOptional();
    }

    private Optional<String> updateGerritAuth(HttpContext httpContext, HttpClientBuilder client)
            throws IOException, HttpStatusException {
        Optional<String> gerritAuthOptional = tryGerritHttpAuth(client, httpContext)
                .or(tryGerritHttpFormAuth(client, httpContext));
        loginCache.setGerritAuthOptional(gerritAuthOptional);
        return gerritAuthOptional;
    }

    /**
     * Handles LDAP auth (but not LDAP_HTTP) which uses a HTML form.
     */
    private Optional<String> tryGerritHttpFormAuth(HttpClientBuilder client, HttpContext httpContext)
            throws IOException, HttpStatusException {
        if (!authData.isLoginAndPasswordAvailable()) {
            return Optional.absent();
        }
        String loginUrl = authData.getHost() + "/login/";
        HttpPost method = new HttpPost(loginUrl);
        List<BasicNameValuePair> parameters = Lists.newArrayList(
                new BasicNameValuePair("username", authData.getLogin()),
                new BasicNameValuePair("password", authData.getPassword()));
        method.setEntity(new UrlEncodedFormEntity(parameters, Consts.UTF_8));
        HttpResponse loginResponse = httpRequestExecutor.execute(client, method, httpContext);
        return extractGerritAuth(loginResponse);
    }

    /**
     * Try to authenticate against Gerrit instances with HTTP auth (not OAuth or something like that).
     * In case of success we get a GerritAccount cookie. In that case no more login credentials need to be sent as
     * long as we use the *same* HTTP client. Even requests against authenticated rest api (/a) will be processed
     * with the GerritAccount cookie.
     *
     * This is a workaround for "double" HTTP authentication (i.e. reverse proxy *and* Gerrit do HTTP authentication
     * for rest api (/a)).
     *
     * Following old notes from README about the issue:
     * If you have correctly set up a HTTP Password in Gerrit, but still have authentication issues, your Gerrit instance
     * might be behind a HTTP Reverse Proxy (like Nginx or Apache) with enabled HTTP Authentication. You can identify that if
     * you have to enter an username and password (browser password request) for opening the Gerrit web interface. Since this
     * plugin uses Gerrit REST API (with authentication enabled), you need to tell your system administrator that he should
     * disable HTTP Authentication for any request to <code>/a</code> path (e.g. https://git.example.com/a). For these requests
     * HTTP Authentication is done by Gerrit (double HTTP Authentication will not work). For more information see
     * [Gerrit documentation].
     * [Gerrit documentation]: https://gerrit-review.googlesource.com/Documentation/rest-api.html#authentication
     */
    private Optional<String> tryGerritHttpAuth(HttpClientBuilder client, HttpContext httpContext)
            throws IOException, HttpStatusException {
        String loginUrl = authData.getHost() + "/login/";
        HttpResponse loginResponse = httpRequestExecutor.execute(client, new HttpGet(loginUrl), httpContext);
        return extractGerritAuth(loginResponse);
    }

    private Optional<String> extractGerritAuth(HttpResponse loginResponse) throws IOException, HttpStatusException {
        checkStatusCodeServerError(loginResponse);
        if (loginResponse.getStatusLine().getStatusCode() != HttpStatus.SC_UNAUTHORIZED) {
            return getXsrfCookie().or(getXsrfFromHtmlBody(loginResponse));
        }
        return Optional.absent();
    }

    /**
     * In Gerrit >= 2.12 the XSRF token got moved to a cookie.
     * Introduced in: https://gerrit-review.googlesource.com/72031/
     */
    private Optional<String> getXsrfCookie() {
        Optional<Cookie> xsrfCookie = findCookie("XSRF_TOKEN");
        if (xsrfCookie.isPresent()) {
            return Optional.of(xsrfCookie.get().getValue());
        }
        return Optional.absent();
    }

    /**
     * In Gerrit < 2.12 the XSRF token was included in the start page HTML.
     */
    private Optional<String> getXsrfFromHtmlBody(HttpResponse loginResponse) throws IOException {
        Optional<Cookie> gerritAccountCookie = findGerritAccountCookie();
        if (gerritAccountCookie.isPresent()) {
            Matcher matcher = GERRIT_AUTH_PATTERN
                    .matcher(EntityUtils.toString(loginResponse.getEntity(), Consts.UTF_8));
            if (matcher.find()) {
                return Optional.of(matcher.group(1));
            }
        }
        return Optional.absent();
    }

    private Optional<Cookie> findGerritAccountCookie() {
        return findCookie("GerritAccount");
    }

    private Optional<Cookie> findCookie(final String cookieName) {
        List<Cookie> cookies = cookieStore.getCookies();
        return Iterables.tryFind(cookies, new Predicate<Cookie>() {
            @Override
            public boolean apply(Cookie cookie) {
                return cookie.getName().equals(cookieName);
            }
        });
    }

    private HttpClientBuilder getHttpClient(HttpContext httpContext) {
        HttpClientBuilder client = HttpClients.custom();

        client.useSystemProperties(); // see also: com.intellij.util.net.ssl.CertificateManager

        // we need to get redirected result after login (which is done with POST) for extracting xGerritAuth
        client.setRedirectStrategy(new LaxRedirectStrategy());

        httpContext.setAttribute(HttpClientContext.COOKIE_STORE, cookieStore);

        RequestConfig.Builder requestConfig = RequestConfig.custom().setConnectTimeout(CONNECTION_TIMEOUT_MS) // how long it takes to connect to remote host
                .setSocketTimeout(CONNECTION_TIMEOUT_MS) // how long it takes to retrieve data from remote host
                .setConnectionRequestTimeout(CONNECTION_TIMEOUT_MS);
        client.setDefaultRequestConfig(requestConfig.build());

        CredentialsProvider credentialsProvider = getCredentialsProvider();
        client.setDefaultCredentialsProvider(credentialsProvider);

        if (authData.isLoginAndPasswordAvailable()) {
            credentialsProvider.setCredentials(AuthScope.ANY,
                    new UsernamePasswordCredentials(authData.getLogin(), authData.getPassword()));

            BasicScheme basicAuth = new BasicScheme();
            httpContext.setAttribute(PREEMPTIVE_AUTH, basicAuth);
            client.addInterceptorFirst(new PreemptiveAuthHttpRequestInterceptor(authData));
        }

        client.addInterceptorLast(new UserAgentHttpRequestInterceptor());

        for (HttpClientBuilderExtension httpClientBuilderExtension : httpClientBuilderExtensions) {
            client = httpClientBuilderExtension.extend(client, authData);
            credentialsProvider = httpClientBuilderExtension.extendCredentialProvider(client, credentialsProvider,
                    authData);
        }

        return client;
    }

    /**
     * With this impl, it only returns the same credentials once. Otherwise it's possible that a loop will occur.
     * When server returns status code 401, the HTTP client provides the same credentials forever.
     * Since we create a new HTTP client for every request, we can handle it this way.
     */
    private BasicCredentialsProvider getCredentialsProvider() {
        return new BasicCredentialsProvider() {
            private Set<AuthScope> authAlreadyTried = Sets.newHashSet();

            @Override
            public Credentials getCredentials(AuthScope authscope) {
                if (authAlreadyTried.contains(authscope)) {
                    return null;
                }
                authAlreadyTried.add(authscope);
                return super.getCredentials(authscope);
            }
        };
    }

    private JsonElement parseResponse(InputStream response) throws IOException {
        Reader reader = new InputStreamReader(response, Consts.UTF_8);
        try {
            return new JsonParser().parse(reader);
        } catch (JsonSyntaxException jse) {
            throw new IOException(String.format("Couldn't parse response: %n%s", CharStreams.toString(reader)),
                    jse);
        } finally {
            reader.close();
        }
    }

    /**
     * @throws HttpStatusException on any error (client 4xx and server 5xx).
     */
    private void checkStatusCode(HttpResponse response) throws HttpStatusException, IOException {
        checkStatusCodeClientError(response);
        checkStatusCodeServerError(response);
    }

    /**
     * @throws HttpStatusException on client error (4xx).
     */
    private void checkStatusCodeClientError(HttpResponse response) throws HttpStatusException, IOException {
        checkStatusCodeError(response, 400, 499);
    }

    /**
     * @throws HttpStatusException on server error (5xx).
     */
    private void checkStatusCodeServerError(HttpResponse response) throws HttpStatusException, IOException {
        checkStatusCodeError(response, 500, 599);
    }

    private void checkStatusCodeError(HttpResponse response, int errorIfMin, int errorIfMax)
            throws HttpStatusException, IOException {
        StatusLine statusLine = response.getStatusLine();
        int code = statusLine.getStatusCode();
        if (code >= errorIfMin && code <= errorIfMax) {
            throwHttpStatusException(response);
        }
    }

    private void throwHttpStatusException(HttpResponse response) throws IOException, HttpStatusException {
        StatusLine statusLine = response.getStatusLine();
        String body = "<empty>";
        HttpEntity entity = response.getEntity();
        if (entity != null) {
            body = EntityUtils.toString(entity).trim();
        }
        String message = String.format("Request not successful. Message: %s. Status-Code: %s. Content: %s.",
                statusLine.getReasonPhrase(), statusLine.getStatusCode(), body);
        throw new HttpStatusException(statusLine.getStatusCode(), statusLine.getReasonPhrase(), message);
    }

    private void checkContentType(HttpEntity entity) throws RestApiException {
        Header contentType = entity.getContentType();
        if (contentType != null && !contentType.getValue().contains(JSON_MIME_TYPE)) {
            throw new RestApiException(String.format("Expected JSON but got '%s'.", contentType.getValue()));
        }
    }

    /**
     * With preemptive auth, it will send the basic authentication response even before the server gives an unauthorized
     * response in certain situations, thus reducing the overhead of making the connection again.
     *
     * Based on:
     * https://subversion.jfrog.org/jfrog/build-info/trunk/build-info-client/src/main/java/org/jfrog/build/client/PreemptiveHttpClient.java
     */
    private static class PreemptiveAuthHttpRequestInterceptor implements HttpRequestInterceptor {
        private GerritAuthData authData;

        public PreemptiveAuthHttpRequestInterceptor(GerritAuthData authData) {
            this.authData = authData;
        }

        @Override
        public void process(final HttpRequest request, final HttpContext context)
                throws HttpException, IOException {
            // never ever send credentials preemptively to a host which is not the configured Gerrit host
            if (!isForGerritHost(request)) {
                return;
            }

            AuthState authState = (AuthState) context.getAttribute(HttpClientContext.TARGET_AUTH_STATE);

            // if no auth scheme available yet, try to initialize it preemptively
            if (authState.getAuthScheme() == null) {
                AuthScheme authScheme = (AuthScheme) context.getAttribute(PREEMPTIVE_AUTH);
                UsernamePasswordCredentials creds = new UsernamePasswordCredentials(authData.getLogin(),
                        authData.getPassword());
                authState.update(authScheme, creds);
            }
        }

        /**
         * Checks if request is intended for Gerrit host.
         */
        private boolean isForGerritHost(HttpRequest request) {
            if (!(request instanceof HttpRequestWrapper))
                return false;
            HttpRequest originalRequest = ((HttpRequestWrapper) request).getOriginal();
            if (!(originalRequest instanceof HttpRequestBase))
                return false;
            URI uri = ((HttpRequestBase) originalRequest).getURI();
            URI authDataUri = URI.create(authData.getHost());
            if (uri == null || uri.getHost() == null)
                return false;
            boolean hostEquals = uri.getHost().equals(authDataUri.getHost());
            boolean portEquals = uri.getPort() == authDataUri.getPort();
            return hostEquals && portEquals;
        }
    }

    private static class UserAgentHttpRequestInterceptor implements HttpRequestInterceptor {

        @Override
        public void process(final HttpRequest request, final HttpContext context)
                throws HttpException, IOException {
            Header existingUserAgent = request.getFirstHeader(HttpHeaders.USER_AGENT);
            String userAgent = String.format("gerrit-rest-java-client/%s", Version.get());
            userAgent += " using " + existingUserAgent.getValue();
            request.setHeader(HttpHeaders.USER_AGENT, userAgent);
        }
    }
}