From 4772488896928f8bf5a224ded1e0d302d3f665db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20K=C3=B6rber?= Date: Thu, 27 Apr 2017 22:36:46 +0200 Subject: [PATCH] Add Order resource --- .../org/shredzone/acme4j/Authorization.java | 97 +--------- .../main/java/org/shredzone/acme4j/Order.java | 171 ++++++++++++++++++ .../org/shredzone/acme4j/Registration.java | 36 ++++ .../shredzone/acme4j/connector/Resource.java | 1 + .../shredzone/acme4j/AuthorizationTest.java | 79 ++------ .../java/org/shredzone/acme4j/OrderTest.java | 133 ++++++++++++++ .../shredzone/acme4j/RegistrationTest.java | 68 ++++++- .../acme4j/connector/ResourceTest.java | 3 +- .../java/org/shredzone/acme4j/it/OrderIT.java | 71 ++++++++ .../org/shredzone/acme4j/it/SessionIT.java | 1 + .../src/test/resources/json.properties | 49 ++++- 11 files changed, 547 insertions(+), 162 deletions(-) create mode 100644 acme4j-client/src/main/java/org/shredzone/acme4j/Order.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java create mode 100644 acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java index 08ab0d94..f8823d56 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Authorization.java @@ -19,9 +19,6 @@ import static org.shredzone.acme4j.util.AcmeUtils.parseTimestamp; import java.net.HttpURLConnection; import java.net.URL; import java.time.Instant; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.List; @@ -46,7 +43,6 @@ public class Authorization extends AcmeResource { private Status status; private Instant expires; private List challenges; - private List> combinations; private boolean loaded = false; protected Authorization(Session session, URL location) { @@ -101,17 +97,8 @@ public class Authorization extends AcmeResource { } /** - * Gets all combinations of challenges supported by the server. - */ - public List> getCombinations() { - load(); - return combinations; - } - - /** - * Finds a single {@link Challenge} of the given type. Responding to this - * {@link Challenge} is sufficient for authorization. This is a convenience call to - * {@link #findCombination(String...)}. + * Finds a {@link Challenge} of the given type. Responding to this {@link Challenge} + * is sufficient for authorization. * * @param type * Challenge name (e.g. "http-01") @@ -121,45 +108,11 @@ public class Authorization extends AcmeResource { * if the type does not match the expected Challenge class type */ @SuppressWarnings("unchecked") - public T findChallenge(String type) { - return (T) findCombination(type).stream().findFirst().orElse(null); - } - - /** - * Finds a combination of {@link Challenge} types that the client supports. The client - * has to respond to all of the {@link Challenge}s returned. However, this - * method attempts to find the combination with the smallest number of - * {@link Challenge}s. - * - * @param types - * Challenge name or names (e.g. "http-01"), in no particular order. - * Basically this is a collection of all challenge types supported by your - * implementation. - * @return Matching {@link Challenge} combination, or an empty collection if the ACME - * server does not support any of your challenges. The challenges are returned - * in no particular order. The result may be a subset of the types you have - * provided, if fewer challenges are actually required for a successful - * validation. - */ - public Collection findCombination(String... types) { - Collection available = Arrays.asList(types); - Collection combinationTypes = new ArrayList<>(); - - Collection result = Collections.emptyList(); - - for (List combination : getCombinations()) { - combinationTypes.clear(); - for (Challenge c : combination) { - combinationTypes.add(c.getType()); - } - - if (available.containsAll(combinationTypes) && - (result.isEmpty() || result.size() > combination.size())) { - result = combination; - } - } - - return Collections.unmodifiableCollection(result); + public T findChallenge(final String type) { + return (T) getChallenges().stream() + .filter(ch -> type.equals(ch.getType())) + .reduce((a, b) -> {throw new AcmeProtocolException("Found more than one challenge of type " + type);}) + .orElse(null); } /** @@ -240,7 +193,6 @@ public class Authorization extends AcmeResource { } challenges = fetchChallenges(json); - combinations = fetchCombinations(json, challenges); loaded = true; } @@ -261,39 +213,4 @@ public class Authorization extends AcmeResource { .collect(toList())); } - /** - * Fetches all possible combination of {@link Challenge} that are defined in the JSON. - * - * @param json - * {@link JSON} to read - * @param challenges - * List of available {@link Challenge} - * @return List of {@link Challenge} combinations - */ - private List> fetchCombinations(JSON json, List challenges) { - JSON.Array jsonCombinations = json.get("combinations").asArray(); - if (jsonCombinations.isEmpty()) { - return Arrays.asList(challenges); - } - - return Collections.unmodifiableList(jsonCombinations.stream() - .map(JSON.Value::asArray) - .map(this::findChallenges) - .collect(toList())); - } - - /** - * Converts an array of challenge indexes to a list of matching {@link Challenge}. - * - * @param combination - * {@link Array} of the challenge indexes - * @return List of matching {@link Challenge} - */ - private List findChallenges(JSON.Array combination) { - return combination.stream() - .mapToInt(JSON.Value::asInt) - .mapToObj(challenges::get) - .collect(toList()); - } - } diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java new file mode 100644 index 00000000..b20fb7c8 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Order.java @@ -0,0 +1,171 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2017 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; + +import static java.util.stream.Collectors.toList; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Instant; +import java.util.List; + +import org.shredzone.acme4j.connector.Connection; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeProtocolException; +import org.shredzone.acme4j.util.JSON; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Represents a certificate order. + */ +public class Order extends AcmeResource { + private static final long serialVersionUID = 5435808648658292177L; + private static final Logger LOG = LoggerFactory.getLogger(Order.class); + + private Status status; + private Instant expires; + private byte[] csr; + private Instant notBefore; + private Instant notAfter; + private List authorizations; + private URL certificate; + private boolean loaded = false; + + protected Order(Session session, URL location) { + super(session); + setLocation(location); + } + + /** + * Creates a new instance of {@link Order} and binds it to the {@link Session}. + * + * @param session + * {@link Session} to be used + * @param location + * Location URL of the order + * @return {@link Order} bound to the session and location + */ + public static Order bind(Session session, URL location) { + return new Order(session, location); + } + + /** + * Returns the current status of the order. + */ + public Status getStatus() { + load(); + return status; + } + + /** + * Gets the expiry date of the authorization, if set by the server. + */ + public Instant getExpires() { + load(); + return expires; + } + + /** + * Gets the CSR that was used for the order. + */ + public byte[] getCsr() { + load(); + return csr; + } + + /** + * Gets the "not before" date that was used for the order, or {@code null}. + */ + public Instant getNotBefore() { + load(); + return notBefore; + } + + /** + * Gets the "not after" date that was used for the order, or {@code null}. + */ + public Instant getNotAfter() { + load(); + return notAfter; + } + + /** + * Gets the {@link Authorization} required for this order. + */ + public List getAuthorizations() { + load(); + Session session = getSession(); + return authorizations.stream() + .map(loc -> Authorization.bind(session, loc)) + .collect(toList()); + } + + /** + * Gets the {@link URL} where the certificate can be downloaded from, if it is + * available. {@code null} otherwise. + */ + public URL getCertificateLocation() { + load(); + return certificate; + } + + /** + * Updates the order to the current account status. + */ + public void update() throws AcmeException { + LOG.debug("update"); + try (Connection conn = getSession().provider().connect()) { + conn.sendRequest(getLocation(), getSession()); + conn.accept(HttpURLConnection.HTTP_OK); + + JSON json = conn.readJsonResponse(); + unmarshal(json); + } + } + + /** + * Lazily updates the object's state when one of the getters is invoked. + */ + protected void load() { + if (!loaded) { + try { + update(); + } catch (AcmeException ex) { + throw new AcmeProtocolException("Could not load lazily", ex); + } + } + } + + /** + * Sets order properties according to the given JSON data. + * + * @param json + * JSON data + */ + public void unmarshal(JSON json) { + this.status = json.get("status").asStatusOrElse(Status.UNKNOWN); + this.expires = json.get("expires").asInstant(); + this.csr = json.get("csr").asBinary(); + this.notBefore = json.get("notBefore").asInstant(); + this.notAfter = json.get("notAfter").asInstant(); + this.certificate = json.get("certificate").asURL(); + this.authorizations = json.get("authorizations").asArray().stream() + .map(JSON.Value::asURL) + .collect(toList()); + + loaded = true; + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java index 67be5e8d..0ee48396 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Registration.java @@ -158,6 +158,42 @@ public class Registration extends AcmeResource { } } + /** + * Orders a certificate. The certificate will be associated with this registration. + * + * @param csr + * CSR containing the parameters for the certificate being requested + * @param notBefore + * Requested "notBefore" date in the certificate, or {@code null} + * @param notAfter + * Requested "notAfter" date in the certificate, or {@code null} + * @return {@link Order} object for this domain + */ + public Order orderCertificate(byte[] csr, Instant notBefore, Instant notAfter) throws AcmeException { + Objects.requireNonNull(csr, "csr"); + + LOG.debug("orderCertificate"); + try (Connection conn = getSession().provider().connect()) { + JSONBuilder claims = new JSONBuilder(); + claims.putBase64("csr", csr); + if (notBefore != null) { + claims.put("notBefore", notBefore); + } + if (notAfter != null) { + claims.put("notAfter", notAfter); + } + + conn.sendSignedRequest(getSession().resourceUrl(Resource.NEW_ORDER), claims, getSession()); + conn.accept(HttpURLConnection.HTTP_CREATED); + + JSON json = conn.readJsonResponse(); + + Order order = new Order(getSession(), conn.getLocation()); + order.unmarshal(json); + return order; + } + } + /** * Authorizes a domain. The domain is associated with this registration. *

diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java index a756b424..ef7c86ed 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/Resource.java @@ -23,6 +23,7 @@ public enum Resource { NEW_AUTHZ("new-authz"), NEW_CERT("new-cert"), NEW_NONCE("new-nonce"), + NEW_ORDER("new-order"), REVOKE_CERT("revoke-cert"); private final String path; diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java index b6306ab9..15032f44 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AuthorizationTest.java @@ -23,7 +23,6 @@ import java.net.HttpURLConnection; import java.net.URL; import java.time.Duration; import java.time.Instant; -import java.util.Collection; import java.util.concurrent.atomic.AtomicBoolean; import org.junit.Test; @@ -32,6 +31,7 @@ import org.shredzone.acme4j.challenge.Dns01Challenge; import org.shredzone.acme4j.challenge.Http01Challenge; import org.shredzone.acme4j.challenge.TlsSni02Challenge; import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.exception.AcmeRetryAfterException; import org.shredzone.acme4j.provider.TestableConnectionProvider; import org.shredzone.acme4j.util.JSON; @@ -43,12 +43,12 @@ import org.shredzone.acme4j.util.JSONBuilder; public class AuthorizationTest { private static final String SNAILMAIL_TYPE = "snail-01"; // a non-existent challenge + private static final String DUPLICATE_TYPE = "duplicate-01"; // a duplicate challenge private URL locationUrl = url("http://example.com/acme/registration"); /** - * Test that {@link Authorization#findChallenge(String)} does only find standalone - * challenges, and nothing else. + * Test that {@link Authorization#findChallenge(String)} finds challenges. */ @Test public void testFindChallenge() throws IOException { @@ -58,60 +58,30 @@ public class AuthorizationTest { Challenge c1 = authorization.findChallenge(SNAILMAIL_TYPE); assertThat(c1, is(nullValue())); - // HttpChallenge is available as standalone challenge + // HttpChallenge is available Challenge c2 = authorization.findChallenge(Http01Challenge.TYPE); assertThat(c2, is(notNullValue())); assertThat(c2, is(instanceOf(Http01Challenge.class))); - // TlsSniChallenge is available, but not as standalone challenge - Challenge c3 = authorization.findChallenge(TlsSni02Challenge.TYPE); - assertThat(c3, is(nullValue())); + // Dns01Challenge is available + Challenge c3 = authorization.findChallenge(Dns01Challenge.TYPE); + assertThat(c3, is(notNullValue())); + assertThat(c3, is(instanceOf(Dns01Challenge.class))); + + // TlsSni02Challenge is available + Challenge c4 = authorization.findChallenge(TlsSni02Challenge.TYPE); + assertThat(c4, is(notNullValue())); + assertThat(c4, is(instanceOf(TlsSni02Challenge.class))); } /** - * Test that {@link Authorization#findCombination(String...)} does only find proper - * combinations. + * Test that {@link Authorization#findChallenge(String)} fails on duplicate + * challenges. */ - @Test - @SuppressWarnings("unchecked") - public void testFindCombination() throws IOException { + @Test(expected = AcmeProtocolException.class) + public void testFailDuplicateChallenges() throws IOException { Authorization authorization = createChallengeAuthorization(); - - // Standalone challenge - Collection c1 = authorization.findCombination(Http01Challenge.TYPE); - assertThat(c1, hasSize(1)); - assertThat(c1, contains(instanceOf(Http01Challenge.class))); - - // Available combined challenge - Collection c2 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni02Challenge.TYPE); - assertThat(c2, hasSize(2)); - assertThat(c2, contains(instanceOf(Dns01Challenge.class), - instanceOf(TlsSni02Challenge.class))); - - // Order does not matter - Collection c3 = authorization.findCombination(TlsSni02Challenge.TYPE, Dns01Challenge.TYPE); - assertThat(c3, hasSize(2)); - assertThat(c3, contains(instanceOf(Dns01Challenge.class), - instanceOf(TlsSni02Challenge.class))); - - // Finds smaller combinations as well - Collection c4 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni02Challenge.TYPE, SNAILMAIL_TYPE); - assertThat(c4, hasSize(2)); - assertThat(c4, contains(instanceOf(Dns01Challenge.class), - instanceOf(TlsSni02Challenge.class))); - - // Finds the smallest possible combination - Collection c5 = authorization.findCombination(Dns01Challenge.TYPE, TlsSni02Challenge.TYPE, Http01Challenge.TYPE); - assertThat(c5, hasSize(1)); - assertThat(c5, contains(instanceOf(Http01Challenge.class))); - - // Finds only entire combinations - Collection c6 = authorization.findCombination(Dns01Challenge.TYPE); - assertThat(c6, is(empty())); - - // Does not find challenges that have not been provided - Collection c7 = authorization.findCombination(SNAILMAIL_TYPE); - assertThat(c7, is(empty())); + authorization.findChallenge(DUPLICATE_TYPE); } /** @@ -161,12 +131,6 @@ public class AuthorizationTest { assertThat(auth.getChallenges(), containsInAnyOrder( (Challenge) httpChallenge, (Challenge) dnsChallenge)); - assertThat(auth.getCombinations(), hasSize(2)); - assertThat(auth.getCombinations().get(0), containsInAnyOrder( - (Challenge) httpChallenge)); - assertThat(auth.getCombinations().get(1), containsInAnyOrder( - (Challenge) httpChallenge, (Challenge) dnsChallenge)); - provider.close(); } @@ -279,12 +243,6 @@ public class AuthorizationTest { assertThat(auth.getChallenges(), containsInAnyOrder( (Challenge) httpChallenge, (Challenge) dnsChallenge)); - assertThat(auth.getCombinations(), hasSize(2)); - assertThat(auth.getCombinations().get(0), containsInAnyOrder( - (Challenge) httpChallenge)); - assertThat(auth.getCombinations().get(1), containsInAnyOrder( - (Challenge) httpChallenge, (Challenge) dnsChallenge)); - provider.close(); } @@ -327,6 +285,7 @@ public class AuthorizationTest { provider.putTestChallenge(Http01Challenge.TYPE, new Http01Challenge(session)); provider.putTestChallenge(Dns01Challenge.TYPE, new Dns01Challenge(session)); provider.putTestChallenge(TlsSni02Challenge.TYPE, new TlsSni02Challenge(session)); + provider.putTestChallenge(DUPLICATE_TYPE, new Challenge(session)); Authorization authorization = new Authorization(session, locationUrl); authorization.unmarshalAuthorization(getJsonAsObject("authorizationChallenges")); diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java new file mode 100644 index 00000000..c67c742a --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderTest.java @@ -0,0 +1,133 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2017 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; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static org.shredzone.acme4j.util.AcmeUtils.parseTimestamp; +import static org.shredzone.acme4j.util.TestUtils.*; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.Test; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.provider.TestableConnectionProvider; +import org.shredzone.acme4j.util.JSON; +import org.shredzone.acme4j.util.TestUtils; + +/** + * Unit tests for {@link Order}. + */ +public class OrderTest { + + private URL locationUrl = url("http://example.com/acme/order/1234"); + + /** + * Test that order is properly updated. + */ + @Test + public void testUpdate() throws Exception { + byte[] csr = TestUtils.getResourceAsByteArray("/csr.der"); + + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public void sendRequest(URL url, Session session) { + assertThat(url, is(locationUrl)); + } + + @Override + public int accept(int... httpStatus) throws AcmeException { + assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_OK)); + return HttpURLConnection.HTTP_OK; + } + + @Override + public JSON readJsonResponse() { + return getJsonAsObject("updateOrderResponse"); + } + }; + + Session session = provider.createSession(); + + Order order = new Order(session, locationUrl); + order.update(); + + assertThat(order.getStatus(), is(Status.PENDING)); + assertThat(order.getExpires(), is(parseTimestamp("2015-03-01T14:09:00Z"))); + assertThat(order.getLocation(), is(locationUrl)); + + assertThat(order.getNotBefore(), is(parseTimestamp("2016-01-01T00:00:00Z"))); + assertThat(order.getNotAfter(), is(parseTimestamp("2016-01-08T00:00:00Z"))); + assertThat(order.getCertificateLocation(), is(url("https://example.com/acme/cert/1234"))); + assertThat(order.getCsr(), is(csr)); + + List auths = order.getAuthorizations(); + assertThat(auths.size(), is(2)); + assertThat(auths.stream().map(Authorization::getLocation)::iterator, + containsInAnyOrder( + url("https://example.com/acme/authz/1234"), + url("https://example.com/acme/authz/2345"))); + + provider.close(); + } + + /** + * Test lazy loading. + */ + @Test + public void testLazyLoading() throws Exception { + final AtomicBoolean requestWasSent = new AtomicBoolean(false); + + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public void sendRequest(URL url, Session session) { + requestWasSent.set(true); + assertThat(url, is(locationUrl)); + } + + @Override + public int accept(int... httpStatus) throws AcmeException { + assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_OK)); + return HttpURLConnection.HTTP_OK; + } + + @Override + public JSON readJsonResponse() { + return getJsonAsObject("updateOrderResponse"); + } + }; + + Session session = provider.createSession(); + + Order order = new Order(session, locationUrl); + + // Lazy loading + assertThat(requestWasSent.get(), is(false)); + assertThat(order.getCertificateLocation(), is(url("https://example.com/acme/cert/1234"))); + assertThat(requestWasSent.get(), is(true)); + + // Subsequent queries do not trigger another load + requestWasSent.set(false); + assertThat(order.getCertificateLocation(), is(url("https://example.com/acme/cert/1234"))); + assertThat(order.getStatus(), is(Status.PENDING)); + assertThat(order.getExpires(), is(parseTimestamp("2015-03-01T14:09:00Z"))); + assertThat(requestWasSent.get(), is(false)); + + provider.close(); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java index cd0495d0..2df2a1cc 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/RegistrationTest.java @@ -15,6 +15,7 @@ package org.shredzone.acme4j; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; +import static org.shredzone.acme4j.util.AcmeUtils.parseTimestamp; import static org.shredzone.acme4j.util.TestUtils.*; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; @@ -31,6 +32,7 @@ import java.time.ZoneId; import java.util.Arrays; import java.util.Collection; import java.util.Iterator; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.jose4j.jws.JsonWebSignature; @@ -267,12 +269,6 @@ public class RegistrationTest { assertThat(auth.getChallenges(), containsInAnyOrder( (Challenge) httpChallenge, (Challenge) dnsChallenge)); - assertThat(auth.getCombinations(), hasSize(2)); - assertThat(auth.getCombinations().get(0), containsInAnyOrder( - (Challenge) httpChallenge)); - assertThat(auth.getCombinations().get(1), containsInAnyOrder( - (Challenge) httpChallenge, (Challenge) dnsChallenge)); - provider.close(); } @@ -302,6 +298,66 @@ public class RegistrationTest { provider.close(); } + /** + * Test that a order can be requested. + */ + @Test + public void testOrder() throws AcmeException, IOException { + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public void sendSignedRequest(URL url, JSONBuilder claims, Session session) { + assertThat(url, is(resourceUrl)); + assertThat(claims.toString(), sameJSONAs(getJson("requestOrderRequest"))); + assertThat(session, is(notNullValue())); + } + + @Override + public int accept(int... httpStatus) throws AcmeException { + assertThat(httpStatus, isIntArrayContainingInAnyOrder(HttpURLConnection.HTTP_CREATED)); + return HttpURLConnection.HTTP_CREATED; + } + + @Override + public JSON readJsonResponse() { + return getJsonAsObject("requestOrderResponse"); + } + + @Override + public URL getLocation() { + return locationUrl; + } + }; + + provider.putTestResource(Resource.NEW_ORDER, resourceUrl); + + byte[] csr = TestUtils.getResourceAsByteArray("/csr.der"); + ZoneId utc = ZoneId.of("UTC"); + Instant notBefore = LocalDate.of(2016, 1, 1).atStartOfDay(utc).toInstant(); + Instant notAfter = LocalDate.of(2016, 1, 8).atStartOfDay(utc).toInstant(); + + Registration registration = new Registration(provider.createSession(), locationUrl); + + Order order = registration.orderCertificate(csr, notBefore, notAfter); + + assertThat(order.getLocation(), is(locationUrl)); + assertThat(order.getCsr(), is(csr)); + assertThat(order.getStatus(), is(Status.PENDING)); + assertThat(order.getExpires(), is(parseTimestamp("2016-01-01T00:00:00Z"))); + assertThat(order.getLocation(), is(locationUrl)); + assertThat(order.getNotBefore(), is(notBefore)); + assertThat(order.getNotAfter(), is(notAfter)); + assertThat(order.getCertificateLocation(), is(nullValue())); + + List auths = order.getAuthorizations(); + assertThat(auths.size(), is(2)); + assertThat(auths.stream().map(Authorization::getLocation)::iterator, + containsInAnyOrder( + url("https://example.com/acme/authz/1234"), + url("https://example.com/acme/authz/2345"))); + + provider.close(); + } + /** * Test that a certificate can be requested and is delivered synchronously. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java index 5eb4fbab..80555f30 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/ResourceTest.java @@ -32,11 +32,12 @@ public class ResourceTest { assertThat(Resource.NEW_AUTHZ.path(), is("new-authz")); assertThat(Resource.NEW_CERT.path(), is("new-cert")); assertThat(Resource.NEW_NONCE.path(), is("new-nonce")); + assertThat(Resource.NEW_ORDER.path(), is("new-order")); assertThat(Resource.NEW_REG.path(), is("new-reg")); assertThat(Resource.REVOKE_CERT.path(), is("revoke-cert")); // fails if there are untested future Resource values - assertThat(Resource.values().length, is(6)); + assertThat(Resource.values().length, is(7)); } } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java b/acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java new file mode 100644 index 00000000..32ebd114 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/it/OrderIT.java @@ -0,0 +1,71 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2017 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.it; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; + +import java.io.IOException; +import java.security.KeyPair; +import java.time.Duration; +import java.time.Instant; + +import org.junit.Test; +import org.shredzone.acme4j.Authorization; +import org.shredzone.acme4j.Order; +import org.shredzone.acme4j.Registration; +import org.shredzone.acme4j.RegistrationBuilder; +import org.shredzone.acme4j.Session; +import org.shredzone.acme4j.Status; +import org.shredzone.acme4j.challenge.Http01Challenge; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.util.TestUtils; + +/** + * Tests the complete process of ordering a certificate. + */ +public class OrderIT extends AbstractPebbleIT { + + @Test + public void testOrder() throws AcmeException, IOException { + KeyPair keyPair = createKeyPair(); + Session session = new Session(pebbleURI(), keyPair); + + Registration reg = new RegistrationBuilder().agreeToTermsOfService().create(session); + + byte[] csr = TestUtils.getResourceAsByteArray("/csr.der"); + Instant notBefore = Instant.now(); + Instant notAfter = notBefore.plus(Duration.ofDays(20L)); + + Order order = reg.orderCertificate(csr, notBefore, notAfter); + assertThat(order.getCsr(), is(csr)); + assertThat(order.getNotBefore(), is(notBefore)); + assertThat(order.getNotAfter(), is(notAfter)); + assertThat(order.getStatus(), is(Status.PENDING)); + + for (Authorization auth : order.getAuthorizations()) { + processAuthorization(auth); + } + } + + private void processAuthorization(Authorization auth) throws AcmeException { + assertThat(auth.getDomain(), is("example.com")); + if (auth.getStatus() == Status.PENDING) { + Http01Challenge challenge = auth.findChallenge(Http01Challenge.TYPE); + assertThat(challenge, is(notNullValue())); + challenge.trigger(); + } + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java b/acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java index d0f1acbf..4278bc1c 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/it/SessionIT.java @@ -57,6 +57,7 @@ public class SessionIT extends AbstractPebbleIT { assertIsPebbleUrl(session.resourceUrl(Resource.NEW_NONCE)); assertIsPebbleUrl(session.resourceUrl(Resource.NEW_REG)); + assertIsPebbleUrl(session.resourceUrl(Resource.NEW_ORDER)); } @Test diff --git a/acme4j-client/src/test/resources/json.properties b/acme4j-client/src/test/resources/json.properties index cde9df9c..281aac6e 100644 --- a/acme4j-client/src/test/resources/json.properties +++ b/acme4j-client/src/test/resources/json.properties @@ -100,8 +100,7 @@ newAuthorizationResponse = \ "uri": "https://example.com/authz/asdf/1",\ "token": "DGyRejmCefe7v4NfDGDKfA"\ }\ - ],\ - "combinations": [[0], [0,1]]\ + ]\ } updateAuthorizationResponse = \ @@ -125,8 +124,41 @@ updateAuthorizationResponse = \ "uri": "https://example.com/authz/asdf/1",\ "token": "DGyRejmCefe7v4NfDGDKfA"\ }\ + ]\ + } + +requestOrderRequest = \ + {\ + "csr": "MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb",\ + "notBefore": "2016-01-01T00:00:00Z",\ + "notAfter": "2016-01-08T00:00:00Z"\ + } + +requestOrderResponse = \ + {\ + "status": "pending",\ + "expires": "2016-01-01T00:00:00Z",\ + "csr": "MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb",\ + "notBefore": "2016-01-01T00:00:00Z",\ + "notAfter": "2016-01-08T00:00:00Z",\ + "authorizations": [\ + "https://example.com/acme/authz/1234",\ + "https://example.com/acme/authz/2345"\ + ]\ + } + +updateOrderResponse = \ + {\ + "status": "pending",\ + "expires": "2015-03-01T14:09:00Z",\ + "csr": "MIIChDCCAWwCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPemmumcNGR0hsPo-2N6nkJ0FcEMdb0_MlucHR0dNeHEvn8vmcQHlYRjkDVX0aypnfKQI3tvhTBKLdlNvbVIW1TQ_Wbqh9TQlC8G3Hog8nRQ2vAzO4sH6nhvdrAFUmq6hkATpU3iQuDvtYu03ExaYHKsItLocl1OndaQizBn5udBv1baOW3Kd790k6lEWGrD-TXo6uwuMha2k_YBGNKd4S4UuPmbPV9SUVW8JSylBSgDhvY3BHv-dfdIMhVwRMZDFaa0mHDIYUiwcEaU5x4P6Q5bGP2wxcUPCLwFsbAK5K6B2T2P3A2fNjGBAlHwEkg6VMvi7jax8MD-oRnku2M2JLAgMBAAGgKTAnBgkqhkiG9w0BCQ4xGjAYMBYGA1UdEQQPMA2CC2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQACnMZdjE1jVqnkHkEeGrMXujWuwuiKLZVa4YZ9fL0UIGOqqf4b9-3JmtEiLB9ycQO5N9rW4V-6_DBMeoeRBLu-wranHnxU4ds6GzNzBxKfI86_8t5pdQK4Cglv7yfseseZRdQtvcR2ejkW0F3SL1DF5Sk3T46aRYiUXxeoNC4Uh3zoIHOv8YGUa-DuZQ6OnHMhPrdsfU09L7KVAMTq1bodjGWmgoIJm4x5JSm19GbhYAm9Q9XWnN56YHqgS3FtS9n3wDxz7Dvo24whto1tUU5hnjrp31rTvyxG3kydoEZf2Ciq_82bQDb40kwnoO6RytPYJVMRIBsP2mCfaFtIt9Eb",\ + "notBefore": "2016-01-01T00:00:00Z",\ + "notAfter": "2016-01-08T00:00:00Z",\ + "authorizations": [\ + "https://example.com/acme/authz/1234",\ + "https://example.com/acme/authz/2345"\ ],\ - "combinations": [[0], [0,1]]\ + "certificate": "https://example.com/acme/cert/1234"\ } triggerHttpChallenge = \ @@ -207,9 +239,16 @@ authorizationChallenges = \ "type": "tls-sni-02",\ "uri": "https://example.com/authz/asdf/2",\ "token": "VNLBdSiZ3LppU2CRG8bilqlwq4DuApJMg3ZJowU6JhQ"\ + },\ + {\ + "type": "duplicate-01",\ + "uri": "https://example.com/authz/asdf/3"\ + },\ + {\ + "type": "duplicate-01",\ + "uri": "https://example.com/authz/asdf/4"\ }\ - ],\ - "combinations": [[0], [1,2]]\ + ]\ }