Evaluate retry-after header

pull/30/head
Richard Körber 2016-07-21 00:56:22 +02:00
parent cef5984f81
commit 5049cd5ffd
9 changed files with 241 additions and 10 deletions

View File

@ -28,6 +28,7 @@ import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.TimestampParser;
import org.slf4j.Logger;
@ -162,6 +163,13 @@ public class Authorization extends AcmeResource {
/**
* Updates the {@link Authorization}. After invocation, the {@link Authorization}
* reflects the current state at the ACME server.
*
* @throws AcmeRetryAfterException
* the auhtorization is still being validated, and the server returned an
* estimated date when the validation will be completed. If you are
* polling for the authorization to complete, you should wait for the date
* given in {@link AcmeRetryAfterException#getRetryAfter()}. Note that the
* authorization status is updated even if this exception was thrown.
*/
public void update() throws AcmeException {
LOG.debug("update");
@ -171,10 +179,15 @@ public class Authorization extends AcmeResource {
conn.throwAcmeException();
}
// HTTP_ACCEPTED requires Retry-After header to be set
Map<String, Object> result = conn.readJsonResponse();
unmarshalAuthorization(result);
if (rc == HttpURLConnection.HTTP_ACCEPTED) {
Date retryAfter = conn.getRetryAfterHeader();
throw new AcmeRetryAfterException(
"authorization is not completed yet",
retryAfter);
}
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}

View File

@ -19,6 +19,7 @@ import java.net.URI;
import java.security.cert.CertificateEncodingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import org.shredzone.acme4j.connector.Connection;
@ -26,6 +27,7 @@ import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -80,20 +82,29 @@ public class Certificate extends AcmeResource {
* Downloads the certificate. The result is cached.
*
* @return {@link X509Certificate} that was downloaded
* @throws AcmeRetryAfterException
* the certificate is still being created, and the server returned an
* estimated date when it will be ready for download. You should wait for
* the date given in {@link AcmeRetryAfterException#getRetryAfter()}
* before trying again.
*/
public X509Certificate download() throws AcmeException {
if (cert == null) {
LOG.debug("download");
try (Connection conn = getSession().provider().connect()) {
int rc = conn.sendRequest(getLocation());
if (rc == HttpURLConnection.HTTP_ACCEPTED) {
Date retryAfter = conn.getRetryAfterHeader();
throw new AcmeRetryAfterException(
"certificate is not available for download yet",
retryAfter);
}
if (rc != HttpURLConnection.HTTP_OK) {
conn.throwAcmeException();
}
// TODO: HTTP_ACCEPTED plus Retry-After header if not yet available
chainCertUri = conn.getLink("up");
cert = conn.readCertificate();
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
@ -106,6 +117,11 @@ public class Certificate extends AcmeResource {
* Downloads the certificate chain. The result is cached.
*
* @return Chain of {@link X509Certificate}s
* @throws AcmeRetryAfterException
* the certificate is still being created, and the server returned an
* estimated date when it will be ready for download. You should wait for
* the date given in {@link AcmeRetryAfterException#getRetryAfter()}
* before trying again.
*/
public X509Certificate[] downloadChain() throws AcmeException {
if (chain == null) {

View File

@ -31,6 +31,7 @@ import org.shredzone.acme4j.connector.Connection;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeNetworkException;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.TimestampParser;
import org.slf4j.Logger;
@ -228,16 +229,31 @@ public class Challenge extends AcmeResource {
/**
* Updates the state of this challenge.
*
* @throws AcmeRetryAfterException
* the challenge is still being validated, and the server returned an
* estimated date when the challenge will be completed. If you are polling
* for the challenge to complete, you should wait for the date given in
* {@link AcmeRetryAfterException#getRetryAfter()}. Note that the
* challenge status is updated even if this exception was thrown.
*/
public void update() throws AcmeException {
LOG.debug("update");
try (Connection conn = getSession().provider().connect()) {
int rc = conn.sendRequest(getLocation());
if (rc != HttpURLConnection.HTTP_ACCEPTED) {
if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_ACCEPTED) {
conn.throwAcmeException();
}
unmarshall(conn.readJsonResponse());
if (rc == HttpURLConnection.HTTP_ACCEPTED) {
Date retryAfter = conn.getRetryAfterHeader();
if (retryAfter != null) {
throw new AcmeRetryAfterException("challenge is not completed yet",
retryAfter);
}
}
} catch (IOException ex) {
throw new AcmeNetworkException(ex);
}

View File

@ -0,0 +1,41 @@
/*
* acme4j - Java ACME client
*
* Copyright (C) 2016 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.exception;
import java.util.Date;
/**
* This exception is thrown when a server side process has not been completed yet, and the
* server returned an estimated retry date.
*
* @author Richard "Shred" Körber
*/
public class AcmeRetryAfterException extends AcmeException {
private static final long serialVersionUID = 4461979121063649905L;
private final Date retryAfter;
public AcmeRetryAfterException(String msg, Date retryAfter) {
super(msg);
this.retryAfter = retryAfter;
}
/**
* Returns the retry-after date returned by the server.
*/
public Date getRetryAfter() {
return (retryAfter != null ? new Date(retryAfter.getTime()) : null);
}
}

View File

@ -14,13 +14,14 @@
package org.shredzone.acme4j;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.*;
import static org.shredzone.acme4j.util.TestUtils.getJsonAsMap;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.util.Collection;
import java.util.Date;
import java.util.Map;
import org.junit.Test;
@ -28,6 +29,7 @@ import org.shredzone.acme4j.challenge.Challenge;
import org.shredzone.acme4j.challenge.Dns01Challenge;
import org.shredzone.acme4j.challenge.Http01Challenge;
import org.shredzone.acme4j.challenge.TlsSni02Challenge;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.TimestampParser;
@ -156,6 +158,64 @@ public class AuthorizationTest {
provider.close();
}
/**
* Test that authorization is properly updated, with retry-after header set.
*/
@Test
public void testUpdateRetryAfter() throws Exception {
final long retryAfter = System.currentTimeMillis() + 30 * 1000L;
TestableConnectionProvider provider = new TestableConnectionProvider() {
@Override
public int sendRequest(URI uri) {
assertThat(uri, is(locationUri));
return HttpURLConnection.HTTP_ACCEPTED;
}
@Override
public Map<String, Object> readJsonResponse() {
return getJsonAsMap("updateAuthorizationResponse");
}
@Override
public Date getRetryAfterHeader() {
return new Date(retryAfter);
}
};
Session session = provider.createSession();
Http01Challenge httpChallenge = new Http01Challenge(session);
Dns01Challenge dnsChallenge = new Dns01Challenge(session);
provider.putTestChallenge("http-01", httpChallenge);
provider.putTestChallenge("dns-01", dnsChallenge);
Authorization auth = new Authorization(session, locationUri);
try {
auth.update();
fail("Expected AcmeRetryAfterException");
} catch (AcmeRetryAfterException ex) {
assertThat(ex.getRetryAfter(), is(new Date(retryAfter)));
}
assertThat(auth.getDomain(), is("example.org"));
assertThat(auth.getStatus(), is(Status.VALID));
assertThat(auth.getExpires(), is(TimestampParser.parse("2016-01-02T17:12:40Z")));
assertThat(auth.getLocation(), is(locationUri));
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();
}
/**
* Test that an authorization can be deactivated.
*/

View File

@ -14,7 +14,7 @@
package org.shredzone.acme4j;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.*;
import static org.shredzone.acme4j.util.TestUtils.getJson;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
@ -22,10 +22,12 @@ import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.Date;
import org.junit.Test;
import org.shredzone.acme4j.connector.Resource;
import org.shredzone.acme4j.exception.AcmeException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.TestUtils;
@ -84,6 +86,38 @@ public class CertificateTest {
provider.close();
}
/**
* Test that a {@link AcmeRetryAfterException} is thrown.
*/
@Test
public void testRetryAfter() throws AcmeException, IOException {
final long retryAfter = System.currentTimeMillis() + 30 * 1000L;
TestableConnectionProvider provider = new TestableConnectionProvider() {
@Override
public int sendRequest(URI uri) {
assertThat(uri, is(locationUri));
return HttpURLConnection.HTTP_ACCEPTED;
}
@Override
public Date getRetryAfterHeader() {
return new Date(retryAfter);
}
};
Certificate cert = new Certificate(provider.createSession(), locationUri);
try {
cert.download();
fail("Expected AcmeRetryAfterException");
} catch (AcmeRetryAfterException ex) {
assertThat(ex.getRetryAfter(), is(new Date(retryAfter)));
}
provider.close();
}
/**
* Test that a certificate can be revoked.
*/

View File

@ -14,7 +14,7 @@
package org.shredzone.acme4j.challenge;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.*;
import static org.shredzone.acme4j.util.TestUtils.*;
import static uk.co.datumedge.hamcrest.json.SameJSONAs.sameJSONAs;
@ -27,6 +27,7 @@ import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.KeyPair;
import java.util.Date;
import java.util.Map;
import org.jose4j.base64url.Base64Url;
@ -39,6 +40,7 @@ import org.junit.Test;
import org.shredzone.acme4j.Session;
import org.shredzone.acme4j.Status;
import org.shredzone.acme4j.exception.AcmeProtocolException;
import org.shredzone.acme4j.exception.AcmeRetryAfterException;
import org.shredzone.acme4j.provider.TestableConnectionProvider;
import org.shredzone.acme4j.util.ClaimBuilder;
import org.shredzone.acme4j.util.SignatureUtils;
@ -208,7 +210,7 @@ public class ChallengeTest {
@Override
public int sendRequest(URI uri) {
assertThat(uri, is(locationUri));
return HttpURLConnection.HTTP_ACCEPTED;
return HttpURLConnection.HTTP_OK;
}
@Override
@ -230,6 +232,49 @@ public class ChallengeTest {
provider.close();
}
/**
* Test that a challenge is properly updated, with Retry-After header.
*/
@Test
public void testUpdateRetryAfter() throws Exception {
final long retryAfter = System.currentTimeMillis() + 30 * 1000L;
TestableConnectionProvider provider = new TestableConnectionProvider() {
@Override
public int sendRequest(URI uri) {
assertThat(uri, is(locationUri));
return HttpURLConnection.HTTP_ACCEPTED;
}
@Override
public Map<String, Object> readJsonResponse() {
return getJsonAsMap("updateHttpChallengeResponse");
}
@Override
public Date getRetryAfterHeader() {
return new Date(retryAfter);
}
};
Session session = provider.createSession();
Challenge challenge = new Http01Challenge(session);
challenge.unmarshall(getJsonAsMap("triggerHttpChallengeResponse"));
try {
challenge.update();
fail("Expected AcmeRetryAfterException");
} catch (AcmeRetryAfterException ex) {
assertThat(ex.getRetryAfter(), is(new Date(retryAfter)));
}
assertThat(challenge.getStatus(), is(Status.VALID));
assertThat(challenge.getLocation(), is(locationUri));
provider.close();
}
/**
* Test that challenge serialization works correctly.
*/

View File

@ -48,6 +48,8 @@ while (challenge.getStatus() != Status.VALID) {
This is a very simple example. You should limit the number of loop iterations, and abort the loop when the status should turn to `INVALID`. If you know when the CA server actually requested your response (e.g. when you notice a HTTP request on the response file), you should start polling after that event.
`update()` may throw an `AcmeRetryAfterException`, giving an estimated time in `getRetryAfter()` for when the challenge is completed. You should then wait until that moment has been reached, before trying again. The challenge state is still updated when this exception is thrown.
As soon as all the necessary challenges are `VALID`, you have successfully associated the domain with your account.
If your final certificate will contain further domains or subdomains, repeat the authorization run with each of them.
@ -67,6 +69,8 @@ auth.update();
After invoking `update()`, the `Authorization` object contains the current server state about your authorization, including the domain name, the overall status, and an expiry date.
`update()` may throw an `AcmeRetryAfterException`, giving an estimated time in `getRetryAfter()` for when all challenges are completed. You should then wait until that moment has been reached, before trying again. The authorization state is still updated when this exception is thrown.
## Deactivate an Authorization
It is possible to deactivate an `Authorization`, for example if you sell the associated domain.

View File

@ -45,6 +45,8 @@ X509Certificate[] chain = cert.downloadChain();
Congratulations! You have just created your first certificate via _acme4j_.
`download()` may throw an `AcmeRetryAfterException`, giving an estimated time in `getRetryAfter()` for when the certificate is ready for download. You should then wait until that moment has been reached, before trying again.
To recreate a `Certificate` object from the location, just bind it:
```java