408 lines
14 KiB
PHP
408 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\V1;
|
|
|
|
use DateTimeImmutable;
|
|
use DateTimeInterface;
|
|
use Illuminate\Support\Facades\Config;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Infra\Database\Models\ApiClient;
|
|
use Presentation\Api\V1\Dto\ApiClientUserDetails;
|
|
use Presentation\Api\V1\Http\Middlewares\VerifyHmacSignature;
|
|
use Ramsey\Uuid\Uuid;
|
|
|
|
/**
|
|
* @param array<string, mixed>|string|null $body
|
|
* @return array<string, string>
|
|
*/
|
|
function generateHmacHeaders(
|
|
string $method,
|
|
string $pathAndQuery,
|
|
string $clientIdentifier,
|
|
string $clientSecret,
|
|
array | string | null $body = null,
|
|
?DateTimeImmutable $timestamp = null,
|
|
?string $nonce = null,
|
|
): array {
|
|
$timestamp ??= new DateTimeImmutable('now');
|
|
$nonce ??= Uuid::uuid7()->toString();
|
|
|
|
$timestampFormatted = $timestamp->format(DateTimeInterface::ATOM);
|
|
|
|
$bodyString = match (true) {
|
|
is_array($body) => json_encode($body) ?: '[]',
|
|
is_string($body) => $body,
|
|
default => '',
|
|
};
|
|
|
|
$bodyHash = $bodyString !== ''
|
|
? hash('sha256', $bodyString)
|
|
: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
|
|
|
|
// Parse and sort query parameters
|
|
$parsed = parse_url($pathAndQuery);
|
|
$path = $parsed['path'] ?? '/';
|
|
$query = $parsed['query'] ?? null;
|
|
|
|
if ($query !== null && $query !== '') {
|
|
parse_str($query, $queryParams);
|
|
ksort($queryParams);
|
|
$sortedQuery = http_build_query($queryParams);
|
|
$path = $path . '?' . $sortedQuery;
|
|
}
|
|
|
|
$payload = implode('|', [
|
|
strtoupper($method),
|
|
$path,
|
|
$timestampFormatted,
|
|
$nonce,
|
|
$bodyHash,
|
|
]);
|
|
|
|
$signature = hash_hmac('sha256', $payload, $clientSecret);
|
|
|
|
return [
|
|
'X-Client-Identifier' => $clientIdentifier,
|
|
'X-Timestamp' => $timestampFormatted,
|
|
'X-Nonce' => $nonce,
|
|
'X-Signature' => $signature,
|
|
];
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
Config::set('auth.hmac.allow_dev_bypass', false);
|
|
|
|
Route::middleware(VerifyHmacSignature::class)->get('/api/v1/test-hmac-auth', function () {
|
|
/** @var ApiClientUserDetails|null $user */
|
|
$user = request()->attributes->get('user_details');
|
|
|
|
return response()->json([
|
|
'message' => 'ok',
|
|
'client' => $user?->name(),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('VerifyHmacSignature Middleware Feature Tests', function (): void {
|
|
it('rejects request with missing client identifier', function (): void {
|
|
// GIVEN / WHEN
|
|
/** @var \Tests\TestCase $this */
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
|
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
|
|
'X-Nonce' => Uuid::uuid7()->toString(),
|
|
'X-Signature' => str_repeat('a', 64),
|
|
]);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Missing or invalid client identifier in the request headers.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request with missing timestamp', function (): void {
|
|
// GIVEN / WHEN
|
|
/** @var \Tests\TestCase $this */
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
|
'X-Client-Identifier' => str_repeat('c', 43),
|
|
'X-Nonce' => Uuid::uuid7()->toString(),
|
|
'X-Signature' => str_repeat('a', 64),
|
|
]);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Missing or invalid timestamp in the request headers.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request with missing signature', function (): void {
|
|
// GIVEN / WHEN
|
|
/** @var \Tests\TestCase $this */
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
|
'X-Client-Identifier' => str_repeat('c', 43),
|
|
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
|
|
'X-Nonce' => Uuid::uuid7()->toString(),
|
|
]);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Missing or invalid HMAC signature in the request headers.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request with missing nonce', function (): void {
|
|
// GIVEN / WHEN
|
|
/** @var \Tests\TestCase $this */
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', [
|
|
'X-Client-Identifier' => str_repeat('c', 43),
|
|
'X-Timestamp' => (new DateTimeImmutable())->format(DateTimeInterface::ATOM),
|
|
'X-Signature' => str_repeat('a', 64),
|
|
]);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Missing or invalid nonce in the request headers.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request when client does not exist in database', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
$headers = generateHmacHeaders(
|
|
method: 'GET',
|
|
pathAndQuery: '/api/v1/test-hmac-auth',
|
|
clientIdentifier: str_repeat('c', 43),
|
|
clientSecret: str_repeat('s', 98),
|
|
body: []
|
|
);
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Authentication failed.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request when client is inactive or suspended', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
|
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
|
|
|
ApiClient::factory()->create([
|
|
'id' => Uuid::uuid7()->toString(),
|
|
'name' => 'Inactive Client',
|
|
'slug' => 'test-' . uniqid(),
|
|
'identifier' => $identifier,
|
|
'secret' => $secret,
|
|
'is_active' => false,
|
|
]);
|
|
|
|
$headers = generateHmacHeaders(
|
|
method: 'GET',
|
|
pathAndQuery: '/api/v1/test-hmac-auth',
|
|
clientIdentifier: $identifier,
|
|
clientSecret: $secret,
|
|
body: []
|
|
);
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Authentication failed.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request when timestamp is outside skew window', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
|
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
|
|
|
ApiClient::factory()->create([
|
|
'id' => Uuid::uuid7()->toString(),
|
|
'name' => 'Active Client',
|
|
'slug' => 'test-' . uniqid(),
|
|
'identifier' => $identifier,
|
|
'secret' => $secret,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$oldTimestamp = new DateTimeImmutable('-10 minutes');
|
|
$headers = generateHmacHeaders(
|
|
method: 'GET',
|
|
pathAndQuery: '/api/v1/test-hmac-auth',
|
|
clientIdentifier: $identifier,
|
|
clientSecret: $secret,
|
|
body: [],
|
|
timestamp: $oldTimestamp
|
|
);
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Authentication failed.',
|
|
]);
|
|
});
|
|
|
|
it('rejects request when HMAC signature does not match', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
|
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
|
|
|
ApiClient::factory()->create([
|
|
'id' => Uuid::uuid7()->toString(),
|
|
'name' => 'Active Client',
|
|
'slug' => 'test-' . uniqid(),
|
|
'identifier' => $identifier,
|
|
'secret' => $secret,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$headers = generateHmacHeaders(
|
|
method: 'GET',
|
|
pathAndQuery: '/api/v1/test-hmac-auth',
|
|
clientIdentifier: $identifier,
|
|
clientSecret: $secret,
|
|
body: []
|
|
);
|
|
$headers['X-Signature'] = hash('sha256', 'wrong_signature_payload');
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Authentication failed.',
|
|
]);
|
|
});
|
|
|
|
it('accepts request and authenticates client with valid HMAC credentials', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
|
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
|
|
|
ApiClient::factory()->create([
|
|
'id' => Uuid::uuid7()->toString(),
|
|
'name' => 'Valid API Client',
|
|
'slug' => 'test-' . uniqid(),
|
|
'identifier' => $identifier,
|
|
'secret' => $secret,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$headers = generateHmacHeaders(
|
|
method: 'GET',
|
|
pathAndQuery: '/api/v1/test-hmac-auth',
|
|
clientIdentifier: $identifier,
|
|
clientSecret: $secret,
|
|
body: []
|
|
);
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
|
|
// THEN
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'message' => 'ok',
|
|
'client' => 'Valid API Client',
|
|
]);
|
|
});
|
|
|
|
it('rejects replayed request with already used nonce', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
$identifier = 'arcadia_id_' . str_repeat('a', 32);
|
|
$secret = 'arcadia_sec_' . str_repeat('b', 86);
|
|
|
|
ApiClient::factory()->create([
|
|
'id' => Uuid::uuid7()->toString(),
|
|
'name' => 'Valid API Client',
|
|
'slug' => 'test-' . uniqid(),
|
|
'identifier' => $identifier,
|
|
'secret' => $secret,
|
|
'is_active' => true,
|
|
]);
|
|
|
|
$headers = generateHmacHeaders(
|
|
method: 'GET',
|
|
pathAndQuery: '/api/v1/test-hmac-auth',
|
|
clientIdentifier: $identifier,
|
|
clientSecret: $secret,
|
|
body: []
|
|
);
|
|
|
|
// First request succeeds
|
|
$firstResponse = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
$firstResponse->assertStatus(200);
|
|
|
|
// WHEN (Second identical request with same nonce)
|
|
$secondResponse = $this->getJson('/api/v1/test-hmac-auth', $headers);
|
|
|
|
// THEN
|
|
$secondResponse->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Authentication failed.',
|
|
]);
|
|
});
|
|
|
|
it('bypasses HMAC authentication in local/testing when dev bypass is explicitly enabled', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
Config::set('auth.hmac.allow_dev_bypass', true);
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth');
|
|
|
|
// THEN
|
|
$response->assertStatus(200)
|
|
->assertJson([
|
|
'message' => 'ok',
|
|
'client' => 'Arcadia Dev Test Client',
|
|
]);
|
|
});
|
|
|
|
it('strictly rejects HMAC bypass in production environment even if allow_dev_bypass is enabled', function (): void {
|
|
// GIVEN
|
|
/** @var \Tests\TestCase $this */
|
|
Config::set('auth.hmac.allow_dev_bypass', true);
|
|
app()['env'] = 'production';
|
|
|
|
// WHEN
|
|
$response = $this->getJson('/api/v1/test-hmac-auth');
|
|
|
|
// THEN
|
|
$response->assertStatus(401)
|
|
->assertHeader('Content-Type', 'application/problem+json')
|
|
->assertJson([
|
|
'status' => 401,
|
|
'title' => 'Unauthorized',
|
|
'detail' => 'Missing or invalid client identifier in the request headers.',
|
|
]);
|
|
});
|
|
});
|