HttpClientConnector.java

/*
 * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
 *
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Distribution License v. 1.0 which is available at
 * https://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 */
package org.eclipse.jgit.internal.transport.sshd.proxy;

import static java.nio.charset.StandardCharsets.US_ASCII;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.text.MessageFormat.format;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;

import org.apache.sshd.client.session.ClientSession;
import org.apache.sshd.common.io.IoSession;
import org.apache.sshd.common.util.Readable;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
import org.eclipse.jgit.annotations.NonNull;
import org.eclipse.jgit.internal.transport.sshd.GssApiMechanisms;
import org.eclipse.jgit.internal.transport.sshd.SshdText;
import org.eclipse.jgit.internal.transport.sshd.auth.AuthenticationHandler;
import org.eclipse.jgit.internal.transport.sshd.auth.BasicAuthentication;
import org.eclipse.jgit.internal.transport.sshd.auth.GssApiAuthentication;
import org.eclipse.jgit.util.Base64;
import org.ietf.jgss.GSSContext;

/**
 * Simple HTTP proxy connector using Basic Authentication.
 */
public class HttpClientConnector extends AbstractClientProxyConnector {

	private static final String HTTP_HEADER_PROXY_AUTHENTICATION = "Proxy-Authentication:"; //$NON-NLS-1$

	private static final String HTTP_HEADER_PROXY_AUTHORIZATION = "Proxy-Authorization:"; //$NON-NLS-1$

	private HttpAuthenticationHandler basic;

	private HttpAuthenticationHandler negotiate;

	private List<HttpAuthenticationHandler> availableAuthentications;

	private Iterator<HttpAuthenticationHandler> clientAuthentications;

	private HttpAuthenticationHandler authenticator;

	private boolean ongoing;

	/**
	 * Creates a new {@link HttpClientConnector}. The connector supports
	 * anonymous proxy connections as well as Basic and Negotiate
	 * authentication.
	 *
	 * @param proxyAddress
	 *            of the proxy server we're connecting to
	 * @param remoteAddress
	 *            of the target server to connect to
	 */
	public HttpClientConnector(@NonNull InetSocketAddress proxyAddress,
			@NonNull InetSocketAddress remoteAddress) {
		this(proxyAddress, remoteAddress, null, null);
	}

	/**
	 * Creates a new {@link HttpClientConnector}. The connector supports
	 * anonymous proxy connections as well as Basic and Negotiate
	 * authentication. If a user name and password are given, the connector
	 * tries pre-emptive Basic authentication.
	 *
	 * @param proxyAddress
	 *            of the proxy server we're connecting to
	 * @param remoteAddress
	 *            of the target server to connect to
	 * @param proxyUser
	 *            to authenticate at the proxy with
	 * @param proxyPassword
	 *            to authenticate at the proxy with
	 */
	public HttpClientConnector(@NonNull InetSocketAddress proxyAddress,
			@NonNull InetSocketAddress remoteAddress, String proxyUser,
			char[] proxyPassword) {
		super(proxyAddress, remoteAddress, proxyUser, proxyPassword);
		basic = new HttpBasicAuthentication();
		negotiate = new NegotiateAuthentication();
		availableAuthentications = new ArrayList<>(2);
		availableAuthentications.add(negotiate);
		availableAuthentications.add(basic);
		clientAuthentications = availableAuthentications.iterator();
	}

	private void close() {
		HttpAuthenticationHandler current = authenticator;
		authenticator = null;
		if (current != null) {
			current.close();
		}
	}

	@Override
	public void sendClientProxyMetadata(ClientSession sshSession)
			throws Exception {
		init(sshSession);
		IoSession session = sshSession.getIoSession();
		session.addCloseFutureListener(f -> close());
		StringBuilder msg = connect();
		if ((proxyUser != null && !proxyUser.isEmpty())
				|| (proxyPassword != null && proxyPassword.length > 0)) {
			authenticator = basic;
			basic.setParams(null);
			basic.start();
			msg = authenticate(msg, basic.getToken());
			clearPassword();
			proxyUser = null;
		}
		ongoing = true;
		try {
			send(msg, session);
		} catch (Exception e) {
			ongoing = false;
			throw e;
		}
	}

	private void send(StringBuilder msg, IoSession session) throws Exception {
		byte[] data = eol(msg).toString().getBytes(US_ASCII);
		Buffer buffer = new ByteArrayBuffer(data.length, false);
		buffer.putRawBytes(data);
		session.writeBuffer(buffer).verify(getTimeout());
	}

	private StringBuilder connect() {
		StringBuilder msg = new StringBuilder();
		// Persistent connections are the default in HTTP 1.1 (see RFC 2616),
		// but let's be explicit.
		return msg.append(format(
				"CONNECT {0}:{1} HTTP/1.1\r\nProxy-Connection: keep-alive\r\nConnection: keep-alive\r\nHost: {0}:{1}\r\n", //$NON-NLS-1$
				remoteAddress.getHostString(),
				Integer.toString(remoteAddress.getPort())));
	}

	private StringBuilder authenticate(StringBuilder msg, String token) {
		msg.append(HTTP_HEADER_PROXY_AUTHORIZATION).append(' ').append(token);
		return eol(msg);
	}

	private StringBuilder eol(StringBuilder msg) {
		return msg.append('\r').append('\n');
	}

	@Override
	public void messageReceived(IoSession session, Readable buffer)
			throws Exception {
		try {
			int length = buffer.available();
			byte[] data = new byte[length];
			buffer.getRawBytes(data, 0, length);
			String[] reply = new String(data, US_ASCII)
					.split("\r\n"); //$NON-NLS-1$
			handleMessage(session, Arrays.asList(reply));
		} catch (Exception e) {
			if (authenticator != null) {
				authenticator.close();
				authenticator = null;
			}
			ongoing = false;
			try {
				setDone(false);
			} catch (Exception inner) {
				e.addSuppressed(inner);
			}
			throw e;
		}
	}

	private void handleMessage(IoSession session, List<String> reply)
			throws Exception {
		if (reply.isEmpty() || reply.get(0).isEmpty()) {
			throw new IOException(
					format(SshdText.get().proxyHttpUnexpectedReply,
							proxyAddress, "<empty>")); //$NON-NLS-1$
		}
		try {
			StatusLine status = HttpParser.parseStatusLine(reply.get(0));
			if (!ongoing) {
				throw new IOException(format(
						SshdText.get().proxyHttpUnexpectedReply, proxyAddress,
						Integer.toString(status.getResultCode()),
						status.getReason()));
			}
			switch (status.getResultCode()) {
			case HttpURLConnection.HTTP_OK:
				if (authenticator != null) {
					authenticator.close();
				}
				authenticator = null;
				ongoing = false;
				setDone(true);
				break;
			case HttpURLConnection.HTTP_PROXY_AUTH:
				List<AuthenticationChallenge> challenges = HttpParser
						.getAuthenticationHeaders(reply,
								HTTP_HEADER_PROXY_AUTHENTICATION);
				authenticator = selectProtocol(challenges, authenticator);
				if (authenticator == null) {
					throw new IOException(
							format(SshdText.get().proxyCannotAuthenticate,
									proxyAddress));
				}
				String token = authenticator.getToken();
				if (token == null) {
					throw new IOException(
							format(SshdText.get().proxyCannotAuthenticate,
									proxyAddress));
				}
				send(authenticate(connect(), token), session);
				break;
			default:
				throw new IOException(format(SshdText.get().proxyHttpFailure,
						proxyAddress, Integer.toString(status.getResultCode()),
						status.getReason()));
			}
		} catch (HttpParser.ParseException e) {
			throw new IOException(
					format(SshdText.get().proxyHttpUnexpectedReply,
							proxyAddress, reply.get(0)),
					e);
		}
	}

	private HttpAuthenticationHandler selectProtocol(
			List<AuthenticationChallenge> challenges,
			HttpAuthenticationHandler current) throws Exception {
		if (current != null && !current.isDone()) {
			AuthenticationChallenge challenge = getByName(challenges,
					current.getName());
			if (challenge != null) {
				current.setParams(challenge);
				current.process();
				return current;
			}
		}
		if (current != null) {
			current.close();
		}
		while (clientAuthentications.hasNext()) {
			HttpAuthenticationHandler next = clientAuthentications.next();
			if (!next.isDone()) {
				AuthenticationChallenge challenge = getByName(challenges,
						next.getName());
				if (challenge != null) {
					next.setParams(challenge);
					next.start();
					return next;
				}
			}
		}
		return null;
	}

	private AuthenticationChallenge getByName(
			List<AuthenticationChallenge> challenges,
			String name) {
		return challenges.stream()
				.filter(c -> c.getMechanism().equalsIgnoreCase(name))
				.findFirst().orElse(null);
	}

	private interface HttpAuthenticationHandler
			extends AuthenticationHandler<AuthenticationChallenge, String> {

		public String getName();
	}

	/**
	 * @see <a href="https://tools.ietf.org/html/rfc7617">RFC 7617</a>
	 */
	private class HttpBasicAuthentication
			extends BasicAuthentication<AuthenticationChallenge, String>
			implements HttpAuthenticationHandler {

		private boolean asked;

		public HttpBasicAuthentication() {
			super(proxyAddress, proxyUser, proxyPassword);
		}

		@Override
		public String getName() {
			return "Basic"; //$NON-NLS-1$
		}

		@Override
		protected void askCredentials() {
			// We ask only once.
			if (asked) {
				throw new IllegalStateException(
						"Basic auth: already asked user for password"); //$NON-NLS-1$
			}
			asked = true;
			super.askCredentials();
			done = true;
		}

		@Override
		public String getToken() throws Exception {
			if (user.indexOf(':') >= 0) {
				throw new IOException(format(
						SshdText.get().proxyHttpInvalidUserName, proxy, user));
			}
			byte[] rawUser = user.getBytes(UTF_8);
			byte[] toEncode = new byte[rawUser.length + 1 + password.length];
			System.arraycopy(rawUser, 0, toEncode, 0, rawUser.length);
			toEncode[rawUser.length] = ':';
			System.arraycopy(password, 0, toEncode, rawUser.length + 1,
					password.length);
			Arrays.fill(password, (byte) 0);
			String result = Base64.encodeBytes(toEncode);
			Arrays.fill(toEncode, (byte) 0);
			return getName() + ' ' + result;
		}

	}

	/**
	 * @see <a href="https://tools.ietf.org/html/rfc4559">RFC 4559</a>
	 */
	private class NegotiateAuthentication
			extends GssApiAuthentication<AuthenticationChallenge, String>
			implements HttpAuthenticationHandler {

		public NegotiateAuthentication() {
			super(proxyAddress);
		}

		@Override
		public String getName() {
			return "Negotiate"; //$NON-NLS-1$
		}

		@Override
		public String getToken() throws Exception {
			return getName() + ' ' + Base64.encodeBytes(token);
		}

		@Override
		protected GSSContext createContext() throws Exception {
			return GssApiMechanisms.createContext(GssApiMechanisms.SPNEGO,
					GssApiMechanisms.getCanonicalName(proxyAddress));
		}

		@Override
		protected byte[] extractToken(AuthenticationChallenge input)
				throws Exception {
			String received = input.getToken();
			if (received == null) {
				return new byte[0];
			}
			return Base64.decode(received);
		}

	}
}