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

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

/*
 * 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.impl;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.KeyPair;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.jose4j.base64url.Base64Url;
import org.jose4j.json.JsonUtil;
import org.jose4j.jwk.PublicJsonWebKey;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.lang.JoseException;
import org.shredzone.acme4j.Registration;
import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.connector.HttpConnector;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.connector.Session;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRateLimitExceededException;
import org.shredzone.acme4j.exception.AcmeServerException;
import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.SignatureUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Default implementation of {@link Connection}.
 *
 * @author Richard "Shred" Körber
 */
public class DefaultConnection implements Connection {
    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);

    private static final Pattern BASE64URL_PATTERN = Pattern.compile("[0-9A-Za-z_-]+");

    protected final HttpConnector httpConnector;
    protected HttpURLConnection conn;

    public DefaultConnection(HttpConnector httpConnector) {
        if (httpConnector == null) {
            throw new NullPointerException("httpConnector must not be null");
        }

        this.httpConnector = httpConnector;
    }

    @Override
    public int sendRequest(URI uri) throws IOException {
        if (uri == null) {
            throw new NullPointerException("uri must not be null");
        }
        assertConnectionIsClosed();

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

        conn = httpConnector.openConnection(uri);
        conn.setRequestMethod("GET");
        conn.setRequestProperty("Accept-Charset", "utf-8");
        conn.setDoOutput(false);

        conn.connect();

        logHeaders();

        return conn.getResponseCode();
    }

    @Override
    public int sendSignedRequest(URI uri, ClaimBuilder claims, Session session, Registration registration)
                throws IOException {
        if (uri == null) {
            throw new NullPointerException("uri must not be null");
        }
        if (claims == null) {
            throw new NullPointerException("claims must not be null");
        }
        if (session == null) {
            throw new NullPointerException("session must not be null");
        }
        if (registration == null) {
            throw new NullPointerException("registration must not be null");
        }
        assertConnectionIsClosed();

        try {
            KeyPair keypair = registration.getKeyPair();

            if (session.getNonce() == null) {
                LOG.debug("Getting initial nonce, HEAD {}", uri);
                conn = httpConnector.openConnection(uri);
                conn.setRequestMethod("HEAD");
                conn.connect();
                updateSession(session);
                conn = null;
            }

            if (session.getNonce() == null) {
                throw new AcmeProtocolException("Server did not provide a nonce");
            }

            LOG.debug("POST {} with claims: {}", uri, claims);

            conn = httpConnector.openConnection(uri);
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Accept", "application/json");
            conn.setRequestProperty("Accept-Charset", "utf-8");
            conn.setRequestProperty("Content-Type", "application/json");
            conn.setDoOutput(true);

            final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(keypair.getPublic());

            JsonWebSignature jws = new JsonWebSignature();
            jws.setPayload(claims.toString());
            jws.getHeaders().setObjectHeaderValue("nonce", Base64Url.encode(session.getNonce()));
            jws.getHeaders().setJwkHeaderValue("jwk", jwk);
            jws.setAlgorithmHeaderValue(SignatureUtils.keyAlgorithm(jwk));
            jws.setKey(keypair.getPrivate());
            byte[] outputData = jws.getCompactSerialization().getBytes("utf-8");

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

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

            logHeaders();

            updateSession(session);

            return conn.getResponseCode();
        } catch (JoseException ex) {
            throw new AcmeProtocolException("Failed to generate a JSON request", ex);
        }
    }

    @Override
    public Map readJsonResponse() throws IOException {
        assertConnectionIsOpen();

        String contentType = conn.getHeaderField("Content-Type");
        if (!("application/json".equals(contentType)
                    || "application/problem+json".equals(contentType))) {
            throw new AcmeProtocolException("Unexpected content type: " + contentType);
        }

        StringBuilder sb = new StringBuilder();
        Map result = null;

        try {
            InputStream in = (conn.getResponseCode() < 400 ? conn.getInputStream() : conn.getErrorStream());
            if (in != null) {
                String response = readStream(in);

                result = JsonUtil.parseJson(response);
                LOG.debug("Result JSON: {}", sb);
            }

        } catch (JoseException ex) {
            throw new AcmeProtocolException("Failed to parse response: " + sb, ex);
        }

        return result;
    }

    private String readStream(InputStream in) throws IOException {
        StringBuilder sb = new StringBuilder();

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, "utf-8"))) {
            String line = reader.readLine();

            while (line != null) {
                sb.append(line);
                line = reader.readLine();
            }
        }

        return sb.toString();
    }

    @Override
    public X509Certificate readCertificate() throws IOException {
        assertConnectionIsOpen();

        String contentType = conn.getHeaderField("Content-Type");
        if (!("application/pkix-cert".equals(contentType))) {
            throw new AcmeProtocolException("Unexpected content type: " + contentType);
        }

        try (InputStream in = conn.getInputStream()) {
            CertificateFactory cf = CertificateFactory.getInstance("X.509");
            return (X509Certificate) cf.generateCertificate(in);
        } catch (CertificateException ex) {
            throw new AcmeProtocolException("Failed to read certificate", ex);
        }
    }

    @Override
    public Map readDirectory() throws IOException {
        assertConnectionIsOpen();

        String contentType = conn.getHeaderField("Content-Type");
        if (!("application/json".equals(contentType))) {
            throw new AcmeProtocolException("Unexpected content type: " + contentType);
        }

        EnumMap resourceMap = new EnumMap<>(Resource.class);
        String response = "";

        try {
            response = readStream(conn.getInputStream());

            Map result = JsonUtil.parseJson(response);
            for (Map.Entry entry : result.entrySet()) {
                Resource res = Resource.parse(entry.getKey());
                if (res != null) {
                    URI uri = new URI(entry.getValue().toString());
                    resourceMap.put(res, uri);
                }
            }

            LOG.debug("Resource directory: {}", resourceMap);
        } catch (JoseException | URISyntaxException ex) {
            throw new AcmeProtocolException("Failed to read directory: " + response, ex);
        }

        return resourceMap;
    }

    @Override
    public void updateSession(Session session) {
        assertConnectionIsOpen();

        String nonceHeader = conn.getHeaderField("Replay-Nonce");
        if (nonceHeader == null || nonceHeader.trim().isEmpty()) {
            return;
        }

        if (!BASE64URL_PATTERN.matcher(nonceHeader).matches()) {
            throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
        }

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

        session.setNonce(Base64Url.decode(nonceHeader));
    }

    @Override
    public URI getLocation() {
        assertConnectionIsOpen();

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

        try {
            LOG.debug("Location: {}", location);
            return new URI(location);
        } catch (URISyntaxException ex) {
            throw new AcmeProtocolException("Bad Location header: " + location);
        }
    }

    @Override
    public URI getLink(String relation) {
        assertConnectionIsOpen();

        List links = conn.getHeaderFields().get("Link");
        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()) {
                    try {
                        String location = m.group(1);
                        LOG.debug("Link: {} -> {}", relation, location);
                        return new URI(location);
                    } catch (URISyntaxException ex) {
                        throw new AcmeProtocolException("Bad '" + relation + "' Link header: " + link);
                    }
                }
            }
        }

        return null;
    }

    @Override
    public void throwAcmeException() throws AcmeException, IOException {
        assertConnectionIsOpen();

        if ("application/problem+json".equals(conn.getHeaderField("Content-Type"))) {
            Map map = readJsonResponse();
            String type = (String) map.get("type");
            String detail = (String) map.get("detail");

            if (detail == null) {
                detail = "general problem";
            }

            if (type == null) {
                throw new AcmeException(detail);
            }

            switch (type) {
                case "urn:acme:error:unauthorized":
                case "urn:ietf:params:acme:error:unauthorized":
                    throw new AcmeUnauthorizedException(type, detail);

                case "urn:acme:error:rateLimited":
                case "urn:ietf:params:acme:error:rateLimited":
                    throw new AcmeRateLimitExceededException(type, detail);

                default:
                    throw new AcmeServerException(type, detail);
            }
        } else {
            throw new AcmeException("HTTP " + conn.getResponseCode() + ": "
                + conn.getResponseMessage());
        }
    }

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

    /**
     * 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()) {
            Map> headers = conn.getHeaderFields();
            for (String key : headers.keySet()) {
                for (String value : headers.get(key)) {
                    LOG.debug("HEADER {}: {}", key, value);
                }
            }
        }
    }

}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy