Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.56% |
40 / 41 |
|
83.33% |
5 / 6 |
CRAP | |
0.00% |
0 / 1 |
| CalendarRsvpTokenService | |
97.50% |
39 / 40 |
|
83.33% |
5 / 6 |
15 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| generateToken | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| verifyToken | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| parseVerifiedPayload | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
5.01 | |||
| base64UrlEncode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| base64UrlDecode | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 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\Modules\Calendar\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | /** |
| 12 | * Generates and validates cryptographic HMAC-SHA256 signed RSVP tokens for calendar invitations. |
| 13 | * |
| 14 | * Allows invitees to securely register RSVP decisions directly from email client links without |
| 15 | * requiring account credentials, while strictly protecting against replay and forgery. |
| 16 | * |
| 17 | * @package App\Modules\Calendar\Application\Service |
| 18 | */ |
| 19 | final readonly class CalendarRsvpTokenService |
| 20 | { |
| 21 | private const string DEFAULT_SECRET = 'ammonly_calendar_rsvp_secret_salt_2026'; |
| 22 | private const int DEFAULT_TTL_SECONDS = 2592000; // 30 days |
| 23 | |
| 24 | public function __construct( |
| 25 | private string $secretKey = self::DEFAULT_SECRET, |
| 26 | private int $ttlSeconds = self::DEFAULT_TTL_SECONDS |
| 27 | ) { |
| 28 | } |
| 29 | |
| 30 | /** |
| 31 | * Generates a signed, URL-safe RSVP token for a calendar event attendee. |
| 32 | * |
| 33 | * @param int $calendarId Event ID in database. |
| 34 | * @param string $attendeeEmail Attendee email address. |
| 35 | * @param int|null $customTtl Optional custom TTL in seconds. |
| 36 | * @return string URL-safe signed token string. |
| 37 | */ |
| 38 | public function generateToken(int $calendarId, string $attendeeEmail, ?int $customTtl = null): string |
| 39 | { |
| 40 | $ttl = $customTtl !== null ? $customTtl : $this->ttlSeconds; |
| 41 | $expiresAt = time() + $ttl; |
| 42 | |
| 43 | $payload = [ |
| 44 | 'cid' => $calendarId, |
| 45 | 'eml' => strtolower(trim($attendeeEmail)), |
| 46 | 'exp' => $expiresAt, |
| 47 | ]; |
| 48 | |
| 49 | $encodedPayload = $this->base64UrlEncode((string) json_encode($payload, JSON_THROW_ON_ERROR)); |
| 50 | $signature = hash_hmac('sha256', $encodedPayload, $this->secretKey, true); |
| 51 | $encodedSignature = $this->base64UrlEncode($signature); |
| 52 | |
| 53 | return $encodedPayload . '.' . $encodedSignature; |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * Validates a signed RSVP token and returns the verified payload data. |
| 58 | * |
| 59 | * @param string $token Signed token string. |
| 60 | * @return array{calendar_id: int, attendee_email: string}|null Verified payload or null if invalid/expired. |
| 61 | */ |
| 62 | public function verifyToken(string $token): ?array |
| 63 | { |
| 64 | $parts = explode('.', $token, 2); |
| 65 | if (count($parts) !== 2) { |
| 66 | return null; |
| 67 | } |
| 68 | |
| 69 | [$encodedPayload, $encodedSignature] = $parts; |
| 70 | $expectedSignature = hash_hmac('sha256', $encodedPayload, $this->secretKey, true); |
| 71 | $actualSignature = $this->base64UrlDecode($encodedSignature); |
| 72 | |
| 73 | if (!hash_equals($expectedSignature, $actualSignature)) { |
| 74 | return null; |
| 75 | } |
| 76 | |
| 77 | return $this->parseVerifiedPayload($encodedPayload); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * @return array{calendar_id: int, attendee_email: string}|null |
| 82 | */ |
| 83 | private function parseVerifiedPayload(string $encodedPayload): ?array |
| 84 | { |
| 85 | $rawJson = $this->base64UrlDecode($encodedPayload); |
| 86 | $data = json_decode($rawJson, true); |
| 87 | if (!is_array($data)) { |
| 88 | return null; |
| 89 | } |
| 90 | |
| 91 | $cid = (int) ($data['cid'] ?? 0); |
| 92 | $eml = (string) ($data['eml'] ?? ''); |
| 93 | $exp = (int) ($data['exp'] ?? 0); |
| 94 | |
| 95 | if ($cid <= 0 || $eml === '' || $exp < time()) { |
| 96 | return null; |
| 97 | } |
| 98 | |
| 99 | return [ |
| 100 | 'calendar_id' => $cid, |
| 101 | 'attendee_email' => $eml, |
| 102 | ]; |
| 103 | } |
| 104 | |
| 105 | private function base64UrlEncode(string $data): string |
| 106 | { |
| 107 | return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); |
| 108 | } |
| 109 | |
| 110 | private function base64UrlDecode(string $data): string |
| 111 | { |
| 112 | $remainder = strlen($data) % 4; |
| 113 | if ($remainder > 0) { |
| 114 | $data .= str_repeat('=', 4 - $remainder); |
| 115 | } |
| 116 | |
| 117 | $decoded = base64_decode(strtr($data, '-_', '+/'), true); |
| 118 | return $decoded !== false ? $decoded : ''; |
| 119 | } |
| 120 | } |