diff --git a/phpstan.neon b/phpstan.neon index 4d3ae25..500b1bd 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,9 +5,6 @@ parameters: dynamicConstantNames: - YII_DEBUG - YII_ENV - - YII_ENV_DEV - - YII_ENV_PROD - - YII_ENV_TEST level: 5 diff --git a/src/Framework/Repository/FinderAccountRepository.php b/src/Framework/Repository/FinderAccountRepository.php index 4de4a0b..9374bfa 100644 --- a/src/Framework/Repository/FinderAccountRepository.php +++ b/src/Framework/Repository/FinderAccountRepository.php @@ -16,17 +16,21 @@ public function __construct( ) { } - public function findByEmail(string $email): ActiveRecordInterface|array|null + public function findByEmail(string $email): Account|null { - return $this->finderRepository->findByOneCondition($this->account, ['email' => $email]); + $account = $this->finderRepository->findByOneCondition($this->account, ['email' => $email]); + + return $account instanceof Account ? $account : null; } - public function findByUsername(string $username): ActiveRecordInterface|array|null + public function findByUsername(string $username): Account|null { - return $this->finderRepository->findByOneCondition($this->account, ['username' => $username]); + $account = $this->finderRepository->findByOneCondition($this->account, ['username' => $username]); + + return $account instanceof Account ? $account : null; } - public function findByUsernameOrEmail(string $usernameOrEmail): ActiveRecordInterface|array|null + public function findByUsernameOrEmail(string $usernameOrEmail): Account|null { return filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL) ? $this->findByEmail($usernameOrEmail) diff --git a/src/Service/TokenToUrl.php b/src/Service/TokenToUrl.php index 806cd74..6e39875 100644 --- a/src/Service/TokenToUrl.php +++ b/src/Service/TokenToUrl.php @@ -57,7 +57,7 @@ private function register(int $id, int $type): bool { $account = $this->finderRepository->findById($this->account, $id); - if ($account === null || $account === []) { + if ($account === null) { throw new RuntimeException('Invalid user identity.'); } diff --git a/src/UseCase/Login/LoginController.php b/src/UseCase/Login/LoginController.php index 2bfdc74..237557c 100644 --- a/src/UseCase/Login/LoginController.php +++ b/src/UseCase/Login/LoginController.php @@ -28,12 +28,13 @@ final class LoginController extends Controller public function __construct( $id, Module $module, + private readonly Account $account, private readonly AjaxValidator $ajaxValidator, private readonly FinderAccountRepository $finderAccountRepository, + private readonly LoginService $loginService, private readonly PasswordHasher $passwordHasher, private readonly User $user, private readonly UserModule $userModule, - private readonly LoginService $loginService, array $config = [] ) { parent::__construct($id, $module, $config); @@ -51,32 +52,36 @@ public function behaviors(): array 'roles' => ['?'], ], ], - 'denyCallback' => function () { - if ($this->user->getIsGuest() === false) { - return $this->goHome(); - } - }, ], ]; } public function actionIndex(): Response|string { - $account = null; - - if ( - $this->request instanceof Request && - $this->request->getIsPost() === true && - $this->request->post('LoginForm') !== null - ) { - $login = $this->request->post('LoginForm')['login'] ?? ''; - /** @var Account|null $account */ - $account = $this->finderAccountRepository->findByUsernameOrEmail($login); + if ($this->user->getIsGuest() === false) { + return $this->goHome(); } - $loginForm = new $this->formModelClass($account, $this->passwordHasher, $this->userModule); + $loginForm = new $this->formModelClass( + $this->account, + $this->finderAccountRepository, + $this->passwordHasher, + $this->userModule, + ); $event = new LoginEvent($loginForm, $this->userModule); + if ($this->userModule->allowLogin === false && $this->userModule->allowLoginByIPs === []) { + $this->trigger(LoginEvent::MODULE_DISABLE, $event); + + return $this->goHome(); + } + + if ($this->userModule->allowLoginByIPs !== [] && !$this->loginService->checkAllowedIp($this->request->userIP)) { + $this->trigger(LoginEvent::IP_NOT_ALLOWED, $event); + + return $this->goHome(); + } + $this->trigger(LoginEvent::BEFORE_LOGIN, $event); $this->ajaxValidator->validate($loginForm); @@ -84,7 +89,7 @@ public function actionIndex(): Response|string $this->request instanceof Request && $loginForm->load($this->request->post()) && $loginForm->validate() && - $this->loginService->run($account, $loginForm->autoLogin()) + $this->loginService->run($loginForm) ) { $this->trigger(LoginEvent::AFTER_LOGIN, $event); diff --git a/src/UseCase/Login/LoginEvent.php b/src/UseCase/Login/LoginEvent.php index 5530ec7..720871a 100644 --- a/src/UseCase/Login/LoginEvent.php +++ b/src/UseCase/Login/LoginEvent.php @@ -12,6 +12,8 @@ final class LoginEvent extends Event { public const AFTER_LOGIN = 'afterLogin'; public const BEFORE_LOGIN = 'beforeLogin'; + public const IP_NOT_ALLOWED = 'ipNotAllowed'; + public const MODULE_DISABLE = 'moduleDisable'; public function __construct( public readonly Model $model, diff --git a/src/UseCase/Login/LoginForm.php b/src/UseCase/Login/LoginForm.php index c1d44e9..ad090de 100644 --- a/src/UseCase/Login/LoginForm.php +++ b/src/UseCase/Login/LoginForm.php @@ -7,6 +7,7 @@ use Yii; use yii\base\Model; use Yii\User\ActiveRecord\Account; +use Yii\User\Framework\Repository\FinderAccountRepository; use Yii\User\UserModule; use Yiisoft\Security\PasswordHasher; @@ -17,7 +18,8 @@ final class LoginForm extends Model public int $rememberMe = 0; public function __construct( - private readonly Account|null $account, + public Account|null $account, + private readonly FinderAccountRepository $finderAccountRepository, private readonly PasswordHasher $passwordHasher, private readonly UserModule $userModule, array $config = [] @@ -41,13 +43,6 @@ public function rules(): array ['login', 'string', 'min' => 3, 'max' => 255], ['login', 'match', 'pattern' => $this->userModule->usernameRegex], ['login', 'required'], - [ - 'login', - function (string $attribute): void { - $this->addError($attribute, Yii::t('yii.user', 'Invalid login or password.')); - }, - 'when' => fn (): bool => $this->account === null, - ], // password validate ['password', 'trim'], ['password', 'string', 'min' => 6, 'max' => 72], @@ -57,8 +52,7 @@ function (string $attribute): void { function (string $attribute): void { $this->addError($attribute, Yii::t('yii.user', 'Invalid login or password.')); }, - 'when' => fn (): bool => $this->account === null || - $this->passwordHasher->validate($this->password, $this->account->password_hash) === false, + 'when' => fn (): bool => $this->validatePassword(), ], ]; } @@ -67,4 +61,15 @@ public function autoLogin(): int { return $this->rememberMe ?: 0; } + + private function validatePassword(): bool + { + $this->account = $this->finderAccountRepository->findByUsernameOrEmail($this->login); + + if ($this->account === null || $this->account->password_hash === null) { + return false; + } + + return $this->passwordHasher->validate($this->password, $this->account->password_hash) === false; + } } diff --git a/src/UseCase/Login/LoginService.php b/src/UseCase/Login/LoginService.php index f95102c..8ea3086 100644 --- a/src/UseCase/Login/LoginService.php +++ b/src/UseCase/Login/LoginService.php @@ -5,23 +5,42 @@ namespace Yii\User\UseCase\Login; use Yii\CoreLibrary\Repository\PersistenceRepositoryInterface; -use Yii\User\ActiveRecord\Account; +use yii\helpers\IpHelper; +use Yii\User\UserModule; use yii\web\User; final class LoginService { public function __construct( private readonly PersistenceRepositoryInterface $persistenceRepository, - private readonly User $user + private readonly User $user, + private readonly UserModule $userModule, ) { } - public function run(Account $account, int $autoLogin = 0): bool + public function run(LoginForm $loginForm): bool { - if ($this->user->login($account->identity, $autoLogin)) { + if ($loginForm->account->identity === null) { + return false; + } + + $account = $loginForm->account; + + if ($this->user->login($account->identity, $loginForm->autoLogin())) { $result = $this->persistenceRepository->updateAtttributes($account, ['last_login_at' => time()]); } return $result ?? false; } + + public function checkAllowedIp(string $userIP): bool + { + foreach ($this->userModule->allowLoginByIPs as $allowedIP) { + if (IpHelper::inRange($userIP, $allowedIP)) { + return true; + } + } + + return false; + } } diff --git a/src/UseCase/Login/view/index.php b/src/UseCase/Login/view/index.php index f78c987..75c3614 100644 --- a/src/UseCase/Login/view/index.php +++ b/src/UseCase/Login/view/index.php @@ -107,7 +107,7 @@ ) ?> - register) : ?> + allowRegister) : ?> class('mt-3 text-center') diff --git a/src/UseCase/Register/RegisterController.php b/src/UseCase/Register/RegisterController.php index 23b6a92..2aa84a3 100644 --- a/src/UseCase/Register/RegisterController.php +++ b/src/UseCase/Register/RegisterController.php @@ -59,7 +59,7 @@ public function actionIndex(): Response|string $registerForm = new $this->formModelClass($this->userModule); $event = new RegisterEvent($registerForm, $this->userModule); - if ($this->userModule->register === false) { + if ($this->userModule->allowRegister === false) { $this->trigger(RegisterEvent::MODULE_DISABLE, $event); return $this->goHome(); diff --git a/src/UserModule.php b/src/UserModule.php index e7068c6..124a2c5 100644 --- a/src/UserModule.php +++ b/src/UserModule.php @@ -56,6 +56,9 @@ final class UserModule public readonly string $urlConfirmation; public function __construct( + public readonly bool $allowLogin = true, + public readonly array $allowLoginByIPs = [], + public readonly bool $allowRegister = true, public readonly int $autoLogin = 1209600, public readonly bool $confirmation = false, public readonly bool $floatLabels = true, @@ -67,7 +70,6 @@ public function __construct( public readonly array $mailerWelcomeLayout = ['html' => 'welcome', 'text' => 'text/welcome'], string|null $mailerWelcomeSubject = null, public readonly bool $passwordRecovery = true, - public readonly bool $register = true, public readonly bool $showPassword = false, public readonly int $token2FAWithin = 3600, public readonly int $tokenConfirmWithin = 86400, diff --git a/tests/Acceptance/LoginCest.php b/tests/Acceptance/LoginCest.php index bc51290..b282953 100644 --- a/tests/Acceptance/LoginCest.php +++ b/tests/Acceptance/LoginCest.php @@ -11,6 +11,52 @@ final class LoginCest { + public function allowLogin(AcceptanceTester $I): void + { + $I->amGoingTo('disable login page.'); + $I->allowLogin(false); + + $I->amGoingTo('go to the page login.'); + $I->amOnRoute('login/index'); + + $I->expectTo('see message home page.'); + $I->see(Yii::t('app.basic', 'Web Application')); + + $I->amGoingTo('enable login page.'); + $I->allowLogin(true); + } + + public function allowLoginByIps(AcceptanceTester $I): void + { + $I->amGoingTo('allow login by ips.'); + $I->allowLoginByIPs(false, ['127.0.0.1', '127.0.0.2', '127.0.0.3']); + + $I->amGoingTo('go to the page login.'); + $I->amOnRoute('login/index'); + + $I->expectTo('see message login page.'); + $I->see(Yii::t('yii.user', 'Sign in'), 'h1'); + $I->see(Yii::t('yii.user', 'Please fill out the following fields to Sign in.')); + + $I->amGoingTo('disable login by ips.'); + $I->allowLogin(true, []); + } + + public function allowLoginByIpsFailed(AcceptanceTester $I): void + { + $I->amGoingTo('allow login by ips.'); + $I->allowLoginByIPs(false, ['172.0.0.1']); + + $I->amGoingTo('go to the page login.'); + $I->amOnRoute('login/index'); + + $I->expectTo('see message home page.'); + $I->see(Yii::t('app.basic', 'Web Application')); + + $I->amGoingTo('disable login by ips.'); + $I->allowLogin(true, []); + } + public function indexPage(AcceptanceTester $I): void { $I->amGoingTo('navigate to the login page.'); diff --git a/tests/Acceptance/RegisterCest.php b/tests/Acceptance/RegisterCest.php index f341f65..b6319f0 100644 --- a/tests/Acceptance/RegisterCest.php +++ b/tests/Acceptance/RegisterCest.php @@ -9,19 +9,19 @@ final class RegisterCest { - public function disablePage(AcceptanceTester $I): void + public function allowRegister(AcceptanceTester $I): void { $I->amGoingTo('disable register page.'); - $I->accountRegister(false); + $I->allowRegister(false); - $I->amGoingTo('go to the page registration.'); + $I->amGoingTo('go to the page register.'); $I->amOnRoute('register/index'); - $I->expectTo('see message register disabled.'); + $I->expectTo('see message home page.'); $I->see(Yii::t('app.basic', 'Web Application')); $I->amGoingTo('enable register page.'); - $I->accountRegister(true); + $I->allowRegister(true); } public function indexPage(AcceptanceTester $I): void diff --git a/tests/Support/AcceptanceTester.php b/tests/Support/AcceptanceTester.php index 575a90b..151db5d 100644 --- a/tests/Support/AcceptanceTester.php +++ b/tests/Support/AcceptanceTester.php @@ -51,13 +51,38 @@ public function accountGeneratePassword(bool $option): void ); } - public function accountRegister(bool $option): void + public function allowLogin(bool $option): void { \Yii::$container->set( \Yii\User\UserModule::class, [ '__construct()' => [ - 'register' => $option, + 'allowLogin' => $option, + ], + ], + ); + } + + public function allowLoginByIPs(bool $allowLogin, array $ips): void + { + \Yii::$container->set( + \Yii\User\UserModule::class, + [ + '__construct()' => [ + 'allowLogin' => $allowLogin, + 'allowLoginByIPs' => $ips, + ], + ], + ); + } + + public function allowRegister(bool $option): void + { + \Yii::$container->set( + \Yii\User\UserModule::class, + [ + '__construct()' => [ + 'allowRegister' => $option, ], ], );