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.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
017import static java.util.stream.Collectors.toList;
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.nio.charset.StandardCharsets;
028import java.security.KeyPair;
029import java.security.cert.CertificateException;
030import java.security.cert.CertificateFactory;
031import java.security.cert.X509Certificate;
032import java.time.Instant;
033import java.time.ZoneId;
034import java.time.ZonedDateTime;
035import java.time.format.DateTimeParseException;
036import java.util.ArrayList;
037import java.util.Collection;
038import java.util.List;
039import java.util.Objects;
040import java.util.Optional;
041import java.util.regex.Matcher;
042import java.util.regex.Pattern;
043
044import edu.umd.cs.findbugs.annotations.Nullable;
045import org.shredzone.acme4j.Login;
046import org.shredzone.acme4j.Problem;
047import org.shredzone.acme4j.Session;
048import org.shredzone.acme4j.exception.AcmeException;
049import org.shredzone.acme4j.exception.AcmeNetworkException;
050import org.shredzone.acme4j.exception.AcmeProtocolException;
051import org.shredzone.acme4j.exception.AcmeRateLimitedException;
052import org.shredzone.acme4j.exception.AcmeRetryAfterException;
053import org.shredzone.acme4j.exception.AcmeServerException;
054import org.shredzone.acme4j.exception.AcmeUnauthorizedException;
055import org.shredzone.acme4j.exception.AcmeUserActionRequiredException;
056import org.shredzone.acme4j.toolbox.AcmeUtils;
057import org.shredzone.acme4j.toolbox.JSON;
058import org.shredzone.acme4j.toolbox.JSONBuilder;
059import org.shredzone.acme4j.toolbox.JoseUtils;
060import org.slf4j.Logger;
061import org.slf4j.LoggerFactory;
062
063/**
064 * Default implementation of {@link Connection}.
065 */
066public class DefaultConnection implements Connection {
067    private static final Logger LOG = LoggerFactory.getLogger(DefaultConnection.class);
068
069    private static final String ACCEPT_HEADER = "Accept";
070    private static final String ACCEPT_CHARSET_HEADER = "Accept-Charset";
071    private static final String ACCEPT_LANGUAGE_HEADER = "Accept-Language";
072    private static final String CACHE_CONTROL_HEADER = "Cache-Control";
073    private static final String CONTENT_TYPE_HEADER = "Content-Type";
074    private static final String DATE_HEADER = "Date";
075    private static final String EXPIRES_HEADER = "Expires";
076    private static final String IF_MODIFIED_SINCE_HEADER = "If-Modified-Since";
077    private static final String LAST_MODIFIED_HEADER = "Last-Modified";
078    private static final String LINK_HEADER = "Link";
079    private static final String LOCATION_HEADER = "Location";
080    private static final String REPLAY_NONCE_HEADER = "Replay-Nonce";
081    private static final String RETRY_AFTER_HEADER = "Retry-After";
082    private static final String DEFAULT_CHARSET = "utf-8";
083    private static final String MIME_JSON = "application/json";
084    private static final String MIME_JSON_PROBLEM = "application/problem+json";
085    private static final String MIME_CERTIFICATE_CHAIN = "application/pem-certificate-chain";
086
087    private static final URI BAD_NONCE_ERROR = URI.create("urn:ietf:params:acme:error:badNonce");
088    private static final int MAX_ATTEMPTS = 10;
089
090    private static final Pattern NO_CACHE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*no-(?:cache|store)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
091    private static final Pattern MAX_AGE_PATTERN = Pattern.compile("(?:^|.*?,)\\s*max-age=(\\d+)\\s*(?:,.*|$)", Pattern.CASE_INSENSITIVE);
092
093    protected final HttpConnector httpConnector;
094    protected @Nullable  HttpURLConnection conn;
095
096    /**
097     * Creates a new {@link DefaultConnection}.
098     *
099     * @param httpConnector
100     *            {@link HttpConnector} to be used for HTTP connections
101     */
102    public DefaultConnection(HttpConnector httpConnector) {
103        this.httpConnector = Objects.requireNonNull(httpConnector, "httpConnector");
104    }
105
106    @Override
107    public void resetNonce(Session session) throws AcmeException {
108        assertConnectionIsClosed();
109
110        try {
111            session.setNonce(null);
112
113            URL newNonceUrl = session.resourceUrl(Resource.NEW_NONCE);
114
115            LOG.debug("HEAD {}", newNonceUrl);
116
117            conn = httpConnector.openConnection(newNonceUrl, session.networkSettings());
118            conn.setRequestMethod("HEAD");
119            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
120            conn.connect();
121
122            logHeaders();
123
124            int rc = conn.getResponseCode();
125            if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_NO_CONTENT) {
126                throwAcmeException();
127            }
128
129            String nonce = getNonce();
130            if (nonce == null) {
131                throw new AcmeProtocolException("Server did not provide a nonce");
132            }
133            session.setNonce(nonce);
134        } catch (IOException ex) {
135            throw new AcmeNetworkException(ex);
136        } finally {
137            conn = null;
138        }
139    }
140
141    @Override
142    public int sendRequest(URL url, Session session, @Nullable ZonedDateTime ifModifiedSince)
143            throws AcmeException {
144        return sendRequest(url, session, MIME_JSON, ifModifiedSince);
145    }
146
147    @Override
148    public int sendCertificateRequest(URL url, Login login) throws AcmeException {
149        return sendSignedRequest(url, null, login.getSession(), login.getKeyPair(),
150                login.getAccountLocation(), MIME_CERTIFICATE_CHAIN);
151    }
152
153    @Override
154    public int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException {
155        return sendSignedRequest(url, null, login.getSession(), login.getKeyPair(),
156                login.getAccountLocation(), MIME_JSON);
157    }
158
159    @Override
160    public int sendSignedRequest(URL url, JSONBuilder claims, Login login) throws AcmeException {
161        return sendSignedRequest(url, claims, login.getSession(), login.getKeyPair(),
162                login.getAccountLocation(), MIME_JSON);
163    }
164
165    @Override
166    public int sendSignedRequest(URL url, JSONBuilder claims, Session session, KeyPair keypair)
167                throws AcmeException {
168        return sendSignedRequest(url, claims, session, keypair, null, MIME_JSON);
169    }
170
171    @Override
172    public JSON readJsonResponse() throws AcmeException {
173        assertConnectionIsOpen();
174
175        if (conn.getContentLength() == 0) {
176            throw new AcmeProtocolException("Empty response");
177        }
178
179        String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
180        if (!(MIME_JSON.equals(contentType) || MIME_JSON_PROBLEM.equals(contentType))) {
181            throw new AcmeProtocolException("Unexpected content type: " + contentType);
182        }
183
184        try {
185            InputStream in =
186                    conn.getResponseCode() < 400 ? conn.getInputStream() : conn.getErrorStream();
187            if (in == null) {
188                throw new AcmeProtocolException("JSON response is empty");
189            }
190
191            JSON result = JSON.parse(in);
192            LOG.debug("Result JSON: {}", result);
193            return result;
194        } catch (IOException ex) {
195            throw new AcmeNetworkException(ex);
196        }
197    }
198
199    @Override
200    public List<X509Certificate> readCertificates() throws AcmeException {
201        assertConnectionIsOpen();
202
203        String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
204        if (!(MIME_CERTIFICATE_CHAIN.equals(contentType))) {
205            throw new AcmeProtocolException("Unexpected content type: " + contentType);
206        }
207
208        try (InputStream in = new TrimmingInputStream(conn.getInputStream())) {
209            CertificateFactory cf = CertificateFactory.getInstance("X.509");
210            return cf.generateCertificates(in).stream()
211                    .map(c -> (X509Certificate) c)
212                    .collect(toList());
213        } catch (IOException ex) {
214            throw new AcmeNetworkException(ex);
215        } catch (CertificateException ex) {
216            throw new AcmeProtocolException("Failed to read certificate", ex);
217        }
218    }
219
220    @Override
221    public void handleRetryAfter(String message) throws AcmeException {
222        assertConnectionIsOpen();
223
224        Optional<Instant> retryAfter = getRetryAfterHeader();
225        if (retryAfter.isPresent()) {
226            throw new AcmeRetryAfterException(message, retryAfter.get());
227        }
228    }
229
230    @Override
231    @Nullable
232    public String getNonce() {
233        assertConnectionIsOpen();
234
235        String nonceHeader = conn.getHeaderField(REPLAY_NONCE_HEADER);
236        if (nonceHeader == null || nonceHeader.trim().isEmpty()) {
237            return null;
238        }
239
240        if (!AcmeUtils.isValidBase64Url(nonceHeader)) {
241            throw new AcmeProtocolException("Invalid replay nonce: " + nonceHeader);
242        }
243
244        LOG.debug("Replay Nonce: {}", nonceHeader);
245
246        return nonceHeader;
247    }
248
249    @Override
250    @Nullable
251    public URL getLocation() {
252        assertConnectionIsOpen();
253
254        String location = conn.getHeaderField(LOCATION_HEADER);
255        if (location == null) {
256            return null;
257        }
258
259        LOG.debug("Location: {}", location);
260        return resolveRelative(location);
261    }
262
263    @Override
264    public Optional<ZonedDateTime> getLastModified() {
265        assertConnectionIsOpen();
266
267        String header = conn.getHeaderField(LAST_MODIFIED_HEADER);
268        if (header != null) {
269            try {
270                return Optional.of(ZonedDateTime.parse(header, RFC_1123_DATE_TIME));
271            } catch (DateTimeParseException ex) {
272                LOG.debug("Ignored invalid Last-Modified date: {}", header, ex);
273            }
274        }
275        return Optional.empty();
276    }
277
278    @Override
279    public Optional<ZonedDateTime> getExpiration() {
280        assertConnectionIsOpen();
281
282        String cacheHeader = conn.getHeaderField(CACHE_CONTROL_HEADER);
283        if (cacheHeader != null) {
284            if (NO_CACHE_PATTERN.matcher(cacheHeader).matches()) {
285                return Optional.empty();
286            }
287
288            Matcher m = MAX_AGE_PATTERN.matcher(cacheHeader);
289            if (m.matches()) {
290                int maxAge = Integer.parseInt(m.group(1));
291                if (maxAge == 0) {
292                    return Optional.empty();
293                }
294
295                return Optional.of(ZonedDateTime.now(ZoneId.of("UTC")).plusSeconds(maxAge));
296            }
297        }
298
299        String expiresHeader = conn.getHeaderField(EXPIRES_HEADER);
300        if (expiresHeader != null) {
301            try {
302                return Optional.of(ZonedDateTime.parse(expiresHeader, RFC_1123_DATE_TIME));
303            } catch (DateTimeParseException ex) {
304                LOG.debug("Ignored invalid Expires date: {}", expiresHeader, ex);
305            }
306        }
307
308        return Optional.empty();
309    }
310
311    @Override
312    public Collection<URL> getLinks(String relation) {
313        return collectLinks(relation).stream()
314                .map(this::resolveRelative)
315                .collect(toList());
316    }
317
318    @Override
319    public void close() {
320        conn = null;
321    }
322
323    /**
324     * Sends an unsigned GET request.
325     *
326     * @param url
327     *            {@link URL} to send the request to.
328     * @param session
329     *            {@link Session} instance to be used for signing and tracking
330     * @param accept
331     *            Accept header
332     * @param ifModifiedSince
333     *            Set an If-Modified-Since header with the given date. If set, an
334     *            NOT_MODIFIED response is accepted as valid.
335     * @return HTTP 200 class status that was returned
336     */
337    protected int sendRequest(URL url, Session session, String accept,
338              @Nullable ZonedDateTime ifModifiedSince) throws AcmeException {
339        Objects.requireNonNull(url, "url");
340        Objects.requireNonNull(session, "session");
341        Objects.requireNonNull(accept, "accept");
342        assertConnectionIsClosed();
343
344        LOG.debug("GET {}", url);
345
346        try {
347            conn = httpConnector.openConnection(url, session.networkSettings());
348            conn.setRequestMethod("GET");
349            conn.setRequestProperty(ACCEPT_HEADER, accept);
350            conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET);
351            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
352            if (ifModifiedSince != null) {
353                conn.setRequestProperty(IF_MODIFIED_SINCE_HEADER, ifModifiedSince.format(RFC_1123_DATE_TIME));
354            }
355            conn.setDoOutput(false);
356
357            conn.connect();
358
359            logHeaders();
360
361            String nonce = getNonce();
362            if (nonce != null) {
363                session.setNonce(nonce);
364            }
365
366            int rc = conn.getResponseCode();
367            if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_CREATED
368                && (rc != HttpURLConnection.HTTP_NOT_MODIFIED || ifModifiedSince == null)) {
369                throwAcmeException();
370            }
371            return rc;
372        } catch (IOException ex) {
373            throw new AcmeNetworkException(ex);
374        }
375    }
376
377    /**
378     * Sends a signed POST request.
379     *
380     * @param url
381     *            {@link URL} to send the request to.
382     * @param claims
383     *            {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
384     *            request.
385     * @param session
386     *            {@link Session} instance to be used for signing and tracking
387     * @param keypair
388     *            {@link KeyPair} to be used for signing
389     * @param accountLocation
390     *            If set, the account location is set as "kid" header. If {@code null},
391     *            the public key is set as "jwk" header.
392     * @param accept
393     *            Accept header
394     * @return HTTP 200 class status that was returned
395     */
396    protected int sendSignedRequest(URL url, @Nullable JSONBuilder claims, Session session,
397                KeyPair keypair, @Nullable URL accountLocation, String accept) throws AcmeException {
398        Objects.requireNonNull(url, "url");
399        Objects.requireNonNull(session, "session");
400        Objects.requireNonNull(keypair, "keypair");
401        Objects.requireNonNull(accept, "accept");
402        assertConnectionIsClosed();
403
404        int attempt = 1;
405        while (true) {
406            try {
407                return performRequest(url, claims, session, keypair, accountLocation, accept);
408            } catch (AcmeServerException ex) {
409                if (!BAD_NONCE_ERROR.equals(ex.getType())) {
410                    throw ex;
411                }
412                if (attempt == MAX_ATTEMPTS) {
413                    throw ex;
414                }
415                LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS);
416                attempt++;
417            }
418        }
419    }
420
421    /**
422     * Performs the POST request.
423     *
424     * @param url
425     *            {@link URL} to send the request to.
426     * @param claims
427     *            {@link JSONBuilder} containing claims. {@code null} for POST-as-GET
428     *            request.
429     * @param session
430     *            {@link Session} instance to be used for signing and tracking
431     * @param keypair
432     *            {@link KeyPair} to be used for signing
433     * @param accountLocation
434     *            If set, the account location is set as "kid" header. If {@code null},
435     *            the public key is set as "jwk" header.
436     * @param accept
437     *            Accept header
438     * @return HTTP 200 class status that was returned
439     */
440    private int performRequest(URL url, @Nullable JSONBuilder claims, Session session,
441                KeyPair keypair, @Nullable URL accountLocation, String accept)
442                throws AcmeException {
443        try {
444            if (session.getNonce() == null) {
445                resetNonce(session);
446            }
447
448            conn = httpConnector.openConnection(url, session.networkSettings());
449            conn.setRequestMethod("POST");
450            conn.setRequestProperty(ACCEPT_HEADER, accept);
451            conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET);
452            conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag());
453            conn.setRequestProperty(CONTENT_TYPE_HEADER, "application/jose+json");
454            conn.setDoOutput(true);
455
456            JSONBuilder jose = JoseUtils.createJoseRequest(
457                    url,
458                    keypair,
459                    claims,
460                    session.getNonce(),
461                    accountLocation != null ? accountLocation.toString() : null
462            );
463
464            byte[] outputData = jose.toString().getBytes(StandardCharsets.UTF_8);
465
466            conn.setFixedLengthStreamingMode(outputData.length);
467            conn.connect();
468
469            try (OutputStream out = conn.getOutputStream()) {
470                out.write(outputData);
471            }
472
473            logHeaders();
474
475            session.setNonce(getNonce());
476
477            int rc = conn.getResponseCode();
478            if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_CREATED) {
479                throwAcmeException();
480            }
481            return rc;
482        } catch (IOException ex) {
483            throw new AcmeNetworkException(ex);
484        }
485    }
486
487    /**
488     * Gets the instant sent with the Retry-After header.
489     */
490    private Optional<Instant> getRetryAfterHeader() {
491        // See RFC 2616 section 14.37
492        String header = conn.getHeaderField(RETRY_AFTER_HEADER);
493        if (header != null) {
494            try {
495                // delta-seconds
496                if (header.matches("^\\d+$")) {
497                    int delta = Integer.parseInt(header);
498                    long date = conn.getHeaderFieldDate(DATE_HEADER, System.currentTimeMillis());
499                    return Optional.of(Instant.ofEpochMilli(date).plusSeconds(delta));
500                }
501
502                // HTTP-date
503                long date = conn.getHeaderFieldDate(RETRY_AFTER_HEADER, 0L);
504                if (date != 0) {
505                    return Optional.of(Instant.ofEpochMilli(date));
506                }
507            } catch (Exception ex) {
508                throw new AcmeProtocolException("Bad retry-after header value: " + header, ex);
509            }
510        }
511
512        return Optional.empty();
513    }
514
515    /**
516     * Throws an {@link AcmeException}. This method throws an exception that tries to
517     * explain the error as precisely as possible.
518     */
519    private void throwAcmeException() throws AcmeException {
520        try {
521            String contentType = AcmeUtils.getContentType(conn.getHeaderField(CONTENT_TYPE_HEADER));
522            if (!MIME_JSON_PROBLEM.equals(contentType)) {
523                throw new AcmeException("HTTP " + conn.getResponseCode() + ": " + conn.getResponseMessage());
524            }
525
526            Problem problem = new Problem(readJsonResponse(), conn.getURL());
527
528            String error = AcmeUtils.stripErrorPrefix(problem.getType().toString());
529
530            if ("unauthorized".equals(error)) {
531                throw new AcmeUnauthorizedException(problem);
532            }
533
534            if ("userActionRequired".equals(error)) {
535                URI tos = collectLinks("terms-of-service").stream()
536                        .findFirst()
537                        .map(this::resolveUri)
538                        .orElse(null);
539                throw new AcmeUserActionRequiredException(problem, tos);
540            }
541
542            if ("rateLimited".equals(error)) {
543                Optional<Instant> retryAfter = getRetryAfterHeader();
544                Collection<URL> rateLimits = getLinks("help");
545                throw new AcmeRateLimitedException(problem, retryAfter.orElse(null), rateLimits);
546            }
547
548            throw new AcmeServerException(problem);
549        } catch (IOException ex) {
550            throw new AcmeNetworkException(ex);
551        }
552    }
553
554    /**
555     * Asserts that the connection is currently open. Throws an exception if not.
556     */
557    private void assertConnectionIsOpen() {
558        if (conn == null) {
559            throw new IllegalStateException("Not connected.");
560        }
561    }
562
563    /**
564     * Asserts that the connection is currently closed. Throws an exception if not.
565     */
566    private void assertConnectionIsClosed() {
567        if (conn != null) {
568            throw new IllegalStateException("Previous connection is not closed.");
569        }
570    }
571
572    /**
573     * Log all HTTP headers in debug mode.
574     */
575    private void logHeaders() {
576        if (!LOG.isDebugEnabled()) {
577            return;
578        }
579
580        conn.getHeaderFields().forEach((key, headers) ->
581            headers.forEach(value ->
582                LOG.debug("HEADER {}: {}", key, value)
583            )
584        );
585    }
586
587    /**
588     * Collects links of the given relation.
589     *
590     * @param relation
591     *            Link relation
592     * @return Collection of links, unconverted
593     */
594    private Collection<String> collectLinks(String relation) {
595        assertConnectionIsOpen();
596
597        List<String> result = new ArrayList<>();
598
599        List<String> links = conn.getHeaderFields().get(LINK_HEADER);
600        if (links != null) {
601            Pattern p = Pattern.compile("<(.*?)>\\s*;\\s*rel=\"?"+ Pattern.quote(relation) + "\"?");
602            for (String link : links) {
603                Matcher m = p.matcher(link);
604                if (m.matches()) {
605                    String location = m.group(1);
606                    LOG.debug("Link: {} -> {}", relation, location);
607                    result.add(location);
608                }
609            }
610        }
611
612        return result;
613    }
614
615    /**
616     * Resolves a relative link against the connection's last URL.
617     *
618     * @param link
619     *            Link to resolve. Absolute links are just converted to an URL. May be
620     *            {@code null}.
621     * @return Absolute URL of the given link, or {@code null} if the link was
622     *         {@code null}.
623     */
624    @Nullable
625    private URL resolveRelative(@Nullable String link) {
626        if (link == null) {
627            return null;
628        }
629
630        assertConnectionIsOpen();
631        try {
632            return new URL(conn.getURL(), link);
633        } catch (MalformedURLException ex) {
634            throw new AcmeProtocolException("Cannot resolve relative link: " + link, ex);
635        }
636    }
637
638    /**
639     * Resolves a relative URI against the connection's last URL.
640     *
641     * @param uri
642     *            URI to resolve
643     * @return Absolute URI of the given link, or {@code null} if the URI was
644     *         {@code null}.
645     */
646    @Nullable
647    private URI resolveUri(@Nullable String uri) {
648        if (uri == null) {
649            return null;
650        }
651
652        try {
653            return conn.getURL().toURI().resolve(uri);
654        } catch (URISyntaxException ex) {
655            throw new AcmeProtocolException("Invalid URI", ex);
656        }
657    }
658
659}