diff --git a/src/App.php b/src/App.php index 1275dc98..ac153e96 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/Auth/AuthServiceProvider.php b/src/Auth/AuthServiceProvider.php new file mode 100644 index 00000000..fefa2354 --- /dev/null +++ b/src/Auth/AuthServiceProvider.php @@ -0,0 +1,34 @@ +bind(AuthenticationManager::class); + } + + public function boot(): void + { + $this->commands([ + PersonalAccessTokensTableCommand::class, + PurgeExpiredTokens::class, + ]); + } +} diff --git a/src/Auth/AuthenticationManager.php b/src/Auth/AuthenticationManager.php new file mode 100644 index 00000000..b965451c --- /dev/null +++ b/src/Auth/AuthenticationManager.php @@ -0,0 +1,94 @@ +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) + ->whereGreaterThan('expires_at', Date::now()->toDateTimeString()) + ->first(); + + if (! $accessToken) { + return false; + } + + $accessToken->lastUsedAt = Date::now(); + $accessToken->save(); + + /** @var class-string $userModel */ + $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; + } + + 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/Auth/AuthenticationToken.php b/src/Auth/AuthenticationToken.php new file mode 100644 index 00000000..42918441 --- /dev/null +++ b/src/Auth/AuthenticationToken.php @@ -0,0 +1,38 @@ +id; + } + + public function toString(): string + { + return $this->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..1307d404 --- /dev/null +++ b/src/Auth/Concerns/HasApiTokens.php @@ -0,0 +1,103 @@ +tokenableType = static::class; + $model->tokenableId = $this->getKey(); + + return $model; + } + + public function tokens(): PersonalAccessTokenQuery + { + $model = new (config('auth.tokens.model')); + + return $model::query() + ->whereEqual('tokenable_type', static::class) + ->whereEqual('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 * 12)); + + $token = $this->token(); + $token->name = $name; + $token->token = hash('sha256', $plainTextToken); + $token->abilities = json_encode($abilities); + $token->expiresAt = $expiresAt; + $token->save(); + + Event::emitAsync(new TokenCreated($token)); + + return new AuthenticationToken( + id: $token->id, + token: $plainTextToken, + expiresAt: $expiresAt + ); + } + + public function generateTokenValue(): string + { + $entropy = bin2hex(random_bytes(32)); + $checksum = substr(hash('sha256', $entropy), 0, 8); + + return sprintf( + '%s%s_%s', + config('auth.tokens.prefix', ''), + $entropy, + $checksum + ); + } + + public function currentAccessToken(): PersonalAccessToken|null + { + return $this->accessToken; + } + + public function withAccessToken(PersonalAccessToken $accessToken): static + { + $this->accessToken = $accessToken; + + 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/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/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/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..d747910b --- /dev/null +++ b/src/Auth/Events/TokenCreated.php @@ -0,0 +1,24 @@ +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(), + ]; + } +} 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/src/Auth/Events/TokenValidated.php b/src/Auth/Events/TokenValidated.php new file mode 100644 index 00000000..b1e5e16d --- /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/Auth/PersonalAccessToken.php b/src/Auth/PersonalAccessToken.php new file mode 100644 index 00000000..9b82a603 --- /dev/null +++ b/src/Auth/PersonalAccessToken.php @@ -0,0 +1,65 @@ +abilities === null) { + return null; + } + + return json_decode($this->abilities, true); + } +} diff --git a/src/Auth/PersonalAccessTokenQuery.php b/src/Auth/PersonalAccessTokenQuery.php new file mode 100644 index 00000000..b4d83fe4 --- /dev/null +++ b/src/Auth/PersonalAccessTokenQuery.php @@ -0,0 +1,12 @@ +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 new file mode 100644 index 00000000..da6feb16 --- /dev/null +++ b/src/Http/Middlewares/Authenticated.php @@ -0,0 +1,86 @@ +getHeader('Authorization'); + + if (! $this->hasToken($authorizationHeader)) { + return $this->unauthorized(); + } + + $token = $this->extractToken($authorizationHeader); + + /** @var AuthenticationManager $auth */ + $auth = App::make(AuthenticationManager::class); + + $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()); + + 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/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/src/Http/Request.php b/src/Http/Request.php index cddd3218..fcf8be2b 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -20,6 +20,7 @@ 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; @@ -28,22 +29,27 @@ class Request implements Arrayable { + use HasUser; use HasHeaders; use HasCookies; use HasQueryParameters; protected readonly BodyParser $body; + 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 = []; + public function __construct( + protected ServerRequest $request + ) { + $routeAttributes = []; $this->session = null; if ($request->hasAttribute(Router::class)) { - $attributes = $request->getAttribute(Router::class); + $routeAttributes = $request->getAttribute(Router::class); } if ($request->hasAttribute(ServerSession::class)) { @@ -51,7 +57,7 @@ public function __construct(protected ServerRequest $request) } $this->query = Query::fromUri($request->getUri()); - $this->attributes = new RouteAttributes($attributes); + $this->routeAttributes = new RouteAttributes($routeAttributes); $this->body = $this->getParser(); } @@ -108,10 +114,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 @@ -141,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/src/Http/Requests/Concerns/HasUser.php b/src/Http/Requests/Concerns/HasUser.php new file mode 100644 index 00000000..af8e2f8e --- /dev/null +++ b/src/Http/Requests/Concerns/HasUser.php @@ -0,0 +1,73 @@ +request->hasAttribute($key)) { + return $this->request->getAttribute($key); + } + + 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 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/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/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 bd2a86b0..25b417df 100644 --- a/src/Session/SessionMiddleware.php +++ b/src/Session/SessionMiddlewareFactory.php @@ -13,7 +13,7 @@ use Phenix\Redis\ClientWrapper; use Phenix\Session\Constants\Driver; -class SessionMiddleware +class SessionMiddlewareFactory { public static function make(string $host): Middleware { diff --git a/src/Testing/Concerns/InteractWithHeaders.php b/src/Testing/Concerns/InteractWithHeaders.php index 8abd499f..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)); @@ -32,6 +30,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'); 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/src/Util/Str.php b/src/Util/Str.php index e685a0e1..efb097f1 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 @@ -63,4 +71,37 @@ 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); + + $max = intdiv(256, $charactersLength) * $charactersLength; + + $result = ''; + + 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; + } + + $idx = $val % $charactersLength; + $result .= $characters[$idx]; + } + } + + return $result; + } } 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/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 00000000..a04324b5 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,957 @@ +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 { + Event::fake(); + + $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->hasUser() && $request->user() instanceof User ? 'Authenticated' : 'Guest'); + })->middleware(Authenticated::class); + + $this->app->run(); + + $this->get('/profile', headers: [ + 'Authorization' => 'Bearer ' . $authToken->toString(), + ]) + ->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()) + ->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']); + + Event::expect(TokenValidated::class)->toNotBeDispatched(); + Event::expect(FailedTokenValidation::class)->toBeDispatched(); +}); + +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( + id: $token->id, + 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()->assertHeaderIsMissing('Retry-After'); + + $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; + $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 ' . (string) $authToken, + ])->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, + ]); +}); + +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( + id: $token->id, + 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( + id: $token->id, + 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( + id: $token->id, + 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( + id: $token->id, + 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( + id: $token->id, + 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( + id: $token->id, + 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( + id: $token->id, + 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(); + + Route::get('/no-token', function (Request $request) use ($user): Response { + $request->setUser($user); + + 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'); +}); + +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(); +}); 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 2f39e356..ece27c60 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(); }); @@ -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'); }); 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/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php b/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php new file mode 100644 index 00000000..a1ddbc00 --- /dev/null +++ b/tests/Unit/Auth/Console/PersonalAccessTokensTableCommandTest.php @@ -0,0 +1,36 @@ +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(); + + expect($command->getDisplay())->toContain('Personal access tokens table [database/migrations/20251128110000_create_personal_access_tokens_table.php] successfully generated!'); +}); 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.'); +}); 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); diff --git a/tests/Unit/Util/StrTest.php b/tests/Unit/Util/StrTest.php index b29249df..7e74a805 100644 --- a/tests/Unit/Util/StrTest.php +++ b/tests/Unit/Util/StrTest.php @@ -33,3 +33,42 @@ 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); +}); + +it('generates default length string when length is zero', function (): void { + $random = Str::random(0); + + expect(strlen($random))->toBe(16); +}); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index e8833031..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' => [], ], @@ -23,6 +24,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..6ea9fd38 --- /dev/null +++ b/tests/fixtures/application/config/auth.php @@ -0,0 +1,21 @@ + [ + 'model' => Phenix\Auth\User::class, + ], + 'tokens' => [ + '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 + ], +];