diff --git a/README.md b/README.md
index 3087e753..4dbd3e4b 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Phenix framework
[](https://github.com/phenixphp/framework/actions/workflows/run-tests.yml)
-
-
+
+
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();