All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.shredzone.acme4j.connector.DefaultConnection Maven / Gradle / Ivy

The newest version!
/*
 * acme4j - Java ACME client
 *
 * Copyright (C) 2023 Richard "Shred" Körber
 *   http://acme4j.shredzone.org
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 */
package org.shredzone.acme4j.connector;

import static java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
import static java.util.function.Predicate.not;
import static java.util.stream.Collectors.toUnmodifiableList;

import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.security.KeyPair;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.GZIPInputStream;

import edu.umd.cs.findbugs.annotations.Nullable;
import org.shredzone.acme4j.Login;
import org.shredzone.acme4j.Problem;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRateLimitedException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
import org.shredzone.acme4j.toolbox.AcmeUtils;
import org.shredzone.acme4j.toolbox.JSON;
import org.shredzone.acme4j.toolbox.JSONBuilder;
import org.shredzone.acme4j.toolbox.JoseUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Default implementation of {@link Connection}. It communicates with the ACME server via
 * HTTP, with a client that is provided by the given {@link HttpConnector}.
 */
public class DefaultConnection implements Connection {
    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);

    private static final int HTTP_OK = 200;
    private static final int HTTP_CREATED = 201;
    private static final int HTTP_NO_CONTENT = 204;
    private static final int HTTP_NOT_MODIFIED = 304;

    private static final String ACCEPT_HEADER = "Accept";
    private static final String ACCEPT_CHARSET_HEADER = "Accept-Charset";
    private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
    private static final String ACCEPT_ENCODING_HEADER = "Accept-Encoding";
    private static final String CACHE_CONTROL_HEADER = "Cache-Control";
    private static final String CONTENT_TYPE_HEADER = "Content-Type";
    private static final String DATE_HEADER = "Date";
    private static final String EXPIRES_HEADER = "Expires";
    private static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
    private static final String LAST_MODIFIED_HEADER = "Last-Modified";
    private static final String LINK_HEADER = "Link";
    private static final String LOCATION_HEADER = "Location";
    private static final String REPLAY_NONCE_HEADER = "Replay-Nonce";
    private static final String RETRY_AFTER_HEADER = "Retry-After";
    private static final String DEFAULT_CHARSET = "utf-8";
    private static final String MIME_JSON = "application/json";
    private static final String MIME_JSON_PROBLEM = "application/problem+json";
    private static final String MIME_CERTIFICATE_CHAIN = "application/pem-certificate-chain";

    private static final URI BAD_NONCE_ERROR = URI.create("urn:ietf:params:acme:error:badNonce");
    private static final int MAX_ATTEMPTS = 10;

    private static final Pattern NO_CACHE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*no-(?:cache|store)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
    private static final Pattern MAX_AGE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*max-age=(\\d+)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
    private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("^\\d+$");

    protected final HttpConnector httpConnector;
    protected final HttpClient httpClient;
    protected @Nullable HttpResponse lastResponse;

    /**
     * Creates a new {@link DefaultConnection}.
     *
     * @param httpConnector
     *         {@link HttpConnector} to be used for HTTP connections
     */
    public DefaultConnection(HttpConnector httpConnector) {
        this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector");
        this.httpClient = httpConnector.createClientBuilder().build();
    }

    @Override
    public void resetNonce(Session session) throws AcmeException {
        assertConnectionIsClosed();

        try {
            session.setNonce(null);

            var newNonceUrl = session.resourceUrl(Resource.NEW_NONCE);

            LOG.debug("HEAD {}", newNonceUrl);

            sendRequest(session, newNonceUrl, b ->
                    b.method("HEAD", HttpRequest.BodyPublishers.noBody()));

            logHeaders();

            var rc = getResponse().statusCode();
            if (rc != HTTP_OK && rc != HTTP_NO_CONTENT) {
                var message = "Server responded with HTTP " + rc + " while trying to retrieve a nonce";
                var retryAfterInstant = getRetryAfter();
                if (retryAfterInstant.isPresent()) {
                    throw new AcmeRetryAfterException(message, retryAfterInstant.get());
                };
                throw new AcmeException(message);
            }

            session.setNonce(getNonce()
                    .orElseThrow(() -> new AcmeProtocolException("Server did not provide a nonce"))
            );
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        } finally {
            close();
        }
    }

    @Override
    public int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
            throws AcmeException {
        Objects.requireNonNull(url, "url");
        Objects.requireNonNull(session, "session");
        assertConnectionIsClosed();

        LOG.debug("GET {}", url);

        try {
            sendRequest(session, url, builder -> {
                builder.GET();
                builder.header(ACCEPT_HEADER, MIME_JSON);
                if (ifModifiedSince != null) {
                    builder.header(IF_MODIFIED_SINCE_HEADER, ifModifiedSince.format(RFC_1123_DATE_TIME));
                }
            });

            logHeaders();

            getNonce().ifPresent(session::setNonce);

            var rc = getResponse().statusCode();
            if (rc != HTTP_OK && rc != HTTP_CREATED && (rc != HTTP_NOT_MODIFIED || ifModifiedSince == null)) {
                throwAcmeException();
            }
            return rc;
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        }
    }

    @Override
    public int sendCertificateRequest(URL url, Login login) throws AcmeException {
        return sendSignedRequest(url, null, login.getSession(), login.getKeyPair(),
                login.getAccountLocation(), MIME_CERTIFICATE_CHAIN);
    }

    @Override
    public int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException {
        return sendSignedRequest(url, null, login.getSession(), login.getKeyPair(),
                login.getAccountLocation(), MIME_JSON);
    }

    @Override
    public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
        return sendSignedRequest(url, claims, login.getSession(), login.getKeyPair(),
                login.getAccountLocation(), MIME_JSON);
    }

    @Override
    public int sendSignedRequest(URL url, JSONBuilder claims, Session session, KeyPair keypair)
            throws AcmeException {
        return sendSignedRequest(url, claims, session, keypair, null, MIME_JSON);
    }

    @Override
    public JSON readJsonResponse() throws AcmeException {
        expectContentType(Set.of(MIME_JSON, MIME_JSON_PROBLEM));

        try (var in = getResponseBody()) {
            var result = JSON.parse(in);
            LOG.debug("Result JSON: {}", result);
            return result;
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        }
    }

    @Override
    public List readCertificates() throws AcmeException {
        expectContentType(Set.of(MIME_CERTIFICATE_CHAIN));

        try (var in = new TrimmingInputStream(getResponseBody())) {
            var cf = CertificateFactory.getInstance("X.509");
            return cf.generateCertificates(in).stream()
                    .map(X509Certificate.class::cast)
                    .collect(toUnmodifiableList());
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        } catch (CertificateException ex) {
            throw new AcmeProtocolException("Failed to read certificate", ex);
        }
    }

    @Override
    public Optional getNonce() {
        var nonceHeaderOpt = getResponse().headers()
                .firstValue(REPLAY_NONCE_HEADER)
                .map(String::trim)
                .filter(not(String::isEmpty));
        if (nonceHeaderOpt.isPresent()) {
            var nonceHeader = nonceHeaderOpt.get();

            if (!AcmeUtils.isValidBase64Url(nonceHeader)) {
                throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
            }

            LOG.debug("Replay Nonce: {}", nonceHeader);
        }
        return nonceHeaderOpt;
    }

    @Override
    public URL getLocation() {
        return getResponse().headers()
                .firstValue(LOCATION_HEADER)
                .map(l -> {
                    LOG.debug("Location: {}", l);
                    return l;
                })
                .map(this::resolveRelative)
                .orElseThrow(() -> new AcmeProtocolException("location header is missing"));
    }

    @Override
    public Optional getLastModified() {
        return getResponse().headers()
                .firstValue(LAST_MODIFIED_HEADER)
                .map(lm -> {
                    try {
                        return ZonedDateTime.parse(lm, RFC_1123_DATE_TIME);
                    } catch (DateTimeParseException ex) {
                        LOG.debug("Ignored invalid Last-Modified date: {}", lm, ex);
                        return null;
                    }
                });
    }

    @Override
    public Optional getExpiration() {
        var cacheControlHeader = getResponse().headers()
                .firstValue(CACHE_CONTROL_HEADER)
                .filter(not(h -> NO_CACHE_PATTERN.matcher(h).matches()))
                .map(MAX_AGE_PATTERN::matcher)
                .filter(Matcher::matches)
                .map(m -> Integer.parseInt(m.group(1)))
                .filter(maxAge -> maxAge != 0)
                .map(maxAge -> ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(maxAge));

        if (cacheControlHeader.isPresent()) {
            return cacheControlHeader;
        }

        return getResponse().headers()
                .firstValue(EXPIRES_HEADER)
                .flatMap(header -> {
                    try {
                        return Optional.of(ZonedDateTime.parse(header, RFC_1123_DATE_TIME));
                    } catch (DateTimeParseException ex) {
                        LOG.debug("Ignored invalid Expires date: {}", header, ex);
                        return Optional.empty();
                    }
                });
    }

    @Override
    public Collection getLinks(String relation) {
        return collectLinks(relation).stream()
                .map(this::resolveRelative)
                .collect(toUnmodifiableList());
    }

    @Override
    public void close() {
        lastResponse = null;
    }

    /**
     * Sends a HTTP request via http client. This is the central method to be used for
     * sending. It will create a {@link HttpRequest} by using the request builder,
     * configure commnon headers, and then send the request via {@link HttpClient}.
     *
     * @param session
     *         {@link Session} to be used for sending
     * @param url
     *         Target {@link URL}
     * @param body
     *         Callback that completes the {@link HttpRequest.Builder} with the request
     *         body (e.g. HTTP method, request body, more headers).
     */
    protected void sendRequest(Session session, URL url, Consumer body) throws IOException {
        try {
            var builder = httpConnector.createRequestBuilder(url)
                    .header(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET)
                    .header(ACCEPT_LANGUAGE_HEADER, session.getLanguageHeader());

            if (session.networkSettings().isCompressionEnabled()) {
                builder.header(ACCEPT_ENCODING_HEADER, "gzip");
            }

            body.accept(builder);

            lastResponse = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
        } catch (InterruptedException ex) {
            throw new IOException("Request was interrupted", ex);
        }
    }

    /**
     * Sends a signed POST request.
     *
     * @param url
     *         {@link URL} to send the request to.
     * @param claims
     *         {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
     *         request.
     * @param session
     *         {@link Session} instance to be used for signing and tracking
     * @param keypair
     *         {@link KeyPair} to be used for signing
     * @param accountLocation
     *         If set, the account location is set as "kid" header. If {@code null}, the
     *         public key is set as "jwk" header.
     * @param accept
     *         Accept header
     * @return HTTP 200 class status that was returned
     */
    protected int sendSignedRequest(URL url, @Nullable JSONBuilder claims, Session session,
                                    KeyPair keypair, @Nullable URL accountLocation, String accept) throws AcmeException {
        Objects.requireNonNull(url, "url");
        Objects.requireNonNull(session, "session");
        Objects.requireNonNull(keypair, "keypair");
        Objects.requireNonNull(accept, "accept");
        assertConnectionIsClosed();

        var attempt = 1;
        while (true) {
            try {
                return performRequest(url, claims, session, keypair, accountLocation, accept);
            } catch (AcmeServerException ex) {
                if (!BAD_NONCE_ERROR.equals(ex.getType())) {
                    throw ex;
                }
                if (attempt == MAX_ATTEMPTS) {
                    throw ex;
                }
                LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS);
                attempt++;
            }
        }
    }

    /**
     * Performs the POST request.
     *
     * @param url
     *         {@link URL} to send the request to.
     * @param claims
     *         {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
     *         request.
     * @param session
     *         {@link Session} instance to be used for signing and tracking
     * @param keypair
     *         {@link KeyPair} to be used for signing
     * @param accountLocation
     *         If set, the account location is set as "kid" header. If {@code null}, the
     *         public key is set as "jwk" header.
     * @param accept
     *         Accept header
     * @return HTTP 200 class status that was returned
     */
    private int performRequest(URL url, @Nullable JSONBuilder claims, Session session,
                               KeyPair keypair, @Nullable URL accountLocation, String accept)
            throws AcmeException {
        try {
            if (session.getNonce() == null) {
                resetNonce(session);
            }

            var jose = JoseUtils.createJoseRequest(
                    url,
                    keypair,
                    claims,
                    session.getNonce(),
                    accountLocation != null ? accountLocation.toString() : null
            );

            var outputData = jose.toString();

            sendRequest(session, url, builder -> {
                builder.POST(HttpRequest.BodyPublishers.ofString(outputData));
                builder.header(ACCEPT_HEADER, accept);
                builder.header(CONTENT_TYPE_HEADER, "application/jose+json");
            });

            logHeaders();

            session.setNonce(getNonce().orElse(null));

            var rc = getResponse().statusCode();
            if (rc != HTTP_OK && rc != HTTP_CREATED) {
                throwAcmeException();
            }
            return rc;
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        }
    }

    @Override
    public Optional getRetryAfter() {
        return getResponse().headers()
                .firstValue(RETRY_AFTER_HEADER)
                .map(this::parseRetryAfterHeader);
    }

    /**
     * Parses the content of a Retry-After header. The header can either contain a
     * relative or an absolute time.
     *
     * @param header
     *         Retry-After header
     * @return Instant given in the header
     * @throws AcmeProtocolException
     *         if the header content is invalid
     */
    private Instant parseRetryAfterHeader(String header) {
        // See RFC 2616 section 14.37
        try {
            // delta-seconds
            if (DIGITS_ONLY_PATTERN.matcher(header).matches()) {
                var delta = Integer.parseInt(header);
                var date = getResponse().headers().firstValue(DATE_HEADER)
                        .map(d -> ZonedDateTime.parse(d, RFC_1123_DATE_TIME).toInstant())
                        .orElseGet(Instant::now);
                return date.plusSeconds(delta);
            }

            // HTTP-date
            return ZonedDateTime.parse(header, RFC_1123_DATE_TIME).toInstant();
        } catch (RuntimeException ex) {
            throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
        }
    }

    /**
     * Provides an {@link InputStream} of the response body. If the stream is compressed,
     * it will also take care for decompression.
     */
    private InputStream getResponseBody() throws IOException {
        var stream = getResponse().body();
        if (stream == null) {
            throw new AcmeProtocolException("Unexpected empty response");
        }

        if (getResponse().headers().firstValue("Content-Encoding")
                .filter("gzip"::equalsIgnoreCase)
                .isPresent()) {
            stream = new GZIPInputStream(stream);
        }

        return stream;
    }

    /**
     * Throws an {@link AcmeException}. This method throws an exception that tries to
     * explain the error as precisely as possible.
     */
    private void throwAcmeException() throws AcmeException {
        try {
            if (getResponse().headers().firstValue(CONTENT_TYPE_HEADER)
                    .map(AcmeUtils::getContentType)
                    .filter(MIME_JSON_PROBLEM::equals)
                    .isEmpty()) {
                // Generic HTTP error
                throw new AcmeException("HTTP " + getResponse().statusCode());
            }

            var problem = new Problem(readJsonResponse(), getResponse().request().uri().toURL());

            var error = AcmeUtils.stripErrorPrefix(problem.getType().toString());

            if ("unauthorized".equals(error)) {
                throw new AcmeUnauthorizedException(problem);
            }

            if ("userActionRequired".equals(error)) {
                var tos = collectLinks("terms-of-service").stream()
                        .findFirst()
                        .map(this::resolveUri)
                        .orElse(null);
                throw new AcmeUserActionRequiredException(problem, tos);
            }

            if ("rateLimited".equals(error)) {
                var retryAfter = getRetryAfter();
                var rateLimits = getLinks("help");
                throw new AcmeRateLimitedException(problem, retryAfter.orElse(null), rateLimits);
            }

            throw new AcmeServerException(problem);
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        }
    }

    /**
     * Checks if the returned content type is in the list of expected types.
     *
     * @param expectedTypes
     *         content types that are accepted
     * @throws AcmeProtocolException
     *         if the returned content type is different
     */
    private void expectContentType(Set expectedTypes) {
        var contentType = getResponse().headers()
                .firstValue(CONTENT_TYPE_HEADER)
                .map(AcmeUtils::getContentType)
                .orElseThrow(() -> new AcmeProtocolException("No content type header found"));
        if (!expectedTypes.contains(contentType)) {
            throw new AcmeProtocolException("Unexpected content type: " + contentType);
        }
    }

    /**
     * Returns the response of the last request. If there is no connection currently
     * open, an exception is thrown instead.
     * 

* Note that the response provides an {@link InputStream} that can be read only * once. */ private HttpResponse getResponse() { if (lastResponse == null) { throw new IllegalStateException("Not connected."); } return lastResponse; } /** * Asserts that the connection is currently closed. Throws an exception if not. */ private void assertConnectionIsClosed() { if (lastResponse != null) { throw new IllegalStateException("Previous connection is not closed."); } } /** * Log all HTTP headers in debug mode. */ private void logHeaders() { if (!LOG.isDebugEnabled()) { return; } getResponse().headers().map().forEach((key, headers) -> headers.forEach(value -> LOG.debug("HEADER {}: {}", key, value) ) ); } /** * Collects links of the given relation. * * @param relation * Link relation * @return Collection of links, unconverted */ private Collection collectLinks(String relation) { var p = Pattern.compile("<(.*?)>\\s*;\\s*rel=\"?" + Pattern.quote(relation) + "\"?"); return getResponse().headers().allValues(LINK_HEADER) .stream() .map(p::matcher) .filter(Matcher::matches) .map(m -> m.group(1)) .peek(location -> LOG.debug("Link: {} -> {}", relation, location)) .collect(toUnmodifiableList()); } /** * Resolves a relative link against the connection's last URL. * * @param link * Link to resolve. Absolute links are just converted to an URL. * @return Absolute URL of the given link */ private URL resolveRelative(String link) { try { return resolveUri(link).toURL(); } catch (MalformedURLException ex) { throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex); } } /** * Resolves a relative URI against the connection's last URL. * * @param uri * URI to resolve * @return Absolute URI of the given link */ private URI resolveUri(String uri) { return getResponse().request().uri().resolve(uri); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy