Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 6 additions & 0 deletions compose.prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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:-}
Expand Down
3 changes: 3 additions & 0 deletions compose.test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down Expand Up @@ -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:-}
Expand Down
7 changes: 7 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion config/packages/messenger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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%'
Expand Down
2 changes: 2 additions & 0 deletions config/services_test.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
services:
App\Infrastructure\RateLimit\EndpointRateLimiterInterface: '@App\Infrastructure\RateLimit\NullEndpointRateLimiter'

App\Tests\Behat\ApiContext:
public: true
autowire: true
Expand Down
31 changes: 31 additions & 0 deletions migrations/Version20260517120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Override;

final class Version20260517120000 extends AbstractMigration
{
#[Override]
public function getDescription(): string
{
return 'Add endpoint rate limit buckets';
}

public function up(Schema $schema): void
{
$this->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)');
}
}
9 changes: 8 additions & 1 deletion src/Domain/Alert/Processor/CreateFallAlertProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateFallAlertInputDTO, FallAlertOutputDTO>
Expand All @@ -21,19 +23,24 @@
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();

if ($device->isCaregiver()) {
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,
Expand Down
7 changes: 6 additions & 1 deletion src/Domain/Caregiver/Processor/AcceptInviteProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,18 +23,22 @@
public function __construct(
private InviteServiceInterface $inviteService,
private DeviceContextInterface $currentDeviceProvider,
private EndpointRateLimiterInterface $rateLimiter,
) {
}

public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
$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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/Domain/Caregiver/Service/InviteService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions src/Domain/Device/Processor/DeviceRegistrationProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeviceRegistrationInputDTO, DeviceRegistrationOutputDTO>
*/
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,
Expand Down
2 changes: 1 addition & 1 deletion src/Entity/CaregiverInvite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
6 changes: 6 additions & 0 deletions src/Entity/Device.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 45 additions & 0 deletions src/Entity/RateLimitBucket.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'rate_limit_buckets')]
class RateLimitBucket
{
public function __construct(
#[ORM\Id]
#[ORM\Column(length: 64)]
private string $id,
#[ORM\Column(name: 'window_start_at')]
private DateTimeImmutable $windowStartAt,
#[ORM\Column]
private int $hits = 0,
) {
}

public function getWindowStartAt(): DateTimeImmutable
{
return $this->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;
}
}
Loading
Loading