Skip to content
Open
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
20 changes: 13 additions & 7 deletions ProcessMaker/Http/Middleware/ProcessMakerAuthenticate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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]);
Expand All @@ -39,20 +43,22 @@ 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)) {
$request->headers->set('accept', 'application/json,' . $request->header('accept'));
}
}

/**
* @param \Illuminate\Http\Request $request
* @return bool
*/
private function requestHasAcceptJsonHeader(\Illuminate\Http\Request $request): bool
{
return Str::contains($request->header('accept'), ['/json', '+json']);
Expand Down
153 changes: 153 additions & 0 deletions ProcessMaker/Services/InvalidBearerTokenCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace ProcessMaker\Services;

use Illuminate\Auth\AuthenticationException;
use Illuminate\Cache\Repository;
use Illuminate\Contracts\Cache\Repository as RepositoryContract;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class InvalidBearerTokenCache
{
private const CACHE_PREFIX = 'invalid_bearer:';

private const ATTR_STASHED_JWT = 'pm_invalid_bearer_token_record';

private const ATTR_ALREADY_CACHED = 'pm_invalid_bearer_failure_recorded';

private static ?RepositoryContract $repositoryForTesting = null;

private static ?int $ttlSecondsForTesting = null;

public static function prepareApiBearerBeforePassport(Request $request, array $guards): void
{
if (!self::isApiBearerRequest($request, $guards)) {
return;
}

$jwt = self::jwtBearerFromRequest($request);
if ($jwt !== null && self::isInNegativeCache($jwt)) {
throw new AuthenticationException('Unauthenticated.', $guards);
}

$rawBearer = self::bearerFromAuthorizationHeader($request);
if ($rawBearer !== null && !self::looksLikeJwt($rawBearer)) {
throw new AuthenticationException('Unauthenticated.', $guards);
}

if ($rawBearer !== null && self::looksLikeJwt($rawBearer)) {
$request->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;
}
}
6 changes: 6 additions & 0 deletions config/auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Tests\Feature\Http\Middleware;

use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Http\Request;
use ProcessMaker\Services\InvalidBearerTokenCache;
use Tests\TestCase;

class ProcessMakerAuthenticateInvalidBearerTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

InvalidBearerTokenCache::setRepositoryForTesting(new Repository(new ArrayStore()));
InvalidBearerTokenCache::setTtlSecondsForTesting(60);
}

protected function tearDown(): void
{
InvalidBearerTokenCache::setRepositoryForTesting(null);
InvalidBearerTokenCache::setTtlSecondsForTesting(null);

parent::tearDown();
}

public function testNonJwtBearerWithoutAcceptJsonReturns401(): void
{
$response = $this->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.']);
}
}
Loading
Loading