HttpProxyHandler.java

/*
 * Copyright 2014 The Netty Project
 *
 * The Netty Project licenses this file to you 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.
 *
 */
/*
 * Portions Copyright (c) Microsoft Corporation
 */

package com.azure.core.http.netty.implementation;

import com.azure.core.util.AuthorizationChallengeHandler;
import com.azure.core.util.CoreUtils;
import com.azure.core.util.logging.ClientLogger;
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.DefaultFullHttpRequest;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.proxy.ProxyConnectException;
import io.netty.handler.proxy.ProxyHandler;
import io.netty.util.AttributeKey;

import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import java.util.regex.Pattern;

import static com.azure.core.util.AuthorizationChallengeHandler.PROXY_AUTHENTICATE;
import static com.azure.core.util.AuthorizationChallengeHandler.PROXY_AUTHENTICATION_INFO;
import static com.azure.core.util.AuthorizationChallengeHandler.PROXY_AUTHORIZATION;

/**
 * This class handles authorizing requests being sent through a proxy which require authentication.
 */
public final class HttpProxyHandler extends ProxyHandler {
    private static final String VALIDATION_ERROR_TEMPLATE = "The '%s' returned in the 'Proxy-Authentication-Info' "
        + "header doesn't match the value sent in the 'Proxy-Authorization' header. Sent: %s, received: %s.";

    private static final AttributeKey<String> PROXY_AUTHORIZATION_KEY = AttributeKey.newInstance("ProxyAuthorization");

    private static final String NONE = "none";
    private static final String HTTP = "http";

    private static final String CNONCE = "cnonce";
    private static final String NC = "nc";

    private static final String AUTH_BASIC = "basic";
    private static final String AUTH_DIGEST = "digest";

    private static final Pattern AUTH_SCHEME_PATTERN = Pattern.compile("^" + AUTH_DIGEST, Pattern.CASE_INSENSITIVE);

    /*
     * Proxies use 'CONNECT' as the HTTP method.
     */
    private static final String PROXY_METHOD = HttpMethod.CONNECT.name();

    /*
     * Proxies are always the root path.
     */
    private static final String PROXY_URI_PATH = "/";

    /*
     * Digest authentication to a proxy uses the 'CONNECT' method, these can't have a request body.
     */
    private static final Supplier<byte[]> NO_BODY = () -> new byte[0];

    private final ClientLogger logger = new ClientLogger(HttpProxyHandler.class);

    private final AuthorizationChallengeHandler challengeHandler;
    private final AtomicReference<ChallengeHolder> proxyChallengeHolderReference;
    private final HttpClientCodec codec;

    private String authScheme = null;
    private HttpResponseStatus status;
    private HttpHeaders innerHeaders;

    public HttpProxyHandler(InetSocketAddress proxyAddress, AuthorizationChallengeHandler challengeHandler,
        AtomicReference<ChallengeHolder> proxyChallengeHolderReference) {
        super(proxyAddress);

        this.challengeHandler = challengeHandler;
        this.proxyChallengeHolderReference = proxyChallengeHolderReference;
        this.codec = new HttpClientCodec();
    }

    @Override
    public String protocol() {
        return HTTP;
    }

    @Override
    public String authScheme() {
        return (authScheme == null) ? NONE : authScheme;
    }

    @Override
    protected void addCodec(ChannelHandlerContext ctx) {
        ctx.pipeline().addBefore(ctx.name(), null, this.codec);
    }

    @Override
    protected void removeEncoder(ChannelHandlerContext ctx) {
        this.codec.removeOutboundHandler();
    }

    @Override
    protected void removeDecoder(ChannelHandlerContext ctx) {
        this.codec.removeInboundHandler();
    }

    @Override
    protected Object newInitialMessage(ChannelHandlerContext ctx) {
        // This needs to handle no authorization proxying.
        InetSocketAddress destinationAddress = this.destinationAddress();
        String hostString = HttpUtil.formatHostnameForHttp(destinationAddress);
        int port = destinationAddress.getPort();
        String url = hostString + ":" + port;
        String hostHeader = (port == 80 || port == 443) ? url : hostString;
        FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.CONNECT, url,
            Unpooled.EMPTY_BUFFER, false);

        request.headers().set(HttpHeaderNames.HOST, hostHeader);

        // Proxy is expected to have authentication requirements, attempt to authenticate.
        if (challengeHandler != null) {
            String authorizationHeader = createAuthorizationHeader();

            if (!CoreUtils.isNullOrEmpty(authorizationHeader)) {
                authScheme = AUTH_SCHEME_PATTERN.matcher(authorizationHeader).find() ? AUTH_DIGEST : AUTH_BASIC;
                request.headers().set(PROXY_AUTHORIZATION, authorizationHeader);
                ctx.channel().attr(PROXY_AUTHORIZATION_KEY).set(authorizationHeader);
            }
        }

        return request;
    }

    /*
     * Attempts to create a Proxy-Authorization header based on the AuthorizationChallengeHandler and ChallengeHolder
     * configurations.
     */
    private String createAuthorizationHeader() {
        /*
         * Attempt to pipeline the request.
         *
         * Pipelining is only possible if another request has been authenticated using the
         * AuthenticationChallengeHandler, it attempts to leverage state from the previous authentication request.
         * This may fail and result in the server requesting authentication by returning a proxy authentication
         * challenge.
         */
        String authorizationHeader = challengeHandler.attemptToPipelineAuthorization(PROXY_METHOD, PROXY_URI_PATH,
            NO_BODY);

        if (!CoreUtils.isNullOrEmpty(authorizationHeader)) {
            return authorizationHeader;
        }

        ChallengeHolder proxyChallengeHolder = proxyChallengeHolderReference.get();

        /*
         * Check if a proxy authentication challenge has been passed to the channel. This occurs when a proxy
         * authentication challenge response is seen by the connection observer and passed back to a future channel
         * created from the same client.
         */
        if (proxyChallengeHolder != null) {
            // Attempt to apply digest challenges, these are preferred over basic authorization.
            List<Map<String, String>> digestChallenges = proxyChallengeHolder.getDigestChallenges();
            if (!CoreUtils.isNullOrEmpty(digestChallenges)) {
                authorizationHeader = challengeHandler.handleDigest(PROXY_METHOD, PROXY_URI_PATH, digestChallenges,
                    NO_BODY);
            }

            // If digest challenges exist or all failed attempt to use basic authorization.
            if (CoreUtils.isNullOrEmpty(authorizationHeader) && proxyChallengeHolder.hasBasicChallenge()) {
                authorizationHeader = challengeHandler.handleBasic();
            }
        }

        return authorizationHeader;
    }

    @Override
    protected boolean handleResponse(ChannelHandlerContext ctx, Object o) throws ProxyConnectException {
        if (o instanceof HttpResponse) {
            if (status != null) {
                throw logger.logExceptionAsWarning(new RuntimeException("Received too many responses for a request"));
            }

            HttpResponse response = (HttpResponse) o;
            status = response.status();
            innerHeaders = response.headers();

            if (response.status().code() == 407) {
                /*
                 * Attempt to CONNECT to the proxy resulted in a request for authentication, parse the
                 * Proxy-Authenticate headers returned for challenges and update the ChallengeHolder reference. This
                 * will allow subsequent requests to CONNECT to this proxy to use the challenges returned to create a
                 * Proxy-Authorization header.
                 */
                proxyChallengeHolderReference.set(extractChallengesFromHeaders(response.headers()));
            } else if (response.status().code() == 200) {
                /*
                 * Attempt to CONNECT to the proxy succeeded, retrieve the Proxy-Authorization header passed in the
                 * Channel's attributes to compare it against a potential Proxy-Authentication-Info response header.
                 * This header is used by the server to validate to the client that it received the correct request.
                 */
                validateProxyAuthenticationInfo(response.headers().get(PROXY_AUTHENTICATION_INFO),
                    ctx.channel().attr(PROXY_AUTHORIZATION_KEY).get());
            }
        }

        boolean responseComplete = o instanceof LastHttpContent;
        if (responseComplete) {
            if (status == null) {
                throw new io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException(
                    "Never received response for CONNECT request.", innerHeaders);
            } else if (status.code() != 200) {
                throw new io.netty.handler.proxy.HttpProxyHandler.HttpProxyConnectException(
                    "Failed to connect to proxy. Status: " + status, innerHeaders);
            }
        }

        return responseComplete;
    }

    /*
     * Search the response HttpHeaders for Proxy-Authenticate headers.
     */
    private static ChallengeHolder extractChallengesFromHeaders(HttpHeaders headers) {
        boolean hasBasicChallenge = false;
        List<Map<String, String>> digestChallenges = new ArrayList<>();

        for (String proxyAuthenticationHeader : headers.getAll(PROXY_AUTHENTICATE)) {
            String[] typeValuePair = proxyAuthenticationHeader.split(" ", 2);

            String challengeType = typeValuePair[0].trim();
            if (challengeType.equalsIgnoreCase(AUTH_BASIC)) {
                /*
                 * Proxy-Authenticate is requesting Basic authorization, this only needs a flag as Basic authentication
                 * is always the same.
                 */
                hasBasicChallenge = true;
            } else if (challengeType.equalsIgnoreCase(AUTH_DIGEST)) {
                /*
                 * Proxy-Authenticate is requesting Digest authorization, this needs to be parsed for the challenge
                 * information as Digest authentication always changes.
                 */
                Map<String, String> digestChallenge = new HashMap<>();
                for (String challengePiece : typeValuePair[1].split(",")) {
                    String[] kvp = challengePiece.split("=", 2);

                    // Skip challenge information that has no value.
                    if (kvp.length != 2) {
                        continue;
                    }

                    digestChallenge.put(kvp[0].trim(), kvp[1].trim().replace("\"", ""));
                }

                digestChallenges.add(digestChallenge);
            }
        }

        return new ChallengeHolder(hasBasicChallenge, digestChallenges);
    }

    /*
     * Validate the Proxy-Authorization header used in authentication against the server's Proxy-Authentication-Info
     * header. This header is an optional return by the server so this validation is guaranteed to happen.
     */
    private void validateProxyAuthenticationInfo(String infoHeader, String authorizationHeader) {
        // Server didn't return a 'Proxy-Authentication-Info' header, nothing to consume.
        if (CoreUtils.isNullOrEmpty(infoHeader)) {
            return;
        }

        Map<String, String> authenticationInfoPieces = AuthorizationChallengeHandler
            .parseAuthenticationOrAuthorizationHeader(infoHeader);
        Map<String, String> authorizationPieces = AuthorizationChallengeHandler
            .parseAuthenticationOrAuthorizationHeader(authorizationHeader);

        /*
         * If the authentication info response contains a cnonce or nc value it MUST match the value sent in the
         * authorization header. This is the server performing validation to the client that it received the
         * information.
         */
        validateProxyAuthenticationInfoValue(CNONCE, authenticationInfoPieces, authorizationPieces);
        validateProxyAuthenticationInfoValue(NC, authenticationInfoPieces, authorizationPieces);

        challengeHandler.consumeAuthenticationInfoHeader(authenticationInfoPieces);
    }

    /*
     * Validates that the value received in the 'Proxy-Authentication-Info' matches the value sent in the
     * 'Proxy-Authorization' header. If the values don't match an 'IllegalStateException' will be thrown with a message
     * outlining that the values didn't match.
     */
    private void validateProxyAuthenticationInfoValue(String name, Map<String, String> authenticationInfoPieces,
        Map<String, String> authorizationPieces) {
        if (authenticationInfoPieces.containsKey(name)) {
            String sentValue = authorizationPieces.get(name);
            String receivedValue = authenticationInfoPieces.get(name);

            if (!receivedValue.equalsIgnoreCase(sentValue)) {
                throw logger.logExceptionAsError(new IllegalStateException(
                    String.format(VALIDATION_ERROR_TEMPLATE, name, sentValue, receivedValue)));
            }
        }
    }
}