diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java index c02f0d9e..ba22be74 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/Account.java @@ -19,10 +19,8 @@ import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; import java.security.KeyPair; -import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; @@ -140,54 +138,12 @@ public class Account extends AcmeResource { } /** - * Orders a certificate. The certificate will be associated with this account. + * Creates a builder for a new {@link Order}. * - * @param domains - * Domains of the certificate to be ordered. May contain wildcard domains. - * IDN names are accepted and will be ACE encoded automatically. - * @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 + * @return {@link OrderBuilder} object */ - public Order orderCertificate(Collection domains, Instant notBefore, Instant notAfter) - throws AcmeException { - Objects.requireNonNull(domains, "domains"); - if (domains.isEmpty()) { - throw new IllegalArgumentException("Cannot order an empty collection of domains"); - } - - Object[] identifiers = new Object[domains.size()]; - Iterator di = domains.iterator(); - for (int ix = 0; ix < identifiers.length; ix++) { - identifiers[ix] = new JSONBuilder() - .put("type", "dns") - .put("value", toAce(di.next())) - .toMap(); - } - - LOG.debug("orderCertificate"); - try (Connection conn = getSession().provider().connect()) { - JSONBuilder claims = new JSONBuilder(); - claims.array("identifiers", identifiers); - - 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; - } + public OrderBuilder newOrder() throws AcmeException { + return new OrderBuilder(getSession()); } /** diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java new file mode 100644 index 00000000..2a2eb6dd --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/OrderBuilder.java @@ -0,0 +1,162 @@ +/* + * 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.Objects.requireNonNull; +import static org.shredzone.acme4j.toolbox.AcmeUtils.toAce; + +import java.net.HttpURLConnection; +import java.time.Instant; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.shredzone.acme4j.connector.Connection; +import org.shredzone.acme4j.connector.Resource; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.JSONBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A builder for a new {@link Order} object. + */ +public class OrderBuilder { + private static final Logger LOG = LoggerFactory.getLogger(OrderBuilder.class); + + private final Session session; + + private final Set domainSet = new LinkedHashSet<>(); + private Instant notBefore; + private Instant notAfter; + + /** + * Create a new {@link OrderBuilder}. + * + * @param session {@link Session} to bind with + */ + protected OrderBuilder(Session session) { + this.session = session; + } + + /** + * Adds a domain name to the order. + * + * @param domain + * Name of a domain to be ordered. May be a wildcard domain if supported by + * the CA. IDN names are accepted and will be ACE encoded automatically. + * @return itself + */ + public OrderBuilder domain(String domain) { + domainSet.add(toAce(requireNonNull(domain, "domain"))); + return this; + } + + /** + * Adds domain names to the order. + * + * @param domains + * Collection of domain names to be ordered. May be wildcard domains if + * supported by the CA. IDN names are accepted and will be ACE encoded + * automatically. + * @return itself + */ + public OrderBuilder domains(String... domains) { + for (String domain : requireNonNull(domains, "domains")) { + domain(domain); + } + return this; + } + + /** + * Adds a collection of domain names to the order. + * + * @param domains + * Collection of domain names to be ordered. May be wildcard domains if + * supported by the CA. IDN names are accepted and will be ACE encoded + * automatically. + * @return itself + */ + public OrderBuilder domains(Collection domains) { + requireNonNull(domains, "domains").forEach(this::domain); + return this; + } + + /** + * Sets a "not before" date in the certificate. May be ignored by the CA. + * + * @param notBefore "not before" date + * @return itself + */ + public OrderBuilder notBefore(Instant notBefore) { + this.notBefore = requireNonNull(notBefore, "notBefore"); + return this; + } + + /** + * Sets a "not after" date in the certificate. May be ignored by the CA. + * + * @param notAfter "not after" date + * @return itself + */ + public OrderBuilder notAfter(Instant notAfter) { + this.notAfter = requireNonNull(notAfter, "notAfter"); + return this; + } + + /** + * Sends a new order to the server, and returns an {@link Order} object. + * + * @return {@link Order} that was created + */ + public Order create() throws AcmeException { + if (domainSet.isEmpty()) { + throw new IllegalArgumentException("At least one domain is required"); + } + + Object[] identifiers = new Object[domainSet.size()]; + Iterator di = domainSet.iterator(); + for (int ix = 0; ix < identifiers.length; ix++) { + identifiers[ix] = new JSONBuilder() + .put("type", "dns") + .put("value", di.next()) + .toMap(); + } + + LOG.debug("create"); + try (Connection conn = session.provider().connect()) { + JSONBuilder claims = new JSONBuilder(); + claims.array("identifiers", identifiers); + + if (notBefore != null) { + claims.put("notBefore", notBefore); + } + if (notAfter != null) { + claims.put("notAfter", notAfter); + } + + conn.sendSignedRequest(session.resourceUrl(Resource.NEW_ORDER), claims, session); + conn.accept(HttpURLConnection.HTTP_CREATED); + + JSON json = conn.readJsonResponse(); + + Order order = new Order(session, conn.getLocation()); + order.unmarshal(json); + return order; + } + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java index 8898558e..c5043146 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/AccountTest.java @@ -15,7 +15,6 @@ package org.shredzone.acme4j; import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; -import static org.shredzone.acme4j.toolbox.AcmeUtils.parseTimestamp; import static org.shredzone.acme4j.toolbox.TestUtils.*; import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; @@ -25,7 +24,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.security.KeyPair; -import java.time.Instant; import java.util.Arrays; import java.util.Collection; import java.util.Collections; @@ -185,60 +183,6 @@ public class AccountTest { provider.close(); } - /** - * Test that a new {@link Authorization} can be created. - */ - @Test - public void testOrderCertificate() throws Exception { - Instant notBefore = parseTimestamp("2016-01-01T00:00:00Z"); - Instant notAfter = parseTimestamp("2016-01-08T00:00:00Z"); - - TestableConnectionProvider provider = new TestableConnectionProvider() { - @Override - public void sendSignedRequest(URL url, JSONBuilder claims, Session session) { - assertThat(url, is(resourceUrl)); - assertThat(claims.toString(), sameJSONAs(getJSON("requestOrderRequest").toString())); - 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 getJSON("requestOrderResponse"); - } - - @Override - public URL getLocation() { - return locationUrl; - } - }; - - Session session = provider.createSession(); - - provider.putTestResource(Resource.NEW_ORDER, resourceUrl); - - Account account = new Account(session, locationUrl); - Order order = account.orderCertificate( - Arrays.asList("example.com", "www.example.com"), - notBefore, notAfter); - - assertThat(order.getDomains(), containsInAnyOrder("example.com", "www.example.com")); - assertThat(order.getNotBefore(), is(parseTimestamp("2016-01-01T00:10:00Z"))); - assertThat(order.getNotAfter(), is(parseTimestamp("2016-01-08T00:10:00Z"))); - assertThat(order.getExpires(), is(parseTimestamp("2016-01-10T00:00:00Z"))); - assertThat(order.getStatus(), is(Status.PENDING)); - assertThat(order.getLocation(), is(locationUrl)); - assertThat(order.getAuthorizations(), is(notNullValue())); - assertThat(order.getAuthorizations().size(), is(2)); - - provider.close(); - } - /** * Test that a domain can be pre-authorized. */ diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java new file mode 100644 index 00000000..7d9941e7 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/OrderBuilderTest.java @@ -0,0 +1,103 @@ +/* + * 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.toolbox.AcmeUtils.parseTimestamp; +import static org.shredzone.acme4j.toolbox.TestUtils.*; +import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs; + +import java.net.HttpURLConnection; +import java.net.URL; +import java.time.Instant; +import java.util.Arrays; + +import org.junit.Test; +import org.shredzone.acme4j.connector.Resource; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.provider.TestableConnectionProvider; +import org.shredzone.acme4j.toolbox.JSON; +import org.shredzone.acme4j.toolbox.JSONBuilder; + +/** + * Unit tests for {@link OrderBuilder}. + */ +public class OrderBuilderTest { + + private URL resourceUrl = url("http://example.com/acme/resource"); + private URL locationUrl = url("http://example.com/acme/account"); + + /** + * Test that a new {@link Order} can be created. + */ + @Test + public void testOrderCertificate() throws Exception { + Instant notBefore = parseTimestamp("2016-01-01T00:00:00Z"); + Instant notAfter = parseTimestamp("2016-01-08T00:00:00Z"); + + TestableConnectionProvider provider = new TestableConnectionProvider() { + @Override + public void sendSignedRequest(URL url, JSONBuilder claims, Session session) { + assertThat(url, is(resourceUrl)); + assertThat(claims.toString(), sameJSONAs(getJSON("requestOrderRequest").toString())); + 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 getJSON("requestOrderResponse"); + } + + @Override + public URL getLocation() { + return locationUrl; + } + }; + + Session session = provider.createSession(); + + provider.putTestResource(Resource.NEW_ORDER, resourceUrl); + + Account account = new Account(session, locationUrl); + Order order = account.newOrder() + .domains("example.com", "www.example.com") + .domain("example.org") + .domains(Arrays.asList("m.example.com", "m.example.org")) + .notBefore(notBefore) + .notAfter(notAfter) + .create(); + + assertThat(order.getDomains(), containsInAnyOrder( + "example.com", "www.example.com", + "example.org", + "m.example.com", "m.example.org")); + assertThat(order.getNotBefore(), is(parseTimestamp("2016-01-01T00:10:00Z"))); + assertThat(order.getNotAfter(), is(parseTimestamp("2016-01-08T00:10:00Z"))); + assertThat(order.getExpires(), is(parseTimestamp("2016-01-10T00:00:00Z"))); + assertThat(order.getStatus(), is(Status.PENDING)); + assertThat(order.getLocation(), is(locationUrl)); + assertThat(order.getAuthorizations(), is(notNullValue())); + assertThat(order.getAuthorizations().size(), is(2)); + + provider.close(); + } + +} diff --git a/acme4j-client/src/test/resources/json/requestOrderRequest.json b/acme4j-client/src/test/resources/json/requestOrderRequest.json index c5e4ef95..c29a2195 100644 --- a/acme4j-client/src/test/resources/json/requestOrderRequest.json +++ b/acme4j-client/src/test/resources/json/requestOrderRequest.json @@ -7,6 +7,18 @@ { "type": "dns", "value": "www.example.com" + }, + { + "type": "dns", + "value": "example.org" + }, + { + "type": "dns", + "value": "m.example.com" + }, + { + "type": "dns", + "value": "m.example.org" } ], "notBefore": "2016-01-01T00:00:00Z", diff --git a/acme4j-client/src/test/resources/json/requestOrderResponse.json b/acme4j-client/src/test/resources/json/requestOrderResponse.json index 4f9b4f3f..b1d92078 100644 --- a/acme4j-client/src/test/resources/json/requestOrderResponse.json +++ b/acme4j-client/src/test/resources/json/requestOrderResponse.json @@ -9,6 +9,18 @@ { "type": "dns", "value": "www.example.com" + }, + { + "type": "dns", + "value": "example.org" + }, + { + "type": "dns", + "value": "m.example.com" + }, + { + "type": "dns", + "value": "m.example.org" } ], "notBefore": "2016-01-01T00:10:00Z", diff --git a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java index 5e5b2f11..a6fe16b7 100644 --- a/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java +++ b/acme4j-example/src/main/java/org/shredzone/acme4j/ClientTest.java @@ -91,7 +91,7 @@ public class ClientTest { KeyPair domainKeyPair = loadOrCreateDomainKeyPair(); // Order the certificate - Order order = acct.orderCertificate(domains, null, null); + Order order = acct.newOrder().domains(domains).create(); // Perform all required authorizations for (Authorization auth : order.getAuthorizations()) { diff --git a/acme4j-it/src/test/java/org/shredzone/acme4j/it/OrderIT.java b/acme4j-it/src/test/java/org/shredzone/acme4j/it/OrderIT.java index 75acda72..f1b93a4c 100644 --- a/acme4j-it/src/test/java/org/shredzone/acme4j/it/OrderIT.java +++ b/acme4j-it/src/test/java/org/shredzone/acme4j/it/OrderIT.java @@ -23,7 +23,6 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Arrays; import org.junit.Test; import org.shredzone.acme4j.Account; @@ -138,7 +137,11 @@ public class OrderIT extends PebbleITBase { Instant notBefore = Instant.now().truncatedTo(ChronoUnit.MILLIS); Instant notAfter = notBefore.plus(Duration.ofDays(20L)); - Order order = account.orderCertificate(Arrays.asList(domain), notBefore, notAfter); + Order order = account.newOrder() + .domain(domain) + .notBefore(notBefore) + .notAfter(notAfter) + .create(); assertThat(order.getNotBefore(), is(notBefore)); assertThat(order.getNotAfter(), is(notAfter)); assertThat(order.getStatus(), is(Status.PENDING)); diff --git a/src/site/markdown/usage/order.md b/src/site/markdown/usage/order.md index 1bec7e35..295a2275 100644 --- a/src/site/markdown/usage/order.md +++ b/src/site/markdown/usage/order.md @@ -2,14 +2,15 @@ Once you have your account set up, you are ready to order certificates. -Use your `Account` object to order the certificate, by using the `orderCertificate()` method. It requires a collection of domain names to be ordered. You can optionally give your desired `notBefore` and `notAfter` dates for the generated certificate, but it is at the discretion of the CA to use (or ignore) these values. +Use your `Account` object to order the certificate, by using the `newOrder()` method. It returns an OrderBuilder object that helps you to collect the parameters of the order. You can give one or more domain names. Optionally you can also give your desired `notBefore` and `notAfter` dates for the generated certificate, but it is at the discretion of the CA to use (or ignore) these values. ```java Account account = ... // your Account object -Order order = account.orderCertificate( - Arrays.of("example.org", "www.example.org", "m.example.org"), - null, null); +Order order = account.newOrder() + .domains("example.org", "www.example.org", "m.example.org") + .notAfter(Instant.now().plus(Duration.ofDays(20L))) + .create(); ``` The `Order` resource contains a collection of `Authorization`s that can be read from the `getAuthorizations()` method. You must process _all of them_ in order to get the certificate. @@ -89,13 +90,17 @@ order.execute(csr); ## Wildcard Certificates -You can also generate a wildcard certificate that is valid for all subdomains of a domain, by prefixing the domain name with `*.` (e.g. `*.example.org`). The domain itself is not covered by the wildcard certificate, and also needs to be added to the CSR if necessary. +You can also generate a wildcard certificate that is valid for all subdomains of a domain, by prefixing the domain name with `*.` (e.g. `*.example.org`). The domain itself is not covered by the wildcard certificate, and also needs to be added to the order if necessary. You must be able to prove ownership of the domain that you want to order a wildcard certificate for. The corresponding `Authorization` resource only refers to that domain, and does not contain the wildcard notation. -The following example creates a CSR for `example.org` and `*.example.org`: +The following example creates an `Order` and a CSR for `example.org` and `*.example.org`: ```java +Order order = account.newOrder() + .domains("example.org", "*.example.org") + .create(); + KeyPair domainKeyPair = ... // KeyPair to be used for HTTPS encryption CSRBuilder csrb = new CSRBuilder();