diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java index ca1f034f..c28b70ce 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/DefaultConnection.java @@ -137,32 +137,7 @@ public class DefaultConnection implements Connection { @Override public void sendRequest(URL url, Session session) throws AcmeException { - Objects.requireNonNull(url, "url"); - Objects.requireNonNull(session, "session"); - assertConnectionIsClosed(); - - LOG.debug("GET {}", url); - - try { - conn = httpConnector.openConnection(url, session.getProxy()); - conn.setRequestMethod("GET"); - conn.setRequestProperty(ACCEPT_HEADER, MIME_JSON); - conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET); - conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag()); - conn.setDoOutput(false); - - conn.connect(); - - logHeaders(); - - int rc = conn.getResponseCode(); - if (rc != HttpURLConnection.HTTP_OK) { - throwAcmeException(); - } - - } catch (IOException ex) { - throw new AcmeNetworkException(ex); - } + sendRequest(url, session, MIME_JSON); } @Override @@ -189,31 +164,6 @@ public class DefaultConnection implements Connection { return sendSignedRequest(url, claims, session, keypair, null, MIME_JSON); } - private int sendSignedRequest(URL url, @Nullable JSONBuilder claims, Session session, - KeyPair keypair, @Nullable URL accountLocation, String accept) throws AcmeException { - Objects.requireNonNull(url, "url"); - Objects.requireNonNull(session, "session"); - Objects.requireNonNull(keypair, "keypair"); - Objects.requireNonNull(accept, "accept"); - assertConnectionIsClosed(); - - AcmeException lastException = null; - - for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - return performRequest(url, claims, session, keypair, accountLocation, accept); - } catch (AcmeServerException ex) { - if (!BAD_NONCE_ERROR.equals(ex.getType())) { - throw ex; - } - lastException = ex; - LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS); - } - } - - throw new AcmeException("Too many reattempts", lastException); - } - @Override @CheckForNull public JSON readJsonResponse() throws AcmeException { @@ -320,6 +270,96 @@ public class DefaultConnection implements Connection { conn = null; } + /** + * Sends an unsigned GET request. + * + * @param url + * {@link URL} to send the request to. + * @param session + * {@link Session} instance to be used for signing and tracking + * @param accept + * Accept header + * @return HTTP 200 class status that was returned + */ + protected int sendRequest(URL url, Session session, String accept) throws AcmeException { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(accept, "accept"); + assertConnectionIsClosed(); + + LOG.debug("GET {}", url); + + try { + conn = httpConnector.openConnection(url, session.getProxy()); + conn.setRequestMethod("GET"); + conn.setRequestProperty(ACCEPT_HEADER, accept); + conn.setRequestProperty(ACCEPT_CHARSET_HEADER, DEFAULT_CHARSET); + conn.setRequestProperty(ACCEPT_LANGUAGE_HEADER, session.getLocale().toLanguageTag()); + conn.setDoOutput(false); + + conn.connect(); + + logHeaders(); + + String nonce = getNonce(); + if (nonce != null) { + session.setNonce(nonce); + } + + int rc = conn.getResponseCode(); + if (rc != HttpURLConnection.HTTP_OK && rc != HttpURLConnection.HTTP_CREATED) { + throwAcmeException(); + } + return rc; + } catch (IOException ex) { + throw new AcmeNetworkException(ex); + } + } + + /** + * Sends a signed POST request. + * + * @param url + * {@link URL} to send the request to. + * @param claims + * {@link JSONBuilder} containing claims. {@code null} for POST-as-GET + * request. + * @param session + * {@link Session} instance to be used for signing and tracking + * @param keypair + * {@link KeyPair} to be used for signing + * @param accountLocation + * If set, the account location is set as "kid" header. If {@code null}, + * the public key is set as "jwk" header. + * @param accept + * Accept header + * @return HTTP 200 class status that was returned + */ + protected int sendSignedRequest(URL url, @Nullable JSONBuilder claims, Session session, + KeyPair keypair, @Nullable URL accountLocation, String accept) throws AcmeException { + Objects.requireNonNull(url, "url"); + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(keypair, "keypair"); + Objects.requireNonNull(accept, "accept"); + assertConnectionIsClosed(); + + AcmeException lastException = null; + + for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return performRequest(url, claims, session, keypair, accountLocation, accept); + } catch (AcmeServerException ex) { + if (!BAD_NONCE_ERROR.equals(ex.getType())) { + throw ex; + } + lastException = ex; + LOG.info("Bad Replay Nonce, trying again (attempt {}/{})", attempt, MAX_ATTEMPTS); + } + } + + throw new AcmeException("Too many reattempts", lastException); + } + /** * Performs the POST request. * diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/connector/PreDraft15Connection.java b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/PreDraft15Connection.java new file mode 100644 index 00000000..ffeeab09 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/connector/PreDraft15Connection.java @@ -0,0 +1,49 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2018 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.connector; + +import java.net.URL; + +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.exception.AcmeException; + +/** + * This {@link Connection} is used for servers that do not implement the POST-as-GET + * feature that was introduced in ACME draft-15. + * + * @since 2.4 + * @deprecated Only meant for compatibility purposes. If your server needs this + * connection, it should be fixed soon. + */ +@Deprecated +public class PreDraft15Connection extends DefaultConnection { + + private static final String MIME_JSON = "application/json"; + private static final String MIME_CERTIFICATE_CHAIN = "application/pem-certificate-chain"; + + public PreDraft15Connection(HttpConnector httpConnector) { + super(httpConnector); + } + + @Override + public int sendCertificateRequest(URL url, Login login) throws AcmeException { + return sendRequest(url, login.getSession(), MIME_CERTIFICATE_CHAIN); + } + + @Override + public int sendSignedPostAsGetRequest(URL url, Login login) throws AcmeException { + return sendRequest(url, login.getSession(), MIME_JSON); + } + +} diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProvider.java b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProvider.java index 1df319c8..cd953481 100644 --- a/acme4j-client/src/main/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProvider.java +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProvider.java @@ -19,6 +19,7 @@ import java.net.URL; import javax.annotation.ParametersAreNonnullByDefault; +import org.shredzone.acme4j.connector.Connection; import org.shredzone.acme4j.exception.AcmeProtocolException; import org.shredzone.acme4j.provider.AbstractAcmeProvider; import org.shredzone.acme4j.provider.AcmeProvider; @@ -64,4 +65,10 @@ public class LetsEncryptAcmeProvider extends AbstractAcmeProvider { } } + @Override + @SuppressWarnings("deprecation") + public Connection connect() { + return new org.shredzone.acme4j.connector.PreDraft15Connection(createHttpConnector()); + } + } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java index 43bad46f..62f91f17 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/DefaultConnectionTest.java @@ -648,7 +648,12 @@ public class DefaultConnectionTest { public void testSendRequest() throws Exception { when(mockUrlConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); - try (DefaultConnection conn = new DefaultConnection(mockHttpConnection)) { + try (DefaultConnection conn = new DefaultConnection(mockHttpConnection) { + @Override + public String getNonce() { + return null; + } + }) { conn.sendRequest(requestUrl, session); } diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/connector/PreDraft15ConnectionTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/PreDraft15ConnectionTest.java new file mode 100644 index 00000000..baba975b --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/connector/PreDraft15ConnectionTest.java @@ -0,0 +1,125 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2018 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.connector; + +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.Proxy; +import java.net.URI; +import java.net.URL; +import java.security.KeyPair; +import java.util.Locale; + +import org.jose4j.base64url.Base64Url; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentMatchers; +import org.shredzone.acme4j.Login; +import org.shredzone.acme4j.Session; +import org.shredzone.acme4j.exception.AcmeException; +import org.shredzone.acme4j.provider.AcmeProvider; +import org.shredzone.acme4j.toolbox.TestUtils; + +/** + * Unit tests for {@link PreDraft15Connection}. + */ +@SuppressWarnings("deprecation") +public class PreDraft15ConnectionTest { + + private URL requestUrl = TestUtils.url("http://example.com/acme/"); + private URL accountUrl = TestUtils.url(TestUtils.ACCOUNT_URL); + private HttpURLConnection mockUrlConnection; + private HttpConnector mockHttpConnection; + private Session session; + private Login login; + private KeyPair keyPair; + + @Before + public void setup() throws AcmeException, IOException { + mockUrlConnection = mock(HttpURLConnection.class); + + mockHttpConnection = mock(HttpConnector.class); + when(mockHttpConnection.openConnection(requestUrl, Proxy.NO_PROXY)).thenReturn(mockUrlConnection); + + final AcmeProvider mockProvider = mock(AcmeProvider.class); + when(mockProvider.directory( + ArgumentMatchers.any(Session.class), + ArgumentMatchers.eq(URI.create(TestUtils.ACME_SERVER_URI)))) + .thenReturn(TestUtils.getJSON("directory")); + + session = TestUtils.session(mockProvider); + session.setLocale(Locale.JAPAN); + + keyPair = TestUtils.createKeyPair(); + + login = session.login(accountUrl, keyPair); + } + + /** + * Test signed POST-as-GET requests in compatibility mode. + */ + @Test + public void testSendSignedPostAsGetRequest() throws Exception { + when(mockUrlConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + + try (PreDraft15Connection conn = new PreDraft15Connection(mockHttpConnection) { + @Override + public String getNonce() { + return Base64Url.encode("foo-nonce-1-foo".getBytes()); + } + }) { + conn.sendSignedPostAsGetRequest(requestUrl, login); + } + + verify(mockUrlConnection).setRequestMethod("GET"); + verify(mockUrlConnection).setRequestProperty("Accept", "application/json"); + verify(mockUrlConnection).setRequestProperty("Accept-Charset", "utf-8"); + verify(mockUrlConnection).setRequestProperty("Accept-Language", "ja-JP"); + verify(mockUrlConnection).setDoOutput(false); + verify(mockUrlConnection).connect(); + verify(mockUrlConnection).getResponseCode(); + verify(mockUrlConnection, atLeast(0)).getHeaderFields(); + verifyNoMoreInteractions(mockUrlConnection); + } + + /** + * Test certificate POST-as-GET requests in compatibility mode. + */ + @Test + public void testSendCertificateRequest() throws Exception { + when(mockUrlConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + + try (PreDraft15Connection conn = new PreDraft15Connection(mockHttpConnection) { + @Override + public String getNonce() { + return Base64Url.encode("foo-nonce-1-foo".getBytes()); + } + }) { + conn.sendCertificateRequest(requestUrl, login); + } + + verify(mockUrlConnection).setRequestMethod("GET"); + verify(mockUrlConnection).setRequestProperty("Accept", "application/pem-certificate-chain"); + verify(mockUrlConnection).setRequestProperty("Accept-Charset", "utf-8"); + verify(mockUrlConnection).setRequestProperty("Accept-Language", "ja-JP"); + verify(mockUrlConnection).setDoOutput(false); + verify(mockUrlConnection).connect(); + verify(mockUrlConnection).getResponseCode(); + verify(mockUrlConnection, atLeast(0)).getHeaderFields(); + verifyNoMoreInteractions(mockUrlConnection); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProviderTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProviderTest.java index 19dd32b6..5f1c357c 100644 --- a/acme4j-client/src/test/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProviderTest.java +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/provider/letsencrypt/LetsEncryptAcmeProviderTest.java @@ -13,6 +13,7 @@ */ package org.shredzone.acme4j.provider.letsencrypt; +import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.Matchers.is; import static org.junit.Assert.*; import static org.shredzone.acme4j.toolbox.TestUtils.url; @@ -21,6 +22,7 @@ import java.net.URI; import java.net.URISyntaxException; import org.junit.Test; +import org.shredzone.acme4j.connector.Connection; /** * Unit tests for {@link LetsEncryptAcmeProvider}. @@ -66,4 +68,15 @@ public class LetsEncryptAcmeProviderTest { } } + /** + * Test that Boulder is still having pre draft-15 connections. + */ + @Test + @SuppressWarnings("deprecation") + public void testConnect() { + LetsEncryptAcmeProvider provider = new LetsEncryptAcmeProvider(); + Connection connection = provider.connect(); + assertThat(connection, is(instanceOf(org.shredzone.acme4j.connector.PreDraft15Connection.class))); + } + }