diff --git a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php index b64c13812e..283f9b4c16 100644 --- a/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php +++ b/ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php @@ -3,8 +3,11 @@ namespace ProcessMaker\Http\Middleware; use Closure; +use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\Middleware\Authenticate; use Illuminate\Support\Str; +use League\OAuth2\Server\Exception\OAuthServerException; +use ProcessMaker\Services\InvalidBearerTokenCache; class ProcessMakerAuthenticate extends Authenticate { @@ -29,7 +32,8 @@ protected function authenticate($request, array $guards) { $this->addAcceptJsonHeaderIfApiCall($request, $guards); - // Load permissions into the session + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, $guards); + if (auth()->check() && !$request->ajax()) { $permissions = $request->user()->loadPermissions(); session(['permissions' => $permissions]); @@ -39,9 +43,15 @@ protected function authenticate($request, array $guards) } /** - * @param array $guards - * @param \Illuminate\Http\Request $request + * {@inheritdoc} */ + protected function unauthenticated($request, array $guards) + { + InvalidBearerTokenCache::rememberPassportJwtFailure($request, $guards); + + throw new AuthenticationException('Unauthenticated.', $guards); + } + private function addAcceptJsonHeaderIfApiCall(\Illuminate\Http\Request $request, array $guards): void { if (in_array('api', $guards) && !$this->requestHasAcceptJsonHeader($request)) { @@ -49,10 +59,6 @@ private function addAcceptJsonHeaderIfApiCall(\Illuminate\Http\Request $request, } } - /** - * @param \Illuminate\Http\Request $request - * @return bool - */ private function requestHasAcceptJsonHeader(\Illuminate\Http\Request $request): bool { return Str::contains($request->header('accept'), ['/json', '+json']); diff --git a/ProcessMaker/Services/InvalidBearerTokenCache.php b/ProcessMaker/Services/InvalidBearerTokenCache.php new file mode 100644 index 0000000000..b18bec808c --- /dev/null +++ b/ProcessMaker/Services/InvalidBearerTokenCache.php @@ -0,0 +1,153 @@ +attributes->set(self::ATTR_STASHED_JWT, $rawBearer); + } + } + + public static function rememberPassportJwtFailure(Request $request, array $guards = []): void + { + if (!self::isApiBearerRequest($request, $guards)) { + return; + } + + $jwt = self::jwtBearerFromRequest($request); + if ($jwt === null || self::ttlSeconds() <= 0) { + return; + } + + if ($request->attributes->get(self::ATTR_ALREADY_CACHED)) { + return; + } + + self::cache()->put(self::cacheKey($jwt), true, self::ttlSeconds()); + $request->attributes->set(self::ATTR_ALREADY_CACHED, true); + } + + public static function isInNegativeCache(string $token): bool + { + if (self::ttlSeconds() <= 0) { + return false; + } + + return self::cache()->has(self::cacheKey($token)); + } + + public static function setRepositoryForTesting(?RepositoryContract $repository): void + { + self::$repositoryForTesting = $repository; + } + + public static function setTtlSecondsForTesting(?int $ttl): void + { + self::$ttlSecondsForTesting = $ttl; + } + + private static function isApiBearerRequest(Request $request, array $guards): bool + { + if ($guards !== [] && !in_array('api', $guards, true)) { + return false; + } + + if ($guards === [] && !str_starts_with($request->path(), 'api/')) { + return false; + } + + return self::bearerFromAuthorizationHeader($request) !== null + || $request->attributes->has(self::ATTR_STASHED_JWT); + } + + private static function jwtBearerFromRequest(Request $request): ?string + { + $fromHeader = self::bearerFromAuthorizationHeader($request); + if ($fromHeader !== null && self::looksLikeJwt($fromHeader)) { + return $fromHeader; + } + + $stashed = $request->attributes->get(self::ATTR_STASHED_JWT); + if (is_string($stashed) && self::looksLikeJwt($stashed)) { + return $stashed; + } + + return null; + } + + private static function bearerFromAuthorizationHeader(Request $request): ?string + { + $header = $request->header('Authorization', ''); + if (!str_starts_with($header, 'Bearer ')) { + return null; + } + + $token = trim(substr($header, 7)); + + return $token === '' ? null : $token; + } + + private static function looksLikeJwt(string $token): bool + { + return substr_count($token, '.') === 2; + } + + private static function cacheKey(string $token): string + { + return self::CACHE_PREFIX . hash('sha256', $token); + } + + private static function ttlSeconds(): int + { + if (self::$ttlSecondsForTesting !== null) { + return self::$ttlSecondsForTesting; + } + + return (int) config('auth.invalid_bearer_cache_ttl', 60); + } + + private static function cache(): RepositoryContract + { + if (self::$repositoryForTesting !== null) { + return self::$repositoryForTesting; + } + + /** @var Repository $store */ + $store = Cache::store(); + + return $store; + } +} diff --git a/config/auth.php b/config/auth.php index e7811a250d..7b34bcec1d 100644 --- a/config/auth.php +++ b/config/auth.php @@ -111,6 +111,12 @@ 'log_auth_events' => env('LOG_AUTH_EVENTS', true), + /* + | Seconds to cache a failed JWT-shaped Bearer (hash only) so repeat requests skip Passport. + | Set AUTH_INVALID_BEARER_CACHE_TTL=0 to disable. + */ + 'invalid_bearer_cache_ttl' => (int) env('AUTH_INVALID_BEARER_CACHE_TTL', 60), + /* |-------------------------------------------------------------------------- | Password Confirmation Timeout diff --git a/tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php b/tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php new file mode 100644 index 0000000000..6066e40e5c --- /dev/null +++ b/tests/Feature/Http/Middleware/ProcessMakerAuthenticateInvalidBearerTest.php @@ -0,0 +1,55 @@ +withHeaders([ + 'Authorization' => 'Bearer FAKE_JWT', + 'Accept' => 'text/html', + ])->get('/api/1.0/tasks'); + + $response->assertStatus(401); + $response->assertJson(['error' => 'Unauthenticated.']); + } + + public function testCachedInvalidJwtWithoutAcceptJsonReturns401(): void + { + $token = 'aaa.bbb.ccc'; + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer ' . $token); + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'text/html', + ])->get('/api/1.0/tasks'); + + $response->assertStatus(401); + $response->assertJson(['error' => 'Unauthenticated.']); + } +} diff --git a/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php b/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php new file mode 100644 index 0000000000..4a3a41a861 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/InvalidBearerTokenCacheTest.php @@ -0,0 +1,156 @@ +cache = new Repository(new ArrayStore()); + InvalidBearerTokenCache::setRepositoryForTesting($this->cache); + InvalidBearerTokenCache::setTtlSecondsForTesting(60); + } + + protected function tearDown(): void + { + InvalidBearerTokenCache::setRepositoryForTesting(null); + InvalidBearerTokenCache::setTtlSecondsForTesting(null); + parent::tearDown(); + } + + public function testRememberPassportJwtFailureMarksTokenForSkipPassport(): void + { + $token = 'aaa.bbb.ccc'; + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer ' . $token); + + $this->assertFalse(InvalidBearerTokenCache::isInNegativeCache($token)); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->assertTrue(InvalidBearerTokenCache::isInNegativeCache($token)); + } + + public function testPrepareApiBearerBeforePassportThrowsWhenTokenWasCached(): void + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer aaa.bbb.ccc'); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->expectException(AuthenticationException::class); + + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); + } + + public function testPrepareApiBearerBeforePassportRejectsBearerWithoutJwtShape(): void + { + $request = $this->requestWithAuthorization('Bearer FAKE_JWT'); + + $this->expectException(AuthenticationException::class); + + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); + } + + public function testPrepareApiBearerBeforePassportAllowsJwtShapedBearerThrough(): void + { + $request = $this->requestWithAuthorization('Bearer aaa.bbb.ccc'); + + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); + $this->addToAssertionCount(1); + } + + public function testPrepareApiBearerBeforePassportIgnoresEmptyBearer(): void + { + InvalidBearerTokenCache::prepareApiBearerBeforePassport( + $this->requestWithAuthorization('Bearer '), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testPrepareApiBearerBeforePassportIgnoresWhitespaceOnlyBearer(): void + { + InvalidBearerTokenCache::prepareApiBearerBeforePassport( + $this->requestWithAuthorization('Bearer '), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testPrepareApiBearerBeforePassportAppliesWhenApiGuardAmongMultiple(): void + { + InvalidBearerTokenCache::prepareApiBearerBeforePassport( + $this->requestWithAuthorization('Bearer aaa.bbb.ccc'), + ['web', 'api'] + ); + $this->addToAssertionCount(1); + } + + public function testPrepareApiBearerBeforePassportSkipsNonApiGuard(): void + { + InvalidBearerTokenCache::prepareApiBearerBeforePassport( + $this->requestWithAuthorization('Bearer FAKE_JWT'), + ['web'] + ); + $this->addToAssertionCount(1); + } + + public function testPrepareApiBearerBeforePassportSkipsWithoutBearerHeader(): void + { + InvalidBearerTokenCache::prepareApiBearerBeforePassport( + Request::create('/api/1.0/tasks', 'GET'), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testPrepareApiBearerBeforePassportSkipsNonBearerScheme(): void + { + InvalidBearerTokenCache::prepareApiBearerBeforePassport( + $this->requestWithAuthorization('Basic dXNlcjpwYXNz'), + ['api'] + ); + $this->addToAssertionCount(1); + } + + public function testDoesNotCacheMalformedTokensFromRequest(): void + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer FAKE_JWT'); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->assertFalse(InvalidBearerTokenCache::isInNegativeCache('FAKE_JWT')); + } + + public function testRememberPassportJwtFailureUsesStashedTokenAfterPassportClearsHeader(): void + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', 'Bearer aaa.bbb.ccc'); + InvalidBearerTokenCache::prepareApiBearerBeforePassport($request, ['api']); + $request->headers->remove('Authorization'); + + InvalidBearerTokenCache::rememberPassportJwtFailure($request, ['api']); + + $this->assertTrue(InvalidBearerTokenCache::isInNegativeCache('aaa.bbb.ccc')); + } + + private function requestWithAuthorization(string $authorization): Request + { + $request = Request::create('/api/1.0/tasks', 'GET'); + $request->headers->set('Authorization', $authorization); + + return $request; + } +}