001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2015 Richard "Shred" Körber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j.connector;
015
016import static java.util.stream.Collectors.toList;
017import static org.shredzone.acme4j.toolbox.AcmeUtils.keyAlgorithm;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.OutputStream;
022import java.net.HttpURLConnection;
023import java.net.MalformedURLException;
024import java.net.URI;
025import java.net.URISyntaxException;
026import java.net.URL;
027import java.security.KeyPair;
028import java.security.cert.CertificateException;
029import java.security.cert.CertificateFactory;
030import java.security.cert.X509Certificate;
031import java.time.Instant;
032import java.util.ArrayList;
033import java.util.Arrays;
034import java.util.Collection;
035import java.util.List;
036import java.util.Objects;
037import java.util.Optional;
038import java.util.OptionalInt;
039import java.util.regex.Matcher;
040import java.util.regex.Pattern;
041
042import org.jose4j.base64url.Base64Url;
043import org.jose4j.jwk.PublicJsonWebKey;
044import org.jose4j.jws.JsonWebSignature;
045import org.jose4j.lang.JoseException;
046import org.shredzone.acme4j.Session;
047import org.shredzone.acme4j.exception.AcmeAgreementRequiredException;
048import org.shredzone.acme4j.exception.AcmeConflictException;
049import org.shredzone.acme4j.exception.AcmeException;
050import org.shredzone.acme4j.exception.AcmeNetworkException;
051import org.shredzone.acme4j.exception.AcmeProtocolException;
052import org.shredzone.acme4j.exception.AcmeRateLimitExceededException;
053import org.shredzone.acme4j.exception.AcmeRetryAfterException;
054import org.shredzone.acme4j.exception.AcmeServerException;
055import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
056import org.shredzone.acme4j.toolbox.AcmeUtils;
057import org.shredzone.acme4j.toolbox.JSON;
058import org.shredzone.acme4j.toolbox.JSONBuilder;
059import org.slf4j.Logger;
060import org.slf4j.LoggerFactory;
061
062/**
063 * Default implementation of {@link Connection}.
064 */
065public class DefaultConnection implements Connection {
066    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);
067
068    private static final String ACCEPT_HEADER = "Accept";
069    private static final String ACCEPT_CHARSET_HEADER = "Accept-Charset";
070    private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
071    private static final String CONTENT_TYPE_HEADER = "Content-Type";
072    private static final String DATE_HEADER = "Date";
073    private static final String LINK_HEADER = "Link";
074    private static final String LOCATION_HEADER = "Location";
075    private static final String REPLAY_NONCE_HEADER = "Replay-Nonce";
076    private static final String RETRY_AFTER_HEADER = "Retry-After";
077    private static final String DEFAULT_CHARSET = "utf-8";
078
079    private static final Pattern BASE64URL_PATTERN = Pattern.compile("[0-9A-Za-z_-]+");
080
081    protected final HttpConnector httpConnector;
082    protected HttpURLConnection conn;
083
084    /**
085     * Creates a new {@link DefaultConnection}.
086     *
087     * @param httpConnector
088     *            {@link HttpConnector} to be used for HTTP connections
089     */
090    public DefaultConnection(HttpConnector httpConnector) {
091        this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector");
092    }
093
094    @Override
095    public void sendRequest(URL url, Session session) throws AcmeException {
096        Objects.requireNonNull(url, "url");
097        Objects.requireNonNull(session, "session");
098        assertConnectionIsClosed();
099
100        LOG.debug("GET {}", url);
101
102        try {
103            conn = httpConnector.openConnection(url);
104            conn.setRequestMethod("GET");
105            conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET);
106            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
107            conn.setDoOutput(false);
108
109            conn.connect();
110
111            logHeaders();
112        } catch (IOException ex) {
113            throw new AcmeNetworkException(ex);
114        }
115    }
116
117    @Override
118    public void sendSignedRequest(URL url, JSONBuilder claims, Session session) throws AcmeException {
119        Objects.requireNonNull(url, "url");
120        Objects.requireNonNull(claims, "claims");
121        Objects.requireNonNull(session, "session");
122        assertConnectionIsClosed();
123
124        try {
125            KeyPair keypair = session.getKeyPair();
126
127            if (session.getNonce() == null) {
128                LOG.debug("Getting initial nonce, HEAD {}", url);
129                conn = httpConnector.openConnection(url);
130                conn.setRequestMethod("HEAD");
131                conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
132                conn.connect();
133                updateSession(session);
134                conn = null;
135            }
136
137            if (session.getNonce() == null) {
138                throw new AcmeProtocolException("Server did not provide a nonce");
139            }
140
141            LOG.debug("POST {} with claims: {}", url, claims);
142
143            conn = httpConnector.openConnection(url);
144            conn.setRequestMethod("POST");
145            conn.setRequestProperty(ACCEPT_HEADER, "application/json");
146            conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET);
147            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
148            conn.setRequestProperty(CONTENT_TYPE_HEADER, "application/jose+json");
149            conn.setDoOutput(true);
150
151            final PublicJsonWebKey jwk = PublicJsonWebKey.Factory.newPublicJwk(keypair.getPublic());
152
153            JsonWebSignature jws = new JsonWebSignature();
154            jws.setPayload(claims.toString());
155            jws.getHeaders().setObjectHeaderValue("nonce", Base64Url.encode(session.getNonce()));
156            jws.getHeaders().setObjectHeaderValue("url", url);
157            jws.getHeaders().setJwkHeaderValue("jwk", jwk);
158            jws.setAlgorithmHeaderValue(keyAlgorithm(jwk));
159            jws.setKey(keypair.getPrivate());
160            jws.sign();
161
162            JSONBuilder jb = new JSONBuilder();
163            jb.put("protected", jws.getHeaders().getEncodedHeader());
164            jb.put("payload", jws.getEncodedPayload());
165            jb.put("signature", jws.getEncodedSignature());
166            byte[] outputData = jb.toString().getBytes(DEFAULT_CHARSET);
167
168            conn.setFixedLengthStreamingMode(outputData.length);
169            conn.connect();
170
171            try (OutputStream out = conn.getOutputStream()) {
172                out.write(outputData);
173            }
174
175            logHeaders();
176
177            updateSession(session);
178        } catch (IOException ex) {
179            throw new AcmeNetworkException(ex);
180        } catch (JoseException ex) {
181            throw new AcmeProtocolException("Failed to generate a JSON request", ex);
182        }
183    }
184
185    @Override
186    public int accept(int... httpStatus) throws AcmeException {
187        assertConnectionIsOpen();
188
189        try {
190            int rc = conn.getResponseCode();
191            OptionalInt match = Arrays.stream(httpStatus).filter(s -> s == rc).findFirst();
192            if (match.isPresent()) {
193                return match.getAsInt();
194            }
195
196            String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
197            if (!"application/problem+json".equals(contentType)) {
198                throw new AcmeException("HTTP " + rc + ": " + conn.getResponseMessage());
199            }
200
201            JSON json = readJsonResponse();
202
203            if (rc == HttpURLConnection.HTTP_CONFLICT) {
204                throw new AcmeConflictException(json.get("detail").asString(), getLocation());
205            }
206
207            throw createAcmeException(json);
208        } catch (IOException ex) {
209            throw new AcmeNetworkException(ex);
210        }
211    }
212
213    @Override
214    public JSON readJsonResponse() throws AcmeException {
215        assertConnectionIsOpen();
216
217        String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
218        if (!("application/json".equals(contentType)
219                    || "application/problem+json".equals(contentType))) {
220            throw new AcmeProtocolException("Unexpected content type: " + contentType);
221        }
222
223        JSON result = null;
224
225        try {
226            InputStream in =
227                    conn.getResponseCode() < 400 ? conn.getInputStream() : conn.getErrorStream();
228            if (in != null) {
229                result = JSON.parse(in);
230                LOG.debug("Result JSON: {}", result);
231            }
232        } catch (IOException ex) {
233            throw new AcmeNetworkException(ex);
234        }
235
236        return result;
237    }
238
239    @Override
240    public X509Certificate readCertificate() throws AcmeException {
241        assertConnectionIsOpen();
242
243        String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
244        if (!("application/pkix-cert".equals(contentType))) {
245            throw new AcmeProtocolException("Unexpected content type: " + contentType);
246        }
247
248        try (InputStream in = conn.getInputStream()) {
249            CertificateFactory cf = CertificateFactory.getInstance("X.509");
250            return (X509Certificate) cf.generateCertificate(in);
251        } catch (IOException ex) {
252            throw new AcmeNetworkException(ex);
253        } catch (CertificateException ex) {
254            throw new AcmeProtocolException("Failed to read certificate", ex);
255        }
256    }
257
258    @Override
259    public void handleRetryAfter(String message) throws AcmeException {
260        assertConnectionIsOpen();
261
262        try {
263            if (conn.getResponseCode() == HttpURLConnection.HTTP_ACCEPTED) {
264                Optional<Instant> retryAfter = getRetryAfterHeader();
265                if (retryAfter.isPresent()) {
266                    throw new AcmeRetryAfterException(message, retryAfter.get());
267                }
268            }
269        } catch (IOException ex) {
270            throw new AcmeNetworkException(ex);
271        }
272    }
273
274    @Override
275    public void updateSession(Session session) {
276        assertConnectionIsOpen();
277
278        String nonceHeader = conn.getHeaderField(REPLAY_NONCE_HEADER);
279        if (nonceHeader == null || nonceHeader.trim().isEmpty()) {
280            return;
281        }
282
283        if (!BASE64URL_PATTERN.matcher(nonceHeader).matches()) {
284            throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
285        }
286
287        LOG.debug("Replay Nonce: {}", nonceHeader);
288
289        session.setNonce(Base64Url.decode(nonceHeader));
290    }
291
292    @Override
293    public URL getLocation() {
294        assertConnectionIsOpen();
295
296        String location = conn.getHeaderField(LOCATION_HEADER);
297        if (location == null) {
298            return null;
299        }
300
301        LOG.debug("Location: {}", location);
302        return resolveRelative(location);
303    }
304
305    @Override
306    public URL getLink(String relation) {
307        return getLinks(relation).stream()
308                .findFirst()
309                .map(this::resolveRelative)
310                .orElse(null);
311    }
312
313    @Override
314    public URI getLinkAsURI(String relation) {
315        return getLinks(relation).stream()
316                .findFirst()
317                .map(this::resolveRelativeAsURI)
318                .orElse(null);
319    }
320
321    @Override
322    public void close() {
323        conn = null;
324    }
325
326    /**
327     * Returns the link headers of the given relation. The link URIs are unresolved.
328     *
329     * @param relation
330     *            Relation name
331     * @return Link headers
332     */
333    private Collection<String> getLinks(String relation) {
334        assertConnectionIsOpen();
335
336        List<String> result = new ArrayList<>();
337
338        List<String> links = conn.getHeaderFields().get(LINK_HEADER);
339        if (links != null) {
340            Pattern p = Pattern.compile("<(.*?)>\\s*;\\s*rel=\"?"+ Pattern.quote(relation) + "\"?");
341            for (String link : links) {
342                Matcher m = p.matcher(link);
343                if (m.matches()) {
344                    String location = m.group(1);
345                    LOG.debug("Link: {} -> {}", relation, location);
346                    result.add(location);
347                }
348            }
349        }
350
351        return result;
352    }
353
354    /**
355     * Gets the instant sent with the Retry-After header.
356     */
357    private Optional<Instant> getRetryAfterHeader() {
358        // See RFC 2616 section 14.37
359        String header = conn.getHeaderField(RETRY_AFTER_HEADER);
360        if (header != null) {
361            try {
362                // delta-seconds
363                if (header.matches("^\\d+$")) {
364                    int delta = Integer.parseInt(header);
365                    long date = conn.getHeaderFieldDate(DATE_HEADER, System.currentTimeMillis());
366                    return Optional.of(Instant.ofEpochMilli(date).plusSeconds(delta));
367                }
368
369                // HTTP-date
370                long date = conn.getHeaderFieldDate(RETRY_AFTER_HEADER, 0L);
371                if (date != 0) {
372                    return Optional.of(Instant.ofEpochMilli(date));
373                }
374            } catch (Exception ex) {
375                throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
376            }
377        }
378
379        return Optional.empty();
380    }
381
382    /**
383     * Handles a problem by throwing an exception. If a JSON problem was returned, an
384     * {@link AcmeServerException} or subtype will be thrown. Otherwise a generic
385     * {@link AcmeException} is thrown.
386     */
387    private AcmeException createAcmeException(JSON json) {
388        String type = json.get("type").asString();
389        String detail = json.get("detail").asString();
390        String error = AcmeUtils.stripErrorPrefix(type);
391
392        if (type == null) {
393            return new AcmeException(detail);
394        }
395
396        if ("unauthorized".equals(error)) {
397            return new AcmeUnauthorizedException(type, detail);
398        }
399
400        if ("agreementRequired".equals(error)) {
401            URI instance = resolveRelativeAsURI(json.get("instance").asString());
402            URI tos = getLinkAsURI("terms-of-service");
403            return new AcmeAgreementRequiredException(type, detail, tos, instance);
404        }
405
406        if ("rateLimited".equals(error)) {
407            Optional<Instant> retryAfter = getRetryAfterHeader();
408            Collection<URI> rateLimits = getLinks("rate-limit").stream()
409                    .map(this::resolveRelativeAsURI)
410                    .collect(toList());
411            return new AcmeRateLimitExceededException(type, detail, retryAfter.orElse(null), rateLimits);
412        }
413
414        return new AcmeServerException(type, detail);
415    }
416
417    /**
418     * Asserts that the connection is currently open. Throws an exception if not.
419     */
420    private void assertConnectionIsOpen() {
421        if (conn == null) {
422            throw new IllegalStateException("Not connected.");
423        }
424    }
425
426    /**
427     * Asserts that the connection is currently closed. Throws an exception if not.
428     */
429    private void assertConnectionIsClosed() {
430        if (conn != null) {
431            throw new IllegalStateException("Previous connection is not closed.");
432        }
433    }
434
435    /**
436     * Log all HTTP headers in debug mode.
437     */
438    private void logHeaders() {
439        if (!LOG.isDebugEnabled()) {
440            return;
441        }
442
443        conn.getHeaderFields().forEach((key, headers) ->
444            headers.forEach(value ->
445                LOG.debug("HEADER {}: {}", key, value)
446            )
447        );
448    }
449
450    /**
451     * Resolves a relative link against the connection's last URL.
452     *
453     * @param link
454     *            Link to resolve. Absolute links are just converted to an URL. May be
455     *            {@code null}.
456     * @return Absolute URL of the given link, or {@code null} if the link was
457     *         {@code null}.
458     */
459    private URL resolveRelative(String link) {
460        if (link == null) {
461            return null;
462        }
463
464        assertConnectionIsOpen();
465        try {
466            return new URL(conn.getURL(), link);
467        } catch (MalformedURLException ex) {
468            throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex);
469        }
470    }
471
472    /**
473     * Resolves a relative link against the connection's last URL.
474     *
475     * @param link
476     *            Link to resolve. Absolute links are just converted to an URI. May be
477     *            {@code null}.
478     * @return Absolute URI of the given link, or {@code null} if the link was
479     *         {@code null}.
480     */
481    private URI resolveRelativeAsURI(String link) {
482        if (link == null) {
483            return null;
484        }
485
486        assertConnectionIsOpen();
487        try {
488            return conn.getURL().toURI().resolve(link);
489        } catch (URISyntaxException ex) {
490            throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex);
491        }
492    }
493
494}