/*
* 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);
}
}
}