Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.84% |
45 / 49 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
| SqlApiKeyRepository | |
91.67% |
44 / 48 |
|
75.00% |
3 / 4 |
18.19 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| findByToken | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
| touchLastUsed | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| hydrateRow | |
84.62% |
22 / 26 |
|
0.00% |
0 / 1 |
11.44 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Api\Infrastructure\Repository; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Api\Domain\Model\ApiKey; |
| 12 | use App\Core\Api\Domain\Repository\ApiKeyRepositoryInterface; |
| 13 | use DateTimeImmutable; |
| 14 | use PDO; |
| 15 | use Throwable; |
| 16 | |
| 17 | /** |
| 18 | * SQL Implementation of ApiKeyRepositoryInterface. |
| 19 | * |
| 20 | * Persists and validates API keys with SHA-256 constant-time hash verification. |
| 21 | * |
| 22 | * @package App\Core\Api\Infrastructure\Repository |
| 23 | */ |
| 24 | final readonly class SqlApiKeyRepository implements ApiKeyRepositoryInterface |
| 25 | { |
| 26 | /** |
| 27 | * SqlApiKeyRepository constructor. |
| 28 | * |
| 29 | * @param PDO $pdo Database connection instance. |
| 30 | * @param string $tablePrefix Table prefix. |
| 31 | */ |
| 32 | public function __construct( |
| 33 | private PDO $pdo, |
| 34 | private string $tablePrefix = 'a_' |
| 35 | ) { |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * Finds active API key matching raw bearer token. |
| 40 | * |
| 41 | * @param string $rawToken Bearer token from request. |
| 42 | * @return ApiKey|null Found valid API key entity or null. |
| 43 | */ |
| 44 | public function findByToken(string $rawToken): ?ApiKey |
| 45 | { |
| 46 | if (trim($rawToken) === '') { |
| 47 | return null; |
| 48 | } |
| 49 | |
| 50 | $hash = hash('sha256', $rawToken); |
| 51 | $tableName = $this->tablePrefix . 'core_api_key_records'; |
| 52 | |
| 53 | try { |
| 54 | $stmt = $this->pdo->prepare( |
| 55 | "SELECT `id`, `name`, `key_prefix`, `key_hash`, `scopes`, `rate_limit`, |
| 56 | `expires_at`, `last_used_at`, `is_active` |
| 57 | FROM `{$tableName}` |
| 58 | WHERE `is_active` = 1 AND `key_hash` = :hash |
| 59 | LIMIT 1" |
| 60 | ); |
| 61 | $stmt->execute(['hash' => $hash]); |
| 62 | /** @var array<string, mixed>|false $row */ |
| 63 | $row = $stmt->fetch(PDO::FETCH_ASSOC); |
| 64 | |
| 65 | return is_array($row) ? $this->hydrateRow($row) : null; |
| 66 | } catch (Throwable) { |
| 67 | return null; |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Records timestamp of successful API key usage. |
| 73 | * |
| 74 | * @param int $id API key primary identifier. |
| 75 | * @return void |
| 76 | */ |
| 77 | public function touchLastUsed(int $id): void |
| 78 | { |
| 79 | $tableName = $this->tablePrefix . 'core_api_key_records'; |
| 80 | |
| 81 | try { |
| 82 | $stmt = $this->pdo->prepare( |
| 83 | "UPDATE `{$tableName}` |
| 84 | SET `last_used_at` = CURRENT_TIMESTAMP(6) |
| 85 | WHERE `id` = :id" |
| 86 | ); |
| 87 | $stmt->execute(['id' => $id]); |
| 88 | } catch (Throwable) { |
| 89 | // Non-blocking update failure |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /** |
| 94 | * Hydrates associative row into ApiKey entity. |
| 95 | * |
| 96 | * @param array<string, mixed> $row Database row. |
| 97 | * @return ApiKey Domain entity. |
| 98 | */ |
| 99 | private function hydrateRow(array $row): ApiKey |
| 100 | { |
| 101 | /** @var list<string> $scopes */ |
| 102 | $scopes = ['*']; |
| 103 | if (isset($row['scopes']) && is_string($row['scopes'])) { |
| 104 | $decoded = json_decode($row['scopes'], true); |
| 105 | if (is_array($decoded)) { |
| 106 | /** @var list<string> $scopes */ |
| 107 | $scopes = array_values(array_filter($decoded, 'is_string')); |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | $expiresAt = null; |
| 112 | if (!empty($row['expires_at']) && is_string($row['expires_at'])) { |
| 113 | try { |
| 114 | $expiresAt = new DateTimeImmutable($row['expires_at']); |
| 115 | } catch (Throwable) { |
| 116 | $expiresAt = null; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | $lastUsedAt = null; |
| 121 | if (!empty($row['last_used_at']) && is_string($row['last_used_at'])) { |
| 122 | try { |
| 123 | $lastUsedAt = new DateTimeImmutable($row['last_used_at']); |
| 124 | } catch (Throwable) { |
| 125 | $lastUsedAt = null; |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | return new ApiKey( |
| 130 | id: (int) $row['id'], |
| 131 | name: (string) $row['name'], |
| 132 | keyPrefix: (string) $row['key_prefix'], |
| 133 | keyHash: (string) $row['key_hash'], |
| 134 | scopes: $scopes !== [] ? $scopes : ['*'], |
| 135 | rateLimit: max(1, (int) ($row['rate_limit'] ?? 120)), |
| 136 | expiresAt: $expiresAt, |
| 137 | lastUsedAt: $lastUsedAt, |
| 138 | isActive: (bool) $row['is_active'] |
| 139 | ); |
| 140 | } |
| 141 | } |