From b593ca7311351296a6e65771e9645aacfdec870d Mon Sep 17 00:00:00 2001 From: Thilo Schwarz Date: Fri, 23 Jan 2026 15:36:17 +0100 Subject: [PATCH] issue #11 -Implement authentication refactor: Add `CfAuth` interface, `ApiTokenAuth` and `EmailKeyAuth` implementations, and `CfAuthBuilder` for flexible authentication in `CfDnsClient`. Update tests and adjust related classes for compatibility. --- .../codes/thischwa/cf/CfBasicHttpClient.java | 30 +++---- .../java/codes/thischwa/cf/CfDnsClient.java | 78 ++++++++++--------- .../codes/thischwa/cf/auth/ApiTokenAuth.java | 31 ++++++++ .../java/codes/thischwa/cf/auth/CfAuth.java | 19 +++++ .../codes/thischwa/cf/auth/CfAuthBuilder.java | 12 +++ .../codes/thischwa/cf/auth/EmailKeyAuth.java | 38 +++++++++ .../codes/thischwa/cf/auth/package-info.java | 5 ++ .../codes/thischwa/cf/model/RecordEntity.java | 16 ++-- .../codes/thischwa/cf/model/RecordType.java | 2 +- .../codes/thischwa/cf/CfClientPenTest.java | 11 ++- .../java/codes/thischwa/cf/CfClientTest.java | 29 +++---- 11 files changed, 195 insertions(+), 76 deletions(-) create mode 100644 src/main/java/codes/thischwa/cf/auth/ApiTokenAuth.java create mode 100644 src/main/java/codes/thischwa/cf/auth/CfAuth.java create mode 100644 src/main/java/codes/thischwa/cf/auth/CfAuthBuilder.java create mode 100644 src/main/java/codes/thischwa/cf/auth/EmailKeyAuth.java create mode 100644 src/main/java/codes/thischwa/cf/auth/package-info.java diff --git a/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java b/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java index a2ed0c3..875f7fa 100644 --- a/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java +++ b/src/main/java/codes/thischwa/cf/CfBasicHttpClient.java @@ -1,5 +1,6 @@ package codes.thischwa.cf; +import codes.thischwa.cf.auth.CfAuth; import codes.thischwa.cf.model.AbstractResponse; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -19,6 +20,7 @@ import org.apache.hc.core5.http.HttpHeaders; import org.apache.hc.core5.http.io.entity.EntityUtils; import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.http.message.BasicClassicHttpRequest; +import org.jetbrains.annotations.NotNull; /** * Abstract base class for creating HTTP clients to interact with the Cloudflare API. Provides @@ -27,23 +29,20 @@ import org.apache.hc.core5.http.message.BasicClassicHttpRequest; */ @Slf4j abstract class CfBasicHttpClient { - private final String baseUrl; - private final String authEmail; - private final String authKey; + private final String baseUrl; + private final CfAuth auth; private final ObjectMapper objectMapper; - CfBasicHttpClient(String baseUrl, String authEmail, String authKey) - throws IllegalArgumentException { - if (authEmail == null || authEmail.isBlank()) { - throw new IllegalArgumentException("Authentication email must not be null or blank!"); - } - if (authKey == null || authKey.isBlank()) { - throw new IllegalArgumentException("Authentication key must not be null or blank!"); - } + /** + * Creates a new Cloudflare HTTP client with the specified base URL and authentication. + * + * @param baseUrl the base URL for the Cloudflare API + * @param auth the authentication mechanism to use + */ + CfBasicHttpClient(@NotNull String baseUrl, @NotNull CfAuth auth) { this.baseUrl = baseUrl; - this.authEmail = authEmail; - this.authKey = authKey; + this.auth = auth; this.objectMapper = JsonConf.initObjectMapper(); } @@ -53,8 +52,9 @@ abstract class CfBasicHttpClient { request.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); request.addHeader(HttpHeaders.ACCEPT, ContentType.APPLICATION_JSON.getMimeType()); request.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType()); - request.addHeader("X-Auth-Email", authEmail); - request.addHeader("X-Auth-Key", authKey); + if (request instanceof ClassicHttpRequest classicRequest) { + auth.applyAuth(classicRequest); + } }).build(); } diff --git a/src/main/java/codes/thischwa/cf/CfDnsClient.java b/src/main/java/codes/thischwa/cf/CfDnsClient.java index 3e78488..bdc6d5f 100644 --- a/src/main/java/codes/thischwa/cf/CfDnsClient.java +++ b/src/main/java/codes/thischwa/cf/CfDnsClient.java @@ -1,5 +1,6 @@ package codes.thischwa.cf; +import codes.thischwa.cf.auth.CfAuth; import codes.thischwa.cf.fluent.ZoneOperations; import codes.thischwa.cf.fluent.ZoneOperationsImpl; import codes.thischwa.cf.model.AbstractResponse; @@ -26,57 +27,71 @@ import org.jetbrains.annotations.Nullable; * records and zones within the Cloudflare system, including creating, updating, retrieving, and * deleting DNS records. * - *

Example: + *

Example with API token authentication (recommended): *


- * // Create a new CfDnsClient instance
- * CfDnsClient cfDnsClient = new CfDnsClient(
- *     "email@example.com",
- *     "yourApiKey"
- * );
+ * // Create a new CfDnsClient instance with API token
+ * CfDnsClient cfDnsClient = new CfDnsClient(CfAuthBuilder.build("your-api-token"));
+ *
  * // Retrieve a zone
  * ZoneEntity zone = cfDnsClient.zoneGet("example.com");
  * System.out.println("Zone ID: " + zone.getId());
+ *
  * // Retrieve records of a subdomain
- * List<{@link RecordEntity}> records = cfDnsClient.recordGet(zone, "sld");
+ * List<RecordEntity> records = cfDnsClient.recordList(zone, "sld");
  * records.forEach(record ->
  *     System.out.println("Record Type: " + record.getType() + ", Value: " + record.getContent())
  * );
+ *
  * // Create a record for the subdomain "api"
  * RecordEntity created = cfDnsClient.recordCreateSld(zone, "api", 60, RecordType.A, "192.168.1.10");
  * System.out.println("Created Record ID: " + created.getId());
  * 
+ * + *

Example with email/key authentication (legacy): + *


+ * CfDnsClient cfDnsClient = new CfDnsClient(
+ *     CfAuthBuilder.build("email@example.com", "your-api-key")
+ * );
+ * 
+ * + *

Example with exception throwing enabled: + *


+ * // Throws exception when results are empty
+ * CfDnsClient cfDnsClient = new CfDnsClient(true, CfAuthBuilder.build("your-api-token"));
+ * 
+ * + *

Example with custom base URL: + *


+ * CfAuth auth = CfAuthBuilder.build("your-api-token");
+ * auth.setBaseUrl("https://custom-api.example.com");
+ * CfDnsClient cfDnsClient = new CfDnsClient(auth);
+ * 
*/ @Slf4j public class CfDnsClient extends CfBasicHttpClient { - private static final String DEFAULT_BASEURL = "https://api.cloudflare.com/client/v4"; + public static final String DEFAULT_BASEURL = "https://api.cloudflare.com/client/v4"; private final ResponseValidator responseValidator; private final boolean emptyResultThrowsException; /** - * Constructs a new instance of {@code CfDnsClient}. + * Constructs a new instance of {@code CfDnsClient} with default configuration. * - * @param authEmail The email address associated with the Cloudflare account, used for - * authentication. - * @param authKey The API key of the Cloudflare account, used as part of the authentication - * process. + * @param auth The authentication mechanism to use (ApiTokenAuth or EmailKeyAuth) */ - public CfDnsClient(String authEmail, String authKey) { - this(DEFAULT_BASEURL, authEmail, authKey); + public CfDnsClient(CfAuth auth) { + this(false, DEFAULT_BASEURL, auth); } /** * Constructs a new instance of {@code CfDnsClient}. * - * @param baseUrl The base URL of the Cloudflare API to be used for requests. - * @param authEmail The email address associated with the Cloudflare account, used for - * authentication. - * @param authKey The API key of the Cloudflare account, used as part of the authentication - * process. + * @param baseUrl The base URL of the Cloudflare API to be used for requests. + * @param auth The authentication mechanism to use (ApiTokenAuth or EmailKeyAuth) */ - public CfDnsClient(String baseUrl, String authEmail, String authKey) { - this(false, baseUrl, authEmail, authKey); + public CfDnsClient(String baseUrl, CfAuth auth) { + this(false, baseUrl, auth); } /** @@ -85,13 +100,10 @@ public class CfDnsClient extends CfBasicHttpClient { * @param emptyResultThrowsException A boolean value indicating whether an exception should be * thrown when the result is empty. Applies to both single and * multiple result requests. Default is false. - * @param authEmail The email address associated with the Cloudflare account, - * used for authentication. - * @param authKey The API key of the Cloudflare account, used as part of the - * authentication process. + * @param auth The authentication mechanism to use (ApiTokenAuth or EmailKeyAuth) */ - public CfDnsClient(boolean emptyResultThrowsException, String authEmail, String authKey) { - this(emptyResultThrowsException, DEFAULT_BASEURL, authEmail, authKey); + public CfDnsClient(boolean emptyResultThrowsException, CfAuth auth) { + this(emptyResultThrowsException, DEFAULT_BASEURL, auth); } /** @@ -101,14 +113,10 @@ public class CfDnsClient extends CfBasicHttpClient { * thrown when the result is empty. Applies to both single and * multiple result requests. Default is false. * @param baseUrl The base URL for the Cloudflare API endpoint. - * @param authEmail The email associated with the Cloudflare account for - * authentication. - * @param authKey The API key for authenticating the client with Cloudflare - * services. + * @param auth The authentication mechanism to use (ApiTokenAuth or EmailKeyAuth) */ - public CfDnsClient(boolean emptyResultThrowsException, String baseUrl, String authEmail, - String authKey) { - super(baseUrl, authEmail, authKey); + public CfDnsClient(boolean emptyResultThrowsException, String baseUrl, CfAuth auth) { + super(baseUrl, auth); this.responseValidator = new ResponseValidator(emptyResultThrowsException); this.emptyResultThrowsException = emptyResultThrowsException; } diff --git a/src/main/java/codes/thischwa/cf/auth/ApiTokenAuth.java b/src/main/java/codes/thischwa/cf/auth/ApiTokenAuth.java new file mode 100644 index 0000000..8450df9 --- /dev/null +++ b/src/main/java/codes/thischwa/cf/auth/ApiTokenAuth.java @@ -0,0 +1,31 @@ +package codes.thischwa.cf.auth; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.jetbrains.annotations.NotNull; + +/** + * Authentication mechanism using Cloudflare API token. + * This is the recommended authentication method for the Cloudflare API. + */ +public class ApiTokenAuth implements CfAuth { + + private final String apiToken; + + /** + * Creates a new API token authentication object. + * + * @param apiToken the Cloudflare API token + * @throws IllegalArgumentException if the API token is null or blank + */ + public ApiTokenAuth(@NotNull String apiToken) { + if (apiToken.isBlank()) { + throw new IllegalArgumentException("API token must not be null or blank!"); + } + this.apiToken = apiToken; + } + + @Override + public void applyAuth(ClassicHttpRequest request) { + request.addHeader("Authorization", "Bearer " + apiToken); + } +} diff --git a/src/main/java/codes/thischwa/cf/auth/CfAuth.java b/src/main/java/codes/thischwa/cf/auth/CfAuth.java new file mode 100644 index 0000000..5df4ad6 --- /dev/null +++ b/src/main/java/codes/thischwa/cf/auth/CfAuth.java @@ -0,0 +1,19 @@ +package codes.thischwa.cf.auth; + +import org.apache.hc.core5.http.ClassicHttpRequest; + +/** + * Interface for Cloudflare authentication mechanisms. + * Implementations of this interface provide different methods of authentication + * with the Cloudflare API (e.g., API token, email/key combination). + */ +public interface CfAuth { + + /** + * Applies authentication headers to the given HTTP request. + * + * @param request the HTTP request to authenticate + */ + void applyAuth(ClassicHttpRequest request); + +} diff --git a/src/main/java/codes/thischwa/cf/auth/CfAuthBuilder.java b/src/main/java/codes/thischwa/cf/auth/CfAuthBuilder.java new file mode 100644 index 0000000..05b3d46 --- /dev/null +++ b/src/main/java/codes/thischwa/cf/auth/CfAuthBuilder.java @@ -0,0 +1,12 @@ +package codes.thischwa.cf.auth; + +public class CfAuthBuilder { + + public static ApiTokenAuth build(String apiToken) { + return new ApiTokenAuth(apiToken); + } + + public static EmailKeyAuth build(String authEmail, String authKey) { + return new EmailKeyAuth(authEmail, authKey); + } +} diff --git a/src/main/java/codes/thischwa/cf/auth/EmailKeyAuth.java b/src/main/java/codes/thischwa/cf/auth/EmailKeyAuth.java new file mode 100644 index 0000000..ab3e6ab --- /dev/null +++ b/src/main/java/codes/thischwa/cf/auth/EmailKeyAuth.java @@ -0,0 +1,38 @@ +package codes.thischwa.cf.auth; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.jetbrains.annotations.NotNull; + +/** + * Authentication mechanism using Cloudflare account email and API key. + * This is the legacy authentication method for the Cloudflare API. + */ +public class EmailKeyAuth implements CfAuth { + + private final String authEmail; + private final String authKey; + + /** + * Creates a new email/key authentication object. + * + * @param authEmail the email address associated with the Cloudflare account + * @param authKey the API key of the Cloudflare account + * @throws IllegalArgumentException if email or key is null or blank + */ + public EmailKeyAuth(@NotNull String authEmail, @NotNull String authKey) { + if (authEmail.isBlank()) { + throw new IllegalArgumentException("Authentication email must not be null or blank!"); + } + if (authKey.isBlank()) { + throw new IllegalArgumentException("Authentication key must not be null or blank!"); + } + this.authEmail = authEmail; + this.authKey = authKey; + } + + @Override + public void applyAuth(ClassicHttpRequest request) { + request.addHeader("X-Auth-Email", authEmail); + request.addHeader("X-Auth-Key", authKey); + } +} diff --git a/src/main/java/codes/thischwa/cf/auth/package-info.java b/src/main/java/codes/thischwa/cf/auth/package-info.java new file mode 100644 index 0000000..c57458f --- /dev/null +++ b/src/main/java/codes/thischwa/cf/auth/package-info.java @@ -0,0 +1,5 @@ +/** + * The authentication package of CloudflareDNS-java. + */ + +package codes.thischwa.cf.auth; diff --git a/src/main/java/codes/thischwa/cf/model/RecordEntity.java b/src/main/java/codes/thischwa/cf/model/RecordEntity.java index b982e29..7fb32d3 100644 --- a/src/main/java/codes/thischwa/cf/model/RecordEntity.java +++ b/src/main/java/codes/thischwa/cf/model/RecordEntity.java @@ -64,10 +64,10 @@ public class RecordEntity extends AbstractEntity { */ public static RecordEntity build(String name, RecordType type, Integer ttl, String content) { RecordEntity rec = new RecordEntity(); - rec.setName(name); - rec.setType(type.getType()); - rec.setTtl(ttl); - rec.setContent(content); + rec.name = name; + rec.type = type.getType(); + rec.ttl = ttl; + rec.content = content; return rec; } @@ -81,7 +81,7 @@ public class RecordEntity extends AbstractEntity { public static RecordEntity build(String id, String content) { RecordEntity rec = new RecordEntity(); rec.setId(id); - rec.setContent(content); + rec.content = content; return rec; } @@ -104,8 +104,12 @@ public class RecordEntity extends AbstractEntity { throw new IllegalArgumentException("Invalid record type: " + type + ". Must be one of: " + java.util.Arrays.toString(RecordType.values()), e); } - RecordEntity rec = build(name, recordType, ttl, content); + RecordEntity rec = new RecordEntity(); rec.setId(id); + rec.name = name; + rec.type = recordType.getType(); + rec.ttl = ttl; + rec.content = content; return rec; } diff --git a/src/main/java/codes/thischwa/cf/model/RecordType.java b/src/main/java/codes/thischwa/cf/model/RecordType.java index 5fa911f..bff7a80 100644 --- a/src/main/java/codes/thischwa/cf/model/RecordType.java +++ b/src/main/java/codes/thischwa/cf/model/RecordType.java @@ -199,6 +199,6 @@ public enum RecordType { @Override public String toString() { - return getType(); + return type; } } diff --git a/src/test/java/codes/thischwa/cf/CfClientPenTest.java b/src/test/java/codes/thischwa/cf/CfClientPenTest.java index 890de9a..7110c3e 100644 --- a/src/test/java/codes/thischwa/cf/CfClientPenTest.java +++ b/src/test/java/codes/thischwa/cf/CfClientPenTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import codes.thischwa.cf.auth.CfAuthBuilder; import codes.thischwa.cf.model.RecordType; import codes.thischwa.cf.model.ZoneEntity; import java.util.List; @@ -22,24 +23,22 @@ public class CfClientPenTest { private static final String ZONE_STR = "mein-d-ns.de"; // existing baseline zone - private static final String API_EMAIL = System.getenv("API_EMAIL"); - private static final String API_KEY = System.getenv("API_KEY"); + private static final String API_TOKEN = System.getenv("API_TOKEN"); @BeforeAll static void checkEnv() { - assumeTrue(API_EMAIL != null && !API_EMAIL.isBlank(), "API_EMAIL not set; skipping pen tests"); - assumeTrue(API_KEY != null && !API_KEY.isBlank(), "API_KEY not set; skipping pen tests"); + assumeTrue(API_TOKEN != null && !API_TOKEN.isBlank(), "API_TOKEN not set; skipping pen tests"); } private CfDnsClient newClient() { - return new CfDnsClient(true, API_EMAIL, API_KEY); + return new CfDnsClient(true, CfAuthBuilder.build(API_TOKEN)); } @Test @DisplayName("Invalid credentials should not authenticate and must throw CloudflareApiException") void testInvalidCredentialsShouldFail() { // Use syntactically valid but wrong credentials - CfDnsClient badClient = new CfDnsClient("invalid@example.com", UUID.randomUUID().toString()); + CfDnsClient badClient = new CfDnsClient(CfAuthBuilder.build("invalid@example.com", UUID.randomUUID().toString())); assertThrows(CloudflareApiException.class, badClient::zoneList); } diff --git a/src/test/java/codes/thischwa/cf/CfClientTest.java b/src/test/java/codes/thischwa/cf/CfClientTest.java index 70b5c28..632cd4d 100644 --- a/src/test/java/codes/thischwa/cf/CfClientTest.java +++ b/src/test/java/codes/thischwa/cf/CfClientTest.java @@ -8,6 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import codes.thischwa.cf.auth.ApiTokenAuth; +import codes.thischwa.cf.auth.CfAuthBuilder; +import codes.thischwa.cf.auth.EmailKeyAuth; import codes.thischwa.cf.model.BatchEntry; import codes.thischwa.cf.model.RecordEntity; import codes.thischwa.cf.model.RecordType; @@ -28,15 +31,13 @@ public class CfClientTest { private static final String SLD_STR = "devsld"; private static final int TTL = 60; - private static final String API_EMAIL = System.getenv("API_EMAIL"); - private static final String API_KEY = System.getenv("API_KEY"); + private static final String API_TOKEN = System.getenv("API_TOKEN"); - private final CfDnsClient client = new CfDnsClient(true, API_EMAIL, API_KEY); + private final CfDnsClient client = new CfDnsClient(true, CfAuthBuilder.build(API_TOKEN)); @BeforeAll static void checkEnv() { - assumeTrue(API_EMAIL != null && !API_EMAIL.isBlank(), "API_EMAIL not set; skipping pen tests"); - assumeTrue(API_KEY != null && !API_KEY.isBlank(), "API_KEY not set; skipping pen tests"); + assumeTrue(API_TOKEN != null && !API_TOKEN.isBlank(), "API_TOKEN not set; skipping client tests"); } @Test @@ -106,13 +107,13 @@ public class CfClientTest { void testDns() throws Exception { // starting point: already existing zone 'mein-d-ns.de' ZoneEntity z = client.zoneGet(ZONE_STR); - assertEquals("0a83dd6e7f8c46039f2517bbded8115e", z.getId()); + assertEquals("cf9d8b12f61423f280e0a3ea2a96d921", z.getId()); assertEquals("mein-d-ns.de", z.getName()); assertEquals("active", z.getStatus()); assertEquals(2, z.getNameServers().size()); - assertTrue(z.getNameServers().contains("sergi.ns.cloudflare.com")); - assertEquals(4, z.getOriginalNameServers().size()); - assertTrue(z.getOriginalNameServers().contains("a.ns14.net")); + assertTrue(z.getNameServers().contains("rafe.ns.cloudflare.com")); + assertTrue(z.getOriginalNameServers().size() >= 2); + assertTrue(z.getOriginalNameServers().contains("blair.ns.cloudflare.com")); assertNotNull(z.getActivatedOn()); assertNotNull(z.getModifiedOn()); assertNotNull(z.getCreatedOn()); @@ -226,10 +227,12 @@ public class CfClientTest { @Test void testException() { - assertThrows(IllegalArgumentException.class, () -> new CfDnsClient(null, "key")); - assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("email", null)); - assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("email", "")); - assertThrows(IllegalArgumentException.class, () -> new CfDnsClient("", "key")); + // Test EmailKeyAuth validation + assertThrows(IllegalArgumentException.class, () -> new EmailKeyAuth("email", "")); + assertThrows(IllegalArgumentException.class, () -> new EmailKeyAuth("", "key")); + + // Test ApiTokenAuth validation; + assertThrows(IllegalArgumentException.class, () -> new ApiTokenAuth("")); } @Test