From a23c9f103825af908e669b437afaae6e847fb89f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Mon, 10 Nov 2025 16:01:55 -0500 Subject: [PATCH 01/40] feat: add random string generation method and corresponding tests --- src/Util/Str.php | 21 +++++++++++++++++++++ tests/Unit/Util/StrTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Util/Str.php b/src/Util/Str.php index e685a0e1..8b13b991 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -63,4 +63,25 @@ public static function slug(string $value, string $separator = '-'): string return strtolower(preg_replace('/[\s]/u', $separator, $value)); } + + public static function random(int $length = 16): string + { + $length = abs($length); + + if ($length < 1) { + $length = 16; + } + + $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + $charactersLength = strlen($characters); + $result = ''; + + $randomBytes = random_bytes($length); + + for ($i = 0; $i < $length; $i++) { + $result .= $characters[ord($randomBytes[$i]) % $charactersLength]; + } + + return $result; + } } diff --git a/tests/Unit/Util/StrTest.php b/tests/Unit/Util/StrTest.php index b29249df..29f57d66 100644 --- a/tests/Unit/Util/StrTest.php +++ b/tests/Unit/Util/StrTest.php @@ -33,3 +33,36 @@ expect($string)->toBe('Hello World'); expect(Str::finish('Hello', ' World'))->toBe('Hello World'); }); + +it('generates random string with default length', function (): void { + $random = Str::random(); + + expect(strlen($random))->toBe(16); +}); + +it('generates random string with custom length', function (): void { + $length = 32; + $random = Str::random($length); + + expect(strlen($random))->toBe($length); +}); + +it('generates different random strings', function (): void { + $random1 = Str::random(20); + $random2 = Str::random(20); + + expect($random1 === $random2)->toBeFalse(); +}); + +it('generates random string with only allowed characters', function (): void { + $random = Str::random(100); + + expect(preg_match('/^[a-zA-Z0-9]+$/', $random))->toBe(1); +}); + +it('generates single character string', function (): void { + $random = Str::random(1); + + expect(strlen($random))->toBe(1); + expect(preg_match('/^[a-zA-Z0-9]$/', $random))->toBe(1); +}); From c2dcff26d677d0c6db6a5745ac9b4f0256191cf8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 11 Nov 2025 15:35:21 -0500 Subject: [PATCH 02/40] refactor: rename class --- src/App.php | 4 ++-- .../{SessionMiddleware.php => SessionMiddlewareFactory.php} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/Session/{SessionMiddleware.php => SessionMiddlewareFactory.php} (97%) diff --git a/src/App.php b/src/App.php index 50b34b1a..82bea80e 100644 --- a/src/App.php +++ b/src/App.php @@ -22,7 +22,7 @@ use Phenix\Facades\Route; use Phenix\Logging\LoggerFactory; use Phenix\Runtime\Log; -use Phenix\Session\SessionMiddleware; +use Phenix\Session\SessionMiddlewareFactory; class App implements AppContract, Makeable { @@ -169,7 +169,7 @@ private function setRouter(): void /** @var array $globalMiddlewares */ $globalMiddlewares = array_map(fn (string $middleware) => new $middleware(), $middlewares['global']); - $globalMiddlewares[] = SessionMiddleware::make($this->host); + $globalMiddlewares[] = SessionMiddlewareFactory::make($this->host); $this->router = Middleware\stackMiddleware($router, ...$globalMiddlewares); } diff --git a/src/Session/SessionMiddleware.php b/src/Session/SessionMiddlewareFactory.php similarity index 97% rename from src/Session/SessionMiddleware.php rename to src/Session/SessionMiddlewareFactory.php index ac79211b..7999cdd4 100644 --- a/src/Session/SessionMiddleware.php +++ b/src/Session/SessionMiddlewareFactory.php @@ -12,7 +12,7 @@ use Phenix\Database\Constants\Connection; use Phenix\Session\Constants\Driver; -class SessionMiddleware +class SessionMiddlewareFactory { public static function make(string $host): Middleware { From 63992481e44391c1779714000cadace21f85a843 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 11 Nov 2025 16:56:51 -0500 Subject: [PATCH 03/40] fix: add return type declaration to afterEach function in RequestTest --- tests/Feature/RequestTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index 2f39e356..e6cfa9a8 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -14,7 +14,7 @@ use Phenix\Testing\TestResponse; use Tests\Unit\Routing\AcceptJsonResponses; -afterEach(function () { +afterEach(function (): void { $this->app->stop(); }); From de96c2732bf55f3b978f4725997a042752a66ca4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 16:31:20 -0500 Subject: [PATCH 04/40] feat: implement basic authentication using API tokens --- src/Auth/AuthServiceProvider.php | 24 ++++ src/Auth/AuthenticationManager.php | 59 +++++++++ src/Auth/AuthenticationToken.php | 32 +++++ src/Auth/Concerns/HasApiTokens.php | 78 ++++++++++++ src/Auth/PersonalAccessToken.php | 56 +++++++++ src/Auth/PersonalAccessTokenQuery.php | 12 ++ src/Auth/User.php | 38 +++++- src/Auth/UserQuery.php | 12 ++ src/Http/Middlewares/Authenticated.php | 61 ++++++++++ src/Http/Request.php | 29 ++++- .../Concerns/InteractWithStatusCode.php | 7 ++ tests/Feature/AuthenticationTest.php | 112 ++++++++++++++++++ tests/Mocks/Database/Result.php | 12 +- tests/fixtures/application/config/app.php | 1 + tests/fixtures/application/config/auth.php | 17 +++ 15 files changed, 544 insertions(+), 6 deletions(-) create mode 100644 src/Auth/AuthServiceProvider.php create mode 100644 src/Auth/AuthenticationManager.php create mode 100644 src/Auth/AuthenticationToken.php create mode 100644 src/Auth/Concerns/HasApiTokens.php create mode 100644 src/Auth/PersonalAccessToken.php create mode 100644 src/Auth/PersonalAccessTokenQuery.php create mode 100644 src/Auth/UserQuery.php create mode 100644 src/Http/Middlewares/Authenticated.php create mode 100644 tests/Feature/AuthenticationTest.php create mode 100644 tests/fixtures/application/config/auth.php diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php new file mode 100644 index 00000000..2ee70558 --- /dev/null +++ b/src/Auth/AuthServiceProvider.php @@ -0,0 +1,24 @@ +bind(AuthenticationManager::class); + } +} diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php new file mode 100644 index 00000000..ea731242 --- /dev/null +++ b/src/Auth/AuthenticationManager.php @@ -0,0 +1,59 @@ +user; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + public function validate(string $token): bool + { + $hashedToken = hash('sha256', $token); + + /** @var PersonalAccessToken|null $accessToken */ + $accessToken = PersonalAccessToken::query() + ->whereEqual('token', $hashedToken) + ->whereNull('expires_at') + ->orWhereGreaterThan('expires_at', Date::now()->toDateTimeString()) + ->first(); + + if (! $accessToken) { + return false; + } + + $accessToken->lastUsedAt = Date::now(); + $accessToken->save(); + + $userModel = Config::get('auth.users.model', User::class); + + /** @var User|null $user */ + $user = $userModel::find($accessToken->tokenableId); + + if (! $user) { + return false; + } + + if (method_exists($user, 'withAccessToken')) { + $user->withAccessToken($accessToken); + } + + $this->setUser($user); + + return true; + } +} diff --git a/src/Auth/AuthenticationToken.php b/src/Auth/AuthenticationToken.php new file mode 100644 index 00000000..0551c2cf --- /dev/null +++ b/src/Auth/AuthenticationToken.php @@ -0,0 +1,32 @@ +token; + } + + public function expiresAt(): Date + { + return $this->expiresAt; + } + + public function __toString(): string + { + return $this->toString(); + } +} diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php new file mode 100644 index 00000000..77889ed7 --- /dev/null +++ b/src/Auth/Concerns/HasApiTokens.php @@ -0,0 +1,78 @@ +tokenableType = static::class; + $model->tokenableId = $this->getKey(); + + return $model; + } + + public function tokens(): PersonalAccessTokenQuery + { + $model = new (config('auth.tokens.model')); + + return $model::query() + ->where('tokenable_type', static::class) + ->where('tokenable_id', $this->getKey()); + } + + public function createToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken + { + $plainTextToken = $this->generateTokenValue(); + $expiresAt ??= Date::now()->addMinutes(config('auth.tokens.expiration', 60 * 24)); + + $token = $this->token(); + $token->name = $name; + $token->token = hash('sha256', $plainTextToken); + $token->abilities = json_encode($abilities); + $token->expiresAt = $expiresAt; + $token->save(); + + return new AuthenticationToken( + token: $plainTextToken, + expiresAt: $expiresAt + ); + } + + public function generateTokenValue(): string + { + $tokenEntropy = Str::random(40); + + return sprintf( + '%s%s%s', + config('auth.tokens.prefix', ''), + $tokenEntropy, + hash('crc32b', $tokenEntropy) + ); + } + + public function currentAccessToken(): PersonalAccessToken|null + { + return $this->accessToken; + } + + public function withAccessToken(PersonalAccessToken $accessToken): static + { + $this->accessToken = $accessToken; + + return $this; + } +} diff --git a/src/Auth/PersonalAccessToken.php b/src/Auth/PersonalAccessToken.php new file mode 100644 index 00000000..5f1020a5 --- /dev/null +++ b/src/Auth/PersonalAccessToken.php @@ -0,0 +1,56 @@ +getHeader('Authorization'); + + if (! $this->hasToken($authorizationHeader)) { + return $this->unauthorized(); + } + + $token = $this->extractToken($authorizationHeader); + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + if (! $token || ! $auth->validate($token)) { + return $this->unauthorized(); + } + + $request->setAttribute(Config::get('auth.users.model', User::class), $auth->user()); + + 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([ + 'message' => 'Unauthorized', + ], HttpStatus::UNAUTHORIZED)->send(); + } +} diff --git a/src/Http/Request.php b/src/Http/Request.php index cddd3218..b9a78f1e 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -13,7 +13,9 @@ use Amp\Http\Server\Session\Session as ServerSession; use Amp\Http\Server\Trailers; use League\Uri\Components\Query; +use Phenix\Auth\User; use Phenix\Contracts\Arrayable; +use Phenix\Facades\Config; use Phenix\Http\Constants\ContentType; use Phenix\Http\Constants\RequestMode; use Phenix\Http\Contracts\BodyParser; @@ -33,12 +35,16 @@ class Request implements Arrayable use HasQueryParameters; protected readonly BodyParser $body; + protected readonly Query $query; + protected readonly RouteAttributes|null $attributes; + protected Session|null $session; - public function __construct(protected ServerRequest $request) - { + public function __construct( + protected ServerRequest $request + ) { $attributes = []; $this->session = null; @@ -55,6 +61,25 @@ public function __construct(protected ServerRequest $request) $this->body = $this->getParser(); } + public function user(): User|null + { + if ($this->request->hasAttribute(Config::get('auth.users.model', User::class))) { + return $this->request->getAttribute(Config::get('auth.users.model', User::class)); + } + + return null; + } + + public function setUser(User $user): void + { + $this->request->setAttribute(Config::get('auth.users.model', User::class), $user); + } + + public function hasUser(): bool + { + return $this->user() !== null; + } + public function getClient(): Client { return $this->request->getClient(); diff --git a/src/Testing/Concerns/InteractWithStatusCode.php b/src/Testing/Concerns/InteractWithStatusCode.php index 4bb4c1b5..7528a86f 100644 --- a/src/Testing/Concerns/InteractWithStatusCode.php +++ b/src/Testing/Concerns/InteractWithStatusCode.php @@ -50,4 +50,11 @@ public function assertUnprocessableEntity(): self return $this; } + + public function assertUnauthorized(): self + { + Assert::assertEquals(HttpStatus::UNAUTHORIZED->value, $this->response->getStatus()); + + return $this; + } } diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 00000000..4bcf4958 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,112 @@ +app->stop(); +}); + +it('requires authentication', function (): void { + Route::get('/', fn (): Response => response()->plain('Hello')) + ->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/') + ->assertUnauthorized(); +}); + +it('authenticates user with valid 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), // Create token + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), // Save last used at update for token + new Statement(new Result($userData)), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/profile', function (Request $request): Response { + return response()->plain($request->user() instanceof User ? 'Authenticated' : 'Guest'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ]) + ->assertOk() + ->assertBodyContains('Authenticated'); +}); + +it('denies access with invalid token', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->once()) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + Route::get('/profile', fn (): Response => response()->json(['message' => 'Authenticated'])) + ->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer invalid-token', + ]) + ->assertUnauthorized() + ->assertJsonFragment(['message' => 'Unauthorized']); +}); diff --git a/tests/Mocks/Database/Result.php b/tests/Mocks/Database/Result.php index 707f99c3..a0a22a76 100644 --- a/tests/Mocks/Database/Result.php +++ b/tests/Mocks/Database/Result.php @@ -12,6 +12,9 @@ class Result implements SqlResult, IteratorAggregate { protected int $count; + + protected string|int|null $lastInsertId = null; + protected ArrayIterator $fakeResult; public function __construct(array $fakeResult = []) @@ -45,8 +48,13 @@ public function getIterator(): Traversable return $this->fakeResult; } - public function getLastInsertId(): int + public function getLastInsertId(): int|string + { + return $this->lastInsertId ?? 1; + } + + public function setLastInsertedId(string|int|null $id): void { - return 1; + $this->lastInsertId = $id; } } diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 3c8456e5..b2166f17 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -23,6 +23,7 @@ \Phenix\Routing\RouteServiceProvider::class, \Phenix\Database\DatabaseServiceProvider::class, \Phenix\Redis\RedisServiceProvider::class, + \Phenix\Auth\AuthServiceProvider::class, \Phenix\Filesystem\FilesystemServiceProvider::class, \Phenix\Tasks\TaskServiceProvider::class, \Phenix\Views\ViewServiceProvider::class, diff --git a/tests/fixtures/application/config/auth.php b/tests/fixtures/application/config/auth.php new file mode 100644 index 00000000..dc76ce36 --- /dev/null +++ b/tests/fixtures/application/config/auth.php @@ -0,0 +1,17 @@ + [ + 'model' => Phenix\Auth\User::class, + ], + 'tokens' => [ + 'model' => Phenix\Auth\PersonalAccessToken::class, + 'prefix' => '', + 'expiration' => 60 * 24, // in minutes + ], + 'otp' => [ + 'expiration' => 10, // in minutes + ], +]; From 5025497bf1f8b6c0efc889682739a03acb06c402 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 16:31:36 -0500 Subject: [PATCH 05/40] feat: add assertHeaderIsMissing method to validate absence of headers --- src/Testing/Concerns/InteractWithHeaders.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Testing/Concerns/InteractWithHeaders.php b/src/Testing/Concerns/InteractWithHeaders.php index 8abd499f..47db9aa6 100644 --- a/src/Testing/Concerns/InteractWithHeaders.php +++ b/src/Testing/Concerns/InteractWithHeaders.php @@ -32,6 +32,13 @@ public function assertHeaderContains(array $needles): self return $this; } + public function assertHeaderIsMissing(string $name): self + { + Assert::assertNull($this->response->getHeader($name)); + + return $this; + } + public function assertIsJson(): self { $contentType = $this->response->getHeader('content-type'); From 7c8742a4deb95e5755672c67bceb13d915fbb668 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 16:40:24 -0500 Subject: [PATCH 06/40] style: add blank lines --- src/Http/FormRequest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Http/FormRequest.php b/src/Http/FormRequest.php index 7119579a..83b5c4d9 100644 --- a/src/Http/FormRequest.php +++ b/src/Http/FormRequest.php @@ -10,7 +10,9 @@ abstract class FormRequest extends Request { private bool $isValid; + private bool $checked; + protected Validator $validator; public function __construct(ServerRequest $request) From c3076b8d45551cb1afb5ca0af26e0997e7b4b94c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 16:40:42 -0500 Subject: [PATCH 07/40] refactor: rename assertHeaderContains to assertHeaders for consistency --- src/Testing/Concerns/InteractWithHeaders.php | 4 +--- tests/Feature/GlobalMiddlewareTest.php | 2 +- tests/Feature/RequestTest.php | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Testing/Concerns/InteractWithHeaders.php b/src/Testing/Concerns/InteractWithHeaders.php index 47db9aa6..a4c8c073 100644 --- a/src/Testing/Concerns/InteractWithHeaders.php +++ b/src/Testing/Concerns/InteractWithHeaders.php @@ -20,10 +20,8 @@ public function getHeader(string $name): string|null return $this->response->getHeader($name); } - public function assertHeaderContains(array $needles): self + public function assertHeaders(array $needles): self { - $needles = (array) $needles; - foreach ($needles as $header => $value) { Assert::assertNotNull($this->response->getHeader($header)); Assert::assertEquals($value, $this->response->getHeader($header)); diff --git a/tests/Feature/GlobalMiddlewareTest.php b/tests/Feature/GlobalMiddlewareTest.php index 4477198e..85174574 100644 --- a/tests/Feature/GlobalMiddlewareTest.php +++ b/tests/Feature/GlobalMiddlewareTest.php @@ -18,7 +18,7 @@ $this->options('/', headers: ['Access-Control-Request-Method' => 'GET']) ->assertOk() - ->assertHeaderContains(['Access-Control-Allow-Origin' => '*']); + ->assertHeaders(['Access-Control-Allow-Origin' => '*']); }); it('initializes the session middleware', function () { diff --git a/tests/Feature/RequestTest.php b/tests/Feature/RequestTest.php index e6cfa9a8..ece27c60 100644 --- a/tests/Feature/RequestTest.php +++ b/tests/Feature/RequestTest.php @@ -118,7 +118,7 @@ $response = $this->get('/users'); $response->assertOk() - ->assertHeaderContains(['Content-Type' => 'text/html; charset=utf-8']) + ->assertHeaders(['Content-Type' => 'text/html; charset=utf-8']) ->assertBodyContains('') ->assertBodyContains('User index'); }); From f5589edfcdf54770fcecebbde6d72285a38655f8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 17:06:52 -0500 Subject: [PATCH 08/40] refactor: rename attributes to routeAttributes for clarity and consistency --- src/Http/Request.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Http/Request.php b/src/Http/Request.php index b9a78f1e..f0f120a0 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -38,18 +38,18 @@ class Request implements Arrayable protected readonly Query $query; - protected readonly RouteAttributes|null $attributes; + protected readonly RouteAttributes|null $routeAttributes; protected Session|null $session; public function __construct( protected ServerRequest $request ) { - $attributes = []; + $routeAttributes = []; $this->session = null; if ($request->hasAttribute(Router::class)) { - $attributes = $request->getAttribute(Router::class); + $routeAttributes = $request->getAttribute(Router::class); } if ($request->hasAttribute(ServerSession::class)) { @@ -57,7 +57,7 @@ public function __construct( } $this->query = Query::fromUri($request->getUri()); - $this->attributes = new RouteAttributes($attributes); + $this->routeAttributes = new RouteAttributes($routeAttributes); $this->body = $this->getParser(); } @@ -133,10 +133,10 @@ public function isIdempotent(): bool public function route(string|null $key = null, string|int|null $default = null): RouteAttributes|string|int|null { if ($key) { - return $this->attributes->get($key, $default); + return $this->routeAttributes->get($key, $default); } - return $this->attributes; + return $this->routeAttributes; } public function query(string|null $key = null, array|string|int|null $default = null): Query|array|string|null From cae78fb3a34f39b5bb3962109614874a93001a71 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 17:12:23 -0500 Subject: [PATCH 09/40] refactor: add missing docblock for userModel variable in validate method --- src/Auth/AuthenticationManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php index ea731242..86137125 100644 --- a/src/Auth/AuthenticationManager.php +++ b/src/Auth/AuthenticationManager.php @@ -39,6 +39,7 @@ public function validate(string $token): bool $accessToken->lastUsedAt = Date::now(); $accessToken->save(); + /** @var class-string $userModel */ $userModel = Config::get('auth.users.model', User::class); /** @var User|null $user */ From 16d485677a2248aaebf900ea38bd1ff5e24c20bd Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 17:49:36 -0500 Subject: [PATCH 10/40] test: add test for generating default length string when length is zero --- tests/Unit/Util/StrTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Unit/Util/StrTest.php b/tests/Unit/Util/StrTest.php index 29f57d66..7e74a805 100644 --- a/tests/Unit/Util/StrTest.php +++ b/tests/Unit/Util/StrTest.php @@ -66,3 +66,9 @@ expect(strlen($random))->toBe(1); expect(preg_match('/^[a-zA-Z0-9]$/', $random))->toBe(1); }); + +it('generates default length string when length is zero', function (): void { + $random = Str::random(0); + + expect(strlen($random))->toBe(16); +}); From ee0cb8acd9e16e01cff29018551e219842b8e24d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 17:53:02 -0500 Subject: [PATCH 11/40] test: add test for denying access when user is not found --- tests/Feature/AuthenticationTest.php | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 4bcf4958..aa61c4ad 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -110,3 +110,51 @@ ->assertUnauthorized() ->assertJsonFragment(['message' => 'Unauthorized']); }); + +it('denies when user is not found', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $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), // Create token + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), // Save last used at update for token + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/profile', fn (Request $request): Response => response()->plain('Never reached')) + ->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertUnauthorized(); +}); From 3494677cc2f11b8ba8177316a24ec42384f031b4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 18:35:34 -0500 Subject: [PATCH 12/40] refactor: replace where with whereEqual for consistency in tokens method --- src/Auth/Concerns/HasApiTokens.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 77889ed7..447a6768 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -30,8 +30,8 @@ public function tokens(): PersonalAccessTokenQuery $model = new (config('auth.tokens.model')); return $model::query() - ->where('tokenable_type', static::class) - ->where('tokenable_id', $this->getKey()); + ->whereEqual('tokenable_type', static::class) + ->whereEqual('tokenable_id', $this->getKey()); } public function createToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken From 367cec01410a58381478787d5a27821dc6b17f9b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 18:36:29 -0500 Subject: [PATCH 13/40] test: add test for querying user tokens --- tests/Feature/AuthenticationTest.php | 65 ++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index aa61c4ad..b98c8549 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -158,3 +158,68 @@ 'Authorization' => 'Bearer ' . $authToken->toString(), ])->assertUnauthorized(); }); + +it('check user can query 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', '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(5)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($tokenResult), // Create token + new Statement(new Result($tokenData)), + new Statement(new Result([['Query OK']])), // Save last used at update for token + new Statement(new Result($userData)), + new Statement(new Result($tokenData)), // Query tokens + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = $user->createToken('api-token'); + + Route::get('/tokens', function (Request $request): Response { + return response()->json($request->user()->tokens()->get()); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/tokens', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ]) + ->assertOk() + ->assertJsonFragment([ + 'name' => 'api-token', + 'tokenableType' => $user::class, + 'tokenableId' => $user->id, + ]); +}); From 43dd216b97d36139beab5f51bc14d80e54ed2a79 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 14 Nov 2025 21:49:12 -0500 Subject: [PATCH 14/40] feat: implement HasUser trait for user management in requests --- src/Http/Request.php | 23 ++------------------ src/Http/Requests/Concerns/HasUser.php | 30 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 21 deletions(-) create mode 100644 src/Http/Requests/Concerns/HasUser.php diff --git a/src/Http/Request.php b/src/Http/Request.php index f0f120a0..7c4b6952 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -13,15 +13,14 @@ use Amp\Http\Server\Session\Session as ServerSession; use Amp\Http\Server\Trailers; use League\Uri\Components\Query; -use Phenix\Auth\User; use Phenix\Contracts\Arrayable; -use Phenix\Facades\Config; use Phenix\Http\Constants\ContentType; use Phenix\Http\Constants\RequestMode; use Phenix\Http\Contracts\BodyParser; use Phenix\Http\Requests\Concerns\HasCookies; use Phenix\Http\Requests\Concerns\HasHeaders; use Phenix\Http\Requests\Concerns\HasQueryParameters; +use Phenix\Http\Requests\Concerns\HasUser; use Phenix\Http\Requests\FormParser; use Phenix\Http\Requests\JsonParser; use Phenix\Http\Requests\RouteAttributes; @@ -30,6 +29,7 @@ class Request implements Arrayable { + use HasUser; use HasHeaders; use HasCookies; use HasQueryParameters; @@ -61,25 +61,6 @@ public function __construct( $this->body = $this->getParser(); } - public function user(): User|null - { - if ($this->request->hasAttribute(Config::get('auth.users.model', User::class))) { - return $this->request->getAttribute(Config::get('auth.users.model', User::class)); - } - - return null; - } - - public function setUser(User $user): void - { - $this->request->setAttribute(Config::get('auth.users.model', User::class), $user); - } - - public function hasUser(): bool - { - return $this->user() !== null; - } - public function getClient(): Client { return $this->request->getClient(); diff --git a/src/Http/Requests/Concerns/HasUser.php b/src/Http/Requests/Concerns/HasUser.php new file mode 100644 index 00000000..8f8ee9db --- /dev/null +++ b/src/Http/Requests/Concerns/HasUser.php @@ -0,0 +1,30 @@ +request->hasAttribute(Config::get('auth.users.model', User::class))) { + return $this->request->getAttribute(Config::get('auth.users.model', User::class)); + } + + return null; + } + + public function setUser(User $user): void + { + $this->request->setAttribute(Config::get('auth.users.model', User::class), $user); + } + + public function hasUser(): bool + { + return $this->user() !== null; + } +} From df82065ee00855803cd18001e274dcf807fbb084 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 16 Nov 2025 11:14:03 -0500 Subject: [PATCH 15/40] fix: update token validation logic to ensure proper expiration handling --- src/Auth/AuthenticationManager.php | 3 +-- src/Auth/PersonalAccessToken.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php index 86137125..91dd514c 100644 --- a/src/Auth/AuthenticationManager.php +++ b/src/Auth/AuthenticationManager.php @@ -28,8 +28,7 @@ public function validate(string $token): bool /** @var PersonalAccessToken|null $accessToken */ $accessToken = PersonalAccessToken::query() ->whereEqual('token', $hashedToken) - ->whereNull('expires_at') - ->orWhereGreaterThan('expires_at', Date::now()->toDateTimeString()) + ->whereGreaterThan('expires_at', Date::now()->toDateTimeString()) ->first(); if (! $accessToken) { diff --git a/src/Auth/PersonalAccessToken.php b/src/Auth/PersonalAccessToken.php index 5f1020a5..1c9dc29a 100644 --- a/src/Auth/PersonalAccessToken.php +++ b/src/Auth/PersonalAccessToken.php @@ -36,7 +36,7 @@ class PersonalAccessToken extends DatabaseModel public Date|null $lastUsedAt = null; #[DateTime(name: 'expires_at')] - public Date|null $expiresAt = null; + public Date $expiresAt; #[DateTime(name: 'created_at', autoInit: true)] public Date $createdAt; From 19271a3a81d27ceee737375d7bd3713847eeaebe Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 16 Nov 2025 11:22:05 -0500 Subject: [PATCH 16/40] feat: increase token entropy to enhance security in token generation --- src/Auth/Concerns/HasApiTokens.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 447a6768..4d7943fe 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -54,7 +54,7 @@ public function createToken(string $name, array $abilities = ['*'], Date|null $e public function generateTokenValue(): string { - $tokenEntropy = Str::random(40); + $tokenEntropy = Str::random(64); return sprintf( '%s%s%s', From c5f79f0b534e89e131b1d4d00a17292057f4ce3b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 16 Nov 2025 11:22:45 -0500 Subject: [PATCH 17/40] refactor: reorganize use statements for improved readability in Str.php --- src/Util/Str.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Util/Str.php b/src/Util/Str.php index 8b13b991..182b346d 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -8,6 +8,14 @@ use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\UuidV4; +use function ord; +use function preg_replace; +use function random_bytes; +use function str_ends_with; +use function str_starts_with; +use function strlen; +use function strtolower; + class Str extends Utility { public static function snake(string $value): string From 426c6eee47cbfd73db983659458f9bef0a74f5e8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 16 Nov 2025 11:43:10 -0500 Subject: [PATCH 18/40] fix: reduce token expiration time to 6 hours for improved security --- src/Auth/Concerns/HasApiTokens.php | 2 +- tests/fixtures/application/config/auth.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 4d7943fe..269cf62d 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -37,7 +37,7 @@ public function tokens(): PersonalAccessTokenQuery public function createToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken { $plainTextToken = $this->generateTokenValue(); - $expiresAt ??= Date::now()->addMinutes(config('auth.tokens.expiration', 60 * 24)); + $expiresAt ??= Date::now()->addMinutes(config('auth.tokens.expiration', 60 * 6)); $token = $this->token(); $token->name = $name; diff --git a/tests/fixtures/application/config/auth.php b/tests/fixtures/application/config/auth.php index dc76ce36..9deb3598 100644 --- a/tests/fixtures/application/config/auth.php +++ b/tests/fixtures/application/config/auth.php @@ -9,7 +9,7 @@ 'tokens' => [ 'model' => Phenix\Auth\PersonalAccessToken::class, 'prefix' => '', - 'expiration' => 60 * 24, // in minutes + 'expiration' => 60 * 6, // in minutes ], 'otp' => [ 'expiration' => 10, // in minutes From 62af4f9f988049a26e76aa08cbaea2f574062790 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 16 Nov 2025 12:03:22 -0500 Subject: [PATCH 19/40] fix: update token expiration time to 12 hours for improved usability --- tests/fixtures/application/config/auth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/application/config/auth.php b/tests/fixtures/application/config/auth.php index 9deb3598..eae9f7df 100644 --- a/tests/fixtures/application/config/auth.php +++ b/tests/fixtures/application/config/auth.php @@ -9,7 +9,7 @@ 'tokens' => [ 'model' => Phenix\Auth\PersonalAccessToken::class, 'prefix' => '', - 'expiration' => 60 * 6, // in minutes + 'expiration' => 60 * 12, // in minutes ], 'otp' => [ 'expiration' => 10, // in minutes From 6a83aa5abac393721b1cdb197aedc83d9b69d3ef Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sun, 16 Nov 2025 12:05:38 -0500 Subject: [PATCH 20/40] fix: update token expiration time to 12 hours for improved usability --- src/Auth/Concerns/HasApiTokens.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 269cf62d..f5d51944 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -37,7 +37,7 @@ public function tokens(): PersonalAccessTokenQuery public function createToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken { $plainTextToken = $this->generateTokenValue(); - $expiresAt ??= Date::now()->addMinutes(config('auth.tokens.expiration', 60 * 6)); + $expiresAt ??= Date::now()->addMinutes(config('auth.tokens.expiration', 60 * 12)); $token = $this->token(); $token->name = $name; From 15a2fcd101d3c00ca510968d47bbcfaf282cd818 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 27 Nov 2025 17:50:10 -0500 Subject: [PATCH 21/40] feat: implement token rate limiting and attempt tracking in authentication middleware --- src/Auth/AuthenticationManager.php | 35 ++++++++++++++++ src/Http/IpAddress.php | 39 +++++++++++++++++ src/Http/Middlewares/Authenticated.php | 7 ++++ src/Http/Middlewares/TokenRateLimit.php | 49 ++++++++++++++++++++++ tests/fixtures/application/config/app.php | 1 + tests/fixtures/application/config/auth.php | 4 ++ 6 files changed, 135 insertions(+) create mode 100644 src/Http/IpAddress.php create mode 100644 src/Http/Middlewares/TokenRateLimit.php diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php index 91dd514c..b965451c 100644 --- a/src/Auth/AuthenticationManager.php +++ b/src/Auth/AuthenticationManager.php @@ -4,9 +4,12 @@ namespace Phenix\Auth; +use Phenix\Facades\Cache; use Phenix\Facades\Config; use Phenix\Util\Date; +use function sprintf; + class AuthenticationManager { private User|null $user = null; @@ -56,4 +59,36 @@ public function validate(string $token): bool return true; } + + public function increaseAttempts(string $clientIdentifier): void + { + $key = $this->getAttemptKey($clientIdentifier); + + Cache::set( + $key, + $this->getAttempts($clientIdentifier) + 1, + Date::now()->addSeconds( + (int) (Config::get('auth.tokens.rate_limit.window', 300)) + ) + ); + } + + public function getAttempts(string $clientIdentifier): int + { + $key = $this->getAttemptKey($clientIdentifier); + + return (int) Cache::get($key, fn (): int => 0); + } + + public function resetAttempts(string $clientIdentifier): void + { + $key = $this->getAttemptKey($clientIdentifier); + + Cache::delete($key); + } + + protected function getAttemptKey(string $clientIdentifier): string + { + return sprintf('auth:token_attempts:%s', $clientIdentifier); + } } diff --git a/src/Http/IpAddress.php b/src/Http/IpAddress.php new file mode 100644 index 00000000..7fef34df --- /dev/null +++ b/src/Http/IpAddress.php @@ -0,0 +1,39 @@ +getHeader('X-Forwarded-For'); + + if ($xff && $ip = self::getFromHeader($xff)) { + return $ip; + } + + $ip = (string) $request->getClient()->getRemoteAddress(); + + if ($ip !== '') { + return explode(':', $ip)[0] ?? null; + } + + return null; + } + + private static function getFromHeader(string $header): string + { + $parts = explode(',', $header)[0] ?? ''; + + return trim($parts); + } +} diff --git a/src/Http/Middlewares/Authenticated.php b/src/Http/Middlewares/Authenticated.php index 6c4e86f5..edf6d299 100644 --- a/src/Http/Middlewares/Authenticated.php +++ b/src/Http/Middlewares/Authenticated.php @@ -13,6 +13,7 @@ use Phenix\Auth\User; use Phenix\Facades\Config; use Phenix\Http\Constants\HttpStatus; +use Phenix\Http\IpAddress; class Authenticated implements Middleware { @@ -29,10 +30,16 @@ public function handleRequest(Request $request, RequestHandler $next): Response /** @var AuthenticationManager $auth */ $auth = App::make(AuthenticationManager::class); + $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; + if (! $token || ! $auth->validate($token)) { + $auth->increaseAttempts($clientIdentifier); + return $this->unauthorized(); } + $auth->resetAttempts($clientIdentifier); + $request->setAttribute(Config::get('auth.users.model', User::class), $auth->user()); return $next->handleRequest($request); diff --git a/src/Http/Middlewares/TokenRateLimit.php b/src/Http/Middlewares/TokenRateLimit.php new file mode 100644 index 00000000..8bc8f94b --- /dev/null +++ b/src/Http/Middlewares/TokenRateLimit.php @@ -0,0 +1,49 @@ +getHeader('Authorization'); + + if ($authorizationHeader === null || ! str_starts_with($authorizationHeader, 'Bearer ')) { + return $next->handleRequest($request); + } + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; + + $attemptLimit = (int) (Config::get('auth.tokens.rate_limit.attempts', 5)); + $windowSeconds = (int) (Config::get('auth.tokens.rate_limit.window', 300)); + + if ($auth->getAttempts($clientIdentifier) >= $attemptLimit) { + return response()->json( + content: ['error' => 'Too many token validation attempts'], + status: HttpStatus::TOO_MANY_REQUESTS, + headers: [ + 'Retry-After' => (string) $windowSeconds, + ] + )->send(); + } + + return $next->handleRequest($request); + } +} diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 15a2d869..91f68d83 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -15,6 +15,7 @@ 'middlewares' => [ 'global' => [ \Phenix\Http\Middlewares\HandleCors::class, + \Phenix\Http\Middlewares\TokenRateLimit::class, ], 'router' => [], ], diff --git a/tests/fixtures/application/config/auth.php b/tests/fixtures/application/config/auth.php index eae9f7df..6ea9fd38 100644 --- a/tests/fixtures/application/config/auth.php +++ b/tests/fixtures/application/config/auth.php @@ -10,6 +10,10 @@ 'model' => Phenix\Auth\PersonalAccessToken::class, 'prefix' => '', 'expiration' => 60 * 12, // in minutes + 'rate_limit' => [ + 'attempts' => 5, + 'window' => 300, // window in seconds + ], ], 'otp' => [ 'expiration' => 10, // in minutes From 466fb4b6391e6a822a039ff5f74a1ac526bc5510 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 13:18:31 -0500 Subject: [PATCH 22/40] feat: add rate limiting for failed token validations and reset counter on successful authentication --- tests/Feature/AuthenticationTest.php | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index b98c8549..687a7b82 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -2,9 +2,13 @@ declare(strict_types=1); +use Phenix\Auth\AuthenticationToken; +use Phenix\Auth\Concerns\HasApiTokens; +use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\User; use Phenix\Database\Constants\Connection; use Phenix\Facades\Route; +use Phenix\Http\Constants\HttpStatus; use Phenix\Http\Middlewares\Authenticated; use Phenix\Http\Request; use Phenix\Http\Response; @@ -14,6 +18,8 @@ use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; +uses(HasApiTokens::class); + afterEach(function (): void { $this->app->stop(); }); @@ -111,6 +117,119 @@ ->assertJsonFragment(['message' => 'Unauthorized']); }); +it('rate limits failed token validations and sets retry-after header', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->any()) + ->method('prepare') + ->willReturn( + new Statement(new Result()), + ); + + $this->app->swap(Connection::default(), $connection); + + Route::get('/limited', fn (): Response => response()->plain('Never reached')) + ->middleware(Authenticated::class); + + $this->app->run(); + + for ($i = 0; $i < 5; $i++) { + $this->get('/limited', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertUnauthorized(); + } + + $this->get('/limited', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertStatusCode(HttpStatus::TOO_MANY_REQUESTS)->assertHeaders(['Retry-After' => '300']); +}); + +it('resets rate limit counter on successful authentication', 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(), + ], + ]; + + $plainToken = $this->generateTokenValue(); + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(8)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result()), // first 4 failed attempts + new Statement(new Result()), + new Statement(new Result()), + new Statement(new Result()), + new Statement(new Result($tokenData)), // successful auth attempt + new Statement(new Result([['Query OK']])), + new Statement(new Result($userData)), + new Statement(new Result()), // final invalid attempt + ); + + $this->app->swap(Connection::default(), $connection); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/reset', fn (Request $request): Response => response()->plain('ok')) + ->middleware(Authenticated::class); + + $this->app->run(); + + for ($i = 0; $i < 4; $i++) { + $this->get('/reset', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertUnauthorized(); + } + + $this->get('/reset', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + 'X-Forwarded-For' => '203.0.113.10', + ])->assertOk(); + + $this->get('/reset', headers: [ + 'Authorization' => 'Bearer invalid-token', + 'X-Forwarded-For' => '203.0.113.10', + ])->assertUnauthorized(); +}); + it('denies when user is not found', function (): void { $user = new User(); $user->id = 1; From 96802bb6c3163ee3930f81c6297f23b3b24b56f8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 13:31:36 -0500 Subject: [PATCH 23/40] tests: expect ip is null --- src/Http/Request.php | 5 +++++ tests/Unit/Http/RequestTest.php | 1 + 2 files changed, 6 insertions(+) diff --git a/src/Http/Request.php b/src/Http/Request.php index 7c4b6952..fcf8be2b 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -147,6 +147,11 @@ public function session(string|null $key = null, array|string|int|null $default return $this->session; } + public function ip(): string|null + { + return IpAddress::parse($this->request); + } + public function toArray(): array { return $this->body->toArray(); diff --git a/tests/Unit/Http/RequestTest.php b/tests/Unit/Http/RequestTest.php index c0cd8b1b..25154571 100644 --- a/tests/Unit/Http/RequestTest.php +++ b/tests/Unit/Http/RequestTest.php @@ -25,6 +25,7 @@ $formRequest = new Request($request); + expect($formRequest->ip())->toBeNull(); expect($formRequest->route('post'))->toBe('7'); expect($formRequest->route('comment'))->toBe('22'); expect($formRequest->route()->integer('post'))->toBe(7); From e3e3a0c5f8bb57e51c5566abb8e341c77f386e8a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 15:58:33 -0500 Subject: [PATCH 24/40] feat: add ability checks --- src/Http/Requests/Concerns/HasUser.php | 47 ++- tests/Feature/AuthenticationTest.php | 535 +++++++++++++++++++++++++ 2 files changed, 580 insertions(+), 2 deletions(-) diff --git a/src/Http/Requests/Concerns/HasUser.php b/src/Http/Requests/Concerns/HasUser.php index 8f8ee9db..af8e2f8e 100644 --- a/src/Http/Requests/Concerns/HasUser.php +++ b/src/Http/Requests/Concerns/HasUser.php @@ -7,12 +7,16 @@ use Phenix\Auth\User; use Phenix\Facades\Config; +use function in_array; + trait HasUser { public function user(): User|null { - if ($this->request->hasAttribute(Config::get('auth.users.model', User::class))) { - return $this->request->getAttribute(Config::get('auth.users.model', User::class)); + $key = Config::get('auth.users.model', User::class); + + if ($this->request->hasAttribute($key)) { + return $this->request->getAttribute($key); } return null; @@ -27,4 +31,43 @@ public function hasUser(): bool { return $this->user() !== null; } + + public function can(string $ability): bool + { + $user = $this->user(); + + if (! $user || ! $user->currentAccessToken()) { + return false; + } + + $abilities = $user->currentAccessToken()->getAbilities(); + + if ($abilities === null) { + return false; + } + + return in_array($ability, $abilities, true) || in_array('*', $abilities, true); + } + + public function canAny(array $abilities): bool + { + foreach ($abilities as $ability) { + if ($this->can($ability)) { + return true; + } + } + + return false; + } + + public function canAll(array $abilities): bool + { + foreach ($abilities as $ability) { + if (! $this->can($ability)) { + return false; + } + } + + return true; + } } diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 687a7b82..3e9c3f25 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -342,3 +342,538 @@ 'tokenableId' => $user->id, ]); }); + +it('check user permissions', 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(), + ], + ]; + + $plainToken = $this->generateTokenValue(); + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/users', function (Request $request): Response { + if (!$request->can('users.index')) { + return response()->json([ + 'error' => 'Forbidden', + ], HttpStatus::FORBIDDEN); + } + + return response()->plain('ok'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $response = $this->get('/users', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + 'X-Forwarded-For' => '203.0.113.10', + ]); + + $response->assertOk() + ->assertBodyContains('ok'); +}); + +it('denies when abilities is null', 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(), + ], + ]; + + $plainToken = 'plain-null-abilities'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + // abilities stays null on purpose + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + // no abilities field intentionally + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/null-abilities', function (Request $request): Response { + $canSingle = $request->can('anything.here'); + $canAny = $request->canAny(['one.ability', 'second.ability']); + $canAll = $request->canAll(['first.required', 'second.required']); + return response()->plain(($canSingle || $canAny || $canAll) ? 'granted' : 'denied'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/null-abilities', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('denied'); +}); + +it('grants any ability via wildcard asterisk *', 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(), + ], + ]; + + $plainToken = 'plain-wildcard'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['*']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/wildcard', function (Request $request): Response { + return response()->plain( + $request->can('any.ability') && + $request->canAny(['first.ability', 'second.ability']) && + $request->canAll(['one.ability', 'two.ability']) ? 'ok' : 'fail' + ); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/wildcard', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('ok'); +}); + +it('canAny passes when at least one matches', 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(), + ], + ]; + + $plainToken = 'plain-can-any'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-any', function (Request $request): Response { + return response()->plain($request->canAny(['users.delete', 'users.index']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-any', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('ok'); +}); + +it('canAny fails when none match', 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(), + ], + ]; + + $plainToken = 'plain-can-any-fail'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-any-fail', function (Request $request): Response { + return response()->plain($request->canAny(['users.delete', 'tokens.create']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-any-fail', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('fail'); +}); + +it('canAll passes when all match', 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(), + ], + ]; + + $plainToken = 'plain-can-all'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index', 'users.delete']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-all', function (Request $request): Response { + return response()->plain($request->canAll(['users.index', 'users.delete']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-all', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('ok'); +}); + +it('canAll fails when one is missing', 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(), + ], + ]; + + $plainToken = 'plain-can-all-fail'; + + $token = new PersonalAccessToken(); + $token->id = Str::uuid()->toString(); + $token->tokenableType = $user::class; + $token->tokenableId = $user->id; + $token->name = 'api-token'; + $token->abilities = json_encode(['users.index']); + $token->token = hash('sha256', $plainToken); + $token->createdAt = Date::now(); + $token->expiresAt = Date::now()->addMinutes(10); + $token->lastUsedAt = null; + + $tokenData = [ + [ + 'id' => $token->id, + 'tokenable_type' => $token->tokenableType, + 'tokenable_id' => $token->tokenableId, + 'name' => $token->name, + 'abilities' => $token->abilities, + 'token' => $token->token, + 'created_at' => $token->createdAt->toDateTimeString(), + 'last_used_at' => $token->lastUsedAt, + 'expires_at' => $token->expiresAt->toDateTimeString(), + ], + ]; + + $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); + + $authToken = new AuthenticationToken( + token: $plainToken, + expiresAt: $token->expiresAt + ); + + Route::get('/can-all-fail', function (Request $request): Response { + return response()->plain($request->canAll(['users.index', 'users.delete']) ? 'ok' : 'fail'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/can-all-fail', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ])->assertOk()->assertBodyContains('fail'); +}); + +it('returns false when user present but no token', function (): void { + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + // No DB, no middleware: manually attach user without token + Route::get('/no-token', function (Request $request) use ($user): Response { + $request->setUser($user); // user has no currentAccessToken + return response()->plain($request->can('users.index') ? 'ok' : 'fail'); + }); + + $this->app->run(); + + $this->get('/no-token')->assertOk()->assertBodyContains('fail'); +}); + +it('returns false when no user', function (): void { + Route::get('/no-user', function (Request $request): Response { + return response()->plain($request->can('users.index') ? 'ok' : 'fail'); + }); + + $this->app->run(); + + $this->get('/no-user')->assertOk()->assertBodyContains('fail'); +}); From 663ff3d1ad52b85b5d888a54932ed466dee01749 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 16:47:21 -0500 Subject: [PATCH 25/40] feat: add getAbilities method to PersonalAccessToken class --- src/Auth/PersonalAccessToken.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Auth/PersonalAccessToken.php b/src/Auth/PersonalAccessToken.php index 1c9dc29a..9b82a603 100644 --- a/src/Auth/PersonalAccessToken.php +++ b/src/Auth/PersonalAccessToken.php @@ -53,4 +53,13 @@ protected static function newQueryBuilder(): DatabaseQueryBuilder { return new PersonalAccessTokenQuery(); } + + public function getAbilities(): array|null + { + if ($this->abilities === null) { + return null; + } + + return json_decode($this->abilities, true); + } } From cf0aeebdf46c35a9a1f5b25e23df61d4847a6e11 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 17:35:09 -0500 Subject: [PATCH 26/40] feat: add PersonalAccessTokensTableCommand and migration stub for personal access tokens --- src/Auth/AuthServiceProvider.php | 8 +++ .../PersonalAccessTokensTableCommand.php | 60 +++++++++++++++++++ src/stubs/personal_access_tokens_table.stub | 31 ++++++++++ .../PersonalAccessTokensTableCommandTest.php | 39 ++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 src/Auth/Console/PersonalAccessTokensTableCommand.php create mode 100644 src/stubs/personal_access_tokens_table.stub create mode 100644 tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php index 2ee70558..159f43a3 100644 --- a/src/Auth/AuthServiceProvider.php +++ b/src/Auth/AuthServiceProvider.php @@ -4,6 +4,7 @@ namespace Phenix\Auth; +use Phenix\Auth\Console\PersonalAccessTokensTableCommand; use Phenix\Providers\ServiceProvider; use function in_array; @@ -21,4 +22,11 @@ public function register(): void { $this->bind(AuthenticationManager::class); } + + public function boot(): void + { + $this->commands([ + PersonalAccessTokensTableCommand::class, + ]); + } } diff --git a/src/Auth/Console/PersonalAccessTokensTableCommand.php b/src/Auth/Console/PersonalAccessTokensTableCommand.php new file mode 100644 index 00000000..187546c8 --- /dev/null +++ b/src/Auth/Console/PersonalAccessTokensTableCommand.php @@ -0,0 +1,60 @@ +setHelp('This command generates the migration to create the personal access tokens table.'); + + $this->addArgument('name', InputArgument::OPTIONAL, 'The migration file name'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force creation even if file exists'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + // Static timestamped file name for reproducible tests. + $fileName = '20251128110000_create_personal_access_tokens_table'; + $input->setArgument('name', $fileName); + + return parent::execute($input, $output); + } + + protected function outputDirectory(): string + { + return 'database' . DIRECTORY_SEPARATOR . 'migrations'; + } + + protected function stub(): string + { + return 'personal_access_tokens_table.stub'; + } + + protected function commonName(): string + { + return 'Personal access tokens table'; + } +} diff --git a/src/stubs/personal_access_tokens_table.stub b/src/stubs/personal_access_tokens_table.stub new file mode 100644 index 00000000..54600d8b --- /dev/null +++ b/src/stubs/personal_access_tokens_table.stub @@ -0,0 +1,31 @@ +table('personal_access_tokens', ['id' => false, 'primary_key' => 'id']); + + $table->uuid('id'); + $table->string('tokenable_type', 100); + $table->unsignedInteger('tokenable_id'); + $table->string('name', 100); + $table->string('token', 255)->unique(); + $table->text('abilities')->nullable(); + $table->dateTime('last_used_at')->nullable(); + $table->dateTime('expires_at'); + $table->timestamps(); + $table->addIndex(['tokenable_type', 'tokenable_id'], ['name' => 'idx_tokenable']); + $table->addIndex(['expires_at'], ['name' => 'idx_expires_at']); + $table->create(); + } + + public function down(): void + { + $this->table('personal_access_tokens')->drop(); + } +} diff --git a/tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php b/tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php new file mode 100644 index 00000000..0650dabb --- /dev/null +++ b/tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php @@ -0,0 +1,39 @@ +expect( + exists: fn (string $path): bool => false, + get: fn (string $path): string => file_get_contents($path), + put: function (string $path): bool { + $prefix = base_path('database' . DIRECTORY_SEPARATOR . 'migrations'); + if (! str_starts_with($path, $prefix)) { + throw new RuntimeException('Migration path prefix mismatch'); + } + if (! str_ends_with($path, 'create_personal_access_tokens_table.php')) { + throw new RuntimeException('Migration filename suffix mismatch'); + } + + return true; + }, + createDirectory: function (string $path): void { + // Directory creation is mocked + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('tokens:table'); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + if (! str_contains($display, 'Personal access tokens table [database/migrations/20251128110000_create_personal_access_tokens_table.php] successfully generated!')) { + throw new RuntimeException('Expected success output not found'); + } +}); From 4e4100a2954bdd4678992d7b1481d99c8065cfad Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 17:35:17 -0500 Subject: [PATCH 27/40] style: php cs --- tests/Feature/AuthenticationTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 3e9c3f25..d1aa37da 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -403,7 +403,7 @@ ); Route::get('/users', function (Request $request): Response { - if (!$request->can('users.index')) { + if (! $request->can('users.index')) { return response()->json([ 'error' => 'Forbidden', ], HttpStatus::FORBIDDEN); @@ -486,6 +486,7 @@ $canSingle = $request->can('anything.here'); $canAny = $request->canAny(['one.ability', 'second.ability']); $canAll = $request->canAll(['first.required', 'second.required']); + return response()->plain(($canSingle || $canAny || $canAll) ? 'granted' : 'denied'); })->middleware(Authenticated::class); @@ -860,6 +861,7 @@ // No DB, no middleware: manually attach user without token Route::get('/no-token', function (Request $request) use ($user): Response { $request->setUser($user); // user has no currentAccessToken + return response()->plain($request->can('users.index') ? 'ok' : 'fail'); }); From 22a2365f07256ac4a388a61382e8cc8b55ea884d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 18:07:21 -0500 Subject: [PATCH 28/40] feat: add PurgeExpiredTokens command to remove expired personal access tokens --- src/Auth/AuthServiceProvider.php | 2 + src/Auth/Console/PurgeExpiredTokens.php | 52 +++++++++++++++++++ .../Console/PurgeExpiredTokensCommandTest.php | 34 ++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 src/Auth/Console/PurgeExpiredTokens.php create mode 100644 tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php diff --git a/src/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php index 159f43a3..fefa2354 100644 --- a/src/Auth/AuthServiceProvider.php +++ b/src/Auth/AuthServiceProvider.php @@ -5,6 +5,7 @@ namespace Phenix\Auth; use Phenix\Auth\Console\PersonalAccessTokensTableCommand; +use Phenix\Auth\Console\PurgeExpiredTokens; use Phenix\Providers\ServiceProvider; use function in_array; @@ -27,6 +28,7 @@ public function boot(): void { $this->commands([ PersonalAccessTokensTableCommand::class, + PurgeExpiredTokens::class, ]); } } diff --git a/src/Auth/Console/PurgeExpiredTokens.php b/src/Auth/Console/PurgeExpiredTokens.php new file mode 100644 index 00000000..9ec70b5c --- /dev/null +++ b/src/Auth/Console/PurgeExpiredTokens.php @@ -0,0 +1,52 @@ +setHelp('This command removes personal access tokens whose expiration datetime is in the past.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $now = Date::now()->toDateTimeString(); + + $count = PersonalAccessToken::query() + ->whereLessThan('expires_at', $now) + ->count(); + + PersonalAccessToken::query() + ->whereLessThan('expires_at', $now) + ->delete(); + + $output->writeln(sprintf('%d expired token(s) purged successfully.', $count)); + + return Command::SUCCESS; + } +} diff --git a/tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php b/tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php new file mode 100644 index 00000000..5a9669af --- /dev/null +++ b/tests/Unit/Auth/Console/PurgeExpiredTokensCommandTest.php @@ -0,0 +1,34 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $countResult = new Result([['count' => 3]]); + $deleteResult = new Result([['Query OK']]); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($countResult), + new Statement($deleteResult), + ); + + $this->app->swap(Connection::default(), $connection); + + /** @var CommandTester $command */ + $command = $this->phenix('tokens:purge'); + + $command->assertCommandIsSuccessful(); + + $display = $command->getDisplay(); + + expect($display)->toContain('3 expired token(s) purged successfully.'); +}); From d54d5a40228b6eb3143e04f4ebe94d9fa279c0eb Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 18:07:35 -0500 Subject: [PATCH 29/40] refactor: move test to correct namespace --- .../{ => Console}/PersonalAccessTokensTableCommandTest.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) rename tests/Unit/Auth/{ => Console}/PersonalAccessTokensTableCommandTest.php (80%) diff --git a/tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php b/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php similarity index 80% rename from tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php rename to tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php index 0650dabb..a1ddbc00 100644 --- a/tests/Unit/Auth/PersonalAccessTokensTableCommandTest.php +++ b/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php @@ -32,8 +32,5 @@ $command->assertCommandIsSuccessful(); - $display = $command->getDisplay(); - if (! str_contains($display, 'Personal access tokens table [database/migrations/20251128110000_create_personal_access_tokens_table.php] successfully generated!')) { - throw new RuntimeException('Expected success output not found'); - } + expect($command->getDisplay())->toContain('Personal access tokens table [database/migrations/20251128110000_create_personal_access_tokens_table.php] successfully generated!'); }); From 763b8ee8002df645b5cac65d887961a1398c1722 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 18:32:04 -0500 Subject: [PATCH 30/40] feat: enhance token generation with improved entropy and checksum --- src/Auth/Concerns/HasApiTokens.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index f5d51944..6f9fff4d 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -54,13 +54,14 @@ public function createToken(string $name, array $abilities = ['*'], Date|null $e public function generateTokenValue(): string { - $tokenEntropy = Str::random(64); + $entropy = bin2hex(random_bytes(32)); + $checksum = substr(hash('sha256', $entropy), 0, 8); return sprintf( - '%s%s%s', + '%s%s_%s', config('auth.tokens.prefix', ''), - $tokenEntropy, - hash('crc32b', $tokenEntropy) + $entropy, + $checksum ); } From ca43bb011cdb9ce7f8ed42087fd16e3c2abaea31 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 19:12:09 -0500 Subject: [PATCH 31/40] feat: implement event handling for token creation, validation, and failure scenarios --- src/Auth/Concerns/HasApiTokens.php | 8 +++++- src/Auth/Events/FailedTokenValidation.php | 25 ++++++++++++++++++ src/Auth/Events/TokenCreated.php | 26 ++++++++++++++++++ src/Auth/Events/TokenValidated.php | 32 +++++++++++++++++++++++ src/Http/Middlewares/Authenticated.php | 18 +++++++++++++ src/Http/Requests/JsonParser.php | 2 +- tests/Feature/AuthenticationTest.php | 14 ++++++++++ 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/Auth/Events/FailedTokenValidation.php create mode 100644 src/Auth/Events/TokenCreated.php create mode 100644 src/Auth/Events/TokenValidated.php diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 6f9fff4d..8ca8455e 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -5,10 +5,11 @@ namespace Phenix\Auth\Concerns; use Phenix\Auth\AuthenticationToken; +use Phenix\Auth\Events\TokenCreated; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\PersonalAccessTokenQuery; +use Phenix\Facades\Event; use Phenix\Util\Date; -use Phenix\Util\Str; use function sprintf; @@ -46,6 +47,11 @@ public function createToken(string $name, array $abilities = ['*'], Date|null $e $token->expiresAt = $expiresAt; $token->save(); + Event::emitAsync(new TokenCreated( + $token, + $this + )); + return new AuthenticationToken( token: $plainTextToken, expiresAt: $expiresAt diff --git a/src/Auth/Events/FailedTokenValidation.php b/src/Auth/Events/FailedTokenValidation.php new file mode 100644 index 00000000..b74989d1 --- /dev/null +++ b/src/Auth/Events/FailedTokenValidation.php @@ -0,0 +1,25 @@ +payload = [ + 'reason' => $reason, + 'attempted_token_length' => $attemptedToken !== null ? strlen($attemptedToken) : 0, + 'client_ip' => $clientIp, + 'request_path' => $request->getUri()->getPath(), + 'request_method' => $request->getMethod(), + 'attempt_count' => $attemptCount, + ]; + } +} diff --git a/src/Auth/Events/TokenCreated.php b/src/Auth/Events/TokenCreated.php new file mode 100644 index 00000000..7057c7b1 --- /dev/null +++ b/src/Auth/Events/TokenCreated.php @@ -0,0 +1,26 @@ +payload = [ + 'token_id' => $token->id, + 'user_id' => $token->tokenableId, + 'user_type' => $token->tokenableType, + 'name' => $token->name, + 'abilities' => $token->getAbilities(), + 'expires_at' => $token->expiresAt?->toDateTimeString(), + 'created_at' => $token->createdAt?->toDateTimeString() ?? Date::now()->toDateTimeString(), + ]; + } +} diff --git a/src/Auth/Events/TokenValidated.php b/src/Auth/Events/TokenValidated.php new file mode 100644 index 00000000..31d504cc --- /dev/null +++ b/src/Auth/Events/TokenValidated.php @@ -0,0 +1,32 @@ +getAbilities() ?? []; + + $this->payload = [ + 'token_id' => $token->id, + 'user_id' => $token->tokenableId, + 'user_type' => $token->tokenableType, + 'abilities_count' => count($abilities), + 'wildcard' => in_array('*', $abilities, true), + 'expires_at' => $token->expiresAt?->toDateTimeString(), + 'request_path' => $request->getUri()->getPath(), + 'request_method' => $request->getMethod(), + 'client_ip' => $clientIp, + ]; + } +} diff --git a/src/Http/Middlewares/Authenticated.php b/src/Http/Middlewares/Authenticated.php index edf6d299..da6feb16 100644 --- a/src/Http/Middlewares/Authenticated.php +++ b/src/Http/Middlewares/Authenticated.php @@ -10,10 +10,14 @@ use Amp\Http\Server\Response; use Phenix\App; use Phenix\Auth\AuthenticationManager; +use Phenix\Auth\Events\FailedTokenValidation; +use Phenix\Auth\Events\TokenValidated; use Phenix\Auth\User; use Phenix\Facades\Config; +use Phenix\Facades\Event; use Phenix\Http\Constants\HttpStatus; use Phenix\Http\IpAddress; +use Phenix\Http\Request as HttpRequest; class Authenticated implements Middleware { @@ -33,11 +37,25 @@ public function handleRequest(Request $request, RequestHandler $next): Response $clientIdentifier = IpAddress::parse($request) ?? 'unknown'; if (! $token || ! $auth->validate($token)) { + Event::emitAsync(new FailedTokenValidation( + request: new HttpRequest($request), + clientIp: $clientIdentifier, + reason: $token ? 'validation_failed' : 'invalid_format', + attemptedToken: $token, + attemptCount: $auth->getAttempts($clientIdentifier) + )); + $auth->increaseAttempts($clientIdentifier); return $this->unauthorized(); } + Event::emitAsync(new TokenValidated( + token: $auth->user()?->currentAccessToken(), + request: new HttpRequest($request), + clientIp: $clientIdentifier + )); + $auth->resetAttempts($clientIdentifier); $request->setAttribute(Config::get('auth.users.model', User::class), $auth->user()); diff --git a/src/Http/Requests/JsonParser.php b/src/Http/Requests/JsonParser.php index 7c1fc9b7..63d2bb41 100644 --- a/src/Http/Requests/JsonParser.php +++ b/src/Http/Requests/JsonParser.php @@ -66,7 +66,7 @@ public function toArray(): array protected function parse(Request $request): self { - $body = json_decode($request->getBody()->read(), true); + $body = json_decode($request->getBody()->read() ?? '', true); if (json_last_error() === JSON_ERROR_NONE) { $this->body = $body; diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index d1aa37da..8ef530ab 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -4,9 +4,13 @@ use Phenix\Auth\AuthenticationToken; use Phenix\Auth\Concerns\HasApiTokens; +use Phenix\Auth\Events\FailedTokenValidation; +use Phenix\Auth\Events\TokenCreated; +use Phenix\Auth\Events\TokenValidated; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\User; use Phenix\Database\Constants\Connection; +use Phenix\Facades\Event; use Phenix\Facades\Route; use Phenix\Http\Constants\HttpStatus; use Phenix\Http\Middlewares\Authenticated; @@ -35,6 +39,8 @@ }); it('authenticates user with valid token', function (): void { + Event::fake(); + $user = new User(); $user->id = 1; $user->name = 'John Doe'; @@ -92,9 +98,14 @@ ]) ->assertOk() ->assertBodyContains('Authenticated'); + + Event::expect(TokenCreated::class)->toBeDispatched(); + Event::expect(TokenValidated::class)->toBeDispatched(); }); it('denies access with invalid token', function (): void { + Event::fake(); + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); $connection->expects($this->once()) @@ -115,6 +126,9 @@ ]) ->assertUnauthorized() ->assertJsonFragment(['message' => 'Unauthorized']); + + Event::expect(TokenValidated::class)->toNotBeDispatched(); + Event::expect(FailedTokenValidation::class)->toBeDispatched(); }); it('rate limits failed token validations and sets retry-after header', function (): void { From 857f3564590cbb158fb0c3949bdd37728a8002c7 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 19:23:41 -0500 Subject: [PATCH 32/40] feat: simplify TokenCreated event constructor and ensure consistent date handling --- src/Auth/Concerns/HasApiTokens.php | 5 +---- src/Auth/Events/TokenCreated.php | 6 +++--- src/Auth/Events/TokenValidated.php | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 8ca8455e..1ea7bb8a 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -47,10 +47,7 @@ public function createToken(string $name, array $abilities = ['*'], Date|null $e $token->expiresAt = $expiresAt; $token->save(); - Event::emitAsync(new TokenCreated( - $token, - $this - )); + Event::emitAsync(new TokenCreated($token)); return new AuthenticationToken( token: $plainTextToken, diff --git a/src/Auth/Events/TokenCreated.php b/src/Auth/Events/TokenCreated.php index 7057c7b1..2ac2616f 100644 --- a/src/Auth/Events/TokenCreated.php +++ b/src/Auth/Events/TokenCreated.php @@ -11,7 +11,7 @@ class TokenCreated extends AbstractEvent { - public function __construct(PersonalAccessToken $token, User $user) + public function __construct(PersonalAccessToken $token) { $this->payload = [ 'token_id' => $token->id, @@ -19,8 +19,8 @@ public function __construct(PersonalAccessToken $token, User $user) 'user_type' => $token->tokenableType, 'name' => $token->name, 'abilities' => $token->getAbilities(), - 'expires_at' => $token->expiresAt?->toDateTimeString(), - 'created_at' => $token->createdAt?->toDateTimeString() ?? Date::now()->toDateTimeString(), + 'expires_at' => $token->expiresAt->toDateTimeString(), + 'created_at' => $token->createdAt->toDateTimeString(), ]; } } diff --git a/src/Auth/Events/TokenValidated.php b/src/Auth/Events/TokenValidated.php index 31d504cc..b1e5e16d 100644 --- a/src/Auth/Events/TokenValidated.php +++ b/src/Auth/Events/TokenValidated.php @@ -23,7 +23,7 @@ public function __construct(PersonalAccessToken $token, Request $request, string 'user_type' => $token->tokenableType, 'abilities_count' => count($abilities), 'wildcard' => in_array('*', $abilities, true), - 'expires_at' => $token->expiresAt?->toDateTimeString(), + 'expires_at' => $token->expiresAt->toDateTimeString(), 'request_path' => $request->getUri()->getPath(), 'request_method' => $request->getMethod(), 'client_ip' => $clientIp, From 13af13f0d017cdfe2a190453a528d7c54fee12c0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 28 Nov 2025 19:24:42 -0500 Subject: [PATCH 33/40] refactor: remove unused imports in TokenCreated event class --- src/Auth/Events/TokenCreated.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Auth/Events/TokenCreated.php b/src/Auth/Events/TokenCreated.php index 2ac2616f..d747910b 100644 --- a/src/Auth/Events/TokenCreated.php +++ b/src/Auth/Events/TokenCreated.php @@ -5,9 +5,7 @@ namespace Phenix\Auth\Events; use Phenix\Auth\PersonalAccessToken; -use Phenix\Auth\User; use Phenix\Events\AbstractEvent; -use Phenix\Util\Date; class TokenCreated extends AbstractEvent { From ce31fd958aa2be90e69e7e8a214aa6ef8964006d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 15:04:17 -0500 Subject: [PATCH 34/40] feat: add id parameter to AuthenticationToken constructor and update tests --- src/Auth/AuthenticationToken.php | 6 ++++++ src/Auth/Concerns/HasApiTokens.php | 1 + tests/Feature/AuthenticationTest.php | 8 ++++++++ 3 files changed, 15 insertions(+) diff --git a/src/Auth/AuthenticationToken.php b/src/Auth/AuthenticationToken.php index 0551c2cf..42918441 100644 --- a/src/Auth/AuthenticationToken.php +++ b/src/Auth/AuthenticationToken.php @@ -10,11 +10,17 @@ class AuthenticationToken implements Stringable { public function __construct( + protected string $id, protected string $token, protected Date $expiresAt, ) { } + public function id(): string + { + return $this->id; + } + public function toString(): string { return $this->token; diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index 1ea7bb8a..af38bfd4 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -50,6 +50,7 @@ public function createToken(string $name, array $abilities = ['*'], Date|null $e Event::emitAsync(new TokenCreated($token)); return new AuthenticationToken( + id: $token->id, token: $plainTextToken, expiresAt: $expiresAt ); diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 8ef530ab..325b4883 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -217,6 +217,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -412,6 +413,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -492,6 +494,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -566,6 +569,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -640,6 +644,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -710,6 +715,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -780,6 +786,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); @@ -850,6 +857,7 @@ $this->app->swap(Connection::default(), $connection); $authToken = new AuthenticationToken( + id: $token->id, token: $plainToken, expiresAt: $token->expiresAt ); From 453f882a747b8277b1042d4cea3bfca8048c8f82 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 15:04:56 -0500 Subject: [PATCH 35/40] test: assert 'Retry-After' header is missing on successful reset request --- tests/Feature/AuthenticationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 325b4883..b173a78a 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -237,7 +237,7 @@ $this->get('/reset', headers: [ 'Authorization' => 'Bearer ' . $authToken->toString(), 'X-Forwarded-For' => '203.0.113.10', - ])->assertOk(); + ])->assertOk()->assertHeaderIsMissing('Retry-After'); $this->get('/reset', headers: [ 'Authorization' => 'Bearer invalid-token', From 3043d802ba51239348ab0b22e2fee1f84a90b23d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 15:05:05 -0500 Subject: [PATCH 36/40] fix: cast AuthenticationToken to string for authorization header --- tests/Feature/AuthenticationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index b173a78a..6fa89635 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -289,7 +289,7 @@ $this->app->run(); $this->get('/profile', headers: [ - 'Authorization' => 'Bearer ' . $authToken->toString(), + 'Authorization' => 'Bearer ' . (string) $authToken, ])->assertUnauthorized(); }); From a03b766d624876d25bb10c3a52e3fe93249a0b70 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 15:07:33 -0500 Subject: [PATCH 37/40] tests(refactor): update authentication check to use hasUser method for improved accuracy --- tests/Feature/AuthenticationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 6fa89635..0cbeefc9 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -88,7 +88,7 @@ $authToken = $user->createToken('api-token'); Route::get('/profile', function (Request $request): Response { - return response()->plain($request->user() instanceof User ? 'Authenticated' : 'Guest'); + return response()->plain($request->hasUser() && $request->user() instanceof User ? 'Authenticated' : 'Guest'); })->middleware(Authenticated::class); $this->app->run(); From a66d2f3d0010ec75f2e1d3e748d7051d64b857b5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 16:48:30 -0500 Subject: [PATCH 38/40] chore: remove comments --- tests/Feature/AuthenticationTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 0cbeefc9..5c3282fc 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -880,9 +880,8 @@ $user->email = 'john@example.com'; $user->createdAt = Date::now(); - // No DB, no middleware: manually attach user without token Route::get('/no-token', function (Request $request) use ($user): Response { - $request->setUser($user); // user has no currentAccessToken + $request->setUser($user); return response()->plain($request->can('users.index') ? 'ok' : 'fail'); }); From 822a715d930c5019f96c86795a2c4a4d82709b9b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 16:50:15 -0500 Subject: [PATCH 39/40] feat: implement refreshToken method and dispatch TokenRefreshCompleted event --- src/Auth/Concerns/HasApiTokens.php | 20 +++++++++ src/Auth/Events/TokenRefreshCompleted.php | 24 ++++++++++ tests/Feature/AuthenticationTest.php | 55 +++++++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/Auth/Events/TokenRefreshCompleted.php diff --git a/src/Auth/Concerns/HasApiTokens.php b/src/Auth/Concerns/HasApiTokens.php index af38bfd4..1307d404 100644 --- a/src/Auth/Concerns/HasApiTokens.php +++ b/src/Auth/Concerns/HasApiTokens.php @@ -6,6 +6,7 @@ use Phenix\Auth\AuthenticationToken; use Phenix\Auth\Events\TokenCreated; +use Phenix\Auth\Events\TokenRefreshCompleted; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\PersonalAccessTokenQuery; use Phenix\Facades\Event; @@ -80,4 +81,23 @@ public function withAccessToken(PersonalAccessToken $accessToken): static return $this; } + + public function refreshToken(string $name, array $abilities = ['*'], Date|null $expiresAt = null): AuthenticationToken + { + $previous = $this->currentAccessToken(); + + $newToken = $this->createToken($name, $abilities, $expiresAt); + + if ($previous) { + $previous->expiresAt = Date::now(); + $previous->save(); + + Event::emitAsync(new TokenRefreshCompleted( + $previous, + $newToken + )); + } + + return $newToken; + } } diff --git a/src/Auth/Events/TokenRefreshCompleted.php b/src/Auth/Events/TokenRefreshCompleted.php new file mode 100644 index 00000000..40149b13 --- /dev/null +++ b/src/Auth/Events/TokenRefreshCompleted.php @@ -0,0 +1,24 @@ +payload = [ + 'previous_token_id' => $previous->id, + 'user_id' => $previous->tokenableId, + 'user_type' => $previous->tokenableType, + 'previous_expires_at' => $previous->expiresAt->toDateTimeString(), + 'new_token_id' => $newToken->id(), + 'new_expires_at' => $newToken->expiresAt(), + ]; + } +} diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php index 5c3282fc..a04324b5 100644 --- a/tests/Feature/AuthenticationTest.php +++ b/tests/Feature/AuthenticationTest.php @@ -6,6 +6,7 @@ use Phenix\Auth\Concerns\HasApiTokens; use Phenix\Auth\Events\FailedTokenValidation; use Phenix\Auth\Events\TokenCreated; +use Phenix\Auth\Events\TokenRefreshCompleted; use Phenix\Auth\Events\TokenValidated; use Phenix\Auth\PersonalAccessToken; use Phenix\Auth\User; @@ -22,6 +23,8 @@ use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; +use function Amp\delay; + uses(HasApiTokens::class); afterEach(function (): void { @@ -900,3 +903,55 @@ $this->get('/no-user')->assertOk()->assertBodyContains('fail'); }); + +it('refreshes token and dispatches event', function (): void { + Event::fake(); + + $user = new User(); + $user->id = 1; + $user->name = 'John Doe'; + $user->email = 'john@example.com'; + $user->createdAt = Date::now(); + + $previous = new PersonalAccessToken(); + $previous->id = Str::uuid()->toString(); + $previous->tokenableType = $user::class; + $previous->tokenableId = $user->id; + $previous->name = 'api-token'; + $previous->token = hash('sha256', 'previous-plain'); + $previous->createdAt = Date::now(); + $previous->expiresAt = Date::now()->addMinutes(30); + + $insertResult = new Result([[ 'Query OK' ]]); + $newTokenId = Str::uuid()->toString(); + $insertResult->setLastInsertedId($newTokenId); + + $updateResult = new Result([[ 'Query OK' ]]); + + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement($insertResult), + new Statement($updateResult), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->app->run(); + + $user->withAccessToken($previous); + + $oldExpiresAt = $previous->expiresAt; + + $refreshed = $user->refreshToken('api-token'); + + $this->assertInstanceOf(AuthenticationToken::class, $refreshed); + $this->assertSame($newTokenId, $refreshed->id()); + $this->assertNotSame($previous->id, $refreshed->id()); + $this->assertNotEquals($oldExpiresAt->toDateTimeString(), $previous->expiresAt->toDateTimeString()); + + delay(2); + + Event::expect(TokenRefreshCompleted::class)->toBeDispatched(); +}); From 7597218207fc4f98cd04dcd68f961e28f19b4eb4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Sat, 29 Nov 2025 17:00:10 -0500 Subject: [PATCH 40/40] fix: improve random string generation to ensure uniform distribution --- src/Util/Str.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Util/Str.php b/src/Util/Str.php index 182b346d..efb097f1 100644 --- a/src/Util/Str.php +++ b/src/Util/Str.php @@ -82,12 +82,24 @@ public static function random(int $length = 16): string $characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; $charactersLength = strlen($characters); + + $max = intdiv(256, $charactersLength) * $charactersLength; + $result = ''; - $randomBytes = random_bytes($length); + while (strlen($result) < $length) { + $bytes = random_bytes($length); + + for ($i = 0; $i < strlen($bytes) && strlen($result) < $length; $i++) { + $val = ord($bytes[$i]); + + if ($val >= $max) { + continue; + } - for ($i = 0; $i < $length; $i++) { - $result .= $characters[ord($randomBytes[$i]) % $charactersLength]; + $idx = $val % $charactersLength; + $result .= $characters[$idx]; + } } return $result;