Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a23c9f1
feat: add random string generation method and corresponding tests
barbosa89 Nov 10, 2025
c2dcff2
refactor: rename class
barbosa89 Nov 11, 2025
6399248
fix: add return type declaration to afterEach function in RequestTest
barbosa89 Nov 11, 2025
de96c27
feat: implement basic authentication using API tokens
barbosa89 Nov 14, 2025
5025497
feat: add assertHeaderIsMissing method to validate absence of headers
barbosa89 Nov 14, 2025
7c8742a
style: add blank lines
barbosa89 Nov 14, 2025
c3076b8
refactor: rename assertHeaderContains to assertHeaders for consistency
barbosa89 Nov 14, 2025
f5589ed
refactor: rename attributes to routeAttributes for clarity and consis…
barbosa89 Nov 14, 2025
cae78fb
refactor: add missing docblock for userModel variable in validate method
barbosa89 Nov 14, 2025
16d4856
test: add test for generating default length string when length is zero
barbosa89 Nov 14, 2025
ee0cb8a
test: add test for denying access when user is not found
barbosa89 Nov 14, 2025
3494677
refactor: replace where with whereEqual for consistency in tokens method
barbosa89 Nov 14, 2025
367cec0
test: add test for querying user tokens
barbosa89 Nov 14, 2025
43dd216
feat: implement HasUser trait for user management in requests
barbosa89 Nov 15, 2025
df82065
fix: update token validation logic to ensure proper expiration handling
barbosa89 Nov 16, 2025
19271a3
feat: increase token entropy to enhance security in token generation
barbosa89 Nov 16, 2025
c5f79f0
refactor: reorganize use statements for improved readability in Str.php
barbosa89 Nov 16, 2025
426c6ee
fix: reduce token expiration time to 6 hours for improved security
barbosa89 Nov 16, 2025
62af4f9
fix: update token expiration time to 12 hours for improved usability
barbosa89 Nov 16, 2025
6a83aa5
fix: update token expiration time to 12 hours for improved usability
barbosa89 Nov 16, 2025
c2a057c
Merge branch 'develop' of github.com:phenixphp/framework into feature…
barbosa89 Nov 27, 2025
15a2fcd
feat: implement token rate limiting and attempt tracking in authentic…
barbosa89 Nov 27, 2025
466fb4b
feat: add rate limiting for failed token validations and reset counte…
barbosa89 Nov 28, 2025
96802bb
tests: expect ip is null
barbosa89 Nov 28, 2025
e3e3a0c
feat: add ability checks
barbosa89 Nov 28, 2025
663ff3d
feat: add getAbilities method to PersonalAccessToken class
barbosa89 Nov 28, 2025
cf0aeeb
feat: add PersonalAccessTokensTableCommand and migration stub for per…
barbosa89 Nov 28, 2025
4e4100a
style: php cs
barbosa89 Nov 28, 2025
22a2365
feat: add PurgeExpiredTokens command to remove expired personal acces…
barbosa89 Nov 28, 2025
d54d5a4
refactor: move test to correct namespace
barbosa89 Nov 28, 2025
763b8ee
feat: enhance token generation with improved entropy and checksum
barbosa89 Nov 28, 2025
ca43bb0
feat: implement event handling for token creation, validation, and fa…
barbosa89 Nov 29, 2025
857f356
feat: simplify TokenCreated event constructor and ensure consistent d…
barbosa89 Nov 29, 2025
13af13f
refactor: remove unused imports in TokenCreated event class
barbosa89 Nov 29, 2025
ce31fd9
feat: add id parameter to AuthenticationToken constructor and update …
barbosa89 Nov 29, 2025
453f882
test: assert 'Retry-After' header is missing on successful reset request
barbosa89 Nov 29, 2025
3043d80
fix: cast AuthenticationToken to string for authorization header
barbosa89 Nov 29, 2025
a03b766
tests(refactor): update authentication check to use hasUser method fo…
barbosa89 Nov 29, 2025
a66d2f3
chore: remove comments
barbosa89 Nov 29, 2025
822a715
feat: implement refreshToken method and dispatch TokenRefreshComplete…
barbosa89 Nov 29, 2025
7597218
fix: improve random string generation to ensure uniform distribution
barbosa89 Nov 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -169,7 +169,7 @@ private function setRouter(): void
/** @var array<int, Middleware> $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);
}
Expand Down
34 changes: 34 additions & 0 deletions src/Auth/AuthServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Phenix\Auth;

use Phenix\Auth\Console\PersonalAccessTokensTableCommand;
use Phenix\Auth\Console\PurgeExpiredTokens;
use Phenix\Providers\ServiceProvider;

use function in_array;

class AuthServiceProvider extends ServiceProvider
{
public function provides(string $id): bool
{
return in_array($id, [
AuthenticationManager::class,
]);
}

public function register(): void
{
$this->bind(AuthenticationManager::class);
}

public function boot(): void
{
$this->commands([
PersonalAccessTokensTableCommand::class,
PurgeExpiredTokens::class,
]);
}
}
94 changes: 94 additions & 0 deletions src/Auth/AuthenticationManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

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;

public function user(): User|null
{
return $this->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<User> $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);
}
}
38 changes: 38 additions & 0 deletions src/Auth/AuthenticationToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace Phenix\Auth;

use Phenix\Util\Date;
use Stringable;

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;
}

public function expiresAt(): Date
{
return $this->expiresAt;
}

public function __toString(): string
{
return $this->toString();
}
}
103 changes: 103 additions & 0 deletions src/Auth/Concerns/HasApiTokens.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Phenix\Auth\Concerns;

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;
use Phenix\Util\Date;

use function sprintf;

trait HasApiTokens
{
protected PersonalAccessToken|null $accessToken = null;

public function token(): PersonalAccessToken
{
$model = new (config('auth.tokens.model'));
$model->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;
}
}
60 changes: 60 additions & 0 deletions src/Auth/Console/PersonalAccessTokensTableCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace Phenix\Auth\Console;

use Phenix\Console\Maker;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class PersonalAccessTokensTableCommand extends Maker
{
/**
* @var string
*
* @phpcsSuppress SlevomatCodingStandard.TypeHints.PropertyTypeHint
*/
protected static $defaultName = 'tokens:table';

/**
* @var string
*
* @phpcsSuppress SlevomatCodingStandard.TypeHints.PropertyTypeHint
*/
protected static $defaultDescription = 'Creates the database table for personal access tokens.';

protected function configure(): void
{
$this->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';
}
}
Loading