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/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index 77b0fecc..1b4e3748 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -4,7 +4,7 @@ use Phaseolies\Support\Facades\Hash; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Database\Entity\Model; +use Phaseolies\Auth\Authable; class Authenticate { @@ -32,16 +32,16 @@ class Authenticate /** * The current stateless user (for onceUsingId). * - * @var Model|null + * @var Authable|null */ private $statelessUser = null; /** * Per-instance resolved user cache * - * @var Model|null + * @var Authable|null */ - private ?Model $resolvedUser = null; + private ?Authable $resolvedUser = null; /** * Create a new actor instance. @@ -80,11 +80,20 @@ public function __get($name) /** * Resolve a fresh instance of the configured auth model. * - * @return Model + * @return Authable + * @throws \InvalidArgumentException */ - protected function getModel(): Model + protected function getModel(): Authable { - return app($this->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; } /** @@ -145,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; } @@ -157,24 +166,24 @@ public function try(array $credentials = [], bool $remember = false): bool /** * Log in a user instance. * - * @param Model $user + * @param Authable $user * @param bool $remember * @return bool * @throws \InvalidArgumentException */ - public function login($user, bool $remember = false): bool + public function login(Authable $user, bool $remember = false): bool { $authModel = $this->getModel(); $modelClass = $authModel::class; 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' ); } 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; @@ -194,9 +203,9 @@ public function login($user, bool $remember = false): bool * * @param int $id * @param bool $remember - * @return Model|null + * @return Authable|null */ - public function loginUsingId(int $id, bool $remember = false): ?Model + public function loginUsingId(int $id, bool $remember = false): ?Authable { $authModel = $this->getModel(); @@ -213,9 +222,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 Authable|null */ - public function onceUsingId(int $id): ?Model + public function onceUsingId(int $id): ?Authable { $authModel = $this->getModel(); @@ -233,9 +242,9 @@ public function onceUsingId(int $id): ?Model /** * Get the currently authenticated user. * - * @return Model|null + * @return Authable|null */ - public function user(): ?Model + public function user(): ?Authable { if ($this->resolvedUser !== null) { return $this->resolvedUser; @@ -299,14 +308,14 @@ public function user(): ?Model $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); } @@ -317,7 +326,7 @@ public function user(): ?Model // Token didn't match - possible theft attempt $this->expireRememberCookie(); - $user->remember_token = null; + $user->setRememberToken(null); $user->save(); } @@ -343,9 +352,9 @@ public function logout(): void { $user = $this->user(); - if ($user && $user?->remember_token) { - $user->remember_token = null; - $user->withoutHook(); + if ($user && $user->getRememberToken()) { + $user->setRememberToken(null); + $user::withoutHook(); $user->save(); } @@ -370,13 +379,13 @@ public function logout(): void /** * Set the authenticated user in the session. * - * @param Model $user + * @param Authable $user */ - private function setUser(Model $user): void + private function setUser(Authable $user): void { session()->regenerate(); - session()->put($this->getSessionKey(), $user->id); + session()->put($this->getSessionKey(), $user->getAuthIdentifier()); $this->resolvedUser = $user; } @@ -395,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 beeb81f3..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\Database\Entity\Model; +use Phaseolies\Auth\Authable; trait InteractsWithRememberCookie { @@ -30,16 +30,17 @@ protected function getRememberCookieName(): string /** * Set the remember token for the user. * - * @param Model $user + * @param Authable $user * @return void */ - private function setRememberToken(Model $user): void + 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 c7197de1..e4b71f16 100644 --- a/src/Phaseolies/Support/Facades/Auth.php +++ b/src/Phaseolies/Support/Facades/Auth.php @@ -2,29 +2,29 @@ namespace Phaseolies\Support\Facades; +use Phaseolies\Facade\BaseFacade; +use Phaseolies\Auth\Authable; + /** - * @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 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 generateNewRecoveryCodes(): array - * @method static hasTwoFactorEnabled(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(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|string|null id() + * @method static array enableTwoFactorAuth() + * @method static bool disableTwoFactorAuth() + * @method static bool verifyTwoFactorCode(string $code) + * @method static bool verifyRecoveryCode(Authable $user, string $code) + * @method static array generateNewRecoveryCodes() + * @method static bool hasTwoFactorEnabled(Authable $user) + * @method static bool completeTwoFactorLogin() + * @method static string generateTwoFactorQrCode(string $qrCodeUrl) * * @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..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 FakeAuthenticatableModel extends Model + class FakeAuthableModel extends Authable { public static ?self $resolvedUser = null; @@ -106,25 +105,25 @@ class SessionTrackingAuthenticate extends Authenticate { public function __construct( string $actorName, - private ?Model $user = null, + private ?Authable $user = null, ) { parent::__construct($actorName, [ - 'model' => FakeAuthenticatableModel::class, + 'model' => FakeAuthableModel::class, 'session_key' => $actorName . '_session', ]); } - protected function getModel(): Model + protected function getModel(): Authable { - return new FakeAuthenticatableModel(); + return new FakeAuthableModel(); } - public function hasTwoFactorEnabled(Model $user): bool + public function hasTwoFactorEnabled(Authable $user): bool { return false; } - public function user(): ?Model + public function user(): ?Authable { return $this->user ?? parent::user(); } @@ -137,14 +136,21 @@ protected function setUp(): void global $authenticateSessionStore; $authenticateSessionStore = new \Phaseolies\Auth\Security\TestSessionStore(); - FakeAuthenticatableModel::$resolvedUser = null; + FakeAuthableModel::$resolvedUser = null; + } + + public function testAuthableGetAuthKeyNameDefaultsToEmail() + { + $user = new FakeAuthableModel(); + + $this->assertSame('email', $user->getAuthKeyName()); } 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 +164,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 +181,7 @@ public function testLoginRegeneratesSessionIdToPreventFixation() { global $authenticateSessionStore; - $user = new FakeAuthenticatableModel(); + $user = new FakeAuthableModel(); $user->id = 42; $auth = new SessionTrackingAuthenticate('admin'); @@ -188,9 +194,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); @@ -200,5 +206,17 @@ public function testCompleteTwoFactorLoginRegeneratesSessionId() $this->assertTrue($auth->completeTwoFactorLogin()); $this->assertSame(1, $authenticateSessionStore->regenerateCallCount); } + + public function testLoginAcceptsAuthable() + { + $user = new FakeAuthableModel(); + $user->id = 99; + + $auth = new SessionTrackingAuthenticate('admin'); + + $this->assertTrue($auth->login($user)); + $this->assertSame(99, $auth->id()); + $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();