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. 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..ac2fce1c --- /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..8a277732 --- /dev/null +++ b/src/Auth/Middlewares/Guest.php @@ -0,0 +1,45 @@ +getHeader('Authorization'); + $token = $this->hasBearerScheme($header) ? $this->extractBearerToken($header) : null; + + if ($token === null) { + return $next->handleRequest($request); + } + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + if ($auth->validate($token)) { + return $this->unauthorized(); + } + + return $next->handleRequest($request); + } + + 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..ffcc74e9 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,226 @@ ->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('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();