mirror of
				https://github.com/elyby/accounts.git
				synced 2025-05-31 14:11:46 +05:30 
			
		
		
		
	Extract login logics into a separate component. Not quite clean result but enough for upcoming tasks
This commit is contained in:
		| @@ -9,7 +9,6 @@ use Carbon\Carbon; | ||||
| use common\models\Account; | ||||
| use common\models\AccountSession; | ||||
| use DateTime; | ||||
| use Lcobucci\JWT\Token; | ||||
| use Lcobucci\JWT\UnencryptedToken; | ||||
| use League\OAuth2\Server\Entities\AccessTokenEntityInterface; | ||||
| use League\OAuth2\Server\Entities\ScopeEntityInterface; | ||||
|   | ||||
| @@ -1,18 +1,35 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\controllers; | ||||
|  | ||||
| use api\models\authentication\ForgotPasswordForm; | ||||
| use api\models\authentication\LoginForm; | ||||
| use api\models\authentication\LogoutForm; | ||||
| use api\models\authentication\RecoverPasswordForm; | ||||
| use api\models\authentication\RefreshTokenForm; | ||||
| use common\components\Authentication\Entities\Credentials; | ||||
| use common\components\Authentication\Exceptions; | ||||
| use common\components\Authentication\Exceptions\AuthenticationException; | ||||
| use common\components\Authentication\LoginServiceInterface; | ||||
| use common\helpers\Error as E; | ||||
| use common\helpers\StringHelper; | ||||
| use DateTimeImmutable; | ||||
| use Yii; | ||||
| use yii\base\Module; | ||||
| use yii\filters\AccessControl; | ||||
| use yii\helpers\ArrayHelper; | ||||
| use yii\web\Request; | ||||
|  | ||||
| class AuthenticationController extends Controller { | ||||
| final class AuthenticationController extends Controller { | ||||
|  | ||||
|     public function __construct( | ||||
|         string $id, | ||||
|         Module $module, | ||||
|         private readonly LoginServiceInterface $loginService, | ||||
|         array $config = [], | ||||
|     ) { | ||||
|         parent::__construct($id, $module, $config); | ||||
|     } | ||||
|  | ||||
|     public function behaviors(): array { | ||||
|         return ArrayHelper::merge(parent::behaviors(), [ | ||||
| @@ -38,7 +55,7 @@ class AuthenticationController extends Controller { | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     public function verbs() { | ||||
|     public function verbs(): array { | ||||
|         return [ | ||||
|             'login' => ['POST'], | ||||
|             'logout' => ['POST'], | ||||
| @@ -48,30 +65,63 @@ class AuthenticationController extends Controller { | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function actionLogin(): array { | ||||
|         $model = new LoginForm(); | ||||
|         $model->load(Yii::$app->request->post()); | ||||
|         if (($result = $model->login()) === null) { | ||||
|     public function actionLogin(Request $request): array { | ||||
|         $form = new LoginForm(); | ||||
|         $form->load($request->post()); | ||||
|         if (!$form->validate()) { | ||||
|             return [ | ||||
|                 'success' => false, | ||||
|                 'errors' => $form->getFirstErrors(), | ||||
|             ]; | ||||
|         } | ||||
|  | ||||
|         try { | ||||
|             $loginResult = $this->loginService->loginByCredentials(new Credentials( | ||||
|                 login: (string)$form->login, | ||||
|                 password: (string)$form->password, | ||||
|                 totp: (string)$form->totp, | ||||
|                 rememberMe: (bool)$form->rememberMe, | ||||
|             )); | ||||
|         } catch (AuthenticationException $e) { | ||||
|             $data = [ | ||||
|                 'success' => false, | ||||
|                 'errors' => $model->getFirstErrors(), | ||||
|                 'errors' => match ($e::class) { | ||||
|                     Exceptions\UnknownLoginException::class => ['login' => E::LOGIN_NOT_EXIST], | ||||
|                     Exceptions\InvalidPasswordException::class => ['password' => E::PASSWORD_INCORRECT], | ||||
|                     Exceptions\TotpRequiredException::class => ['totp' => E::TOTP_REQUIRED], | ||||
|                     Exceptions\InvalidTotpException::class => ['totp' => E::TOTP_INCORRECT], | ||||
|                     Exceptions\AccountBannedException::class => ['login' => E::ACCOUNT_BANNED], | ||||
|                     Exceptions\AccountNotActivatedException::class => ['login' => E::ACCOUNT_NOT_ACTIVATED], | ||||
|                     default => $e->getMessage(), | ||||
|                 }, | ||||
|             ]; | ||||
|  | ||||
|             if (ArrayHelper::getValue($data['errors'], 'login') === E::ACCOUNT_NOT_ACTIVATED) { | ||||
|                 $data['data']['email'] = $model->getAccount()->email; | ||||
|             if ($e instanceof Exceptions\AccountNotActivatedException) { | ||||
|                 $data['data']['email'] = $e->account->email; | ||||
|             } | ||||
|  | ||||
|             return $data; | ||||
|         } | ||||
|  | ||||
|         return array_merge([ | ||||
|         $token = Yii::$app->tokensFactory->createForWebAccount($loginResult->account, $loginResult->session); | ||||
|         $data = [ | ||||
|             'success' => true, | ||||
|         ], $result->formatAsOAuth2Response()); | ||||
|             'access_token' => $token->toString(), | ||||
|             'expires_in' => $token->claims()->get('exp')->getTimestamp() - (new DateTimeImmutable())->getTimestamp(), | ||||
|         ]; | ||||
|  | ||||
|         if ($loginResult->session) { | ||||
|             $data['refresh_token'] = $loginResult->session->refresh_token; | ||||
|         } | ||||
|  | ||||
|         return $data; | ||||
|     } | ||||
|  | ||||
|     public function actionLogout(): array { | ||||
|         $form = new LogoutForm(); | ||||
|         $form->logout(); | ||||
|         $session = Yii::$app->user->getActiveSession(); | ||||
|         if ($session) { | ||||
|             $this->loginService->logout($session); | ||||
|         } | ||||
|  | ||||
|         return [ | ||||
|             'success' => true, | ||||
|   | ||||
| @@ -6,7 +6,6 @@ namespace api\eventListeners; | ||||
| use api\controllers\AuthenticationController; | ||||
| use api\controllers\SignupController; | ||||
| use api\modules\accounts\actions; | ||||
| use Closure; | ||||
| use Yii; | ||||
| use yii\base\ActionEvent; | ||||
| use yii\base\BootstrapInterface; | ||||
| @@ -16,8 +15,8 @@ use yii\base\Event; | ||||
| final class LogMetricsToStatsd implements BootstrapInterface { | ||||
|  | ||||
|     public function bootstrap($app): void { | ||||
|         Event::on(Controller::class, Controller::EVENT_BEFORE_ACTION, Closure::fromCallable([$this, 'beforeAction'])); | ||||
|         Event::on(Controller::class, Controller::EVENT_AFTER_ACTION, Closure::fromCallable([$this, 'afterAction'])); | ||||
|         Event::on(Controller::class, Controller::EVENT_BEFORE_ACTION, $this->beforeAction(...)); | ||||
|         Event::on(Controller::class, Controller::EVENT_AFTER_ACTION, $this->afterAction(...)); | ||||
|     } | ||||
|  | ||||
|     private function beforeAction(ActionEvent $event): void { | ||||
|   | ||||
| @@ -6,6 +6,7 @@ namespace api\models\authentication; | ||||
| use DateTimeImmutable; | ||||
| use Lcobucci\JWT\UnencryptedToken; | ||||
|  | ||||
| // TODO: remove this class | ||||
| final readonly class AuthenticationResult { | ||||
|  | ||||
|     public function __construct( | ||||
|   | ||||
| @@ -4,14 +4,9 @@ declare(strict_types=1); | ||||
| namespace api\models\authentication; | ||||
|  | ||||
| use api\models\base\ApiForm; | ||||
| use api\validators\TotpValidator; | ||||
| use common\helpers\Error as E; | ||||
| use common\models\Account; | ||||
| use common\models\AccountSession; | ||||
| use Webmozart\Assert\Assert; | ||||
| use Yii; | ||||
|  | ||||
| class LoginForm extends ApiForm { | ||||
| final class LoginForm extends ApiForm { | ||||
|  | ||||
|     public mixed $login = null; | ||||
|  | ||||
| @@ -24,97 +19,10 @@ class LoginForm extends ApiForm { | ||||
|     public function rules(): array { | ||||
|         return [ | ||||
|             ['login', 'required', 'message' => E::LOGIN_REQUIRED], | ||||
|             ['login', 'validateLogin'], | ||||
|  | ||||
|             ['password', 'required', 'when' => fn(self $model): bool => !$model->hasErrors(), 'message' => E::PASSWORD_REQUIRED], | ||||
|             ['password', 'validatePassword'], | ||||
|  | ||||
|             ['totp', 'required', 'when' => fn(self $model): bool => !$model->hasErrors() && $model->getAccount()->is_otp_enabled, 'message' => E::TOTP_REQUIRED], | ||||
|             ['totp', 'validateTotp'], | ||||
|  | ||||
|             ['login', 'validateActivity'], | ||||
|  | ||||
|             ['password', 'required', 'isEmpty' => fn($value) => $value === null, 'message' => E::PASSWORD_REQUIRED], | ||||
|             ['totp', 'string'], | ||||
|             ['rememberMe', 'boolean'], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function validateLogin(string $attribute): void { | ||||
|         if (!$this->hasErrors() && $this->getAccount() === null) { | ||||
|             $this->addError($attribute, E::LOGIN_NOT_EXIST); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function validatePassword(string $attribute): void { | ||||
|         if (!$this->hasErrors()) { | ||||
|             $account = $this->getAccount(); | ||||
|             if ($account === null || !$account->validatePassword($this->password)) { | ||||
|                 $this->addError($attribute, E::PASSWORD_INCORRECT); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function validateTotp(string $attribute): void { | ||||
|         if ($this->hasErrors()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         /** @var Account $account */ | ||||
|         $account = $this->getAccount(); | ||||
|         if (!$account->is_otp_enabled) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $validator = new TotpValidator(['account' => $account]); | ||||
|         $validator->validateAttribute($this, $attribute); | ||||
|     } | ||||
|  | ||||
|     public function validateActivity(string $attribute): void { | ||||
|         if (!$this->hasErrors()) { | ||||
|             /** @var Account $account */ | ||||
|             $account = $this->getAccount(); | ||||
|             if ($account->status === Account::STATUS_BANNED) { | ||||
|                 $this->addError($attribute, E::ACCOUNT_BANNED); | ||||
|             } | ||||
|  | ||||
|             if ($account->status === Account::STATUS_REGISTERED) { | ||||
|                 $this->addError($attribute, E::ACCOUNT_NOT_ACTIVATED); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** @noinspection PhpIncompatibleReturnTypeInspection */ | ||||
|     public function getAccount(): ?Account { | ||||
|         return Account::find()->andWhereLogin($this->login)->one(); | ||||
|     } | ||||
|  | ||||
|     public function login(): ?AuthenticationResult { | ||||
|         if (!$this->validate()) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         $transaction = Yii::$app->db->beginTransaction(); | ||||
|  | ||||
|         /** @var Account $account */ | ||||
|         $account = $this->getAccount(); | ||||
|         if ($account->password_hash_strategy !== Account::PASS_HASH_STRATEGY_YII2) { | ||||
|             $account->setPassword($this->password); | ||||
|             Assert::true($account->save(), 'Unable to upgrade user\'s password'); | ||||
|         } | ||||
|  | ||||
|         $session = null; | ||||
|         if ($this->rememberMe) { | ||||
|             $session = new AccountSession(); | ||||
|             $session->account_id = $account->id; | ||||
|             $session->setIp(Yii::$app->request->userIP); | ||||
|             $session->generateRefreshToken(); | ||||
|             Assert::true($session->save(), 'Cannot save account session model'); | ||||
|         } | ||||
|  | ||||
|         $token = Yii::$app->tokensFactory->createForWebAccount($account, $session); | ||||
|  | ||||
|         $transaction->commit(); | ||||
|  | ||||
|         return new AuthenticationResult($token, $session?->refresh_token); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,23 +0,0 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\models\authentication; | ||||
|  | ||||
| use api\models\base\ApiForm; | ||||
| use Yii; | ||||
|  | ||||
| class LogoutForm extends ApiForm { | ||||
|  | ||||
|     public function logout(): bool { | ||||
|         $component = Yii::$app->user; | ||||
|         $session = $component->getActiveSession(); | ||||
|         if ($session === null) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         $session->delete(); | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -5,7 +5,7 @@ namespace api\models\base; | ||||
|  | ||||
| use yii\base\Model; | ||||
|  | ||||
| class ApiForm extends Model { | ||||
| abstract class ApiForm extends Model { | ||||
|  | ||||
|     public function formName(): string { | ||||
|         return ''; | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\modules\accounts\models; | ||||
|  | ||||
| use api\validators\PasswordRequiredValidator; | ||||
| @@ -8,13 +10,13 @@ use Webmozart\Assert\Assert; | ||||
|  | ||||
| class DisableTwoFactorAuthForm extends AccountActionForm { | ||||
|  | ||||
|     public $totp; | ||||
|     public mixed $totp = null; | ||||
|  | ||||
|     public $password; | ||||
|     public mixed $password = null; | ||||
|  | ||||
|     public function rules(): array { | ||||
|         return [ | ||||
|             ['account', 'validateOtpEnabled'], | ||||
|             ['account', $this->validateOtpEnabled(...)], | ||||
|             ['totp', 'required', 'message' => E::TOTP_REQUIRED], | ||||
|             ['totp', TotpValidator::class, 'account' => $this->getAccount()], | ||||
|             ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()], | ||||
| @@ -34,7 +36,7 @@ class DisableTwoFactorAuthForm extends AccountActionForm { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public function validateOtpEnabled($attribute): void { | ||||
|     private function validateOtpEnabled(string $attribute): void { | ||||
|         if (!$this->getAccount()->is_otp_enabled) { | ||||
|             $this->addError($attribute, E::OTP_NOT_ENABLED); | ||||
|         } | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\modules\accounts\models; | ||||
|  | ||||
| use api\components\User\Component; | ||||
| @@ -10,13 +12,13 @@ use Yii; | ||||
|  | ||||
| class EnableTwoFactorAuthForm extends AccountActionForm { | ||||
|  | ||||
|     public $totp; | ||||
|     public mixed $totp = null; | ||||
|  | ||||
|     public $password; | ||||
|     public mixed $password = null; | ||||
|  | ||||
|     public function rules(): array { | ||||
|         return [ | ||||
|             ['account', 'validateOtpDisabled'], | ||||
|             ['account', $this->validateOtpDisabled(...)], | ||||
|             ['totp', 'required', 'message' => E::TOTP_REQUIRED], | ||||
|             ['totp', TotpValidator::class, 'account' => $this->getAccount()], | ||||
|             ['password', PasswordRequiredValidator::class, 'account' => $this->getAccount()], | ||||
| @@ -41,7 +43,7 @@ class EnableTwoFactorAuthForm extends AccountActionForm { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public function validateOtpDisabled($attribute): void { | ||||
|     private function validateOtpDisabled(string $attribute): void { | ||||
|         if ($this->getAccount()->is_otp_enabled) { | ||||
|             $this->addError($attribute, E::OTP_ALREADY_ENABLED); | ||||
|         } | ||||
|   | ||||
| @@ -7,7 +7,7 @@ use api\controllers\Controller; | ||||
| use api\modules\authserver\models; | ||||
| use Yii; | ||||
|  | ||||
| class AuthenticationController extends Controller { | ||||
| final class AuthenticationController extends Controller { | ||||
|  | ||||
|     public function behaviors(): array { | ||||
|         $behaviors = parent::behaviors(); | ||||
| @@ -27,12 +27,11 @@ class AuthenticationController extends Controller { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array | ||||
|      * @throws \api\modules\authserver\exceptions\ForbiddenOperationException | ||||
|      * @throws \api\modules\authserver\exceptions\IllegalArgumentException | ||||
|      */ | ||||
|     public function actionAuthenticate(): array { | ||||
|         $model = new models\AuthenticationForm(); | ||||
|         /** @var \api\modules\authserver\models\AuthenticationForm $model */ | ||||
|         $model = Yii::createObject(models\AuthenticationForm::class); | ||||
|         $model->load(Yii::$app->request->post()); | ||||
|  | ||||
|         return $model->authenticate()->getResponseData(true); | ||||
| @@ -62,10 +61,6 @@ class AuthenticationController extends Controller { | ||||
|         // In case of an error, an exception is thrown which will be processed by ErrorHandler | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throws \api\modules\authserver\exceptions\ForbiddenOperationException | ||||
|      * @throws \api\modules\authserver\exceptions\IllegalArgumentException | ||||
|      */ | ||||
|     public function actionSignout(): void { | ||||
|         $model = new models\SignoutForm(); | ||||
|         $model->load(Yii::$app->request->post()); | ||||
|   | ||||
| @@ -15,6 +15,28 @@ final readonly class AuthenticateData { | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return array{ | ||||
|      *     accessToken: string, | ||||
|      *     clientToken: string, | ||||
|      *     selectedProfile: array{ | ||||
|      *         id: string, | ||||
|      *         name: string, | ||||
|      *     }, | ||||
|      *     availableProfiles?: array<array{ | ||||
|      *         id: string, | ||||
|      *         name: string, | ||||
|      *     }>, | ||||
|      *     user?: array{ | ||||
|      *         id: string, | ||||
|      *         username: string, | ||||
|      *         properties: array<array{ | ||||
|      *             name: string, | ||||
|      *             value: string, | ||||
|      *         }>, | ||||
|      *     }, | ||||
|      * } | ||||
|      */ | ||||
|     public function getResponseData(bool $includeAvailableProfiles = false): array { | ||||
|         $uuid = str_replace('-', '', $this->account->uuid); | ||||
|         $result = [ | ||||
|   | ||||
| @@ -3,43 +3,40 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace api\modules\authserver\models; | ||||
|  | ||||
| use api\models\authentication\LoginForm; | ||||
| use api\components\Tokens\TokensFactory; | ||||
| use api\models\base\ApiForm; | ||||
| use api\modules\authserver\exceptions\ForbiddenOperationException; | ||||
| use api\modules\authserver\Module as Authserver; | ||||
| use api\modules\authserver\validators\ClientTokenValidator; | ||||
| use api\modules\authserver\validators\RequiredValidator; | ||||
| use api\rbac\Permissions as P; | ||||
| use common\helpers\Error as E; | ||||
| use common\components\Authentication\Entities\Credentials; | ||||
| use common\components\Authentication\Exceptions; | ||||
| use common\components\Authentication\Exceptions\AuthenticationException; | ||||
| use common\components\Authentication\LoginServiceInterface; | ||||
| use common\models\Account; | ||||
| use common\models\OauthClient; | ||||
| use common\models\OauthSession; | ||||
| use Ramsey\Uuid\Uuid; | ||||
| use Webmozart\Assert\Assert; | ||||
| use Yii; | ||||
| use yii\db\Exception; | ||||
|  | ||||
| class AuthenticationForm extends ApiForm { | ||||
| final class AuthenticationForm extends ApiForm { | ||||
|  | ||||
|     /** | ||||
|      * @var string | ||||
|      */ | ||||
|     public $username; | ||||
|     public mixed $username = null; | ||||
|  | ||||
|     /** | ||||
|      * @var string | ||||
|      */ | ||||
|     public $password; | ||||
|     public mixed $password = null; | ||||
|  | ||||
|     /** | ||||
|      * @var string | ||||
|      */ | ||||
|     public $clientToken; | ||||
|     public mixed $clientToken = null; | ||||
|  | ||||
|     /** | ||||
|      * @var string|bool | ||||
|      */ | ||||
|     public $requestUser; | ||||
|     public mixed $requestUser = null; | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly LoginServiceInterface $loginService, | ||||
|         private readonly TokensFactory $tokensFactory, | ||||
|         array $config = [], | ||||
|     ) { | ||||
|         parent::__construct($config); | ||||
|     } | ||||
|  | ||||
|     public function rules(): array { | ||||
|         return [ | ||||
| @@ -50,9 +47,7 @@ class AuthenticationForm extends ApiForm { | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return AuthenticateData | ||||
|      * @throws ForbiddenOperationException | ||||
|      * @throws Exception | ||||
|      */ | ||||
|     public function authenticate(): AuthenticateData { | ||||
|         // This validating method will throw an exception in case when validation will not pass successfully | ||||
| @@ -60,11 +55,7 @@ class AuthenticationForm extends ApiForm { | ||||
|  | ||||
|         Authserver::info("Trying to authenticate user by login = '{$this->username}'."); | ||||
|  | ||||
|         // The previous authorization server implementation used the nickname field instead of username, | ||||
|         // so we keep such behavior | ||||
|         $attribute = !str_contains($this->username, '@') ? 'nickname' : 'email'; | ||||
|  | ||||
|         $password = $this->password; | ||||
|         $password = (string)$this->password; | ||||
|         $totp = null; | ||||
|         if (preg_match('/.{8,}:(\d{6})$/', $password, $matches) === 1) { | ||||
|             $totp = $matches[1]; | ||||
| @@ -73,47 +64,32 @@ class AuthenticationForm extends ApiForm { | ||||
|  | ||||
|         login: | ||||
|  | ||||
|         $loginForm = new LoginForm(); | ||||
|         $loginForm->login = $this->username; | ||||
|         $loginForm->password = $password; | ||||
|         $loginForm->totp = $totp; | ||||
|         $credentials = new Credentials( | ||||
|             login: (string)$this->username, | ||||
|             password: $password, | ||||
|             totp: $totp, | ||||
|         ); | ||||
|  | ||||
|         $isValid = $loginForm->validate(); | ||||
|         // Handle case when user's password matches the template for totp via password | ||||
|         if (!$isValid && $totp !== null && $loginForm->getFirstError('password') === E::PASSWORD_INCORRECT) { | ||||
|             $password = "{$password}:{$totp}"; | ||||
|             $totp = null; | ||||
|  | ||||
|             goto login; | ||||
|         } | ||||
|  | ||||
|         if (!$isValid || $loginForm->getAccount()->status === Account::STATUS_DELETED) { | ||||
|             $errors = $loginForm->getFirstErrors(); | ||||
|             if (isset($errors['login'])) { | ||||
|                 if ($errors['login'] === E::ACCOUNT_BANNED) { | ||||
|                     Authserver::error("User with login = '{$this->username}' is banned"); | ||||
|                     throw new ForbiddenOperationException('This account has been suspended.'); | ||||
|                 } | ||||
|  | ||||
|                 Authserver::error("Cannot find user by login = '{$this->username}'"); | ||||
|             } elseif (isset($errors['password'])) { | ||||
|                 Authserver::error("User with login = '{$this->username}' passed wrong password."); | ||||
|             } elseif (isset($errors['totp'])) { | ||||
|                 if ($errors['totp'] === E::TOTP_REQUIRED) { | ||||
|                     Authserver::error("User with login = '{$this->username}' protected by two factor auth."); | ||||
|                     throw new ForbiddenOperationException('Account protected with two factor auth.'); | ||||
|                 } | ||||
|  | ||||
|                 Authserver::error("User with login = '{$this->username}' passed wrong totp token"); | ||||
|         try { | ||||
|             $result = $this->loginService->loginByCredentials($credentials); | ||||
|         } catch (Exceptions\InvalidPasswordException $e) { | ||||
|             if ($totp !== null) { | ||||
|                 $password = $this->password; | ||||
|                 goto login; | ||||
|             } | ||||
|  | ||||
|             throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); | ||||
|             $this->convertAuthenticationException($e); | ||||
|         } catch (AuthenticationException $e) { | ||||
|             $this->convertAuthenticationException($e); | ||||
|         } | ||||
|  | ||||
|         $account = $result->account; | ||||
|         if ($account->status === Account::STATUS_DELETED) { | ||||
|             throw new ForbiddenOperationException('Invalid credentials. Invalid username or password.'); | ||||
|         } | ||||
|  | ||||
|         /** @var Account $account */ | ||||
|         $account = $loginForm->getAccount(); | ||||
|         $clientToken = $this->clientToken ?: Uuid::uuid4()->toString(); | ||||
|         $token = Yii::$app->tokensFactory->createForMinecraftAccount($account, $clientToken); | ||||
|         $token = $this->tokensFactory->createForMinecraftAccount($account, $clientToken); | ||||
|         $dataModel = new AuthenticateData($account, $token->toString(), $clientToken, (bool)$this->requestUser); | ||||
|         /** @var OauthSession|null $minecraftOauthSession */ | ||||
|         $minecraftOauthSession = $account->getOauthSessions() | ||||
| @@ -134,4 +110,15 @@ class AuthenticationForm extends ApiForm { | ||||
|         return $dataModel; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @throws \api\modules\authserver\exceptions\ForbiddenOperationException | ||||
|      */ | ||||
|     private function convertAuthenticationException(AuthenticationException $e): never { | ||||
|         throw match ($e::class) { | ||||
|             Exceptions\AccountBannedException::class => new ForbiddenOperationException('This account has been suspended.'), | ||||
|             Exceptions\TotpRequiredException::class => new ForbiddenOperationException('Account protected with two factor auth.'), | ||||
|             default => new ForbiddenOperationException('Invalid credentials. Invalid username or password.'), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -3,23 +3,14 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace api\modules\authserver\models; | ||||
|  | ||||
| use api\models\authentication\LoginForm; | ||||
| use api\models\base\ApiForm; | ||||
| use api\modules\authserver\exceptions\ForbiddenOperationException; | ||||
| use api\modules\authserver\validators\RequiredValidator; | ||||
| use common\helpers\Error as E; | ||||
|  | ||||
| class SignoutForm extends ApiForm { | ||||
| final class SignoutForm extends ApiForm { | ||||
|  | ||||
|     /** | ||||
|      * @var string | ||||
|      */ | ||||
|     public $username; | ||||
|     public mixed $username = null; | ||||
|  | ||||
|     /** | ||||
|      * @var string | ||||
|      */ | ||||
|     public $password; | ||||
|     public mixed $password = null; | ||||
|  | ||||
|     public function rules(): array { | ||||
|         return [ | ||||
| @@ -27,32 +18,11 @@ class SignoutForm extends ApiForm { | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return bool | ||||
|      * @throws ForbiddenOperationException | ||||
|      * @throws \api\modules\authserver\exceptions\IllegalArgumentException | ||||
|      */ | ||||
|     public function signout(): bool { | ||||
|         $this->validate(); | ||||
|  | ||||
|         $loginForm = new LoginForm(); | ||||
|         $loginForm->login = $this->username; | ||||
|         $loginForm->password = $this->password; | ||||
|         if (!$loginForm->validate()) { | ||||
|             $errors = $loginForm->getFirstErrors(); | ||||
|             if (isset($errors['login']) && $errors['login'] === E::ACCOUNT_BANNED) { | ||||
|                 // We believe that a blocked one can get out painlessly | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             // The previous authorization server implementation used the nickname field instead of username, | ||||
|             // so we keep such behavior | ||||
|             $attribute = !str_contains($this->username, '@') ? 'nickname' : 'email'; | ||||
|  | ||||
|             throw new ForbiddenOperationException("Invalid credentials. Invalid {$attribute} or password."); | ||||
|         } | ||||
|  | ||||
|         // We're unable to invalidate access tokens because they aren't stored in our database | ||||
|         // We don't give an error about invalid credentials to eliminate a point through which attackers can brut force passwords. | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
|   | ||||
| @@ -1,14 +1,10 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\tests\_pages; | ||||
|  | ||||
| class AuthenticationRoute extends BasePage { | ||||
| final class AuthenticationRoute extends BasePage { | ||||
|  | ||||
|     /** | ||||
|      * @param string $login | ||||
|      * @param string $password | ||||
|      * @param bool|string|null $rememberMeOrToken | ||||
|      * @param bool $rememberMe | ||||
|      */ | ||||
|     public function login(string $login = '', string $password = '', bool|string|null $rememberMeOrToken = null, bool $rememberMe = false): void { | ||||
|         $params = [ | ||||
|             'login' => $login, | ||||
|   | ||||
| @@ -8,7 +8,7 @@ use api\tests\FunctionalTester; | ||||
| use OTPHP\TOTP; | ||||
|  | ||||
| // TODO: very outdated tests. Need to rewrite | ||||
| class LoginCest { | ||||
| final class LoginCest { | ||||
|  | ||||
|     public function testLoginEmailOrUsername(FunctionalTester $I): void { | ||||
|         $route = new AuthenticationRoute($I); | ||||
|   | ||||
| @@ -156,7 +156,7 @@ class AuthorizationCest { | ||||
|         $I->canSeeResponseIsJson(); | ||||
|         $I->canSeeResponseContainsJson([ | ||||
|             'error' => 'ForbiddenOperationException', | ||||
|             'errorMessage' => 'Invalid credentials. Invalid nickname or password.', | ||||
|             'errorMessage' => 'Invalid credentials. Invalid username or password.', | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
| @@ -170,7 +170,7 @@ class AuthorizationCest { | ||||
|         $I->canSeeResponseCodeIs(401); | ||||
|         $I->canSeeResponseContainsJson([ | ||||
|             'error' => 'ForbiddenOperationException', | ||||
|             'errorMessage' => 'Invalid credentials. Invalid nickname or password.', | ||||
|             'errorMessage' => 'Invalid credentials. Invalid username or password.', | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -6,14 +6,15 @@ namespace api\tests\functional\authserver; | ||||
| use api\tests\functional\_steps\AuthserverSteps; | ||||
| use Codeception\Example; | ||||
|  | ||||
| class SignoutCest { | ||||
| final class SignoutCest { | ||||
|  | ||||
|     /** | ||||
|      * @example {"login": "admin", "password": "password_0"} | ||||
|      * @example {"login": "admin@ely.by", "password": "password_0"} | ||||
|      * | ||||
|      * @param \Codeception\Example<array{login: string, password: string}> $example | ||||
|      */ | ||||
|     public function signout(AuthserverSteps $I, Example $example): void { | ||||
|         $I->wantTo('signout by nickname and password'); | ||||
|         $I->sendPOST('/api/authserver/authentication/signout', [ | ||||
|             'username' => $example['login'], | ||||
|             'password' => $example['password'], | ||||
| @@ -23,7 +24,6 @@ class SignoutCest { | ||||
|     } | ||||
|  | ||||
|     public function wrongArguments(AuthserverSteps $I): void { | ||||
|         $I->wantTo('get error on wrong amount of arguments'); | ||||
|         $I->sendPOST('/api/authserver/authentication/signout', [ | ||||
|             'key' => 'value', | ||||
|         ]); | ||||
| @@ -36,21 +36,15 @@ class SignoutCest { | ||||
|     } | ||||
|  | ||||
|     public function wrongNicknameAndPassword(AuthserverSteps $I): void { | ||||
|         $I->wantTo('signout by nickname and password with wrong data'); | ||||
|         $I->sendPOST('/api/authserver/authentication/signout', [ | ||||
|             'username' => 'nonexistent_user', | ||||
|             'password' => 'nonexistent_password', | ||||
|         ]); | ||||
|         $I->canSeeResponseCodeIs(401); | ||||
|         $I->canSeeResponseIsJson(); | ||||
|         $I->canSeeResponseContainsJson([ | ||||
|             'error' => 'ForbiddenOperationException', | ||||
|             'errorMessage' => 'Invalid credentials. Invalid nickname or password.', | ||||
|         ]); | ||||
|         $I->canSeeResponseCodeIs(200); | ||||
|         $I->canSeeResponseEquals(''); | ||||
|     } | ||||
|  | ||||
|     public function bannedAccount(AuthserverSteps $I): void { | ||||
|         $I->wantTo('signout from banned account'); | ||||
|         $I->sendPOST('/api/authserver/authentication/signout', [ | ||||
|             'username' => 'Banned', | ||||
|             'password' => 'password_0', | ||||
|   | ||||
| @@ -1,122 +0,0 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\tests\unit\models\authentication; | ||||
|  | ||||
| use api\models\authentication\LoginForm; | ||||
| use api\tests\unit\TestCase; | ||||
| use common\models\Account; | ||||
| use common\tests\fixtures\AccountFixture; | ||||
| use OTPHP\TOTP; | ||||
|  | ||||
| class LoginFormTest extends TestCase { | ||||
|  | ||||
|     public function _fixtures(): array { | ||||
|         return [ | ||||
|             'accounts' => AccountFixture::class, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function testValidateLogin(): void { | ||||
|         $model = $this->createWithAccount(null); | ||||
|         $model->login = 'mock-login'; | ||||
|         $model->validateLogin('login'); | ||||
|         $this->assertSame(['error.login_not_exist'], $model->getErrors('login')); | ||||
|  | ||||
|         $model = $this->createWithAccount(new Account()); | ||||
|         $model->login = 'mock-login'; | ||||
|         $model->validateLogin('login'); | ||||
|         $this->assertEmpty($model->getErrors('login')); | ||||
|     } | ||||
|  | ||||
|     public function testValidatePassword(): void { | ||||
|         $account = new Account(); | ||||
|         $account->password_hash = '$2y$04$N0q8DaHzlYILCnLYrpZfEeWKEqkPZzbawiS07GbSr/.xbRNweSLU6'; // 12345678 | ||||
|         $account->password_hash_strategy = Account::PASS_HASH_STRATEGY_YII2; | ||||
|  | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->password = '87654321'; | ||||
|         $model->validatePassword('password'); | ||||
|         $this->assertSame(['error.password_incorrect'], $model->getErrors('password')); | ||||
|  | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->password = '12345678'; | ||||
|         $model->validatePassword('password'); | ||||
|         $this->assertEmpty($model->getErrors('password')); | ||||
|     } | ||||
|  | ||||
|     public function testValidateTotp(): void { | ||||
|         $account = new Account(['password' => '12345678']); | ||||
|         $account->password = '12345678'; | ||||
|         $account->is_otp_enabled = true; | ||||
|         $account->otp_secret = 'AAAA'; | ||||
|  | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->password = '12345678'; | ||||
|         $model->totp = '321123'; | ||||
|         $model->validateTotp('totp'); | ||||
|         $this->assertSame(['error.totp_incorrect'], $model->getErrors('totp')); | ||||
|  | ||||
|         $totp = TOTP::create($account->otp_secret); | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->password = '12345678'; | ||||
|         $model->totp = $totp->now(); | ||||
|         $model->validateTotp('totp'); | ||||
|         $this->assertEmpty($model->getErrors('totp')); | ||||
|     } | ||||
|  | ||||
|     public function testValidateActivity(): void { | ||||
|         $account = new Account(); | ||||
|         $account->status = Account::STATUS_REGISTERED; | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->validateActivity('login'); | ||||
|         $this->assertSame(['error.account_not_activated'], $model->getErrors('login')); | ||||
|  | ||||
|         $account = new Account(); | ||||
|         $account->status = Account::STATUS_BANNED; | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->validateActivity('login'); | ||||
|         $this->assertSame(['error.account_banned'], $model->getErrors('login')); | ||||
|  | ||||
|         $account = new Account(); | ||||
|         $account->status = Account::STATUS_ACTIVE; | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->validateActivity('login'); | ||||
|         $this->assertEmpty($model->getErrors('login')); | ||||
|     } | ||||
|  | ||||
|     public function testLogin(): void { | ||||
|         $account = new Account(); | ||||
|         $account->id = 1; | ||||
|         $account->username = 'erickskrauch'; | ||||
|         $account->password_hash = '$2y$04$N0q8DaHzlYILCnLYrpZfEeWKEqkPZzbawiS07GbSr/.xbRNweSLU6'; // 12345678 | ||||
|         $account->password_hash_strategy = Account::PASS_HASH_STRATEGY_YII2; | ||||
|         $account->status = Account::STATUS_ACTIVE; | ||||
|  | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->login = 'erickskrauch'; | ||||
|         $model->password = '12345678'; | ||||
|  | ||||
|         $this->assertNotNull($model->login(), 'model should login user'); | ||||
|     } | ||||
|  | ||||
|     public function testLoginWithRehashing(): void { | ||||
|         /** @var Account $account */ | ||||
|         $account = $this->tester->grabFixture('accounts', 'user-with-old-password-type'); | ||||
|         $model = $this->createWithAccount($account); | ||||
|         $model->login = $account->username; | ||||
|         $model->password = '12345678'; | ||||
|  | ||||
|         $this->assertNotNull($model->login()); | ||||
|         $this->assertSame(Account::PASS_HASH_STRATEGY_YII2, $account->password_hash_strategy); | ||||
|         $this->assertNotSame('133c00c463cbd3e491c28cb653ce4718', $account->password_hash); | ||||
|     } | ||||
|  | ||||
|     private function createWithAccount(?Account $account): LoginForm { | ||||
|         $model = $this->createPartialMock(LoginForm::class, ['getAccount']); | ||||
|         $model->method('getAccount')->willReturn($account); | ||||
|  | ||||
|         return $model; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,37 +0,0 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\tests\unit\models\authentication; | ||||
|  | ||||
| use api\components\User\Component; | ||||
| use api\models\authentication\LogoutForm; | ||||
| use api\tests\unit\TestCase; | ||||
| use common\models\AccountSession; | ||||
| use Yii; | ||||
|  | ||||
| class LogoutFormTest extends TestCase { | ||||
|  | ||||
|     public function testNoActionWhenThereIsNoActiveSession(): void { | ||||
|         $userComp = $this->createPartialMock(Component::class, ['getActiveSession']); | ||||
|         $userComp->method('getActiveSession')->willReturn(null); | ||||
|  | ||||
|         Yii::$app->set('user', $userComp); | ||||
|  | ||||
|         $model = new LogoutForm(); | ||||
|         $this->assertTrue($model->logout()); | ||||
|     } | ||||
|  | ||||
|     public function testActiveSessionShouldBeDeleted(): void { | ||||
|         $session = $this->createPartialMock(AccountSession::class, ['delete']); | ||||
|         $session->expects($this->once())->method('delete')->willReturn(true); | ||||
|  | ||||
|         $userComp = $this->createPartialMock(Component::class, ['getActiveSession']); | ||||
|         $userComp->method('getActiveSession')->willReturn($session); | ||||
|  | ||||
|         Yii::$app->set('user', $userComp); | ||||
|  | ||||
|         $model = new LogoutForm(); | ||||
|         $model->logout(); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -5,10 +5,9 @@ namespace api\tests\unit\modules\accounts\models; | ||||
|  | ||||
| use api\modules\accounts\models\DisableTwoFactorAuthForm; | ||||
| use api\tests\unit\TestCase; | ||||
| use common\helpers\Error as E; | ||||
| use common\models\Account; | ||||
|  | ||||
| class DisableTwoFactorAuthFormTest extends TestCase { | ||||
| final class DisableTwoFactorAuthFormTest extends TestCase { | ||||
|  | ||||
|     public function testPerformAction(): void { | ||||
|         $account = $this->createPartialMock(Account::class, ['save']); | ||||
| @@ -26,18 +25,4 @@ class DisableTwoFactorAuthFormTest extends TestCase { | ||||
|         $this->assertFalse($account->is_otp_enabled); | ||||
|     } | ||||
|  | ||||
|     public function testValidateOtpEnabled(): void { | ||||
|         $account = new Account(); | ||||
|         $account->is_otp_enabled = false; | ||||
|         $model = new DisableTwoFactorAuthForm($account); | ||||
|         $model->validateOtpEnabled('account'); | ||||
|         $this->assertSame([E::OTP_NOT_ENABLED], $model->getErrors('account')); | ||||
|  | ||||
|         $account = new Account(); | ||||
|         $account->is_otp_enabled = true; | ||||
|         $model = new DisableTwoFactorAuthForm($account); | ||||
|         $model->validateOtpEnabled('account'); | ||||
|         $this->assertEmpty($model->getErrors('account')); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -6,11 +6,10 @@ namespace api\tests\unit\modules\accounts\models; | ||||
| use api\components\User\Component; | ||||
| use api\modules\accounts\models\EnableTwoFactorAuthForm; | ||||
| use api\tests\unit\TestCase; | ||||
| use common\helpers\Error as E; | ||||
| use common\models\Account; | ||||
| use Yii; | ||||
|  | ||||
| class EnableTwoFactorAuthFormTest extends TestCase { | ||||
| final class EnableTwoFactorAuthFormTest extends TestCase { | ||||
|  | ||||
|     public function testPerformAction(): void { | ||||
|         $account = $this->createPartialMock(Account::class, ['save']); | ||||
| @@ -30,18 +29,4 @@ class EnableTwoFactorAuthFormTest extends TestCase { | ||||
|         $this->assertTrue($account->is_otp_enabled); | ||||
|     } | ||||
|  | ||||
|     public function testValidateOtpDisabled(): void { | ||||
|         $account = new Account(); | ||||
|         $account->is_otp_enabled = true; | ||||
|         $model = new EnableTwoFactorAuthForm($account); | ||||
|         $model->validateOtpDisabled('account'); | ||||
|         $this->assertSame([E::OTP_ALREADY_ENABLED], $model->getErrors('account')); | ||||
|  | ||||
|         $account = new Account(); | ||||
|         $account->is_otp_enabled = false; | ||||
|         $model = new EnableTwoFactorAuthForm($account); | ||||
|         $model->validateOtpDisabled('account'); | ||||
|         $this->assertEmpty($model->getErrors('account')); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,116 +0,0 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\tests\unit\modules\authserver\models; | ||||
|  | ||||
| use api\modules\authserver\exceptions\ForbiddenOperationException; | ||||
| use api\modules\authserver\models\AuthenticationForm; | ||||
| use api\tests\unit\TestCase; | ||||
| use common\models\Account; | ||||
| use common\models\OauthClient; | ||||
| use common\models\OauthSession; | ||||
| use common\tests\fixtures\AccountFixture; | ||||
| use common\tests\fixtures\OauthClientFixture; | ||||
| use OTPHP\TOTP; | ||||
| use function Ramsey\Uuid\v4 as uuid4; | ||||
|  | ||||
| class AuthenticationFormTest extends TestCase { | ||||
|  | ||||
|     public function _fixtures(): array { | ||||
|         return [ | ||||
|             'accounts' => AccountFixture::class, | ||||
|             'oauthClients' => OauthClientFixture::class, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function testAuthenticateByValidCredentials(): void { | ||||
|         $authForm = new AuthenticationForm(); | ||||
|         $authForm->username = 'admin'; | ||||
|         $authForm->password = 'password_0'; | ||||
|         $authForm->clientToken = uuid4(); | ||||
|         $result = $authForm->authenticate()->getResponseData(); | ||||
|         $this->assertMatchesRegularExpression('/^[\w=-]+\.[\w=-]+\.[\w=-]+$/', $result['accessToken']); | ||||
|         $this->assertSame($authForm->clientToken, $result['clientToken']); | ||||
|         $this->assertSame('df936908b2e1544d96f82977ec213022', $result['selectedProfile']['id']); | ||||
|         $this->assertSame('Admin', $result['selectedProfile']['name']); | ||||
|         $this->assertTrue(OauthSession::find()->andWhere([ | ||||
|             'account_id' => 1, | ||||
|             'client_id' => OauthClient::UNAUTHORIZED_MINECRAFT_GAME_LAUNCHER, | ||||
|         ])->exists()); | ||||
|         $this->assertArrayNotHasKey('user', $result); | ||||
|  | ||||
|         $authForm->requestUser = true; | ||||
|         $result = $authForm->authenticate()->getResponseData(); | ||||
|         $this->assertSame([ | ||||
|             'id' => 'df936908b2e1544d96f82977ec213022', | ||||
|             'username' => 'Admin', | ||||
|             'properties' => [ | ||||
|                 [ | ||||
|                     'name' => 'preferredLanguage', | ||||
|                     'value' => 'en', | ||||
|                 ], | ||||
|             ], | ||||
|         ], $result['user']); | ||||
|     } | ||||
|  | ||||
|     public function testAuthenticateByValidCredentialsWith2FA(): void { | ||||
|         $authForm = new AuthenticationForm(); | ||||
|         $authForm->username = 'otp@gmail.com'; | ||||
|         $authForm->password = 'password_0:' . TOTP::create('BBBB')->now(); | ||||
|         $authForm->clientToken = uuid4(); | ||||
|  | ||||
|         // Just ensure that there is no exception | ||||
|         $this->expectNotToPerformAssertions(); | ||||
|  | ||||
|         $authForm->authenticate(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * This is a special case which ensures that if the user has a password that looks like | ||||
|      * a two-factor code passed in the password field, than he can still log in into his account | ||||
|      */ | ||||
|     public function testAuthenticateEdgyCaseFor2FA(): void { | ||||
|         /** @var Account $account */ | ||||
|         $account = Account::findOne(['email' => 'admin@ely.by']); | ||||
|         $account->setPassword('password_0:123456'); | ||||
|         $account->save(); | ||||
|  | ||||
|         $authForm = new AuthenticationForm(); | ||||
|         $authForm->username = 'admin@ely.by'; | ||||
|         $authForm->password = 'password_0:123456'; | ||||
|         $authForm->clientToken = uuid4(); | ||||
|  | ||||
|         // Just ensure that there is no exception | ||||
|         $this->expectNotToPerformAssertions(); | ||||
|  | ||||
|         $authForm->authenticate(); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider getInvalidCredentialsCases | ||||
|      */ | ||||
|     public function testAuthenticateByWrongCredentials( | ||||
|         string $expectedExceptionMessage, | ||||
|         string $login, | ||||
|         string $password, | ||||
|         string $totp = null, | ||||
|     ): void { | ||||
|         $this->expectException(ForbiddenOperationException::class); | ||||
|         $this->expectExceptionMessage($expectedExceptionMessage); | ||||
|  | ||||
|         $authForm = new AuthenticationForm(); | ||||
|         $authForm->username = $login; | ||||
|         $authForm->password = $password . ($totp ? ":{$totp}" : ''); | ||||
|         $authForm->clientToken = uuid4(); | ||||
|         $authForm->authenticate(); | ||||
|     } | ||||
|  | ||||
|     public function getInvalidCredentialsCases(): iterable { | ||||
|         yield ['Invalid credentials. Invalid nickname or password.', 'wrong-username', 'wrong-password']; | ||||
|         yield ['Invalid credentials. Invalid email or password.', 'wrong-email@ely.by', 'wrong-password']; | ||||
|         yield ['This account has been suspended.', 'Banned', 'password_0']; | ||||
|         yield ['Account protected with two factor auth.', 'AccountWithEnabledOtp', 'password_0']; | ||||
|         yield ['Invalid credentials. Invalid nickname or password.', 'AccountWithEnabledOtp', 'password_0', '123456']; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,15 +1,17 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\tests\unit\validators; | ||||
|  | ||||
| use api\tests\unit\TestCase; | ||||
| use api\validators\TotpValidator; | ||||
| use Carbon\CarbonImmutable; | ||||
| use common\helpers\Error as E; | ||||
| use common\models\Account; | ||||
| use common\tests\_support\ProtectedCaller; | ||||
| use Lcobucci\Clock\FrozenClock; | ||||
| use OTPHP\TOTP; | ||||
|  | ||||
| class TotpValidatorTest extends TestCase { | ||||
|     use ProtectedCaller; | ||||
| final class TotpValidatorTest extends TestCase { | ||||
|  | ||||
|     public function testValidateValue(): void { | ||||
|         $account = new Account(); | ||||
| @@ -18,32 +20,25 @@ class TotpValidatorTest extends TestCase { | ||||
|  | ||||
|         $validator = new TotpValidator(['account' => $account]); | ||||
|  | ||||
|         $result = $this->callProtected($validator, 'validateValue', 123456); | ||||
|         $this->assertSame([E::TOTP_INCORRECT, []], $result); | ||||
|         $this->assertFalse($validator->validate(123456, $error)); | ||||
|         $this->assertSame(E::TOTP_INCORRECT, $error); | ||||
|  | ||||
|         $result = $this->callProtected($validator, 'validateValue', $controlTotp->now()); | ||||
|         $this->assertNull($result); | ||||
|         $error = null; | ||||
|  | ||||
|         $result = $this->callProtected($validator, 'validateValue', $controlTotp->at(time() - 31)); | ||||
|         $this->assertNull($result); | ||||
|         $this->assertTrue($validator->validate($controlTotp->now(), $error)); | ||||
|         $this->assertNull($error); | ||||
|  | ||||
|         $at = time() - 400; | ||||
|         $validator->timestamp = $at; | ||||
|         $result = $this->callProtected($validator, 'validateValue', $controlTotp->now()); | ||||
|         $this->assertSame([E::TOTP_INCORRECT, []], $result); | ||||
|         $error = null; | ||||
|  | ||||
|         $result = $this->callProtected($validator, 'validateValue', $controlTotp->at($at)); | ||||
|         $this->assertNull($result); | ||||
|         // @phpstan-ignore argument.type | ||||
|         $this->assertTrue($validator->validate($controlTotp->at(time() - 31), $error)); | ||||
|         $this->assertNull($error); | ||||
|  | ||||
|         $at = fn(): ?int => null; | ||||
|         $validator->timestamp = $at; | ||||
|         $result = $this->callProtected($validator, 'validateValue', $controlTotp->now()); | ||||
|         $this->assertNull($result); | ||||
|         $error = null; | ||||
|  | ||||
|         $at = fn(): int => time() - 700; | ||||
|         $validator->timestamp = $at; | ||||
|         $result = $this->callProtected($validator, 'validateValue', $controlTotp->at($at())); | ||||
|         $this->assertNull($result); | ||||
|         $validator->setClock(new FrozenClock(CarbonImmutable::now()->subSeconds(400))); | ||||
|         $this->assertFalse($validator->validate($controlTotp->now(), $error)); | ||||
|         $this->assertSame(E::TOTP_INCORRECT, $error); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,49 +1,52 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace api\validators; | ||||
|  | ||||
| use Carbon\FactoryImmutable; | ||||
| use common\helpers\Error as E; | ||||
| use common\models\Account; | ||||
| use OTPHP\TOTP; | ||||
| use Psr\Clock\ClockInterface; | ||||
| use RangeException; | ||||
| use Yii; | ||||
| use yii\base\InvalidConfigException; | ||||
| use yii\validators\Validator; | ||||
|  | ||||
| class TotpValidator extends Validator { | ||||
| final class TotpValidator extends Validator { | ||||
|  | ||||
|     public ?Account $account = null; | ||||
|  | ||||
|     /** | ||||
|      * @var int|callable|null Allows you to set the exact time against which the validation will be performed. | ||||
|      * It may be the unix time or a function returning a unix time. | ||||
|      * If not specified, the current time will be used. | ||||
|      */ | ||||
|     public mixed $timestamp = null; | ||||
|  | ||||
|     public $skipOnEmpty = false; | ||||
|  | ||||
|     private ClockInterface $clock; | ||||
|  | ||||
|     /** | ||||
|      * @throws InvalidConfigException | ||||
|      */ | ||||
|     public function init(): void { | ||||
|         parent::init(); | ||||
|         if ($this->account === null) { | ||||
|             $this->account = Yii::$app->user->identity; | ||||
|         } | ||||
|  | ||||
|         if (!$this->account instanceof Account) { | ||||
|             throw new InvalidConfigException('account should be instance of ' . Account::class); | ||||
|             throw new InvalidConfigException('This validator must be instantiated with the account param'); | ||||
|         } | ||||
|  | ||||
|         if (empty($this->account->otp_secret)) { | ||||
|             throw new InvalidConfigException('account should have not empty otp_secret'); | ||||
|         } | ||||
|  | ||||
|         $this->clock = FactoryImmutable::getDefaultInstance(); | ||||
|     } | ||||
|  | ||||
|     public function setClock(ClockInterface $clock): void { | ||||
|         $this->clock = $clock; | ||||
|     } | ||||
|  | ||||
|     protected function validateValue($value): ?array { | ||||
|         try { | ||||
|             // @phpstan-ignore argument.type (it is non empty, its checked in the init method) | ||||
|             $totp = TOTP::create($this->account->otp_secret); | ||||
|             if (!$totp->verify((string)$value, $this->getTimestamp(), $totp->getPeriod() - 1)) { | ||||
|             // @phpstan-ignore argument.type,argument.type,argument.type (all types are fine, they're just not declared well) | ||||
|             if (!$totp->verify((string)$value, $this->clock->now()->getTimestamp(), $totp->getPeriod() - 1)) { | ||||
|                 return [E::TOTP_INCORRECT, []]; | ||||
|             } | ||||
|         } catch (RangeException) { | ||||
| @@ -53,17 +56,4 @@ class TotpValidator extends Validator { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     private function getTimestamp(): ?int { | ||||
|         $timestamp = $this->timestamp; | ||||
|         if (is_callable($timestamp)) { | ||||
|             $timestamp = call_user_func($this->timestamp); | ||||
|         } | ||||
|  | ||||
|         if ($timestamp === null) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return (int)$timestamp; | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Entities; | ||||
|  | ||||
| use common\models\Account; | ||||
| use common\models\AccountSession; | ||||
|  | ||||
| final readonly class AuthenticationResult { | ||||
|  | ||||
|     public function __construct( | ||||
|         public Account $account, | ||||
|         public ?AccountSession $session = null, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										16
									
								
								common/components/Authentication/Entities/Credentials.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								common/components/Authentication/Entities/Credentials.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Entities; | ||||
|  | ||||
| final readonly class Credentials { | ||||
|  | ||||
|     public function __construct( | ||||
|         public string $login, | ||||
|         public string $password, | ||||
|         public ?string $totp = null, | ||||
|         public bool $rememberMe = false, | ||||
|     ) { | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,19 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use common\models\Account; | ||||
| use Exception; | ||||
| use Throwable; | ||||
|  | ||||
| final class AccountBannedException extends Exception implements AuthenticationException { | ||||
|  | ||||
|     public function __construct( | ||||
|         public readonly Account $account, | ||||
|         ?Throwable $previous = null, | ||||
|     ) { | ||||
|         parent::__construct('The account has been banned', previous: $previous); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,19 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use common\models\Account; | ||||
| use Exception; | ||||
| use Throwable; | ||||
|  | ||||
| final class AccountNotActivatedException extends Exception implements AuthenticationException { | ||||
|  | ||||
|     public function __construct( | ||||
|         public readonly Account $account, | ||||
|         ?Throwable $previous = null, | ||||
|     ) { | ||||
|         parent::__construct('The account has not been activated yet', previous: $previous); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,10 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use Throwable; | ||||
|  | ||||
| interface AuthenticationException extends Throwable { | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use Exception; | ||||
| use Throwable; | ||||
|  | ||||
| final class InvalidPasswordException extends Exception implements AuthenticationException { | ||||
|  | ||||
|     public function __construct(?Throwable $previous = null) { | ||||
|         parent::__construct("The entered password doesn't match the account's password", previous: $previous); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use Exception; | ||||
| use Throwable; | ||||
|  | ||||
| final class InvalidTotpException extends Exception implements AuthenticationException { | ||||
|  | ||||
|     public function __construct(?Throwable $previous = null) { | ||||
|         parent::__construct('Incorrect TOTP value', previous: $previous); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use Exception; | ||||
| use Throwable; | ||||
|  | ||||
| final class TotpRequiredException extends Exception implements AuthenticationException { | ||||
|  | ||||
|     public function __construct(?Throwable $previous = null) { | ||||
|         parent::__construct('Two-factor authentication is enabled for the account and you need to pass the TOTP', previous: $previous); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,15 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication\Exceptions; | ||||
|  | ||||
| use Exception; | ||||
| use Throwable; | ||||
|  | ||||
| final class UnknownLoginException extends Exception implements AuthenticationException { | ||||
|  | ||||
|     public function __construct(?Throwable $previous = null) { | ||||
|         parent::__construct('The account with the specified login does not exist', previous: $previous); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										67
									
								
								common/components/Authentication/LoginService.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								common/components/Authentication/LoginService.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,67 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication; | ||||
|  | ||||
| use api\validators\TotpValidator; | ||||
| use common\components\Authentication\Entities\AuthenticationResult; | ||||
| use common\components\Authentication\Entities\Credentials; | ||||
| use common\models\Account; | ||||
| use common\models\AccountSession; | ||||
| use Webmozart\Assert\Assert; | ||||
| use Yii; | ||||
|  | ||||
| final class LoginService implements LoginServiceInterface { | ||||
|  | ||||
|     public function loginByCredentials(Credentials $credentials): AuthenticationResult { | ||||
|         /** @var Account|null $account */ | ||||
|         $account = Account::find()->andWhereLogin($credentials->login)->one(); | ||||
|         if ($account === null) { | ||||
|             throw new Exceptions\UnknownLoginException(); | ||||
|         } | ||||
|  | ||||
|         if (!$account->validatePassword($credentials->password)) { | ||||
|             throw new Exceptions\InvalidPasswordException(); | ||||
|         } | ||||
|  | ||||
|         if ($account->is_otp_enabled) { | ||||
|             if (empty($credentials->totp)) { | ||||
|                 throw new Exceptions\TotpRequiredException(); | ||||
|             } | ||||
|  | ||||
|             $validator = new TotpValidator(['account' => $account]); | ||||
|             if (!$validator->validate($credentials->totp)) { | ||||
|                 throw new Exceptions\InvalidTotpException(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if ($account->status === Account::STATUS_BANNED) { | ||||
|             throw new Exceptions\AccountBannedException($account); | ||||
|         } | ||||
|  | ||||
|         if ($account->status === Account::STATUS_REGISTERED) { | ||||
|             throw new Exceptions\AccountNotActivatedException($account); | ||||
|         } | ||||
|  | ||||
|         if ($account->password_hash_strategy !== Account::PASS_HASH_STRATEGY_YII2) { | ||||
|             $account->setPassword($credentials->password); | ||||
|             Assert::true($account->save(), 'Unable to upgrade user\'s password'); | ||||
|         } | ||||
|  | ||||
|         $session = null; | ||||
|         if ($credentials->rememberMe) { | ||||
|             $session = new AccountSession(); | ||||
|             $session->account_id = $account->id; | ||||
|             $session->setIp(Yii::$app->request->userIP); | ||||
|             $session->generateRefreshToken(); | ||||
|             Assert::true($session->save(), 'Cannot save account session model'); | ||||
|         } | ||||
|  | ||||
|         return new AuthenticationResult($account, $session); | ||||
|     } | ||||
|  | ||||
|     public function logout(AccountSession $session): void { | ||||
|         $session->delete(); | ||||
|     } | ||||
|  | ||||
| } | ||||
							
								
								
									
										19
									
								
								common/components/Authentication/LoginServiceInterface.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								common/components/Authentication/LoginServiceInterface.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| <?php | ||||
| declare(strict_types=1); | ||||
|  | ||||
| namespace common\components\Authentication; | ||||
|  | ||||
| use common\components\Authentication\Entities\AuthenticationResult; | ||||
| use common\components\Authentication\Entities\Credentials; | ||||
| use common\models\AccountSession; | ||||
|  | ||||
| interface LoginServiceInterface { | ||||
|  | ||||
|     /** | ||||
|      * @throws \common\components\Authentication\Exceptions\AuthenticationException | ||||
|      */ | ||||
|     public function loginByCredentials(Credentials $credentials): AuthenticationResult; | ||||
|  | ||||
|     public function logout(AccountSession $session): void; | ||||
|  | ||||
| } | ||||
| @@ -27,6 +27,7 @@ return [ | ||||
|                 ], | ||||
|             ], | ||||
|             League\OAuth2\Server\AuthorizationServer::class => common\components\OAuth2\AuthorizationServerFactory::build(...), | ||||
|             common\components\Authentication\LoginServiceInterface::class => common\components\Authentication\LoginService::class, | ||||
|         ], | ||||
|     ], | ||||
|     'components' => [ | ||||
|   | ||||
| @@ -77,6 +77,10 @@ class Account extends ActiveRecord { | ||||
|     } | ||||
|  | ||||
|     public function validatePassword(string $password, int $passwordHashStrategy = null): bool { | ||||
|         if (empty($password)) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         if ($passwordHashStrategy === null) { | ||||
|             $passwordHashStrategy = $this->password_hash_strategy; | ||||
|         } | ||||
|   | ||||
| @@ -6,7 +6,7 @@ namespace common\models; | ||||
| use yii\db\ActiveQuery; | ||||
|  | ||||
| /** | ||||
|  * @see Account | ||||
|  * @extends \yii\db\ActiveQuery<\common\models\Account> | ||||
|  */ | ||||
| class AccountQuery extends ActiveQuery { | ||||
|  | ||||
|   | ||||
| @@ -330,36 +330,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/DeleteAccountForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\modules\\\\accounts\\\\models\\\\DisableTwoFactorAuthForm\\:\\:validateOtpEnabled\\(\\) has parameter \\$attribute with no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/DisableTwoFactorAuthForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\DisableTwoFactorAuthForm\\:\\:\\$password has no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/DisableTwoFactorAuthForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\DisableTwoFactorAuthForm\\:\\:\\$totp has no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/DisableTwoFactorAuthForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\modules\\\\accounts\\\\models\\\\EnableTwoFactorAuthForm\\:\\:validateOtpDisabled\\(\\) has parameter \\$attribute with no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/EnableTwoFactorAuthForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\EnableTwoFactorAuthForm\\:\\:\\$password has no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/EnableTwoFactorAuthForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\EnableTwoFactorAuthForm\\:\\:\\$totp has no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/accounts/models/EnableTwoFactorAuthForm.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Property api\\\\modules\\\\accounts\\\\models\\\\SendEmailVerificationForm\\:\\:\\$password has no type specified\\.$#" | ||||
| 			count: 1 | ||||
| @@ -415,11 +385,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: api/modules/authserver/controllers/AuthenticationController.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\modules\\\\authserver\\\\models\\\\AuthenticateData\\:\\:getResponseData\\(\\) return type has no value type specified in iterable type array\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/modules/authserver/models/AuthenticateData.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\modules\\\\authserver\\\\validators\\\\AccessTokenValidator\\:\\:validateValue\\(\\) return type has no value type specified in iterable type array\\.$#" | ||||
| 			count: 1 | ||||
| @@ -860,11 +825,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: api/tests/functional/authserver/RefreshCest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\tests\\\\functional\\\\authserver\\\\SignoutCest\\:\\:signout\\(\\) has parameter \\$example with no value type specified in iterable type Codeception\\\\Example\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/functional/authserver/SignoutCest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\tests\\\\functional\\\\authserver\\\\UsernamesToUuidsCest\\:\\:bulkProfilesEndpoints\\(\\) return type has no value type specified in iterable type array\\.$#" | ||||
| 			count: 1 | ||||
| @@ -1045,11 +1005,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/modules/accounts/models/ChangeUsernameFormTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\tests\\\\unit\\\\modules\\\\authserver\\\\models\\\\AuthenticationFormTest\\:\\:getInvalidCredentialsCases\\(\\) return type has no value type specified in iterable type iterable\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/modules/authserver/models/AuthenticationFormTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\tests\\\\unit\\\\modules\\\\authserver\\\\validators\\\\RequiredValidatorTest\\:\\:callProtected\\(\\) has no return type specified\\.$#" | ||||
| 			count: 1 | ||||
| @@ -1100,31 +1055,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/validators/PasswordRequiredValidatorTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\tests\\\\unit\\\\validators\\\\TotpValidatorTest\\:\\:callProtected\\(\\) has no return type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/validators/TotpValidatorTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method api\\\\tests\\\\unit\\\\validators\\\\TotpValidatorTest\\:\\:callProtected\\(\\) has parameter \\$args with no type specified\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/validators/TotpValidatorTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-30, max\\> given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/validators/TotpValidatorTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-399, max\\> given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/validators/TotpValidatorTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#1 \\$input of method OTPHP\\\\TOTP\\:\\:at\\(\\) expects int\\<0, max\\>, int\\<\\-699, max\\> given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/tests/unit/validators/TotpValidatorTest.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Property api\\\\validators\\\\EmailActivationKeyValidator\\:\\:\\$expired has no type specified\\.$#" | ||||
| 			count: 1 | ||||
| @@ -1150,31 +1080,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: api/validators/PasswordRequiredValidator.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#1 \\$callback of function call_user_func expects callable\\(\\)\\: mixed, \\(callable\\(\\)\\: mixed\\)\\|int\\|null given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/validators/TotpValidator.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#1 \\$otp of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects non\\-empty\\-string, string given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/validators/TotpValidator.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#1 \\$secret of static method OTPHP\\\\TOTP\\:\\:create\\(\\) expects non\\-empty\\-string\\|null, string\\|null given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/validators/TotpValidator.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#2 \\$timestamp of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects int\\<0, max\\>\\|null, int\\|null given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/validators/TotpValidator.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Parameter \\#3 \\$leeway of method OTPHP\\\\TOTP\\:\\:verify\\(\\) expects int\\<0, max\\>\\|null, int given\\.$#" | ||||
| 			count: 1 | ||||
| 			path: api/validators/TotpValidator.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method common\\\\components\\\\EmailsRenderer\\\\Request\\\\TemplateRequest\\:\\:__construct\\(\\) has parameter \\$params with no value type specified in iterable type array\\.$#" | ||||
| 			count: 1 | ||||
| @@ -1285,11 +1190,6 @@ parameters: | ||||
| 			count: 1 | ||||
| 			path: common/models/Account.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Class common\\\\models\\\\AccountQuery extends generic class yii\\\\db\\\\ActiveQuery but does not specify its types\\: T$#" | ||||
| 			count: 1 | ||||
| 			path: common/models/AccountQuery.php | ||||
|  | ||||
| 		- | ||||
| 			message: "#^Method common\\\\models\\\\AccountSession\\:\\:getAccount\\(\\) return type with generic class yii\\\\db\\\\ActiveQuery does not specify its types\\: T$#" | ||||
| 			count: 1 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user