From f923a327cfed46cd3da3c9cdf82efc4b79231fdb Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 13 Mar 2026 21:47:15 +0600 Subject: [PATCH 1/2] multi auth with actor initialize:: --- src/Phaseolies/Auth/ActorManager.php | 74 ++++++ src/Phaseolies/Auth/Security/Authenticate.php | 214 +++++++++++++----- .../Security/InteractsWithRememberCookie.php | 24 +- .../Security/InteractsWithTwoFactorAuth.php | 39 ++-- src/Phaseolies/Helpers/helpers.php | 18 +- .../Providers/FacadeServiceProvider.php | 6 +- 6 files changed, 285 insertions(+), 90 deletions(-) create mode 100644 src/Phaseolies/Auth/ActorManager.php diff --git a/src/Phaseolies/Auth/ActorManager.php b/src/Phaseolies/Auth/ActorManager.php new file mode 100644 index 00000000..f998d7be --- /dev/null +++ b/src/Phaseolies/Auth/ActorManager.php @@ -0,0 +1,74 @@ + + */ + protected array $actors = []; + + /** + * Get (or create) a named actor instance. + * + * @param string|null $name + * @return Authenticate + */ + public function actor(?string $name = null): Authenticate + { + $name ??= config('auth.default', 'web'); + + if (!isset($this->actors[$name])) { + $this->actors[$name] = $this->resolve($name); + } + + return $this->actors[$name]; + } + + /** + * Resolve a actor instance from its config entry. + * + * @param string $name + * @return Authenticate + * @throws \InvalidArgumentException + */ + protected function resolve(string $name): Authenticate + { + $config = config("auth.actors.{$name}"); + + if (!$config) { + throw new \InvalidArgumentException( + "Auth actor [{$name}] is not defined. Check your config/auth.php." + ); + } + + return new Authenticate($name, $config); + } + + /** + * Flush all resolved actor instances + * + * @return void + */ + public function forgetActors(): void + { + $this->actors = []; + } + + /** + * Allows auth()->check(), auth()->user(), etc. to keep working without explicitly calling auth()->actor('web')->... + * + * @param string $method + * @param array $args + * @return mixed + */ + public function __call(string $method, array $args): mixed + { + return $this->actor()->{$method}(...$args); + } +} diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index 419dc73a..1389c4b7 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -4,7 +4,6 @@ use Phaseolies\Support\Facades\Hash; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Support\Facades\Cache; use Phaseolies\Database\Entity\Model; class Authenticate @@ -17,36 +16,135 @@ class Authenticate private $data = []; /** - * The current stateless user (for onceUsingId) + * The name of this actor instance (e.g. "web", "admin"). + * + * @var string + */ + protected string $actorName; + + /** + * The actor configuration array from config/auth.php. + * + * @var array + */ + protected array $config; + + /** + * The current stateless user (for onceUsingId). * * @var Model|null */ private $statelessUser = null; /** - * Cache for user version checks during the current request + * Cache for user version checks during the current request. * * @var array */ private static $versionCheckCache = []; /** - * Get the current authenticated user + * Per-instance resolved user cache (replaces the static $currentUser so + * each actor maintains its own authenticated user independently). * * @var Model|null */ - private static ?Model $currentUser = null; + private ?Model $resolvedUser = null; + /** + * Create a new actor instance. + * + * @param string $actorName + * @param array $config + */ + public function __construct(string $actorName, array $config) + { + $this->actorName = $actorName; + $this->config = $config; + } + + /** + * Set a property on the actor. + * + * @param string $name + * @param mixed $value + */ public function __set($name, $value) { $this->data[$name] = $value; } + /** + * Get a property from the actor. + * + * @param string $name + * @return mixed + */ public function __get($name) { return $this->data[$name] ?? null; } + /** + * Resolve a fresh instance of the configured auth model. + * + * @return Model + */ + protected function getModel(): Model + { + return app($this->config['model']); + } + + /** + * The session key used to store the authenticated user's ID for this actor. + * + * @return string + */ + protected function getSessionKey(): string + { + return $this->config['session_key']; + } + + /** + * The session key used to cache the full user object for this actor. + * + * @return string + */ + protected function getCacheKey(): string + { + return 'cache_auth_' . $this->actorName; + } + + /** + * The session key used to store the "authenticated via remember token" flag. + * + * @return string + */ + protected function getViaRememberKey(): string + { + return 'auth_via_remember_' . $this->actorName; + } + + /** + * The session key used to store a pending 2FA user ID for this actor. + * + * @return string + */ + protected function getTwoFactorUserKey(): string + { + return '2fa_' . $this->actorName . '_user_id'; + } + + /** + * The session key used to store the 2FA remember flag for this actor. + * + * @return string + */ + protected function getTwoFactorRememberKey(): string + { + return '2fa_' . $this->actorName . '_remember'; + } + /** * Attempt to authenticate a user using credentials. * @@ -57,11 +155,11 @@ public function __get($name) */ public function try(array $credentials = [], bool $remember = false): bool { - $authModel = app(config('auth.model')); + $authModel = $this->getModel(); $customAuthKey = $authModel->getAuthKeyName(); $authKeyValue = $credentials[$customAuthKey] ?? ''; - $password = $credentials['password'] ?? ''; + $password = $credentials['password'] ?? ''; $user = $authModel::query()->where($customAuthKey, $authKeyValue)->first(); @@ -84,7 +182,7 @@ public function try(array $credentials = [], bool $remember = false): bool */ public function login($user, bool $remember = false): bool { - $authModel = app(config('auth.model')); + $authModel = $this->getModel(); if (!$user instanceof $authModel) { throw new \InvalidArgumentException( @@ -92,9 +190,9 @@ public function login($user, bool $remember = false): bool ); } - if ($this->hasTwoFactorEnabled($user) && ! $this->isApiRequest()) { - session()->put('2fa_user_id', $user->id); - session()->put('2fa_remember', $remember); + if ($this->hasTwoFactorEnabled($user) && !$this->isApiRequest()) { + session()->put($this->getTwoFactorUserKey(), $user->id); + session()->put($this->getTwoFactorRememberKey(), $remember); return true; } @@ -117,7 +215,7 @@ public function login($user, bool $remember = false): bool */ public function loginUsingId(int $id, bool $remember = false): ?Model { - $authModel = app(config('auth.model')); + $authModel = $this->getModel(); $user = $authModel::find($id); @@ -136,7 +234,7 @@ public function loginUsingId(int $id, bool $remember = false): ?Model */ public function onceUsingId(int $id): ?Model { - $authModel = app(config('auth.model')); + $authModel = $this->getModel(); $user = $authModel::find($id); @@ -156,16 +254,18 @@ public function onceUsingId(int $id): ?Model */ public function user(): ?Model { - if (self::$currentUser !== null) { - return self::$currentUser; + if ($this->resolvedUser !== null) { + return $this->resolvedUser; } - $authModel = app(config('auth.model')); - if ($this->statelessUser !== null) { return $this->statelessUser; } + $authModel = $this->getModel(); + $cacheKey = $this->getCacheKey(); + $sessionKey = $this->getSessionKey(); + if ($this->isApiRequest()) { $hasAuthApi = false; foreach (app('route')->getCurrentMiddlewareNames() ?? [] as $middleware) { @@ -176,27 +276,27 @@ public function user(): ?Model } if ($hasAuthApi) { - return self::$currentUser = $hasAuthApi + return $this->resolvedUser = $hasAuthApi ? app(\Doppar\Flarion\ApiAuthenticate::class)->user() : null; } } - if (session()->has('cache_auth_user')) { - $cache = session('cache_auth_user'); + if (session()->has($cacheKey)) { + $cache = session($cacheKey); if ($this->isUserCacheValid($cache)) { if ($this->isUserCacheValid($cache)) { - return self::$currentUser = $cache['user']; + return $this->resolvedUser = $cache['user']; } } } - if (session()->has('user')) { - $user = $authModel::find(session('user')); + if (session()->has($sessionKey)) { + $user = $authModel::find(session($sessionKey)); if ($user) { $this->cacheUser($user); - return self::$currentUser = $user; + return $this->resolvedUser = $user; } } @@ -234,13 +334,13 @@ public function user(): ?Model if (Hash::check($token, $user->remember_token)) { if ($this->hasTwoFactorEnabled($user)) { - session()->put('2fa_user_id', $user->id); - session()->put('2fa_remember', true); + session()->put($this->getTwoFactorUserKey(), $user->id); + session()->put($this->getTwoFactorRememberKey(), true); } $this->setUser($user); - return self::$currentUser = $user; + return $this->resolvedUser = $user; } // Token didn't match - possible theft attempt @@ -265,18 +365,11 @@ public function check(): bool /** * Logs the currently authenticated user out of the application. * - * This method: - * - Clears the user's `remember_token` to prevent future "remember me" logins. - * - Resets any stateless user data. - * - Removes relevant authentication and user-related data from the session. - * - Invalidates the current session and regenerates the CSRF token for security. - * - Deletes the "remember me" cookie if it exists. - * * @return void */ public function logout(): void { - $user = auth()->user(); + $user = $this->user(); if ($user && $user?->remember_token) { $user->remember_token = null; @@ -284,12 +377,19 @@ public function logout(): void $user->save(); } - session()->forget('user'); - session()->forget('auth_via_remember'); - session()->forget('cache_auth_user'); + $this->resolvedUser = null; + $this->statelessUser = null; - session()->invalidate(); - session()->regenerateToken(); + session()->forget($this->getSessionKey()); + session()->forget($this->getCacheKey()); + session()->forget($this->getViaRememberKey()); + + // Only fully invalidate the session on the default actor so that other + // actors (e.g. "admin") remain active when only the "web" actor logs out. + if ($this->actorName === config('auth.default', 'web')) { + session()->invalidate(); + session()->regenerateToken(); + } if (cookie()->has($this->getRememberCookieName())) { $this->expireRememberCookie(); @@ -303,9 +403,11 @@ public function logout(): void */ private function setUser(Model $user): void { - session()->put('user', $user->id); + session()->put($this->getSessionKey(), $user->id); $this->cacheUser($user); + + $this->resolvedUser = $user; } /** @@ -316,10 +418,10 @@ private function setUser(Model $user): void */ private function cacheUser(Model $user): void { - session()->put('cache_auth_user', [ - 'user' => $user, - 'version' => $user?->updated_at, - 'expires_at' => now()->addMinutes(30)->timestamp + session()->put($this->getCacheKey(), [ + 'user' => $user, + 'version' => $user?->updated_at, + 'expires_at' => now()->addMinutes(30)->timestamp, ]); } @@ -338,8 +440,8 @@ private function isUserCacheValid(array $cache): bool $userId = $cache['user']->id; // Check if we already verified this user's version during this request - if (isset(self::$versionCheckCache[$userId])) { - return $cache['version'] === self::$versionCheckCache[$userId]; + if (isset(self::$versionCheckCache[$this->actorName][$userId])) { + return $cache['version'] === self::$versionCheckCache[$this->actorName][$userId]; } $currentVersion = $cache['user']->newQuery() @@ -347,8 +449,8 @@ private function isUserCacheValid(array $cache): bool ->where('id', $userId) ->first(); - // Cache the version check result for this request - self::$versionCheckCache[$userId] = $currentVersion?->updated_at; + // Cache the version check result for this request, keyed per actor + self::$versionCheckCache[$this->actorName][$userId] = $currentVersion?->updated_at; return $cache['version'] === $currentVersion?->updated_at; } @@ -360,7 +462,7 @@ private function isUserCacheValid(array $cache): bool */ public function viaRemember(): bool { - return session('auth_via_remember', false) + return session($this->getViaRememberKey(), false) && cookie()->has($this->getRememberCookieName()); } @@ -371,7 +473,17 @@ public function viaRemember(): bool */ public function id(): ?int { - return auth()->user()->id ?? null; + return $this->user()?->id ?? null; + } + + /** + * Get the name of the actor. + * + * @return string + */ + public function name(): string + { + return $this->actorName; } /** diff --git a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php index 51351b40..beeb81f3 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php +++ b/src/Phaseolies/Auth/Security/InteractsWithRememberCookie.php @@ -24,7 +24,7 @@ protected function getRememberCookieName(): string { $appName = strtolower(str_replace(' ', '_', config('app.name', 'doppar'))); - return $this->rememberCookiePrefix . sha1($appName); + return $this->rememberCookiePrefix . $this->actorName . '_' . sha1($appName); } /** @@ -41,7 +41,7 @@ private function setRememberToken(Model $user): void $cookieValue = $user->id . '|' . $token . '|' . Hash::make($user->id . $token); - session()->put('auth_via_remember', true); + session()->put($this->getViaRememberKey(), true); $this->setRememberCookie($cookieValue); } @@ -63,12 +63,12 @@ protected function setRememberCookie(string $value): void $this->getRememberCookieName(), $encryptedValue, [ - 'expires' => time() + 60 * 60 * 24 * 30, - 'path' => config('session.path') ?? '/', - 'domain' => config('session.domain') ?? '', - 'secure' => request()->isSecure(), + 'expires' => time() + 60 * 60 * 24 * 30, + 'path' => config('session.path') ?? '/', + 'domain' => config('session.domain') ?? '', + 'secure' => request()->isSecure(), 'httponly' => true, - 'samesite' => config('session.same_site', 'Lax') + 'samesite' => config('session.same_site', 'Lax'), ] ); } @@ -83,12 +83,12 @@ protected function expireRememberCookie(): void $cookieName = $this->getRememberCookieName(); setcookie($cookieName, '', [ - 'expires' => time() - 3600, - 'path' => '/', - 'domain' => config('session.domain') ?? '', - 'secure' => request()->isSecure(), + 'expires' => time() - 3600, + 'path' => '/', + 'domain' => config('session.domain') ?? '', + 'secure' => request()->isSecure(), 'httponly' => true, - 'samesite' => 'lax' + 'samesite' => 'lax', ]); cookie()->remove($cookieName); diff --git a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php index 11a4e989..6c5993f8 100644 --- a/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php +++ b/src/Phaseolies/Auth/Security/InteractsWithTwoFactorAuth.php @@ -7,7 +7,6 @@ use Symfony\Component\Clock\NativeClock; use Psr\Clock\ClockInterface; use Phaseolies\Support\Facades\Crypt; -use Phaseolies\Support\Facades\Auth; use Phaseolies\Database\Entity\Model; use ParagonIE\ConstantTime\Base32; use OTPHP\TOTP; @@ -31,7 +30,7 @@ protected function getClock(): ClockInterface */ public function enableTwoFactorAuth(): array { - $user = Auth::user(); + $user = $this->user(); if (!is_null($user->two_factor_secret)) { throw new \Exception("2FA Already enabled"); @@ -44,11 +43,11 @@ public function enableTwoFactorAuth(): array 30, 'sha1', 6, - auth()->id(), + $this->id(), $this->getClock() ); - $host = parse_url(config('app.url'), PHP_URL_HOST); + $host = parse_url(config('app.url'), PHP_URL_HOST); $issuer = preg_replace('/[^a-zA-Z0-9.\-_]/', '', $host); $totp->setLabel(strtolower(trim(config('app.name')))); @@ -56,13 +55,13 @@ public function enableTwoFactorAuth(): array $recoveryCodes = $this->generateRecoveryCodes(); - $user->two_factor_secret = Crypt::encrypt($secret); + $user->two_factor_secret = Crypt::encrypt($secret); $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($recoveryCodes)); $user->save(); return [ - 'secret' => $secret, - 'qr_code_url' => $totp->getProvisioningUri(), + 'secret' => $secret, + 'qr_code_url' => $totp->getProvisioningUri(), 'recovery_codes' => $recoveryCodes, ]; } @@ -74,9 +73,9 @@ public function enableTwoFactorAuth(): array */ public function disableTwoFactorAuth(): bool { - $user = Auth::user(); + $user = $this->user(); - $user->two_factor_secret = null; + $user->two_factor_secret = null; $user->two_factor_recovery_codes = null; return $user->save(); @@ -105,8 +104,8 @@ protected function generateRecoveryCodes(): array */ public function verifyTwoFactorCode(string $code): bool { - $authModel = app(config('auth.model')); - $user = $authModel::find(session('2fa_user_id')); + $authModel = $this->getModel(); + $user = $authModel::find(session($this->getTwoFactorUserKey())); if (is_null($user->two_factor_secret)) { return false; @@ -120,7 +119,7 @@ public function verifyTwoFactorCode(string $code): bool 30, 'sha1', 6, - session('2fa_user_id'), + session($this->getTwoFactorUserKey()), $this->getClock() ); @@ -172,7 +171,7 @@ public function generateNewRecoveryCodes(): array { $recoveryCodes = $this->generateRecoveryCodes(); - $user = Auth::user(); + $user = $this->user(); $user->two_factor_recovery_codes = Crypt::encrypt(json_encode($recoveryCodes)); $user->save(); @@ -197,14 +196,14 @@ public function hasTwoFactorEnabled(Model $user): bool */ public function completeTwoFactorLogin(): bool { - $authModel = app(config('auth.model')); - $user = $authModel::find(session('2fa_user_id')); - $remember = session('2fa_remember'); + $authModel = $this->getModel(); + $user = $authModel::find(session($this->getTwoFactorUserKey())); + $remember = session($this->getTwoFactorRememberKey()); $this->setUser($user); - session()->forget('2fa_user_id'); - session()->forget('2fa_remember'); + session()->forget($this->getTwoFactorUserKey()); + session()->forget($this->getTwoFactorRememberKey()); if ($remember) { if (!$this->viaRemember()) { @@ -226,8 +225,8 @@ public function generateTwoFactorQrCode(string $qrCodeUrl): string { $options = new QROptions([ 'version' => 10, - 'outputType' => QRCode::OUTPUT_MARKUP_SVG, - 'eccLevel' => QRCode::ECC_M, + 'outputType' => QRCode::OUTPUT_MARKUP_SVG, + 'eccLevel' => QRCode::ECC_M, 'addQuietzone' => true, ]); diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index 3988c1e8..34d54d8c 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -19,6 +19,7 @@ use Phaseolies\Cache\RateLimiter; use Phaseolies\Auth\Security\Authenticate; use Carbon\Carbon; +use Phaseolies\Auth\ActorManager; if (!function_exists('env')) { /** @@ -90,13 +91,22 @@ function request($key = null, $default = null): mixed if (!function_exists('auth')) { /** - * Creates a new Authenticate instance + * Get the ActorManager, or resolve a specific actor by name. * - * @return Authenticate + * Usage: + * auth() → ActorManager (proxies calls to the default actor) + * auth('web') → Authenticate instance for the "web" actor + * auth('admin') → Authenticate instance for the "admin" actor + * + * @param string|null $actor + * @return ActorManager|Authenticate */ - function auth(): Authenticate + function auth(?string $actor = null): ActorManager|Authenticate { - return app('auth'); + /** @var ActorManager $manager */ + $manager = app(ActorManager::class); + + return $actor !== null ? $manager->actor($actor) : $manager; } } diff --git a/src/Phaseolies/Providers/FacadeServiceProvider.php b/src/Phaseolies/Providers/FacadeServiceProvider.php index 196369a4..c3c88cb5 100644 --- a/src/Phaseolies/Providers/FacadeServiceProvider.php +++ b/src/Phaseolies/Providers/FacadeServiceProvider.php @@ -19,8 +19,8 @@ use Phaseolies\Console\Schedule\SchedulePool; use Phaseolies\Config\Config; use Phaseolies\Auth\Security\PasswordHashing; -use Phaseolies\Auth\Security\Authenticate; use Phaseolies\Application; +use Phaseolies\Auth\ActorManager; class FacadeServiceProvider extends ServiceProvider { @@ -47,9 +47,9 @@ public function register() // This provides password hashing and verification functionality. $this->app->singleton('hash', PasswordHashing::class); - // Bind the 'auth' service to a singleton instance of the Authenticate class. + // Bind the 'auth' service to a singleton instance of the GuardManager class. // This handles user authentication and authorization. - $this->app->singleton('auth', Authenticate::class); + $this->app->singleton('auth', ActorManager::class); // Bind the 'crypt' service to a singleton instance of the Encryption class. // This provides encryption and decryption functionality. From 9cd7ea0cdd4264eb8b8c2157f79455249d2d4914 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 13 Mar 2026 22:54:37 +0600 Subject: [PATCH 2/2] remove unnecessary comments --- src/Phaseolies/Auth/Security/Authenticate.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Phaseolies/Auth/Security/Authenticate.php b/src/Phaseolies/Auth/Security/Authenticate.php index 1389c4b7..ecac8751 100644 --- a/src/Phaseolies/Auth/Security/Authenticate.php +++ b/src/Phaseolies/Auth/Security/Authenticate.php @@ -44,8 +44,7 @@ class Authenticate private static $versionCheckCache = []; /** - * Per-instance resolved user cache (replaces the static $currentUser so - * each actor maintains its own authenticated user independently). + * Per-instance resolved user cache * * @var Model|null */ @@ -439,7 +438,6 @@ private function isUserCacheValid(array $cache): bool $userId = $cache['user']->id; - // Check if we already verified this user's version during this request if (isset(self::$versionCheckCache[$this->actorName][$userId])) { return $cache['version'] === self::$versionCheckCache[$this->actorName][$userId]; } @@ -449,7 +447,6 @@ private function isUserCacheValid(array $cache): bool ->where('id', $userId) ->first(); - // Cache the version check result for this request, keyed per actor self::$versionCheckCache[$this->actorName][$userId] = $currentVersion?->updated_at; return $cache['version'] === $currentVersion?->updated_at;