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

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

There is a newer version: 3.4.0
Show newest version
/*
 * acme4j - Java ACME client
 *
 * Copyright (C) 2015 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.stream.Collectors.toList;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
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.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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}.
 */
public class DefaultConnection implements Connection {
    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);

    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 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);

    protected final HttpConnector httpConnector;
    protected @Nullable  HttpURLConnection conn;

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

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

        try {
            session.setNonce(null);

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

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

            conn = httpConnector.openConnection(newNonceUrl, session.networkSettings());
            conn.setRequestMethod("HEAD");
            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
            conn.connect();

            logHeaders();

            int rc = conn.getResponseCode();
            if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_NO_CONTENT) {
                throwAcmeException();
            }

            String nonce = getNonce();
            if (nonce == null) {
                throw new AcmeProtocolException("Server did not provide a nonce");
            }
            session.setNonce(nonce);
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        } finally {
            conn = null;
        }
    }

    @Override
    public int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
            throws AcmeException {
        return sendRequest(url, session, MIME_JSON, ifModifiedSince);
    }

    @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 {
        assertConnectionIsOpen();

        if (conn.getContentLength() == 0) {
            throw new AcmeProtocolException("Empty response");
        }

        String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
        if (!(MIME_JSON.equals(contentType) || MIME_JSON_PROBLEM.equals(contentType))) {
            throw new AcmeProtocolException("Unexpected content type: " + contentType);
        }

        try {
            InputStream in =
                    conn.getResponseCode() < 400 ? conn.getInputStream() : conn.getErrorStream();
            if (in == null) {
                throw new AcmeProtocolException("JSON response is empty");
            }

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

    @Override
    public List readCertificates() throws AcmeException {
        assertConnectionIsOpen();

        String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
        if (!(MIME_CERTIFICATE_CHAIN.equals(contentType))) {
            throw new AcmeProtocolException("Unexpected content type: " + contentType);
        }

        try (InputStream in = new TrimmingInputStream(conn.getInputStream())) {
            CertificateFactory cf = CertificateFactory.getInstance("X.509");
            return cf.generateCertificates(in).stream()
                    .map(c -> (X509Certificate) c)
                    .collect(toList());
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        } catch (CertificateException ex) {
            throw new AcmeProtocolException("Failed to read certificate", ex);
        }
    }

    @Override
    public void handleRetryAfter(String message) throws AcmeException {
        assertConnectionIsOpen();

        Optional retryAfter = getRetryAfterHeader();
        if (retryAfter.isPresent()) {
            throw new AcmeRetryAfterException(message, retryAfter.get());
        }
    }

    @Override
    @Nullable
    public String getNonce() {
        assertConnectionIsOpen();

        String nonceHeader = conn.getHeaderField(REPLAY_NONCE_HEADER);
        if (nonceHeader == null || nonceHeader.trim().isEmpty()) {
            return null;
        }

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

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

        return nonceHeader;
    }

    @Override
    @Nullable
    public URL getLocation() {
        assertConnectionIsOpen();

        String location = conn.getHeaderField(LOCATION_HEADER);
        if (location == null) {
            return null;
        }

        LOG.debug("Location: {}", location);
        return resolveRelative(location);
    }

    @Override
    public Optional getLastModified() {
        assertConnectionIsOpen();

        String header = conn.getHeaderField(LAST_MODIFIED_HEADER);
        if (header != null) {
            try {
                return Optional.of(ZonedDateTime.parse(header, RFC_1123_DATE_TIME));
            } catch (DateTimeParseException ex) {
                LOG.debug("Ignored invalid Last-Modified date: {}", header, ex);
            }
        }
        return Optional.empty();
    }

    @Override
    public Optional getExpiration() {
        assertConnectionIsOpen();

        String cacheHeader = conn.getHeaderField(CACHE_CONTROL_HEADER);
        if (cacheHeader != null) {
            if (NO_CACHE_PATTERN.matcher(cacheHeader).matches()) {
                return Optional.empty();
            }

            Matcher m = MAX_AGE_PATTERN.matcher(cacheHeader);
            if (m.matches()) {
                int maxAge = Integer.parseInt(m.group(1));
                if (maxAge == 0) {
                    return Optional.empty();
                }

                return Optional.of(ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(maxAge));
            }
        }

        String expiresHeader = conn.getHeaderField(EXPIRES_HEADER);
        if (expiresHeader != null) {
            try {
                return Optional.of(ZonedDateTime.parse(expiresHeader, RFC_1123_DATE_TIME));
            } catch (DateTimeParseException ex) {
                LOG.debug("Ignored invalid Expires date: {}", expiresHeader, ex);
            }
        }

        return Optional.empty();
    }

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

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

    /**
     * Sends an unsigned GET request.
     *
     * @param url
     *            {@link URL} to send the request to.
     * @param session
     *            {@link Session} instance to be used for signing and tracking
     * @param accept
     *            Accept header
     * @param ifModifiedSince
     *            Set an If-Modified-Since header with the given date. If set, an
     *            NOT_MODIFIED response is accepted as valid.
     * @return HTTP 200 class status that was returned
     */
    protected int sendRequest(URL url, Session session, String accept,
              @Nullable ZonedDateTime ifModifiedSince) throws AcmeException {
        Objects.requireNonNull(url, "url");
        Objects.requireNonNull(session, "session");
        Objects.requireNonNull(accept, "accept");
        assertConnectionIsClosed();

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

        try {
            conn = httpConnector.openConnection(url, session.networkSettings());
            conn.setRequestMethod("GET");
            conn.setRequestProperty(ACCEPT_HEADER, accept);
            conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET);
            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
            if (ifModifiedSince != null) {
                conn.setRequestProperty(IF_MODIFIED_SINCE_HEADER, ifModifiedSince.format(RFC_1123_DATE_TIME));
            }
            conn.setDoOutput(false);

            conn.connect();

            logHeaders();

            String nonce = getNonce();
            if (nonce != null) {
                session.setNonce(nonce);
            }

            int rc = conn.getResponseCode();
            if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_CREATED
                && (rc != HttpURLConnection.HTTP_NOT_MODIFIED || ifModifiedSince == null)) {
                throwAcmeException();
            }
            return rc;
        } catch (IOException ex) {
            throw new AcmeNetworkException(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();

        int 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);
            }

            conn = httpConnector.openConnection(url, session.networkSettings());
            conn.setRequestMethod("POST");
            conn.setRequestProperty(ACCEPT_HEADER, accept);
            conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET);
            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
            conn.setRequestProperty(CONTENT_TYPE_HEADER, "application/jose+json");
            conn.setDoOutput(true);

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

            byte[] outputData = jose.toString().getBytes(StandardCharsets.UTF_8);

            conn.setFixedLengthStreamingMode(outputData.length);
            conn.connect();

            try (OutputStream out = conn.getOutputStream()) {
                out.write(outputData);
            }

            logHeaders();

            session.setNonce(getNonce());

            int rc = conn.getResponseCode();
            if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_CREATED) {
                throwAcmeException();
            }
            return rc;
        } catch (IOException ex) {
            throw new AcmeNetworkException(ex);
        }
    }

    /**
     * Gets the instant sent with the Retry-After header.
     */
    private Optional getRetryAfterHeader() {
        // See RFC 2616 section 14.37
        String header = conn.getHeaderField(RETRY_AFTER_HEADER);
        if (header != null) {
            try {
                // delta-seconds
                if (header.matches("^\\d+$")) {
                    int delta = Integer.parseInt(header);
                    long date = conn.getHeaderFieldDate(DATE_HEADER, System.currentTimeMillis());
                    return Optional.of(Instant.ofEpochMilli(date).plusSeconds(delta));
                }

                // HTTP-date
                long date = conn.getHeaderFieldDate(RETRY_AFTER_HEADER, 0L);
                if (date != 0) {
                    return Optional.of(Instant.ofEpochMilli(date));
                }
            } catch (Exception ex) {
                throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
            }
        }

        return Optional.empty();
    }

    /**
     * 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 {
            String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
            if (!MIME_JSON_PROBLEM.equals(contentType)) {
                throw new AcmeException("HTTP " + conn.getResponseCode() + ": " + conn.getResponseMessage());
            }

            Problem problem = new Problem(readJsonResponse(), conn.getURL());

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

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

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

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

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

    /**
     * Asserts that the connection is currently open. Throws an exception if not.
     */
    private void assertConnectionIsOpen() {
        if (conn == null) {
            throw new IllegalStateException("Not connected.");
        }
    }

    /**
     * Asserts that the connection is currently closed. Throws an exception if not.
     */
    private void assertConnectionIsClosed() {
        if (conn != null) {
            throw new IllegalStateException("Previous connection is not closed.");
        }
    }

    /**
     * Log all HTTP headers in debug mode.
     */
    private void logHeaders() {
        if (!LOG.isDebugEnabled()) {
            return;
        }

        conn.getHeaderFields().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) {
        assertConnectionIsOpen();

        List result = new ArrayList<>();

        List links = conn.getHeaderFields().get(LINK_HEADER);
        if (links != null) {
            Pattern p = Pattern.compile("<(.*?)>\\s*;\\s*rel=\"?"+ Pattern.quote(relation) + "\"?");
            for (String link : links) {
                Matcher m = p.matcher(link);
                if (m.matches()) {
                    String location = m.group(1);
                    LOG.debug("Link: {} -> {}", relation, location);
                    result.add(location);
                }
            }
        }

        return result;
    }

    /**
     * Resolves a relative link against the connection's last URL.
     *
     * @param link
     *            Link to resolve. Absolute links are just converted to an URL. May be
     *            {@code null}.
     * @return Absolute URL of the given link, or {@code null} if the link was
     *         {@code null}.
     */
    @Nullable
    private URL resolveRelative(@Nullable String link) {
        if (link == null) {
            return null;
        }

        assertConnectionIsOpen();
        try {
            return new URL(conn.getURL(), link);
        } 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, or {@code null} if the URI was
     *         {@code null}.
     */
    @Nullable
    private URI resolveUri(@Nullable String uri) {
        if (uri == null) {
            return null;
        }

        try {
            return conn.getURL().toURI().resolve(uri);
        } catch (URISyntaxException ex) {
            throw new AcmeProtocolException("Invalid URI", ex);
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy