From aa88e40bcd16e8cb643f673ec288ee4978192119 Mon Sep 17 00:00:00 2001 From: sanzgrapher Date: Thu, 24 Sep 2026 14:08:46 +0000 Subject: [PATCH 1/4] Implement Authenticatable contract for flexible authentication models --- .../Auth/Contracts/Authenticatable.php | 19 +++++++++++++ src/Phaseolies/Auth/Security/Authenticate.php | 27 ++++++++++--------- .../Security/InteractsWithRememberCookie.php | 6 ++--- src/Phaseolies/Support/Facades/Auth.php | 17 ++++++------ tests/Auth/AuthenticateTest.php | 18 ++++++++++--- 5 files changed, 59 insertions(+), 28 deletions(-) create mode 100644 src/Phaseolies/Auth/Contracts/Authenticatable.php diff --git a/src/Phaseolies/Auth/Contracts/Authenticatable.php b/src/Phaseolies/Auth/Contracts/Authenticatable.php new file mode 100644 index 00000000..30cb4881 --- /dev/null +++ b/src/Phaseolies/Auth/Contracts/Authenticatable.php @@ -0,0 +1,19 @@ +getModel(); $modelClass = $authModel::class; @@ -194,9 +195,9 @@ public function login($user, bool $remember = false): bool * * @param int $id * @param bool $remember - * @return Model|null + * @return Authenticatable|null */ - public function loginUsingId(int $id, bool $remember = false): ?Model + public function loginUsingId(int $id, bool $remember = false): ?Authenticatable { $authModel = $this->getModel(); @@ -213,9 +214,9 @@ public function loginUsingId(int $id, bool $remember = false): ?Model * Log in a user by their ID for a single request (no session/cookie). * * @param int $id - * @return Model|null + * @return Authenticatable|null */ - public function onceUsingId(int $id): ?Model + public function onceUsingId(int $id): ?Authenticatable { $authModel = $this->getModel(); @@ -233,9 +234,9 @@ public function onceUsingId(int $id): ?Model /** * Get the currently authenticated user. * - * @return Model|null + * @return Authenticatable|null */ - public function user(): ?Model + public function user(): ?Authenticatable { if ($this->resolvedUser !== null) { return $this->resolvedUser; @@ -370,9 +371,9 @@ public function logout(): void /** * Set the authenticated user in the session. * - * @param Model $user + * @param Authenticatable $user */ - private function setUser(Model $user): void + private function setUser(Authenticatable $user): void { session()->regenerate(); diff --git a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php index beeb81f3..d7aef76c 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php +++ b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php @@ -4,7 +4,7 @@ use Phaseolies\Support\Facades\Hash; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Database\Entity\Model; +use Phaseolies\Auth\Contracts\Authenticatable; trait InteractsWithRememberCookie { @@ -30,10 +30,10 @@ protected function getRememberCookieName(): string /** * Set the remember token for the user. * - * @param Model $user + * @param Authenticatable $user * @return void */ - private function setRememberToken(Model $user): void + private function setRememberToken(Authenticatable $user): void { $token = bin2hex(random_bytes(32)); $user->remember_token = Hash::make($token); diff --git a/src/Phaseolies/Support/Facades/Auth.php b/src/Phaseolies/Support/Facades/Auth.php index c7197de1..bb9cb49a 100644 --- a/src/Phaseolies/Support/Facades/Auth.php +++ b/src/Phaseolies/Support/Facades/Auth.php @@ -2,29 +2,28 @@ namespace Phaseolies\Support\Facades; +use Phaseolies\Facade\BaseFacade; + /** * @method static try(array $credentials = [], bool $remember = false): bool - * @method static login(User $user, bool $remember = false): void - * @method static loginUsingId(int $id, bool $remember = false): ?User - * @method static onceUsingId(int $id): ?User - * @method static user(): ?User + * @method static login(\Phaseolies\Auth\Contracts\Authenticatable $user, bool $remember = false): void + * @method static loginUsingId(int $id, bool $remember = false): ?\Phaseolies\Auth\Contracts\Authenticatable + * @method static onceUsingId(int $id): ?\Phaseolies\Auth\Contracts\Authenticatable + * @method static user(): ?\Phaseolies\Auth\Contracts\Authenticatable * @method static check(): bool * @method static logout() * @method static id(): ?int * @method static enableTwoFactorAuth(): array * @method static disableTwoFactorAuth(): bool * @method static verifyTwoFactorCode(string $code): bool - * @method static verifyRecoveryCode(Model $user, string $code): bool + * @method static verifyRecoveryCode(\Phaseolies\Database\Entity\Model $user, string $code): bool * @method static generateNewRecoveryCodes(): array - * @method static hasTwoFactorEnabled(Model $user): bool + * @method static hasTwoFactorEnabled(\Phaseolies\Database\Entity\Model $user): bool * @method static completeTwoFactorLogin(): bool * @method static generateTwoFactorQrCode(string $qrCodeUrl): string * * @see \Phaseolies\Auth\Security\Authenticate */ - -use Phaseolies\Facade\BaseFacade; - class Auth extends BaseFacade { protected static function getFacadeAccessor() diff --git a/tests/Auth/AuthenticateTest.php b/tests/Auth/AuthenticateTest.php index 70fce276..7008a188 100644 --- a/tests/Auth/AuthenticateTest.php +++ b/tests/Auth/AuthenticateTest.php @@ -86,7 +86,7 @@ public function isApiRequest(): bool use Phaseolies\Database\Entity\Model; use PHPUnit\Framework\TestCase; - class FakeAuthenticatableModel extends Model + class FakeAuthenticatableModel extends Model implements \Phaseolies\Auth\Contracts\Authenticatable { public static ?self $resolvedUser = null; @@ -106,7 +106,7 @@ class SessionTrackingAuthenticate extends Authenticate { public function __construct( string $actorName, - private ?Model $user = null, + private ?\Phaseolies\Auth\Contracts\Authenticatable $user = null, ) { parent::__construct($actorName, [ 'model' => FakeAuthenticatableModel::class, @@ -124,7 +124,7 @@ public function hasTwoFactorEnabled(Model $user): bool return false; } - public function user(): ?Model + public function user(): ?\Phaseolies\Auth\Contracts\Authenticatable { return $this->user ?? parent::user(); } @@ -200,5 +200,17 @@ public function testCompleteTwoFactorLoginRegeneratesSessionId() $this->assertTrue($auth->completeTwoFactorLogin()); $this->assertSame(1, $authenticateSessionStore->regenerateCallCount); } + + public function testLoginAcceptsAuthenticatableContract() + { + $user = new FakeAuthenticatableModel(); + $user->id = 99; + + $auth = new SessionTrackingAuthenticate('admin'); + + $this->assertTrue($auth->login($user)); + $this->assertSame(99, $auth->id()); + $this->assertInstanceOf(\Phaseolies\Auth\Contracts\Authenticatable::class, $auth->user()); + } } } From 7138d64286b640ce1ab4e46ea63a2dda3c43a688 Mon Sep 17 00:00:00 2001 From: sanzgrapher Date: Thu, 24 Sep 2026 14:08:46 +0000 Subject: [PATCH 2/4] fix(auth): satisfy phpstan for Authenticatable Expand contract with Model-backed props/methods and rewrite Auth @method tags so analysis accepts multi-actor auth models. --- .../Auth/Contracts/Authenticatable.php | 23 +++++++++++++ src/Phaseolies/Auth/Security/Authenticate.php | 8 +++-- src/Phaseolies/Support/Facades/Auth.php | 34 ++++++++++--------- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/Phaseolies/Auth/Contracts/Authenticatable.php b/src/Phaseolies/Auth/Contracts/Authenticatable.php index 30cb4881..3b529063 100644 --- a/src/Phaseolies/Auth/Contracts/Authenticatable.php +++ b/src/Phaseolies/Auth/Contracts/Authenticatable.php @@ -2,11 +2,20 @@ namespace Phaseolies\Auth\Contracts; +use Phaseolies\Database\Entity\Model; + /** * Contract for models that can be authenticated. * * Application auth models (e.g. User) should implement this so Auth * is not locked to a single concrete class. + * + * @property int|string $id + * @property string|null $email + * @property string|null $remember_token + * @property string|null $two_factor_secret + * @property string|null $two_factor_recovery_codes + * @phpstan-require-extends Model */ interface Authenticatable { @@ -16,4 +25,18 @@ interface Authenticatable * @return string */ public function getAuthKeyName(): string; + + /** + * Get the primary key value for the user. + * + * @return string|null + */ + public function getKey(): ?string; + + /** + * Persist the user attributes. + * + * @return bool + */ + public function save(): bool; } diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index 816eff61..e7f55b0a 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -170,7 +170,7 @@ public function login(Authenticatable $user, bool $remember = false): bool if (!$user instanceof $modelClass) { throw new \InvalidArgumentException( - "Argument #1 ($user) must be an instance of $modelClass " . gettype($user) . ' given' + 'Argument #1 ($user) must be an instance of ' . $modelClass . ', ' . get_debug_type($user) . ' given' ); } @@ -344,9 +344,11 @@ public function logout(): void { $user = $this->user(); - if ($user && $user?->remember_token) { + if ($user && $user->remember_token) { $user->remember_token = null; - $user->withoutHook(); + if ($user instanceof Model) { + $user::withoutHook(); + } $user->save(); } diff --git a/src/Phaseolies/Support/Facades/Auth.php b/src/Phaseolies/Support/Facades/Auth.php index bb9cb49a..01ec6411 100644 --- a/src/Phaseolies/Support/Facades/Auth.php +++ b/src/Phaseolies/Support/Facades/Auth.php @@ -3,24 +3,26 @@ namespace Phaseolies\Support\Facades; use Phaseolies\Facade\BaseFacade; +use Phaseolies\Auth\Contracts\Authenticatable; +use Phaseolies\Database\Entity\Model; /** - * @method static try(array $credentials = [], bool $remember = false): bool - * @method static login(\Phaseolies\Auth\Contracts\Authenticatable $user, bool $remember = false): void - * @method static loginUsingId(int $id, bool $remember = false): ?\Phaseolies\Auth\Contracts\Authenticatable - * @method static onceUsingId(int $id): ?\Phaseolies\Auth\Contracts\Authenticatable - * @method static user(): ?\Phaseolies\Auth\Contracts\Authenticatable - * @method static check(): bool - * @method static logout() - * @method static id(): ?int - * @method static enableTwoFactorAuth(): array - * @method static disableTwoFactorAuth(): bool - * @method static verifyTwoFactorCode(string $code): bool - * @method static verifyRecoveryCode(\Phaseolies\Database\Entity\Model $user, string $code): bool - * @method static generateNewRecoveryCodes(): array - * @method static hasTwoFactorEnabled(\Phaseolies\Database\Entity\Model $user): bool - * @method static completeTwoFactorLogin(): bool - * @method static generateTwoFactorQrCode(string $qrCodeUrl): string + * @method static bool try(array $credentials = [], bool $remember = false) + * @method static void login(Authenticatable $user, bool $remember = false) + * @method static Authenticatable|null loginUsingId(int $id, bool $remember = false) + * @method static Authenticatable|null onceUsingId(int $id) + * @method static Authenticatable|null user() + * @method static bool check() + * @method static void logout() + * @method static int|null id() + * @method static array enableTwoFactorAuth() + * @method static bool disableTwoFactorAuth() + * @method static bool verifyTwoFactorCode(string $code) + * @method static bool verifyRecoveryCode(Model $user, string $code) + * @method static array generateNewRecoveryCodes() + * @method static bool hasTwoFactorEnabled(Model $user) + * @method static bool completeTwoFactorLogin() + * @method static string generateTwoFactorQrCode(string $qrCodeUrl) * * @see \Phaseolies\Auth\Security\Authenticate */ From 74244ca74bfde0b3c1f7d63ca8a1d4726d20473a Mon Sep 17 00:00:00 2001 From: sanzgrapher Date: Thu, 24 Sep 2026 14:08:46 +0000 Subject: [PATCH 3/4] refactor(auth): rename Authenticatable to Authable Avoid Laravel-homonym; Authable is the Doppar auth-model contract. --- .../{Authenticatable.php => Authable.php} | 4 +-- src/Phaseolies/Auth/Security/Authenticate.php | 28 ++++++++--------- .../Security/InteractsWithRememberCookie.php | 6 ++-- src/Phaseolies/Support/Facades/Auth.php | 10 +++---- tests/Auth/AuthenticateTest.php | 30 +++++++++---------- 5 files changed, 39 insertions(+), 39 deletions(-) rename src/Phaseolies/Auth/Contracts/{Authenticatable.php => Authable.php} (91%) diff --git a/src/Phaseolies/Auth/Contracts/Authenticatable.php b/src/Phaseolies/Auth/Contracts/Authable.php similarity index 91% rename from src/Phaseolies/Auth/Contracts/Authenticatable.php rename to src/Phaseolies/Auth/Contracts/Authable.php index 3b529063..9bc6858e 100644 --- a/src/Phaseolies/Auth/Contracts/Authenticatable.php +++ b/src/Phaseolies/Auth/Contracts/Authable.php @@ -5,7 +5,7 @@ use Phaseolies\Database\Entity\Model; /** - * Contract for models that can be authenticated. + * Contract for models that can authenticate. * * Application auth models (e.g. User) should implement this so Auth * is not locked to a single concrete class. @@ -17,7 +17,7 @@ * @property string|null $two_factor_recovery_codes * @phpstan-require-extends Model */ -interface Authenticatable +interface Authable { /** * Get the authentication key name used for identifying the user. diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index e7f55b0a..3ce3aa0a 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -5,7 +5,7 @@ use Phaseolies\Support\Facades\Hash; use Phaseolies\Support\Facades\Crypt; use Phaseolies\Database\Entity\Model; -use Phaseolies\Auth\Contracts\Authenticatable; +use Phaseolies\Auth\Contracts\Authable; class Authenticate { @@ -33,16 +33,16 @@ class Authenticate /** * The current stateless user (for onceUsingId). * - * @var Authenticatable|null + * @var Authable|null */ private $statelessUser = null; /** * Per-instance resolved user cache * - * @var Authenticatable|null + * @var Authable|null */ - private ?Authenticatable $resolvedUser = null; + private ?Authable $resolvedUser = null; /** * Create a new actor instance. @@ -158,12 +158,12 @@ public function try(array $credentials = [], bool $remember = false): bool /** * Log in a user instance. * - * @param Authenticatable $user + * @param Authable $user * @param bool $remember * @return bool * @throws \InvalidArgumentException */ - public function login(Authenticatable $user, bool $remember = false): bool + public function login(Authable $user, bool $remember = false): bool { $authModel = $this->getModel(); $modelClass = $authModel::class; @@ -195,9 +195,9 @@ public function login(Authenticatable $user, bool $remember = false): bool * * @param int $id * @param bool $remember - * @return Authenticatable|null + * @return Authable|null */ - public function loginUsingId(int $id, bool $remember = false): ?Authenticatable + public function loginUsingId(int $id, bool $remember = false): ?Authable { $authModel = $this->getModel(); @@ -214,9 +214,9 @@ public function loginUsingId(int $id, bool $remember = false): ?Authenticatable * Log in a user by their ID for a single request (no session/cookie). * * @param int $id - * @return Authenticatable|null + * @return Authable|null */ - public function onceUsingId(int $id): ?Authenticatable + public function onceUsingId(int $id): ?Authable { $authModel = $this->getModel(); @@ -234,9 +234,9 @@ public function onceUsingId(int $id): ?Authenticatable /** * Get the currently authenticated user. * - * @return Authenticatable|null + * @return Authable|null */ - public function user(): ?Authenticatable + public function user(): ?Authable { if ($this->resolvedUser !== null) { return $this->resolvedUser; @@ -373,9 +373,9 @@ public function logout(): void /** * Set the authenticated user in the session. * - * @param Authenticatable $user + * @param Authable $user */ - private function setUser(Authenticatable $user): void + private function setUser(Authable $user): void { session()->regenerate(); diff --git a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php index d7aef76c..4437a04c 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php +++ b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php @@ -4,7 +4,7 @@ use Phaseolies\Support\Facades\Hash; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Auth\Contracts\Authenticatable; +use Phaseolies\Auth\Contracts\Authable; trait InteractsWithRememberCookie { @@ -30,10 +30,10 @@ protected function getRememberCookieName(): string /** * Set the remember token for the user. * - * @param Authenticatable $user + * @param Authable $user * @return void */ - private function setRememberToken(Authenticatable $user): void + private function setRememberToken(Authable $user): void { $token = bin2hex(random_bytes(32)); $user->remember_token = Hash::make($token); diff --git a/src/Phaseolies/Support/Facades/Auth.php b/src/Phaseolies/Support/Facades/Auth.php index 01ec6411..481f820e 100644 --- a/src/Phaseolies/Support/Facades/Auth.php +++ b/src/Phaseolies/Support/Facades/Auth.php @@ -3,15 +3,15 @@ namespace Phaseolies\Support\Facades; use Phaseolies\Facade\BaseFacade; -use Phaseolies\Auth\Contracts\Authenticatable; +use Phaseolies\Auth\Contracts\Authable; use Phaseolies\Database\Entity\Model; /** * @method static bool try(array $credentials = [], bool $remember = false) - * @method static void login(Authenticatable $user, bool $remember = false) - * @method static Authenticatable|null loginUsingId(int $id, bool $remember = false) - * @method static Authenticatable|null onceUsingId(int $id) - * @method static Authenticatable|null user() + * @method static void login(Authable $user, bool $remember = false) + * @method static Authable|null loginUsingId(int $id, bool $remember = false) + * @method static Authable|null onceUsingId(int $id) + * @method static Authable|null user() * @method static bool check() * @method static void logout() * @method static int|null id() diff --git a/tests/Auth/AuthenticateTest.php b/tests/Auth/AuthenticateTest.php index 7008a188..30dfd853 100644 --- a/tests/Auth/AuthenticateTest.php +++ b/tests/Auth/AuthenticateTest.php @@ -86,7 +86,7 @@ public function isApiRequest(): bool use Phaseolies\Database\Entity\Model; use PHPUnit\Framework\TestCase; - class FakeAuthenticatableModel extends Model implements \Phaseolies\Auth\Contracts\Authenticatable + class FakeAuthableModel extends Model implements \Phaseolies\Auth\Contracts\Authable { public static ?self $resolvedUser = null; @@ -106,17 +106,17 @@ class SessionTrackingAuthenticate extends Authenticate { public function __construct( string $actorName, - private ?\Phaseolies\Auth\Contracts\Authenticatable $user = null, + private ?\Phaseolies\Auth\Contracts\Authable $user = null, ) { parent::__construct($actorName, [ - 'model' => FakeAuthenticatableModel::class, + 'model' => FakeAuthableModel::class, 'session_key' => $actorName . '_session', ]); } protected function getModel(): Model { - return new FakeAuthenticatableModel(); + return new FakeAuthableModel(); } public function hasTwoFactorEnabled(Model $user): bool @@ -124,7 +124,7 @@ public function hasTwoFactorEnabled(Model $user): bool return false; } - public function user(): ?\Phaseolies\Auth\Contracts\Authenticatable + public function user(): ?\Phaseolies\Auth\Contracts\Authable { return $this->user ?? parent::user(); } @@ -137,14 +137,14 @@ protected function setUp(): void global $authenticateSessionStore; $authenticateSessionStore = new \Phaseolies\Auth\Security\TestSessionStore(); - FakeAuthenticatableModel::$resolvedUser = null; + FakeAuthableModel::$resolvedUser = null; } public function testLoginDoesNotStoreFullUserPayloadInSessionCache() { global $authenticateSessionStore; - $user = new FakeAuthenticatableModel(); + $user = new FakeAuthableModel(); $user->id = 42; $user->updated_at = '2026-04-29 10:00:00'; @@ -158,11 +158,11 @@ public function testUserResolvedFromSessionDoesNotCreateSessionUserCache() { global $authenticateSessionStore; - $user = new FakeAuthenticatableModel(); + $user = new FakeAuthableModel(); $user->id = 42; $user->updated_at = '2026-04-29 10:00:00'; - FakeAuthenticatableModel::$resolvedUser = $user; + FakeAuthableModel::$resolvedUser = $user; $authenticateSessionStore->put('admin_session', 42); $auth = new SessionTrackingAuthenticate('admin'); @@ -175,7 +175,7 @@ public function testLoginRegeneratesSessionIdToPreventFixation() { global $authenticateSessionStore; - $user = new FakeAuthenticatableModel(); + $user = new FakeAuthableModel(); $user->id = 42; $auth = new SessionTrackingAuthenticate('admin'); @@ -188,9 +188,9 @@ public function testCompleteTwoFactorLoginRegeneratesSessionId() { global $authenticateSessionStore; - $user = new FakeAuthenticatableModel(); + $user = new FakeAuthableModel(); $user->id = 7; - FakeAuthenticatableModel::$resolvedUser = $user; + FakeAuthableModel::$resolvedUser = $user; $authenticateSessionStore->put('2fa_admin_user_id', 7); $authenticateSessionStore->put('2fa_admin_remember', false); @@ -201,16 +201,16 @@ public function testCompleteTwoFactorLoginRegeneratesSessionId() $this->assertSame(1, $authenticateSessionStore->regenerateCallCount); } - public function testLoginAcceptsAuthenticatableContract() + public function testLoginAcceptsAuthableContract() { - $user = new FakeAuthenticatableModel(); + $user = new FakeAuthableModel(); $user->id = 99; $auth = new SessionTrackingAuthenticate('admin'); $this->assertTrue($auth->login($user)); $this->assertSame(99, $auth->id()); - $this->assertInstanceOf(\Phaseolies\Auth\Contracts\Authenticatable::class, $auth->user()); + $this->assertInstanceOf(\Phaseolies\Auth\Contracts\Authable::class, $auth->user()); } } } From 282eac26915c1f06e171db546ddfe14d07a21f1a Mon Sep 17 00:00:00 2001 From: sanzgrapher Date: Thu, 24 Sep 2026 14:08:58 +0000 Subject: [PATCH 4/4] refactor(auth): make Authable a Model base class Auth models must extend Authable; move auth helpers off Model and drop the dead PHP <7.3 CookieJar path. --- phpstan-baseline.neon | 44 +------ src/Phaseolies/Auth/Authable.php | 108 ++++++++++++++++++ src/Phaseolies/Auth/Contracts/Authable.php | 42 ------- src/Phaseolies/Auth/Security/Authenticate.php | 46 ++++---- .../Security/InteractsWithRememberCookie.php | 7 +- .../Security/InteractsWithTwoFactorAuth.php | 36 +++--- src/Phaseolies/Database/Entity/Model.php | 10 -- src/Phaseolies/Support/CookieJar.php | 21 ---- src/Phaseolies/Support/Facades/Auth.php | 9 +- tests/Auth/AuthenticateTest.php | 24 ++-- tests/Model/ModelTest.php | 6 - 11 files changed, 176 insertions(+), 177 deletions(-) create mode 100644 src/Phaseolies/Auth/Authable.php delete mode 100644 src/Phaseolies/Auth/Contracts/Authable.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 5b19ed23..3759b41b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -60,30 +60,6 @@ parameters: count: 1 path: src/Phaseolies/ApplicationBuilder.php - - - message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$id\.$#' - identifier: property.notFound - count: 5 - path: src/Phaseolies/Auth/Security/Authenticate.php - - - - message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$remember_token\.$#' - identifier: property.notFound - count: 2 - path: src/Phaseolies/Auth/Security/Authenticate.php - - - - message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$two_factor_recovery_codes\.$#' - identifier: property.notFound - count: 4 - path: src/Phaseolies/Auth/Security/Authenticate.php - - - - message: '#^Access to an undefined property Phaseolies\\Database\\Entity\\Model\:\:\$two_factor_secret\.$#' - identifier: property.notFound - count: 3 - path: src/Phaseolies/Auth/Security/Authenticate.php - - message: '#^Access to undefined constant chillerlan\\QRCode\\QRCode\:\:ECC_M\.$#' identifier: classConstant.notFound @@ -97,7 +73,7 @@ parameters: path: src/Phaseolies/Auth/Security/Authenticate.php - - message: '#^Call to an undefined static method Phaseolies\\Database\\Entity\\Model\:\:where\(\)\.$#' + message: '#^Call to an undefined static method Phaseolies\\Auth\\Authable\:\:where\(\)\.$#' identifier: staticMethod.notFound count: 1 path: src/Phaseolies/Auth/Security/Authenticate.php @@ -144,18 +120,6 @@ parameters: count: 1 path: src/Phaseolies/Auth/Security/Authenticate.php - - - message: '#^Using nullsafe property access "\?\-\>id" on left side of \?\? is unnecessary\. Use \-\> instead\.$#' - identifier: nullsafe.neverNull - count: 1 - path: src/Phaseolies/Auth/Security/Authenticate.php - - - - message: '#^Using nullsafe property access on non\-nullable type Phaseolies\\Database\\Entity\\Model\. Use \-\> instead\.$#' - identifier: nullsafe.neverNull - count: 1 - path: src/Phaseolies/Auth/Security/Authenticate.php - - message: '#^Call to function is_string\(\) with string will always evaluate to true\.$#' identifier: function.alreadyNarrowedType @@ -1656,12 +1620,6 @@ parameters: count: 16 path: src/Phaseolies/Support/Collection.php - - - message: '#^Comparison operation "\<" between int\<80500, 80599\> and 70300 is always false\.$#' - identifier: smaller.alwaysFalse - count: 1 - path: src/Phaseolies/Support/CookieJar.php - - message: '#^PHPDoc tag @return with type void is incompatible with native type bool\.$#' identifier: return.phpDocType diff --git a/src/Phaseolies/Auth/Authable.php b/src/Phaseolies/Auth/Authable.php new file mode 100644 index 00000000..decf20bb --- /dev/null +++ b/src/Phaseolies/Auth/Authable.php @@ -0,0 +1,108 @@ +{$this->getKeyName()}; + } + + /** + * Get the password for the user. + * + * @return string|null + */ + public function getAuthPassword(): ?string + { + return $this->password; + } + + /** + * Get the remember token value. + * + * @return string|null + */ + public function getRememberToken(): ?string + { + return $this->remember_token; + } + + /** + * Set the remember token value. + * + * @param string|null $value + * @return void + */ + public function setRememberToken(?string $value): void + { + $this->remember_token = $value; + } + + /** + * Get the two-factor authentication secret. + * + * @return string|null + */ + public function getTwoFactorSecret(): ?string + { + return $this->two_factor_secret; + } + + /** + * Set the two-factor authentication secret. + * + * @param string|null $value + * @return void + */ + public function setTwoFactorSecret(?string $value): void + { + $this->two_factor_secret = $value; + } + + /** + * Get the two-factor recovery codes. + * + * @return string|null + */ + public function getTwoFactorRecoveryCodes(): ?string + { + return $this->two_factor_recovery_codes; + } + + /** + * Set the two-factor recovery codes. + * + * @param string|null $value + * @return void + */ + public function setTwoFactorRecoveryCodes(?string $value): void + { + $this->two_factor_recovery_codes = $value; + } +} diff --git a/src/Phaseolies/Auth/Contracts/Authable.php b/src/Phaseolies/Auth/Contracts/Authable.php deleted file mode 100644 index 9bc6858e..00000000 --- a/src/Phaseolies/Auth/Contracts/Authable.php +++ /dev/null @@ -1,42 +0,0 @@ -config['model']); + $model = app($this->config['model']); + + if (!$model instanceof Authable) { + throw new \InvalidArgumentException( + 'Auth model must extend ' . Authable::class . ', ' . get_debug_type($model) . ' given' + ); + } + + return $model; } /** @@ -146,7 +154,7 @@ public function try(array $credentials = [], bool $remember = false): bool $user = $authModel::where($customAuthKey, $authKeyValue)->first(); - if (!$user || !Hash::check($password, $user->password)) { + if (!$user || !Hash::check($password, $user->getAuthPassword())) { return false; } @@ -175,7 +183,7 @@ public function login(Authable $user, bool $remember = false): bool } if ($this->hasTwoFactorEnabled($user) && !$this->isApiRequest()) { - session()->put($this->getTwoFactorUserKey(), $user->id); + session()->put($this->getTwoFactorUserKey(), $user->getAuthIdentifier()); session()->put($this->getTwoFactorRememberKey(), $remember); return true; @@ -300,14 +308,14 @@ public function user(): ?Authable $user = $authModel::find($id); - if (!$user || !$user->remember_token) { + if (!$user || !$user->getRememberToken()) { $this->expireRememberCookie(); return null; } - if (Hash::check($token, $user->remember_token)) { + if (Hash::check($token, $user->getRememberToken())) { if ($this->hasTwoFactorEnabled($user)) { - session()->put($this->getTwoFactorUserKey(), $user->id); + session()->put($this->getTwoFactorUserKey(), $user->getAuthIdentifier()); session()->put($this->getTwoFactorRememberKey(), true); } @@ -318,7 +326,7 @@ public function user(): ?Authable // Token didn't match - possible theft attempt $this->expireRememberCookie(); - $user->remember_token = null; + $user->setRememberToken(null); $user->save(); } @@ -344,11 +352,9 @@ public function logout(): void { $user = $this->user(); - if ($user && $user->remember_token) { - $user->remember_token = null; - if ($user instanceof Model) { - $user::withoutHook(); - } + if ($user && $user->getRememberToken()) { + $user->setRememberToken(null); + $user::withoutHook(); $user->save(); } @@ -379,7 +385,7 @@ private function setUser(Authable $user): void { session()->regenerate(); - session()->put($this->getSessionKey(), $user->id); + session()->put($this->getSessionKey(), $user->getAuthIdentifier()); $this->resolvedUser = $user; } @@ -398,11 +404,11 @@ public function viaRemember(): bool /** * Get the authenticated user id * - * @return int|null + * @return int|string|null */ - public function id(): ?int + public function id(): int|string|null { - return $this->user()?->id ?? null; + return $this->user()?->getAuthIdentifier(); } /** diff --git a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php index 4437a04c..b3d29dc4 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php +++ b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php @@ -4,7 +4,7 @@ use Phaseolies\Support\Facades\Hash; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Auth\Contracts\Authable; +use Phaseolies\Auth\Authable; trait InteractsWithRememberCookie { @@ -36,10 +36,11 @@ protected function getRememberCookieName(): string private function setRememberToken(Authable $user): void { $token = bin2hex(random_bytes(32)); - $user->remember_token = Hash::make($token); + $user->setRememberToken(Hash::make($token)); $user->save(); - $cookieValue = $user->id . '|' . $token . '|' . Hash::make($user->id . $token); + $id = $user->getAuthIdentifier(); + $cookieValue = $id . '|' . $token . '|' . Hash::make($id . $token); session()->put($this->getViaRememberKey(), true); diff --git a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php index c8655d7f..1e937442 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php +++ b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php @@ -9,7 +9,7 @@ use Symfony\Component\Clock\NativeClock; use Psr\Clock\ClockInterface; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Database\Entity\Model; +use Phaseolies\Auth\Authable; use ParagonIE\ConstantTime\Base32; use OTPHP\TOTP; @@ -34,7 +34,7 @@ public function enableTwoFactorAuth(): array { $user = $this->user(); - if (!is_null($user->two_factor_secret)) { + if (!is_null($user->getTwoFactorSecret())) { throw new \Exception("2FA Already enabled"); } @@ -57,8 +57,8 @@ public function enableTwoFactorAuth(): array $recoveryCodes = $this->generateRecoveryCodes(); - $user->two_factor_secret = Crypt::encrypt($secret); - $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($recoveryCodes)); + $user->setTwoFactorSecret(Crypt::encrypt($secret)); + $user->setTwoFactorRecoveryCodes(Crypt::encrypt(json_encode($recoveryCodes))); $user->save(); return [ @@ -77,8 +77,8 @@ public function disableTwoFactorAuth(): bool { $user = $this->user(); - $user->two_factor_secret = null; - $user->two_factor_recovery_codes = null; + $user->setTwoFactorSecret(null); + $user->setTwoFactorRecoveryCodes(null); return $user->save(); } @@ -109,12 +109,12 @@ public function verifyTwoFactorCode(string $code): bool $authModel = $this->getModel(); $user = $authModel::find(session($this->getTwoFactorUserKey())); - if (is_null($user->two_factor_secret)) { + if (is_null($user->getTwoFactorSecret())) { return false; } try { - $secret = Crypt::decrypt($user->two_factor_secret); + $secret = Crypt::decrypt($user->getTwoFactorSecret()); $totp = TOTP::create( $secret, @@ -134,26 +134,26 @@ public function verifyTwoFactorCode(string $code): bool /** * Verify a recovery code * - * @param Model $user + * @param Authable $user * @param string $code * @return bool */ - public function verifyRecoveryCode(Model $user, string $code): bool + public function verifyRecoveryCode(Authable $user, string $code): bool { - if (is_null($user->two_factor_recovery_codes)) { + if (is_null($user->getTwoFactorRecoveryCodes())) { return false; } - $codes = Crypt::decrypt($user->two_factor_recovery_codes); + $codes = Crypt::decrypt($user->getTwoFactorRecoveryCodes()); foreach ($codes as $key => $recoveryCode) { if (strtoupper(trim($code)) === $recoveryCode) { unset($codes[$key]); if (!empty($codes)) { - $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($codes)); + $user->setTwoFactorRecoveryCodes(Crypt::encrypt(json_encode($codes))); } else { - $user->two_factor_recovery_codes = null; + $user->setTwoFactorRecoveryCodes(null); } $user->save(); @@ -174,7 +174,7 @@ public function generateNewRecoveryCodes(): array $recoveryCodes = $this->generateRecoveryCodes(); $user = $this->user(); - $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($recoveryCodes)); + $user->setTwoFactorRecoveryCodes(Crypt::encrypt(json_encode($recoveryCodes))); $user->save(); return $recoveryCodes; @@ -183,12 +183,12 @@ public function generateNewRecoveryCodes(): array /** * Check if user has 2FA enabled * - * @param Model $user + * @param Authable $user * @return bool */ - public function hasTwoFactorEnabled(Model $user): bool + public function hasTwoFactorEnabled(Authable $user): bool { - return !is_null($user->two_factor_secret); + return !is_null($user->getTwoFactorSecret()); } /** diff --git a/src/Phaseolies/Database/Entity/Model.php b/src/Phaseolies/Database/Entity/Model.php index 93d1b898..fa3cb881 100644 --- a/src/Phaseolies/Database/Entity/Model.php +++ b/src/Phaseolies/Database/Entity/Model.php @@ -1137,16 +1137,6 @@ public function getRelationValue(string $relation) return $this->getRelation($relation); } - /** - * Get the authentication key name used for identifying the user. - * - * @return string - */ - public function getAuthKeyName(): string - { - return "email"; - } - /** * Check is the model usage timestamps * diff --git a/src/Phaseolies/Support/CookieJar.php b/src/Phaseolies/Support/CookieJar.php index faf459ee..df35d112 100644 --- a/src/Phaseolies/Support/CookieJar.php +++ b/src/Phaseolies/Support/CookieJar.php @@ -148,27 +148,6 @@ protected function setCookie(Cookie $cookie): bool $options['partitioned'] = true; } - if (PHP_VERSION_ID < 70300) { - // For PHP < 7.3 - $path = $options['path']; - if (isset($options['samesite'])) { - $path .= '; samesite=' . $options['samesite']; - } - if (isset($options['partitioned'])) { - $path .= '; partitioned'; - } - return setcookie( - $name, - $value, - $options['expires'], - $path, - $options['domain'], - $options['secure'], - $options['httponly'] - ); - } - - // For PHP >= 7.3 return setcookie($name, $value, $options); } diff --git a/src/Phaseolies/Support/Facades/Auth.php b/src/Phaseolies/Support/Facades/Auth.php index 481f820e..e4b71f16 100644 --- a/src/Phaseolies/Support/Facades/Auth.php +++ b/src/Phaseolies/Support/Facades/Auth.php @@ -3,8 +3,7 @@ namespace Phaseolies\Support\Facades; use Phaseolies\Facade\BaseFacade; -use Phaseolies\Auth\Contracts\Authable; -use Phaseolies\Database\Entity\Model; +use Phaseolies\Auth\Authable; /** * @method static bool try(array $credentials = [], bool $remember = false) @@ -14,13 +13,13 @@ * @method static Authable|null user() * @method static bool check() * @method static void logout() - * @method static int|null id() + * @method static int|string|null id() * @method static array enableTwoFactorAuth() * @method static bool disableTwoFactorAuth() * @method static bool verifyTwoFactorCode(string $code) - * @method static bool verifyRecoveryCode(Model $user, string $code) + * @method static bool verifyRecoveryCode(Authable $user, string $code) * @method static array generateNewRecoveryCodes() - * @method static bool hasTwoFactorEnabled(Model $user) + * @method static bool hasTwoFactorEnabled(Authable $user) * @method static bool completeTwoFactorLogin() * @method static string generateTwoFactorQrCode(string $qrCodeUrl) * diff --git a/tests/Auth/AuthenticateTest.php b/tests/Auth/AuthenticateTest.php index 30dfd853..828594cd 100644 --- a/tests/Auth/AuthenticateTest.php +++ b/tests/Auth/AuthenticateTest.php @@ -81,12 +81,11 @@ public function isApiRequest(): bool namespace Tests\Unit\Auth { - use Phaseolies\Auth\ActorManager; + use Phaseolies\Auth\Authable; use Phaseolies\Auth\Security\Authenticate; - use Phaseolies\Database\Entity\Model; use PHPUnit\Framework\TestCase; - class FakeAuthableModel extends Model implements \Phaseolies\Auth\Contracts\Authable + class FakeAuthableModel extends Authable { public static ?self $resolvedUser = null; @@ -106,7 +105,7 @@ class SessionTrackingAuthenticate extends Authenticate { public function __construct( string $actorName, - private ?\Phaseolies\Auth\Contracts\Authable $user = null, + private ?Authable $user = null, ) { parent::__construct($actorName, [ 'model' => FakeAuthableModel::class, @@ -114,17 +113,17 @@ public function __construct( ]); } - protected function getModel(): Model + protected function getModel(): Authable { return new FakeAuthableModel(); } - public function hasTwoFactorEnabled(Model $user): bool + public function hasTwoFactorEnabled(Authable $user): bool { return false; } - public function user(): ?\Phaseolies\Auth\Contracts\Authable + public function user(): ?Authable { return $this->user ?? parent::user(); } @@ -140,6 +139,13 @@ protected function setUp(): void FakeAuthableModel::$resolvedUser = null; } + public function testAuthableGetAuthKeyNameDefaultsToEmail() + { + $user = new FakeAuthableModel(); + + $this->assertSame('email', $user->getAuthKeyName()); + } + public function testLoginDoesNotStoreFullUserPayloadInSessionCache() { global $authenticateSessionStore; @@ -201,7 +207,7 @@ public function testCompleteTwoFactorLoginRegeneratesSessionId() $this->assertSame(1, $authenticateSessionStore->regenerateCallCount); } - public function testLoginAcceptsAuthableContract() + public function testLoginAcceptsAuthable() { $user = new FakeAuthableModel(); $user->id = 99; @@ -210,7 +216,7 @@ public function testLoginAcceptsAuthableContract() $this->assertTrue($auth->login($user)); $this->assertSame(99, $auth->id()); - $this->assertInstanceOf(\Phaseolies\Auth\Contracts\Authable::class, $auth->user()); + $this->assertInstanceOf(Authable::class, $auth->user()); } } } diff --git a/tests/Model/ModelTest.php b/tests/Model/ModelTest.php index 56747392..2b553b2b 100644 --- a/tests/Model/ModelTest.php +++ b/tests/Model/ModelTest.php @@ -254,12 +254,6 @@ public function testRouteKeyName() $this->assertEquals('id', $model->getRouteKeyName()); } - public function testAuthKeyName() - { - $model = new TestModel(); - $this->assertEquals('email', $model->getAuthKeyName()); - } - public function testTimestampsUsage() { $model = new TestModel();