diff --git a/.env b/.env index 3ad20ab..67e00fd 100644 --- a/.env +++ b/.env @@ -18,6 +18,8 @@ APP_ENV=dev APP_SECRET=change-me APP_SHARE_DIR=var/share +TRUSTED_PROXIES=127.0.0.1,REMOTE_ADDR +DEVICE_TOKEN_HASH_SECRET=change-me ###< symfony/framework-bundle ### ###> symfony/routing ### @@ -45,6 +47,7 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' # MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages # MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages MESSENGER_TRANSPORT_DSN=redis://127.0.0.1:6380/messages +MESSENGER_FAILED_TRANSPORT_DSN=redis://127.0.0.1:6380/failed_messages ###< symfony/messenger ### REDIS_URL=redis://127.0.0.1:6380 diff --git a/README.md b/README.md index 7f94635..5783f5c 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,11 @@ vendor/bin/php-cs-fixer fix --dry-run --diff --verbose --sequential Core environment variables: - `APP_SECRET` +- `DEVICE_TOKEN_HASH_SECRET` - `DATABASE_URL` - `MESSENGER_TRANSPORT_DSN` +- `MESSENGER_FAILED_TRANSPORT_DSN` +- `TRUSTED_PROXIES` - `APP_SHARE_DIR` - `PUSH_PROVIDER` (`fake` or `fcm`) - `FCM_PROJECT_ID` diff --git a/compose.prod.yaml b/compose.prod.yaml index 26d0e8b..c1fd034 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -16,8 +16,11 @@ services: restart: unless-stopped environment: SERVER_NAME: http://localhost, http://app + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR} + DEVICE_TOKEN_HASH_SECRET: ${DEVICE_TOKEN_HASH_SECRET:-changeme} DATABASE_URL: postgresql://${POSTGRES_USER:-fall_guardian}:${POSTGRES_PASSWORD:-fall_guardian}@postgres:5432/${POSTGRES_DB:-fall_guardian}?serverVersion=${POSTGRES_VERSION:-16}&charset=utf8 MESSENGER_TRANSPORT_DSN: redis://redis:6379/messages + MESSENGER_FAILED_TRANSPORT_DSN: redis://redis:6379/failed_messages REDIS_URL: redis://redis:6379 APP_SECRET: ${APP_SECRET:-changeme} PUSH_PROVIDER: ${PUSH_PROVIDER:-fake} @@ -56,8 +59,11 @@ services: restart: unless-stopped environment: APP_SECRET: ${APP_SECRET:-changeme} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR} + DEVICE_TOKEN_HASH_SECRET: ${DEVICE_TOKEN_HASH_SECRET:-changeme} DATABASE_URL: postgresql://${POSTGRES_USER:-fall_guardian}:${POSTGRES_PASSWORD:-fall_guardian}@postgres:5432/${POSTGRES_DB:-fall_guardian}?serverVersion=${POSTGRES_VERSION:-16}&charset=utf8 MESSENGER_TRANSPORT_DSN: redis://redis:6379/messages + MESSENGER_FAILED_TRANSPORT_DSN: redis://redis:6379/failed_messages REDIS_URL: redis://redis:6379 PUSH_PROVIDER: ${PUSH_PROVIDER:-fake} FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} diff --git a/compose.test.yaml b/compose.test.yaml index 34b5fe0..6dce132 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -11,8 +11,11 @@ services: environment: APP_ENV: test APP_SECRET: ${APP_SECRET:-test-secret} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR} + DEVICE_TOKEN_HASH_SECRET: ${DEVICE_TOKEN_HASH_SECRET:-test-secret} DATABASE_URL: postgresql://${POSTGRES_USER:-fall_guardian}:${POSTGRES_PASSWORD:-fall_guardian}@postgres:5432/${POSTGRES_DB:-fall_guardian_test}?serverVersion=${POSTGRES_VERSION:-16}&charset=utf8 MESSENGER_TRANSPORT_DSN: in-memory:// + MESSENGER_FAILED_TRANSPORT_DSN: in-memory:// REDIS_URL: redis://redis:6379 PUSH_PROVIDER: fake volumes: diff --git a/compose.yaml b/compose.yaml index d51da0e..0ddf68b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,8 +13,11 @@ services: APP_ENV: ${APP_ENV:-dev} APP_DEBUG: ${APP_DEBUG:-1} APP_SECRET: ${APP_SECRET:-changeme} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR} + DEVICE_TOKEN_HASH_SECRET: ${DEVICE_TOKEN_HASH_SECRET:-changeme} DATABASE_URL: postgresql://${POSTGRES_USER:-fall_guardian}:${POSTGRES_PASSWORD:-fall_guardian}@postgres:5432/${POSTGRES_DB:-fall_guardian}?serverVersion=${POSTGRES_VERSION:-16}&charset=utf8 MESSENGER_TRANSPORT_DSN: redis://redis:6379/messages + MESSENGER_FAILED_TRANSPORT_DSN: redis://redis:6379/failed_messages REDIS_URL: redis://redis:6379 PUSH_PROVIDER: ${PUSH_PROVIDER:-fake} FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} @@ -61,8 +64,11 @@ services: APP_ENV: ${APP_ENV:-dev} APP_DEBUG: ${APP_DEBUG:-1} APP_SECRET: ${APP_SECRET:-changeme} + TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.1,REMOTE_ADDR} + DEVICE_TOKEN_HASH_SECRET: ${DEVICE_TOKEN_HASH_SECRET:-changeme} DATABASE_URL: postgresql://${POSTGRES_USER:-fall_guardian}:${POSTGRES_PASSWORD:-fall_guardian}@postgres:5432/${POSTGRES_DB:-fall_guardian}?serverVersion=${POSTGRES_VERSION:-16}&charset=utf8 MESSENGER_TRANSPORT_DSN: redis://redis:6379/messages + MESSENGER_FAILED_TRANSPORT_DSN: redis://redis:6379/failed_messages REDIS_URL: redis://redis:6379 PUSH_PROVIDER: ${PUSH_PROVIDER:-fake} FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index 7e1ee1f..95bd83b 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -1,6 +1,13 @@ # see https://symfony.com/doc/current/reference/configuration/framework.html framework: secret: '%env(APP_SECRET)%' + trusted_proxies: '%env(TRUSTED_PROXIES)%' + trusted_headers: + - 'x-forwarded-for' + - 'x-forwarded-host' + - 'x-forwarded-proto' + - 'x-forwarded-port' + - 'x-forwarded-prefix' # Note that the session will be started ONLY if you read or write from it. session: true diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index e78ebcd..1d1848b 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -4,7 +4,7 @@ framework: transports: async: '%env(MESSENGER_TRANSPORT_DSN)%' - failed: 'in-memory://' + failed: '%env(MESSENGER_FAILED_TRANSPORT_DSN)%' routing: App\Domain\Alert\Message\SendFallAlertPushMessage: async diff --git a/config/services.yaml b/config/services.yaml index 108b054..70a4ffa 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -11,6 +11,7 @@ parameters: app.push_provider: '%env(string:PUSH_PROVIDER)%' app.fcm.project_id: '%env(string:FCM_PROJECT_ID)%' app.fcm.service_account_json: '%env(string:FCM_SERVICE_ACCOUNT_JSON)%' + app.device_token_hash_secret: '%env(string:DEVICE_TOKEN_HASH_SECRET)%' services: # default configuration for services in *this* file @@ -34,6 +35,7 @@ services: App\Domain\Caregiver\Port\CaregiverInviteRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverInviteRepository' App\Domain\Caregiver\Port\CaregiverLinkRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverLinkRepository' App\Domain\Caregiver\Port\CaregiverPushTokenRepositoryInterface: '@App\Infrastructure\Persistence\DoctrineCaregiverPushTokenRepository' + App\Infrastructure\RateLimit\EndpointRateLimiterInterface: '@App\Infrastructure\RateLimit\FixedWindowEndpointRateLimiter' App\Infrastructure\Push\FakePushStore: arguments: @@ -49,6 +51,10 @@ services: $projectId: '%app.fcm.project_id%' $serviceAccountJson: '%app.fcm.service_account_json%' + App\Infrastructure\Http\Security\DeviceTokenHasher: + arguments: + $secret: '%app.device_token_hash_secret%' + App\Infrastructure\Push\DelegatingPushGateway: arguments: $provider: '%app.push_provider%' diff --git a/config/services_test.yaml b/config/services_test.yaml index 028d804..a0b0ef5 100644 --- a/config/services_test.yaml +++ b/config/services_test.yaml @@ -1,4 +1,6 @@ services: + App\Infrastructure\RateLimit\EndpointRateLimiterInterface: '@App\Infrastructure\RateLimit\NullEndpointRateLimiter' + App\Tests\Behat\ApiContext: public: true autowire: true diff --git a/migrations/Version20260517120000.php b/migrations/Version20260517120000.php new file mode 100644 index 0000000..9d5dd3b --- /dev/null +++ b/migrations/Version20260517120000.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE caregiver_invites ALTER code TYPE VARCHAR(32)'); + $this->addSql('CREATE TABLE rate_limit_buckets (id VARCHAR(64) NOT NULL, window_start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, hits INT NOT NULL, PRIMARY KEY(id))'); + } + + #[Override] + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE rate_limit_buckets'); + $this->addSql('ALTER TABLE caregiver_invites ALTER code TYPE VARCHAR(8)'); + } +} diff --git a/src/Domain/Alert/Processor/CreateFallAlertProcessor.php b/src/Domain/Alert/Processor/CreateFallAlertProcessor.php index 8584fda..ea52f47 100644 --- a/src/Domain/Alert/Processor/CreateFallAlertProcessor.php +++ b/src/Domain/Alert/Processor/CreateFallAlertProcessor.php @@ -10,8 +10,10 @@ use App\Domain\Alert\Response\FallAlertOutputDTO; use App\Domain\Alert\Service\AlertIngestionServiceInterface; use App\Infrastructure\Http\Security\DeviceContextInterface; +use App\Infrastructure\RateLimit\EndpointRateLimiterInterface; use DateTimeImmutable; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * @implements ProcessorInterface @@ -21,12 +23,15 @@ public function __construct( private AlertIngestionServiceInterface $alertIngestionService, private DeviceContextInterface $currentDeviceProvider, + private EndpointRateLimiterInterface $rateLimiter, ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): FallAlertOutputDTO { - assert($data instanceof CreateFallAlertInputDTO); + if (!$data instanceof CreateFallAlertInputDTO) { + throw new BadRequestHttpException('Invalid fall alert payload.'); + } $device = $this->currentDeviceProvider->requireDevice(); @@ -34,6 +39,8 @@ public function process(mixed $data, Operation $operation, array $uriVariables = throw new AccessDeniedHttpException('Caregiver devices cannot create fall alerts.'); } + $this->rateLimiter->consume('fall_alert_create', 6, 60, $device->getPublicId()); + $alert = $this->alertIngestionService->createAlert( $device, $data->clientAlertId, diff --git a/src/Domain/Caregiver/Processor/AcceptInviteProcessor.php b/src/Domain/Caregiver/Processor/AcceptInviteProcessor.php index 9779090..8e97bcf 100644 --- a/src/Domain/Caregiver/Processor/AcceptInviteProcessor.php +++ b/src/Domain/Caregiver/Processor/AcceptInviteProcessor.php @@ -9,6 +9,7 @@ use App\Domain\Caregiver\Request\AcceptInviteInputDTO; use App\Domain\Caregiver\Service\InviteServiceInterface; use App\Infrastructure\Http\Security\DeviceContextInterface; +use App\Infrastructure\RateLimit\EndpointRateLimiterInterface; use DomainException; use RuntimeException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -22,6 +23,7 @@ public function __construct( private InviteServiceInterface $inviteService, private DeviceContextInterface $currentDeviceProvider, + private EndpointRateLimiterInterface $rateLimiter, ) { } @@ -29,11 +31,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables = { $rawCode = $uriVariables['code'] ?? ''; $code = is_string($rawCode) ? $rawCode : ''; + $device = $this->currentDeviceProvider->requireDevice(); + + $this->rateLimiter->consume('invite_accept', 5, 600, $device->getPublicId()); try { $this->inviteService->acceptInvite( $code, - $this->currentDeviceProvider->requireDevice(), + $device, ); } catch (RuntimeException $e) { throw new NotFoundHttpException($e->getMessage(), $e); diff --git a/src/Domain/Caregiver/Processor/RegisterPushTokenProcessor.php b/src/Domain/Caregiver/Processor/RegisterPushTokenProcessor.php index 453392f..5bdf18b 100644 --- a/src/Domain/Caregiver/Processor/RegisterPushTokenProcessor.php +++ b/src/Domain/Caregiver/Processor/RegisterPushTokenProcessor.php @@ -10,6 +10,7 @@ use App\Domain\Caregiver\Service\InviteServiceInterface; use App\Infrastructure\Http\Security\DeviceContextInterface; use DomainException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; /** @@ -25,7 +26,9 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null { - assert($data instanceof RegisterPushTokenInputDTO); + if (!$data instanceof RegisterPushTokenInputDTO) { + throw new BadRequestHttpException('Invalid push token payload.'); + } try { $this->inviteService->registerPushToken( diff --git a/src/Domain/Caregiver/Service/InviteService.php b/src/Domain/Caregiver/Service/InviteService.php index 46ae760..2c1af21 100644 --- a/src/Domain/Caregiver/Service/InviteService.php +++ b/src/Domain/Caregiver/Service/InviteService.php @@ -18,7 +18,7 @@ final readonly class InviteService implements InviteServiceInterface { - private const int CODE_LENGTH = 8; + private const int CODE_BYTES = 16; private const int TTL_MINUTES = 30; @@ -35,7 +35,7 @@ public function createInvite(Device $protectedDevice): CaregiverInvite throw new DomainException('Only protected-person devices can create invites.'); } - $code = strtoupper(substr(bin2hex(random_bytes(4)), 0, self::CODE_LENGTH)); + $code = strtoupper(bin2hex(random_bytes(self::CODE_BYTES))); $expiresAt = new DateTimeImmutable(sprintf('+%d minutes', self::TTL_MINUTES)); $invite = new CaregiverInvite($protectedDevice, $code, $expiresAt); diff --git a/src/Domain/Device/Processor/DeviceRegistrationProcessor.php b/src/Domain/Device/Processor/DeviceRegistrationProcessor.php index b639ad3..b3d78e0 100644 --- a/src/Domain/Device/Processor/DeviceRegistrationProcessor.php +++ b/src/Domain/Device/Processor/DeviceRegistrationProcessor.php @@ -10,19 +10,27 @@ use App\Domain\Device\Response\DeviceRegistrationOutputDTO; use App\Domain\Device\Service\DeviceRegistrationService; use App\Enum\DeviceType; +use App\Infrastructure\RateLimit\EndpointRateLimiterInterface; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** * @implements ProcessorInterface */ final readonly class DeviceRegistrationProcessor implements ProcessorInterface { - public function __construct(private DeviceRegistrationService $deviceRegistrationService) - { + public function __construct( + private DeviceRegistrationService $deviceRegistrationService, + private EndpointRateLimiterInterface $rateLimiter, + ) { } public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): DeviceRegistrationOutputDTO { - assert($data instanceof DeviceRegistrationInputDTO); + if (!$data instanceof DeviceRegistrationInputDTO) { + throw new BadRequestHttpException('Invalid device registration payload.'); + } + + $this->rateLimiter->consume('device_registration', 20, 60); return $this->deviceRegistrationService->register( $data->platform, diff --git a/src/Entity/CaregiverInvite.php b/src/Entity/CaregiverInvite.php index 7d31523..2b178a5 100644 --- a/src/Entity/CaregiverInvite.php +++ b/src/Entity/CaregiverInvite.php @@ -26,7 +26,7 @@ class CaregiverInvite public function __construct(#[ORM\ManyToOne(targetEntity: Device::class)] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] - private Device $device, #[ORM\Column(length: 8)] + private Device $device, #[ORM\Column(length: 32)] private string $code, #[ORM\Column(name: 'expires_at')] private DateTimeImmutable $expiresAt) { diff --git a/src/Entity/Device.php b/src/Entity/Device.php index 98d80ae..a1627f7 100644 --- a/src/Entity/Device.php +++ b/src/Entity/Device.php @@ -69,6 +69,12 @@ public function getTokenHash(): string return $this->tokenHash; } + public function rotateTokenHash(string $tokenHash): void + { + $this->tokenHash = $tokenHash; + $this->touch(); + } + public function getPlatform(): string { return $this->platform; diff --git a/src/Entity/RateLimitBucket.php b/src/Entity/RateLimitBucket.php new file mode 100644 index 0000000..f10c95d --- /dev/null +++ b/src/Entity/RateLimitBucket.php @@ -0,0 +1,45 @@ +windowStartAt; + } + + public function getHits(): int + { + return $this->hits; + } + + public function reset(DateTimeImmutable $windowStartAt): void + { + $this->windowStartAt = $windowStartAt; + $this->hits = 0; + } + + public function hit(): void + { + ++$this->hits; + } +} diff --git a/src/Infrastructure/Http/Security/DeviceTokenAuthenticator.php b/src/Infrastructure/Http/Security/DeviceTokenAuthenticator.php index 896c39b..0f9ab02 100644 --- a/src/Infrastructure/Http/Security/DeviceTokenAuthenticator.php +++ b/src/Infrastructure/Http/Security/DeviceTokenAuthenticator.php @@ -40,9 +40,16 @@ public function authenticate(Request $request): SelfValidatingPassport } $hashedToken = $this->tokenHasher->hash($matches['token']); + $legacyHash = $this->tokenHasher->legacyHash($matches['token']); - return new SelfValidatingPassport(new UserBadge($hashedToken, function (string $userIdentifier): DeviceApiUser { + return new SelfValidatingPassport(new UserBadge($hashedToken, function (string $userIdentifier) use ($legacyHash): DeviceApiUser { $device = $this->deviceRepository->findActiveByTokenHash($userIdentifier); + $authenticatedWithLegacyHash = false; + + if (!$device instanceof \App\Entity\Device) { + $device = $this->deviceRepository->findActiveByTokenHash($legacyHash); + $authenticatedWithLegacyHash = $device instanceof \App\Entity\Device; + } if (!$device instanceof \App\Entity\Device) { throw new AuthenticationException('Invalid device token.'); @@ -50,6 +57,12 @@ public function authenticate(Request $request): SelfValidatingPassport $device->touchSeenAt(); + if ($authenticatedWithLegacyHash) { + $device->rotateTokenHash($userIdentifier); + } + + $this->deviceRepository->save($device); + return new DeviceApiUser($device); })); } diff --git a/src/Infrastructure/Http/Security/DeviceTokenHasher.php b/src/Infrastructure/Http/Security/DeviceTokenHasher.php index 9429845..a246c4e 100644 --- a/src/Infrastructure/Http/Security/DeviceTokenHasher.php +++ b/src/Infrastructure/Http/Security/DeviceTokenHasher.php @@ -4,9 +4,21 @@ namespace App\Infrastructure\Http\Security; -final class DeviceTokenHasher +final readonly class DeviceTokenHasher { + public function __construct(private string $secret) + { + } + public function hash(string $plainToken): string + { + return hash_hmac('sha256', $plainToken, $this->secret); + } + + /** + * Temporary transition shim for devices registered before HMAC hashing. + */ + public function legacyHash(string $plainToken): string { return hash('sha256', $plainToken); } diff --git a/src/Infrastructure/Push/FcmPushGateway.php b/src/Infrastructure/Push/FcmPushGateway.php index 88dcf28..f1500ca 100644 --- a/src/Infrastructure/Push/FcmPushGateway.php +++ b/src/Infrastructure/Push/FcmPushGateway.php @@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Contracts\HttpClient\HttpClientInterface; -final readonly class FcmPushGateway implements PushGatewayInterface +final class FcmPushGateway implements PushGatewayInterface { private const string FCM_SEND_URL = 'https://fcm.googleapis.com/v1/projects/%s/messages:send'; @@ -17,10 +17,18 @@ private const string OAUTH_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; + private const int TOKEN_EXPIRY_SAFETY_SECONDS = 60; + + private const float HTTP_TIMEOUT_SECONDS = 10.0; + + private ?string $cachedAccessToken = null; + + private int $cachedAccessTokenExpiresAt = 0; + public function __construct( - private HttpClientInterface $httpClient, - private string $projectId, - private string $serviceAccountJson, + private readonly HttpClientInterface $httpClient, + private readonly string $projectId, + private readonly string $serviceAccountJson, ) { } @@ -87,6 +95,7 @@ public function send(string $fcmToken, string $alertId, string $fallTimestamp, ? 'Content-Type' => 'application/json', ], 'body' => json_encode($payload), + 'timeout' => self::HTTP_TIMEOUT_SECONDS, ], ); @@ -96,7 +105,7 @@ public function send(string $fcmToken, string $alertId, string $fallTimestamp, ? $body = json_decode($responseBody, true); if ($statusCode < 200 || $statusCode >= 300) { - throw new RuntimeException(sprintf('FCM send failed (HTTP %d): %s', $statusCode, $responseBody)); + throw new RuntimeException(sprintf('FCM send failed (HTTP %d).', $statusCode)); } $providerMessageId = is_array($body) && isset($body['name']) && is_string($body['name']) @@ -111,6 +120,12 @@ public function send(string $fcmToken, string $alertId, string $fallTimestamp, ? private function getAccessToken(): string { + $now = time(); + + if (null !== $this->cachedAccessToken && $this->cachedAccessTokenExpiresAt > $now + self::TOKEN_EXPIRY_SAFETY_SECONDS) { + return $this->cachedAccessToken; + } + /** @var array|null $serviceAccount */ $serviceAccount = json_decode($this->serviceAccountJson, true); @@ -121,7 +136,6 @@ private function getAccessToken(): string $clientEmail = is_string($serviceAccount['client_email'] ?? null) ? $serviceAccount['client_email'] : ''; $privateKeyPem = is_string($serviceAccount['private_key'] ?? null) ? $serviceAccount['private_key'] : ''; - $now = time(); $headerJson = json_encode(['alg' => 'RS256', 'typ' => 'JWT']); $claimsJson = json_encode([ 'iss' => $clientEmail, @@ -153,6 +167,7 @@ private function getAccessToken(): string 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], + 'timeout' => self::HTTP_TIMEOUT_SECONDS, ]); /** @var array|null $tokenData */ @@ -162,7 +177,11 @@ private function getAccessToken(): string throw new RuntimeException('Failed to obtain FCM access token.'); } - return $tokenData['access_token']; + $expiresIn = isset($tokenData['expires_in']) && is_int($tokenData['expires_in']) ? $tokenData['expires_in'] : 3600; + $this->cachedAccessToken = $tokenData['access_token']; + $this->cachedAccessTokenExpiresAt = $now + $expiresIn; + + return $this->cachedAccessToken; } private static function base64UrlEncode(string $value): string diff --git a/src/Infrastructure/RateLimit/EndpointRateLimiterInterface.php b/src/Infrastructure/RateLimit/EndpointRateLimiterInterface.php new file mode 100644 index 0000000..0dbbc89 --- /dev/null +++ b/src/Infrastructure/RateLimit/EndpointRateLimiterInterface.php @@ -0,0 +1,10 @@ +requestStack->getCurrentRequest()?->getClientIp() ?? 'unknown'; + $bucketId = hash('sha256', $bucketName.'|'.$subject); + + $this->entityManager->wrapInTransaction(function () use ($bucketId, $now, $limit, $windowSeconds): void { + $bucket = $this->entityManager->find(RateLimitBucket::class, $bucketId, LockMode::PESSIMISTIC_WRITE); + + if (!$bucket instanceof RateLimitBucket) { + $bucket = new RateLimitBucket($bucketId, $now); + $this->entityManager->persist($bucket); + } + + $elapsedSeconds = $now->getTimestamp() - $bucket->getWindowStartAt()->getTimestamp(); + + if ($elapsedSeconds >= $windowSeconds) { + $bucket->reset($now); + $elapsedSeconds = 0; + } + + if ($bucket->getHits() >= $limit) { + $retryAfter = max(1, $windowSeconds - $elapsedSeconds); + + throw new TooManyRequestsHttpException($retryAfter, 'Too many requests. Please retry later.'); + } + + $bucket->hit(); + }); + } +} diff --git a/src/Infrastructure/RateLimit/NullEndpointRateLimiter.php b/src/Infrastructure/RateLimit/NullEndpointRateLimiter.php new file mode 100644 index 0000000..7d97a5c --- /dev/null +++ b/src/Infrastructure/RateLimit/NullEndpointRateLimiter.php @@ -0,0 +1,12 @@ +inviteService = $this->createMock(InviteServiceInterface::class); $this->currentDeviceProvider = $this->createMock(DeviceContextInterface::class); - $this->processor = new AcceptInviteProcessor($this->inviteService, $this->currentDeviceProvider); + $this->rateLimiter = $this->createMock(EndpointRateLimiterInterface::class); + $this->processor = new AcceptInviteProcessor($this->inviteService, $this->currentDeviceProvider, $this->rateLimiter); } #[Test] public function itAcceptsInviteAndReturnsNull(): void { $device = $this->createMock(Device::class); + $device->method('getPublicId')->willReturn('caregiver-1'); $this->currentDeviceProvider->method('requireDevice')->willReturn($device); + $this->rateLimiter->expects($this->once())->method('consume')->with('invite_accept', 5, 600, 'caregiver-1'); $this->inviteService->method('acceptInvite')->willReturn($this->createMock(CaregiverLink::class)); $result = $this->processor->process(null, $this->createMock(Operation::class), ['code' => 'ABCD1234']); diff --git a/tests/Unit/Domain/CreateFallAlertProcessorTest.php b/tests/Unit/Domain/CreateFallAlertProcessorTest.php index 21c2b84..c35c283 100644 --- a/tests/Unit/Domain/CreateFallAlertProcessorTest.php +++ b/tests/Unit/Domain/CreateFallAlertProcessorTest.php @@ -12,6 +12,7 @@ use App\Entity\FallAlert; use App\Enum\FallAlertStatus; use App\Infrastructure\Http\Security\DeviceContextInterface; +use App\Infrastructure\RateLimit\EndpointRateLimiterInterface; use DateTimeImmutable; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -25,22 +26,27 @@ final class CreateFallAlertProcessorTest extends TestCase private DeviceContextInterface&MockObject $currentDeviceProvider; + private EndpointRateLimiterInterface&MockObject $rateLimiter; + private CreateFallAlertProcessor $processor; protected function setUp(): void { $this->alertIngestionService = $this->createMock(AlertIngestionServiceInterface::class); $this->currentDeviceProvider = $this->createMock(DeviceContextInterface::class); - $this->processor = new CreateFallAlertProcessor($this->alertIngestionService, $this->currentDeviceProvider); + $this->rateLimiter = $this->createMock(EndpointRateLimiterInterface::class); + $this->processor = new CreateFallAlertProcessor($this->alertIngestionService, $this->currentDeviceProvider, $this->rateLimiter); } #[Test] public function itCreatesAlertAndReturnsOutputDTO(): void { $device = $this->createMock(Device::class); + $device->method('getPublicId')->willReturn('device-1'); $alert = $this->buildAlertMock('client-001'); $this->currentDeviceProvider->method('requireDevice')->willReturn($device); + $this->rateLimiter->expects($this->once())->method('consume')->with('fall_alert_create', 6, 60, 'device-1'); $this->alertIngestionService->method('createAlert')->willReturn($alert); $data = new CreateFallAlertInputDTO(); @@ -58,6 +64,7 @@ public function itCreatesAlertAndReturnsOutputDTO(): void public function itFallsBackToNowWhenTimestampAbsent(): void { $device = $this->createMock(Device::class); + $device->method('getPublicId')->willReturn('device-2'); $alert = $this->buildAlertMock('client-002'); $this->currentDeviceProvider->method('requireDevice')->willReturn($device); diff --git a/tests/Unit/Domain/DeviceRegistrationProcessorTest.php b/tests/Unit/Domain/DeviceRegistrationProcessorTest.php index b4dd2ca..a3ae436 100644 --- a/tests/Unit/Domain/DeviceRegistrationProcessorTest.php +++ b/tests/Unit/Domain/DeviceRegistrationProcessorTest.php @@ -11,6 +11,7 @@ use App\Domain\Device\Service\DeviceRegistrationService; use App\Entity\Device; use App\Infrastructure\Http\Security\DeviceTokenHasher; +use App\Infrastructure\RateLimit\EndpointRateLimiterInterface; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -19,13 +20,16 @@ final class DeviceRegistrationProcessorTest extends TestCase { private DeviceRepositoryInterface&MockObject $deviceRepository; + private EndpointRateLimiterInterface&MockObject $rateLimiter; + private DeviceRegistrationProcessor $processor; protected function setUp(): void { $this->deviceRepository = $this->createMock(DeviceRepositoryInterface::class); - $service = new DeviceRegistrationService(new DeviceTokenHasher(), $this->deviceRepository); - $this->processor = new DeviceRegistrationProcessor($service); + $this->rateLimiter = $this->createMock(EndpointRateLimiterInterface::class); + $service = new DeviceRegistrationService(new DeviceTokenHasher('test-secret'), $this->deviceRepository); + $this->processor = new DeviceRegistrationProcessor($service, $this->rateLimiter); } #[Test] @@ -40,6 +44,7 @@ public function itDelegatesToServiceAndReturnsDTO(): void return true; })); + $this->rateLimiter->expects($this->once())->method('consume')->with('device_registration', 20, 60); $data = new DeviceRegistrationInputDTO(); $data->platform = 'ios'; diff --git a/tests/Unit/Domain/DeviceRegistrationServiceTest.php b/tests/Unit/Domain/DeviceRegistrationServiceTest.php index 52bc68d..e194005 100644 --- a/tests/Unit/Domain/DeviceRegistrationServiceTest.php +++ b/tests/Unit/Domain/DeviceRegistrationServiceTest.php @@ -23,7 +23,7 @@ protected function setUp(): void { $this->repository = $this->createMock(DeviceRepositoryInterface::class); // DeviceTokenHasher is final — use the real instance (pure, no I/O) - $this->service = new DeviceRegistrationService(new DeviceTokenHasher(), $this->repository); + $this->service = new DeviceRegistrationService(new DeviceTokenHasher('test-secret'), $this->repository); } #[Test] diff --git a/tests/Unit/Domain/InviteServiceTest.php b/tests/Unit/Domain/InviteServiceTest.php index 75181e4..9bd74f7 100644 --- a/tests/Unit/Domain/InviteServiceTest.php +++ b/tests/Unit/Domain/InviteServiceTest.php @@ -50,7 +50,8 @@ public function itCreatesInviteForProtectedPersonDevice(): void $invite = $this->service->createInvite($device); self::assertInstanceOf(CaregiverInvite::class, $invite); - self::assertSame(8, strlen($invite->getCode())); + self::assertSame(32, strlen($invite->getCode())); + self::assertMatchesRegularExpression('/^[A-F0-9]{32}$/', $invite->getCode()); } #[Test] diff --git a/tests/Unit/Infrastructure/DeviceTokenAuthenticatorTest.php b/tests/Unit/Infrastructure/DeviceTokenAuthenticatorTest.php index ead4567..952988a 100644 --- a/tests/Unit/Infrastructure/DeviceTokenAuthenticatorTest.php +++ b/tests/Unit/Infrastructure/DeviceTokenAuthenticatorTest.php @@ -27,7 +27,7 @@ final class DeviceTokenAuthenticatorTest extends TestCase protected function setUp(): void { $this->deviceRepository = $this->createMock(DeviceRepositoryInterface::class); - $this->authenticator = new DeviceTokenAuthenticator($this->deviceRepository, new DeviceTokenHasher()); + $this->authenticator = new DeviceTokenAuthenticator($this->deviceRepository, new DeviceTokenHasher('test-secret')); } #[Test] @@ -51,7 +51,7 @@ public function itRejectsRequestsWithoutBearerToken(): void public function itLoadsDeviceUserFromBearerToken(): void { $plainToken = 'plain-token'; - $hashedToken = hash('sha256', $plainToken); + $hashedToken = hash_hmac('sha256', $plainToken, 'test-secret'); $device = new Device('device-id', $hashedToken, 'ios', '1.0.0'); $this->deviceRepository @@ -59,6 +59,7 @@ public function itLoadsDeviceUserFromBearerToken(): void ->method('findActiveByTokenHash') ->with($hashedToken) ->willReturn($device); + $this->deviceRepository->expects($this->once())->method('save')->with($device); $request = Request::create('/api/v1/alerts'); $request->headers->set('Authorization', 'Bearer '.$plainToken); @@ -73,6 +74,35 @@ public function itLoadsDeviceUserFromBearerToken(): void self::assertNotNull($device->getLastSeenAt()); } + #[Test] + public function itAcceptsLegacySha256TokenHashDuringTransition(): void + { + $plainToken = 'plain-token'; + $hmacHash = hash_hmac('sha256', $plainToken, 'test-secret'); + $legacyHash = hash('sha256', $plainToken); + $device = new Device('device-id', $legacyHash, 'ios', '1.0.0'); + + $this->deviceRepository + ->expects($this->exactly(2)) + ->method('findActiveByTokenHash') + ->willReturnMap([ + [$hmacHash, null], + [$legacyHash, $device], + ]); + $this->deviceRepository->expects($this->once())->method('save')->with($device); + + $request = Request::create('/api/v1/alerts'); + $request->headers->set('Authorization', 'Bearer '.$plainToken); + + $passport = $this->authenticator->authenticate($request); + $userBadge = $passport->getBadge(UserBadge::class); + self::assertInstanceOf(UserBadge::class, $userBadge); + + self::assertInstanceOf(DeviceApiUser::class, $userBadge->getUser()); + self::assertNotNull($device->getLastSeenAt()); + self::assertSame($hmacHash, $device->getTokenHash()); + } + #[Test] public function itRejectsUnknownDeviceToken(): void { diff --git a/tests/Unit/Infrastructure/FcmPushGatewayTest.php b/tests/Unit/Infrastructure/FcmPushGatewayTest.php index 8d4ada8..62f2829 100644 --- a/tests/Unit/Infrastructure/FcmPushGatewayTest.php +++ b/tests/Unit/Infrastructure/FcmPushGatewayTest.php @@ -29,14 +29,20 @@ public function itUsesBase64UrlEncodedJwtForOauthAssertion(): void $assertion = null; $fcmPayload = null; - $client = new MockHttpClient(static function (string $method, string $url, array $options) use (&$assertion, &$fcmPayload): MockResponse { + $oauthOptions = null; + $fcmOptions = null; + $oauthCalls = 0; + $client = new MockHttpClient(static function (string $method, string $url, array $options) use (&$assertion, &$fcmPayload, &$oauthOptions, &$fcmOptions, &$oauthCalls): MockResponse { if ('https://oauth2.googleapis.com/token' === $url) { + ++$oauthCalls; + $oauthOptions = $options; parse_str((string) $options['body'], $body); $assertion = is_string($body['assertion'] ?? null) ? $body['assertion'] : null; - return new MockResponse(json_encode(['access_token' => 'access-token']) ?: '{}'); + return new MockResponse(json_encode(['access_token' => 'access-token', 'expires_in' => 3600]) ?: '{}'); } + $fcmOptions = $options; $decodedPayload = json_decode((string) $options['body'], true); $fcmPayload = is_array($decodedPayload) ? $decodedPayload : null; @@ -53,8 +59,12 @@ public function itUsesBase64UrlEncodedJwtForOauthAssertion(): void ); $gateway->send('fcm-token', 'alert-id', '2026-01-01T00:00:00+00:00', null, null); + $gateway->send('fcm-token-2', 'alert-id-2', '2026-01-01T00:00:00+00:00', null, null); self::assertIsString($assertion); + self::assertSame(1, $oauthCalls); + self::assertSame(10.0, $oauthOptions['timeout'] ?? null); + self::assertSame(10.0, $fcmOptions['timeout'] ?? null); $segments = explode('.', $assertion); self::assertCount(3, $segments); diff --git a/tests/Unit/Infrastructure/FixedWindowEndpointRateLimiterTest.php b/tests/Unit/Infrastructure/FixedWindowEndpointRateLimiterTest.php new file mode 100644 index 0000000..4bffc9d --- /dev/null +++ b/tests/Unit/Infrastructure/FixedWindowEndpointRateLimiterTest.php @@ -0,0 +1,94 @@ +createMock(EntityManagerInterface::class); + $entityManager->expects($this->once())->method('persist')->with($this->isInstanceOf(RateLimitBucket::class)); + $entityManager->expects($this->once()) + ->method('find') + ->with(RateLimitBucket::class, hash('sha256', 'device_registration|203.0.113.10'), LockMode::PESSIMISTIC_WRITE) + ->willReturn(null); + $this->executeTransactions($entityManager); + + $limiter = new FixedWindowEndpointRateLimiter($entityManager, $this->requestStack()); + + $limiter->consume('device_registration', 20, 60); + } + + #[Test] + public function itRejectsWhenBucketLimitIsReached(): void + { + $bucket = new RateLimitBucket( + hash('sha256', 'invite_accept|caregiver-1'), + new DateTimeImmutable(), + 5, + ); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('find')->willReturn($bucket); + $this->executeTransactions($entityManager); + + $limiter = new FixedWindowEndpointRateLimiter($entityManager, $this->requestStack()); + + $this->expectException(TooManyRequestsHttpException::class); + + $limiter->consume('invite_accept', 5, 600, 'caregiver-1'); + } + + #[Test] + public function itResetsExpiredWindowBeforeConsuming(): void + { + $bucket = new RateLimitBucket( + hash('sha256', 'invite_accept|caregiver-1'), + new DateTimeImmutable('-61 seconds'), + 5, + ); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('find')->willReturn($bucket); + $this->executeTransactions($entityManager); + + $limiter = new FixedWindowEndpointRateLimiter($entityManager, $this->requestStack()); + + $limiter->consume('invite_accept', 5, 60, 'caregiver-1'); + + self::assertSame(1, $bucket->getHits()); + } + + private function executeTransactions(EntityManagerInterface&MockObject $entityManager): void + { + $entityManager + ->method('wrapInTransaction') + ->willReturnCallback(static fn (callable $callback): mixed => $callback()); + } + + private function requestStack(): RequestStack + { + $request = Request::create('/'); + $request->server->set('REMOTE_ADDR', '203.0.113.10'); + + $requestStack = new RequestStack(); + $requestStack->push($request); + + return $requestStack; + } +}