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}