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}