diff --git a/.env.example b/.env.example index af492e576..7d20152d7 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,7 @@ SSL_ENABLED=true DB_LOG_ENABLED=true ACCESS_TOKEN_CACHE_LIFETIME=300 API_RESPONSE_CACHE_LIFETIME=600 +CACHE_API_RESPONSE_API_ENDPOINT_LIFETIME=3600 LOG_EMAIL_TO=smarcet@gmail.com LOG_EMAIL_FROM=smarcet@gmail.com diff --git a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php index 502bb64eb..255d26642 100644 --- a/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php +++ b/app/Http/Middleware/OAuth2BearerAccessTokenRequestValidator.php @@ -14,6 +14,8 @@ use App\Models\ResourceServer\IAccessTokenService; use App\Models\ResourceServer\IApiEndpointRepository; use Closure; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Response; @@ -33,6 +35,7 @@ */ class OAuth2BearerAccessTokenRequestValidator { + const EndpointCacheVersionKey = 'api_endpoint_cache_version'; /** * @var IResourceServerContext @@ -70,6 +73,60 @@ public function __construct( $this->token_service = $token_service; } + /** + * Invalidates every cached endpoint lookup (call it after endpoints or scopes change). + */ + public static function flushEndpointCache(): void + { + Cache::forever(self::EndpointCacheVersionKey, uniqid()); + } + + /** + * Only what the validator needs from the endpoint, cached per (route, method). + * Unknown endpoints are cached as missing. + * + * @param string $route + * @param string $method + * @return array{exists: bool, active: bool, scopes: string[]} + */ + private function getEndpointInfo(string $route, string $method): array + { + $loader = function () use ($route, $method): array { + $endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($route, $method); + if (is_null($endpoint)) { + return ['exists' => false, 'active' => false, 'scopes' => []]; + } + return [ + 'exists' => true, + 'active' => $endpoint->isActive(), + 'scopes' => $endpoint->getScopesNames(), + ]; + }; + + $lifetime = intval(Config::get('cache_api_response.api_endpoint_lifetime', 3600)); + if ($lifetime <= 0) { + return $loader(); + } + + // a missing version (cache cleared or evicted) gets a fresh one, so entries cached under + // a previous version are never read again + $version = Cache::get(self::EndpointCacheVersionKey); + if (is_null($version)) { + Cache::add(self::EndpointCacheVersionKey, uniqid('', true)); + $version = Cache::get(self::EndpointCacheVersionKey); + } + + $key = sprintf + ( + 'api_endpoint_%s_%s_%s', + $version, + $route, + $method + ); + + return Cache::remember($key, $lifetime, $loader); + } + /** * @param \Illuminate\Http\Request $request * @param Closure $next @@ -131,10 +188,10 @@ public function handle($request, Closure $next) ); } - $endpoint = $this->endpoint_repository->getApiEndpointByUrlAndMethod($route, $method); + $endpoint = $this->getEndpointInfo($route, $method); //api endpoint must be registered on db and active - if (is_null($endpoint) || !$endpoint->isActive()) { + if (!$endpoint['exists'] || !$endpoint['active']) { Log::warning ( sprintf('OAuth2BearerAccessTokenRequestValidator::handle API endpoint does not exits! (%s:%s)', $route, $method) @@ -184,7 +241,7 @@ public function handle($request, Closure $next) } //check scopes Log::debug('OAuth2BearerAccessTokenRequestValidator::handle checking token scopes ...'); - $endpoint_scopes = $endpoint->getScopesNames(); + $endpoint_scopes = $endpoint['scopes']; Log::debug(sprintf("OAuth2BearerAccessTokenRequestValidator::handle endpoint scopes %s", implode(' ',$endpoint_scopes))); Log::debug(sprintf("OAuth2BearerAccessTokenRequestValidator::handle token scopes %s", $token_info->getScope())); $token_scopes = explode(' ', $token_info->getScope()); diff --git a/config/cache_api_response.php b/config/cache_api_response.php index cb728400e..72aee5449 100644 --- a/config/cache_api_response.php +++ b/config/cache_api_response.php @@ -20,4 +20,6 @@ 'get_summits_response_lifetime' => env('CACHE_API_RESPONSE_GET_SUMMITS_LIFETIME', 600), 'file_url_lifetime' => env('CACHE_API_RESPONSE_FILE_URL_LIFETIME', 3600), 'event_has_access_lifetime' => env('CACHE_API_RESPONSE_EVENT_HAS_ACCESS_LIFETIME', 600), + // 0 disables the cache of the endpoint lookup done by the bearer token validator + 'api_endpoint_lifetime' => env('CACHE_API_RESPONSE_API_ENDPOINT_LIFETIME', 3600), ]; \ No newline at end of file diff --git a/database/migrations/config/APIEndpointsMigrationHelper.php b/database/migrations/config/APIEndpointsMigrationHelper.php index 32ad97dd6..7a099fe45 100644 --- a/database/migrations/config/APIEndpointsMigrationHelper.php +++ b/database/migrations/config/APIEndpointsMigrationHelper.php @@ -30,6 +30,11 @@ * $this->addSql($this->insertEndpointAuthzGroup('summits', $endpointName, $groupSlug)); * } * } + * + * Note: the bearer token validator caches the endpoint lookup (cache_api_response.api_endpoint_lifetime). + * A deploy running these migrations must flush it (php artisan cache:clear, or + * OAuth2BearerAccessTokenRequestValidator::flushEndpointCache()), otherwise new routes + * answer 400 until the lifetime expires. */ trait APIEndpointsMigrationHelper { diff --git a/database/seeders/ApiEndpointsSeeder.php b/database/seeders/ApiEndpointsSeeder.php index af083c4c3..91de62e74 100644 --- a/database/seeders/ApiEndpointsSeeder.php +++ b/database/seeders/ApiEndpointsSeeder.php @@ -22,6 +22,7 @@ use App\Security\OrganizationScopes; use App\Security\MemberScopes; use App\Models\Foundation\Main\IGroup; +use App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator; use App\Security\CompanyScopes; use App\Security\SponsoredProjectScope; @@ -50,6 +51,8 @@ public function run() $this->seedSummitMediaFileTypeEndpoints(); $this->seedElectionsEndpoints(); $this->seedRSVPInvitationsEndpoints(); + // endpoints/scopes changed, drop the validator lookups cached by route and method + OAuth2BearerAccessTokenRequestValidator::flushEndpointCache(); } private function seedAttendeeBadgesEndpoints() diff --git a/tests/OAuth2BearerEndpointCacheTest.php b/tests/OAuth2BearerEndpointCacheTest.php new file mode 100644 index 000000000..5ba321212 --- /dev/null +++ b/tests/OAuth2BearerEndpointCacheTest.php @@ -0,0 +1,149 @@ +shouldReceive('isActive')->andReturn($active); + $endpoint->shouldReceive('getScopesNames')->andReturn($scopes); + return $endpoint; + } + + private function lookup($repository): array + { + $validator = new OAuth2BearerAccessTokenRequestValidator( + Mockery::mock(IResourceServerContext::class), + $repository, + Mockery::mock(IAccessTokenService::class) + ); + $m = new \ReflectionMethod($validator, 'getEndpointInfo'); + $m->setAccessible(true); + return $m->invoke($validator, self::Route, self::Method); + } + + public function testCacheMissPopulatesAndHitSkipsRepository(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->once() + ->with(self::Route, self::Method) + ->andReturn($this->endpoint(true, ['scope-a', 'scope-b'])); + + $expected = ['exists' => true, 'active' => true, 'scopes' => ['scope-a', 'scope-b']]; + $this->assertSame($expected, $this->lookup($repo)); + $this->assertSame($expected, $this->lookup($repo)); + } + + public function testInactiveEndpointIsReportedInactive(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->once() + ->andReturn($this->endpoint(false, ['scope-a'])); + + $this->assertFalse($this->lookup($repo)['active']); + $this->assertFalse($this->lookup($repo)['active']); + } + + public function testUnknownEndpointIsCachedAsMissing(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod')->once()->andReturn(null); + + $this->assertFalse($this->lookup($repo)['exists']); + $this->assertFalse($this->lookup($repo)['exists']); + } + + public function testLifetimeZeroDisablesCache(): void + { + Config::set('cache_api_response.api_endpoint_lifetime', 0); + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->twice() + ->andReturn($this->endpoint(true, ['scope-a'])); + + $this->lookup($repo); + $this->lookup($repo); + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } + + public function testMissingVersionIsPersistedAndNotReused(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->twice() + ->andReturn($this->endpoint(true, ['scope-a'])); + + $this->assertNull(Cache::get(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey)); + $this->lookup($repo); + $version = Cache::get(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey); + $this->assertNotNull($version); + $this->assertNotSame('0', $version); + + // the version is evicted: entries cached under it must not be served again + Cache::forget(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey); + $this->lookup($repo); + $this->assertNotSame( + $version, + Cache::get(OAuth2BearerAccessTokenRequestValidator::EndpointCacheVersionKey) + ); + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } + + public function testFlushForcesNewLookup(): void + { + $repo = Mockery::mock(IApiEndpointRepository::class); + $repo->shouldReceive('getApiEndpointByUrlAndMethod') + ->twice() + ->andReturn($this->endpoint(true, ['scope-a'])); + + $this->lookup($repo); + OAuth2BearerAccessTokenRequestValidator::flushEndpointCache(); + $this->lookup($repo); + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } +}