Skip to content

Commit

Permalink
Merge pull request #36 from RogierW/http-client-patch1
Browse files Browse the repository at this point in the history
Introduce 'HttpClientInterface' and implementation follow-up
  • Loading branch information
RogierW authored Nov 21, 2023
2 parents 24bec87 + 3af6ccc commit e20981e
Show file tree
Hide file tree
Showing 29 changed files with 496 additions and 250 deletions.
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,26 @@ You can install the package via composer:

## Usage

You can create an instance of `Rogierw\RwAcme\Api` client.
Create an instance of `Rogierw\RwAcme\Api` client and provide it with a local account that will be used to store the account keys.

```php
$client = new Api('[email protected]', __DIR__ . '/__account');
$localAccount = new \Rogierw\RwAcme\Support\LocalFileAccount(__DIR__.'/__account', '[email protected]');
$client = new Api(localAccount: $localAccount);
```

You could also create a client and pass the local account data later:

```php
$client = new Api();

// Do some stuff.

$localAccount = new \Rogierw\RwAcme\Support\LocalFileAccount(__DIR__.'/__account', '[email protected]');
$client->setLocalAccount($localAccount);
```

> Please note that **setting a local account is required** before making any of the calls detailed below.
### Creating an account
```php
if (!$client->account()->exists()) {
Expand All @@ -39,6 +53,10 @@ if (!$client->account()->exists()) {
$account = $client->account()->get();
```

### Difference between `account` and `localAccount`
- `account` is the account created at the ACME (Let's Encrypt) server with data from the `localAccount`.
- `localAccount` handles the private/public key pair and contact email address used to sign requests to the ACME server. Depending on the implementation, this data is stored locally or, for example, in a database.

### Creating an order
```php
$order = $client->order()->new($account, ['example.com']);
Expand Down
18 changes: 18 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Test Suite">
<directory suffix="Test.php">./tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./app</directory>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>
62 changes: 36 additions & 26 deletions src/Api.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,41 @@
use Rogierw\RwAcme\Endpoints\DomainValidation;
use Rogierw\RwAcme\Endpoints\Nonce;
use Rogierw\RwAcme\Endpoints\Order;
use Rogierw\RwAcme\Exceptions\LetsEncryptClientException;
use Rogierw\RwAcme\Http\Client;
use Rogierw\RwAcme\Support\Str;
use Rogierw\RwAcme\Interfaces\AcmeAccountInterface;
use Rogierw\RwAcme\Interfaces\HttpClientInterface;

class Api
{
const PRODUCTION_URL = 'https://acme-v02.api.letsencrypt.org';
const STAGING_URL = 'https://acme-staging-v02.api.letsencrypt.org';
private const PRODUCTION_URL = 'https://acme-v02.api.letsencrypt.org';
private const STAGING_URL = 'https://acme-staging-v02.api.letsencrypt.org';

private string $baseUrl;
private Client $httpClient;

public function __construct(
private readonly string $accountEmail,
private string $accountKeysPath,
bool $staging = false,
private ?LoggerInterface $logger = null
private ?AcmeAccountInterface $localAccount = null,
private ?LoggerInterface $logger = null,
private HttpClientInterface|null $httpClient = null,
) {
$this->baseUrl = $staging ? self::STAGING_URL : self::PRODUCTION_URL;
$this->httpClient = new Client();
}

public function setLocalAccount(AcmeAccountInterface $account): self
{
$this->localAccount = $account;

return $this;
}

public function localAccount(): AcmeAccountInterface
{
if ($this->localAccount === null) {
throw new LetsEncryptClientException('No account set.');
}

return $this->localAccount;
}

public function directory(): Directory
Expand Down Expand Up @@ -60,32 +76,26 @@ public function certificate(): Certificate
return new Certificate($this);
}

public function getAccountEmail(): string
public function getBaseUrl(): string
{
return $this->accountEmail;
return $this->baseUrl;
}

public function getAccountKeysPath(): string
public function getHttpClient(): HttpClientInterface
{
if (!Str::endsWith($this->accountKeysPath, '/')) {
$this->accountKeysPath .= '/';
}

if (!is_dir($this->accountKeysPath)) {
mkdir($this->accountKeysPath, 0755, true);
// Create a default client if none is set.
if ($this->httpClient === null) {
$this->httpClient = new Client();
}

return $this->accountKeysPath;
return $this->httpClient;
}

public function getBaseUrl(): string
public function setHttpClient(HttpClientInterface $httpClient): self
{
return $this->baseUrl;
}
$this->httpClient = $httpClient;

public function getHttpClient(): Client
{
return $this->httpClient;
return $this;
}

public function setLogger(LoggerInterface $logger): self
Expand All @@ -95,10 +105,10 @@ public function setLogger(LoggerInterface $logger): self
return $this;
}

public function logger(string $level, string $message): void
public function logger(string $level, string $message, array $context = []): void
{
if ($this->logger instanceof LoggerInterface) {
$this->logger->log($level, $message);
$this->logger->log($level, $message, $context);
}
}
}
3 changes: 1 addition & 2 deletions src/DTO/AccountData.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Rogierw\RwAcme\DTO;

use Rogierw\RwAcme\Http\Response;
use Rogierw\RwAcme\Support\Arr;
use Rogierw\RwAcme\Support\Url;
use Spatie\LaravelData\Data;

Expand All @@ -23,7 +22,7 @@ public function __construct(

public static function fromResponse(Response $response): AccountData
{
$url = trim(Arr::get($response->getRawHeaders(), 'Location', ''));
$url = trim($response->getHeader('location', ''));

return new self(
id: Url::extractId($url),
Expand Down
4 changes: 2 additions & 2 deletions src/DTO/OrderData.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ public function __construct(

public static function fromResponse(Response $response, string $accountUrl = ''): OrderData
{
$url = Arr::get($response->getRawHeaders(), 'Location');
$url = $response->getHeader('location');

if (empty($url)) {
$url = Arr::get($response->getHeaders(), 'url');
$url = $response->getRequestedUrl();
}

$url = trim(rtrim($url, '?'));
Expand Down
95 changes: 34 additions & 61 deletions src/Endpoints/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,103 +3,76 @@
namespace Rogierw\RwAcme\Endpoints;

use Rogierw\RwAcme\DTO\AccountData;
use Rogierw\RwAcme\Support\CryptRSA;
use Rogierw\RwAcme\Exceptions\LetsEncryptClientException;
use Rogierw\RwAcme\Http\Response;
use Rogierw\RwAcme\Support\JsonWebSignature;
use RuntimeException;

class Account extends Endpoint
{
public function exists(): bool
{
if (!is_dir($this->client->getAccountKeysPath())) {
return false;
}

if (is_file($this->client->getAccountKeysPath() . 'private.pem')
&& is_file($this->client->getAccountKeysPath() . 'public.pem')) {
return true;
}

return false;
return $this->client->localAccount()->exists();
}

public function create(): AccountData
{
$this->initAccountDirectory();
$this->client->localAccount()->generateNewKeys();

$payload = [
'contact' => $this->buildContactPayload($this->client->getAccountEmail()),
'contact' => ['mailto:'.$this->client->localAccount()->getEmailAddress()],
'termsOfServiceAgreed' => true,
];

$newAccountUrl = $this->client->directory()->newAccount();

$signedPayload = JsonWebSignature::generate(
$payload,
$newAccountUrl,
$this->client->nonce()->getNew(),
$this->client->getAccountKeysPath()
);
$response = $this->postToAccountUrl($payload);

$response = $this->client->getHttpClient()->post(
$newAccountUrl,
$signedPayload
);

if ($response->getHttpResponseCode() === 201 && array_key_exists('Location', $response->getRawHeaders())) {
if ($response->getHttpResponseCode() === 201 && $response->hasHeader('location')) {
return AccountData::fromResponse($response);
}

throw new RuntimeException('Creating account failed.');
$this->throwError($response, 'Creating account failed');
}

public function get(): AccountData
{
if (!$this->exists()) {
throw new RuntimeException('Account keys not found.');
throw new LetsEncryptClientException('Local account keys not found.');
}

$payload = [
'onlyReturnExisting' => true,
];
// Use the newAccountUrl to get the account data based on the key.
// See https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.1
$payload = ['onlyReturnExisting' => true];
$response = $this->postToAccountUrl($payload);

$newAccountUrl = $this->client->directory()->newAccount();
if ($response->getHttpResponseCode() === 200) {
return AccountData::fromResponse($response);
}

$signedPayload = JsonWebSignature::generate(
$this->throwError($response, 'Retrieving account failed');
}

private function signPayload(array $payload): array
{
return JsonWebSignature::generate(
$payload,
$newAccountUrl,
$this->client->directory()->newAccount(),
$this->client->nonce()->getNew(),
$this->client->getAccountKeysPath()
$this->client->localAccount()->getPrivateKey(),
);

$response = $this->client->getHttpClient()->post($newAccountUrl, $signedPayload);

if ($response->getHttpResponseCode() === 400) {
throw new RuntimeException($response->getBody());
}

return AccountData::fromResponse($response);
}

private function initAccountDirectory(string $keyType = 'RSA'): void
private function postToAccountUrl(array $payload): Response
{
if ($keyType !== 'RSA') {
throw new RuntimeException('Key type is not supported.');
}

if (!is_dir($this->client->getAccountKeysPath())) {
mkdir($this->client->getAccountKeysPath());
}

if ($keyType === 'RSA') {
CryptRSA::generate($this->client->getAccountKeysPath());
}
return $this->client->getHttpClient()->post(
$this->client->directory()->newAccount(),
$this->signPayload($payload)
);
}

private function buildContactPayload(string $email): array
protected function throwError(Response $response, string $defaultMessage): never
{
return [
'mailto:' . $email,
];
$message = $response->getBody()['details'] ?? $defaultMessage;
$this->logResponse('error', $message, $response);

throw new LetsEncryptClientException($message);
}
}
16 changes: 9 additions & 7 deletions src/Endpoints/Certificate.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

use Rogierw\RwAcme\DTO\CertificateBundleData;
use Rogierw\RwAcme\DTO\OrderData;
use Rogierw\RwAcme\Exceptions\LetsEncryptClientException;
use Rogierw\RwAcme\Support\Base64;
use RuntimeException;

class Certificate extends Endpoint
{
Expand All @@ -16,7 +16,9 @@ public function getBundle(OrderData $orderData): CertificateBundleData
$response = $this->client->getHttpClient()->post($orderData->certificateUrl, $signedPayload);

if ($response->getHttpResponseCode() !== 200) {
throw new RuntimeException('Failed to fetch certificate.');
$this->logResponse('error', 'Failed to fetch certificate', $response);

throw new LetsEncryptClientException('Failed to fetch certificate.');
}

return CertificateBundleData::fromResponse($response);
Expand All @@ -25,11 +27,11 @@ public function getBundle(OrderData $orderData): CertificateBundleData
public function revoke(string $pem, int $reason = 0): bool
{
if (($data = openssl_x509_read($pem)) === false) {
throw new RuntimeException('Could not parse the certificate.');
throw new LetsEncryptClientException('Could not parse the certificate.');
}

if (openssl_x509_export($data, $certificate) === false) {
throw new RuntimeException('Could not export the certificate.');
throw new LetsEncryptClientException('Could not export the certificate.');
}

preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches);
Expand All @@ -48,10 +50,10 @@ public function revoke(string $pem, int $reason = 0): bool

$response = $this->client->getHttpClient()->post($revokeUrl, $signedPayload);

if ($response->getHttpResponseCode() === 200) {
return true;
if ($response->getHttpResponseCode() !== 200) {
$this->logResponse('error', 'Failed to revoke certificate', $response);
}

return false;
return $response->getHttpResponseCode() === 200;
}
}
Loading

0 comments on commit e20981e

Please sign in to comment.