From 8a79a4987efd2943526dd73fe74b4ac51bd85b07 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 25 Mar 2026 14:56:03 +0000 Subject: [PATCH 1/4] feat: implement Guest middleware to handle requests without authorization and validate bearer tokens --- src/Auth/Middlewares/Authenticated.php | 21 +-- .../Concerns/InteractsWithBearerTokens.php | 38 ++++ src/Auth/Middlewares/Guest.php | 50 ++++++ src/Auth/Middlewares/TokenRateLimit.php | 7 +- tests/Feature/AuthenticationTest.php | 170 ++++++++++++++++++ 5 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php create mode 100644 src/Auth/Middlewares/Guest.php diff --git a/src/Auth/Middlewares/Authenticated.php b/src/Auth/Middlewares/Authenticated.php index 8fec53be..67d6a816 100644 --- a/src/Auth/Middlewares/Authenticated.php +++ b/src/Auth/Middlewares/Authenticated.php @@ -12,6 +12,7 @@ use Phenix\Auth\AuthenticationManager; use Phenix\Auth\Events\FailedTokenValidation; use Phenix\Auth\Events\TokenValidated; +use Phenix\Auth\Middlewares\Concerns\InteractsWithBearerTokens; use Phenix\Auth\User; use Phenix\Facades\Config; use Phenix\Facades\Event; @@ -21,15 +22,17 @@ class Authenticated implements Middleware { + use InteractsWithBearerTokens; + public function handleRequest(Request $request, RequestHandler $next): Response { $authorizationHeader = $request->getHeader('Authorization'); - if (! $this->hasToken($authorizationHeader)) { + if (! $this->hasBearerScheme($authorizationHeader)) { return $this->unauthorized(); } - $token = $this->extractToken($authorizationHeader); + $token = $this->extractBearerToken($authorizationHeader); /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); @@ -63,20 +66,6 @@ public function handleRequest(Request $request, RequestHandler $next): Response return $next->handleRequest($request); } - protected function hasToken(string|null $token): bool - { - return $token !== null - && trim($token) !== '' - && str_starts_with($token, 'Bearer '); - } - - protected function extractToken(string $authorizationHeader): string|null - { - $parts = explode(' ', $authorizationHeader, 2); - - return isset($parts[1]) ? trim($parts[1]) : null; - } - protected function unauthorized(): Response { return response()->json([ diff --git a/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php b/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php new file mode 100644 index 00000000..f571f6f8 --- /dev/null +++ b/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php @@ -0,0 +1,38 @@ +hasBearerScheme($authorizationHeader)) { + return null; + } + + $authorizationHeader = trim((string) $authorizationHeader); + + if (preg_match('/^Bearer\s+([A-Za-z0-9\\-._~+\\/]+=*)$/i', $authorizationHeader, $matches) !== 1) { + return null; + } + + return trim($matches[1]) !== '' ? $matches[1] : null; + } +} diff --git a/src/Auth/Middlewares/Guest.php b/src/Auth/Middlewares/Guest.php new file mode 100644 index 00000000..524bf2d9 --- /dev/null +++ b/src/Auth/Middlewares/Guest.php @@ -0,0 +1,50 @@ +getHeader('Authorization'); + + if (! $this->hasBearerScheme($authorizationHeader)) { + return $next->handleRequest($request); + } + + $token = $this->extractBearerToken($authorizationHeader); + + if ($token === null) { + return $next->handleRequest($request); + } + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + if (! $auth->validate($token)) { + return $next->handleRequest($request); + } + + return $this->unauthorized(); + } + + protected function unauthorized(): Response + { + return response()->json([ + 'message' => 'Unauthorized', + ], HttpStatus::UNAUTHORIZED)->send(); + } +} diff --git a/src/Auth/Middlewares/TokenRateLimit.php b/src/Auth/Middlewares/TokenRateLimit.php index 86e59a75..3afd876d 100644 --- a/src/Auth/Middlewares/TokenRateLimit.php +++ b/src/Auth/Middlewares/TokenRateLimit.php @@ -10,19 +10,20 @@ use Amp\Http\Server\Response; use Phenix\App; use Phenix\Auth\AuthenticationManager; +use Phenix\Auth\Middlewares\Concerns\InteractsWithBearerTokens; use Phenix\Facades\Config; use Phenix\Http\Constants\HttpStatus; use Phenix\Http\Ip; -use function str_starts_with; - class TokenRateLimit implements Middleware { + use InteractsWithBearerTokens; + public function handleRequest(Request $request, RequestHandler $next): Response { $authorizationHeader = $request->getHeader('Authorization'); - if ($authorizationHeader === null || ! str_starts_with($authorizationHeader, 'Bearer ')) { + if (! $this->hasBearerScheme($authorizationHeader)) { return $next->handleRequest($request); } diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 935fbca1..30160fb0 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -9,6 +9,7 @@ use Phenix\Auth\Events\TokenRefreshCompleted; use Phenix\Auth\Events\TokenValidated; use Phenix\Auth\Middlewares\Authenticated; +use Phenix\Auth\Middlewares\Guest; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\User; use Phenix\Database\Constants\Connection; @@ -47,6 +48,175 @@ ->assertUnauthorized(); }); +it('allows guest requests without authorization header', function (): void { + Route::get('/guest', fn (): Response => response()->plain('Guest')) + ->middleware(Guest::class); + + $this->app->run(); + + $this->get('/guest') + ->assertOk() + ->assertBodyContains('Guest'); +}); + +it('rejects guest requests with valid bearer token', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $tokenData = [ + [ + 'id' => Str::uuid()->toString(), + 'tokenable_type' => $user::class, + 'tokenable_id' => $user->id, + 'name' => 'api-token', + 'token' => hash('sha256', 'valid-token'), + 'created_at' => Date::now()->toDateTimeString(), + 'last_used_at' => null, + 'expires_at' => null, + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $tokenResult = new Result([['Query OK']]); + $tokenResult->setLastInsertedId($tokenData[0]['id']); + + $connection->expects($this->exactly(4)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($tokenResult), + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/guest', fn (): Response => response()->plain('Never reached')) + ->middleware(Guest::class); + + $this->app->run(); + + $this->get('/guest', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertUnauthorized(); +}); + +it('allows guest requests with invalid bearer token', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result())); + + $this->app->swap(Connection::default(), $connection); + + Route::get('/guest', fn (): Response => response()->plain('Guest')) + ->middleware(Guest::class); + + $this->app->run(); + + $this->get('/guest', headers: [ + 'Authorization' => 'Bearer invalid-token', + ])->assertOk()->assertBodyContains('Guest'); +}); + +it('allows guest requests with expired bearer token', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturn(new Statement(new Result())); + + $this->app->swap(Connection::default(), $connection); + + $expiredToken = new AuthenticationToken( + id: Str::uuid()->toString(), + token: 'expired-token', + expiresAt: Date::now()->subMinute() + ); + + Route::get('/guest', fn (): Response => response()->plain('Guest')) + ->middleware(Guest::class); + + $this->app->run(); + + $this->get('/guest', headers: [ + 'Authorization' => 'Bearer ' . $expiredToken->toString(), + ])->assertOk()->assertBodyContains('Guest'); +}); + +it('rejects guest requests with lowercase bearer scheme when token is valid', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $tokenData = [ + [ + 'id' => Str::uuid()->toString(), + 'tokenable_type' => $user::class, + 'tokenable_id' => $user->id, + 'name' => 'api-token', + 'token' => hash('sha256', 'valid-token'), + 'created_at' => Date::now()->toDateTimeString(), + 'last_used_at' => null, + 'expires_at' => null, + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $tokenResult = new Result([['Query OK']]); + $tokenResult->setLastInsertedId($tokenData[0]['id']); + + $connection->expects($this->exactly(4)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($tokenResult), + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/guest', fn (): Response => response()->plain('Never reached')) + ->middleware(Guest::class); + + $this->app->run(); + + $this->get('/guest', headers: [ + 'Authorization' => 'bearer ' . $authToken->toString(), + ])->assertUnauthorized(); +}); + it('authenticates user with valid token', function (): void { Event::fake(); From d8ff9a642ea2af895b8c7e5c48fbc87f9ed5f916 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 25 Mar 2026 14:56:20 +0000 Subject: [PATCH 2/4] docs(fix): correct Packagist badge links in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3087e753..4dbd3e4b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Phenix framework [![run-tests](https://github.com/phenixphp/framework/actions/workflows/run-tests.yml/badge.svg)](https://github.com/phenixphp/framework/actions/workflows/run-tests.yml) -Latest Stable Version -License +Latest Stable Version +License Phenix is a web framework built on pure PHP, without external extensions, based on the [Amphp](https://amphp.org/) ecosystem, which provides non-blocking operations, asynchronism and parallel code execution natively. It runs in the PHP SAPI CLI and on its own server, it is simply powerful. From e4291605f8574431483a3a8373cb7161e4101d47 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 25 Mar 2026 16:47:13 +0000 Subject: [PATCH 3/4] fix: correct regex pattern for extracting bearer tokens --- src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php b/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php index f571f6f8..ac2fce1c 100644 --- a/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php +++ b/src/Auth/Middlewares/Concerns/InteractsWithBearerTokens.php @@ -29,7 +29,7 @@ protected function extractBearerToken(string|null $authorizationHeader): string| $authorizationHeader = trim((string) $authorizationHeader); - if (preg_match('/^Bearer\s+([A-Za-z0-9\\-._~+\\/]+=*)$/i', $authorizationHeader, $matches) !== 1) { + if (preg_match('/^Bearer\s+([A-Za-z0-9._~+\\/-]+=*)$/i', $authorizationHeader, $matches) !== 1) { return null; } From 90133a2932ee06e74aa1729b09efab8e9868f695 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 25 Mar 2026 16:59:50 +0000 Subject: [PATCH 4/4] fix: improve Guest middleware to handle falsy bearer token values correctly --- src/Auth/Middlewares/Guest.php | 15 +++----- tests/Feature/AuthenticationTest.php | 51 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/Auth/Middlewares/Guest.php b/src/Auth/Middlewares/Guest.php index 524bf2d9..8a277732 100644 --- a/src/Auth/Middlewares/Guest.php +++ b/src/Auth/Middlewares/Guest.php @@ -19,13 +19,8 @@ class Guest implements Middleware public function handleRequest(Request $request, RequestHandler $next): Response { - $authorizationHeader = $request->getHeader('Authorization'); - - if (! $this->hasBearerScheme($authorizationHeader)) { - return $next->handleRequest($request); - } - - $token = $this->extractBearerToken($authorizationHeader); + $header = $request->getHeader('Authorization'); + $token = $this->hasBearerScheme($header) ? $this->extractBearerToken($header) : null; if ($token === null) { return $next->handleRequest($request); @@ -34,11 +29,11 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); - if (! $auth->validate($token)) { - return $next->handleRequest($request); + if ($auth->validate($token)) { + return $this->unauthorized(); } - return $this->unauthorized(); + return $next->handleRequest($request); } protected function unauthorized(): Response diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 30160fb0..ffcc74e9 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -217,6 +217,57 @@ ])->assertUnauthorized(); }); +it('treats falsy bearer token values as present tokens, not as missing tokens', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $userData = [ + [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->createdAt->toDateTimeString(), + ], + ]; + + $tokenData = [ + [ + 'id' => Str::uuid()->toString(), + 'tokenable_type' => $user::class, + 'tokenable_id' => $user->id, + 'name' => 'api-token', + 'token' => hash('sha256', '0'), + 'created_at' => Date::now()->toDateTimeString(), + 'last_used_at' => null, + 'expires_at' => null, + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + Route::get('/guest', fn (): Response => response()->plain('Never reached')) + ->middleware(Guest::class); + + $this->app->run(); + + $this->get('/guest', headers: [ + 'Authorization' => 'Bearer 0', + ])->assertUnauthorized(); +}); + it('authenticates user with valid token', function (): void { Event::fake();