Extract login logics into a separate component. Not quite clean result but enough for upcoming tasks

This commit is contained in:
ErickSkrauch 2025-01-17 21:37:35 +01:00
parent 1c2969a4be
commit be4697e6eb
No known key found for this signature in database
GPG Key ID: 669339FCBB30EE0E
39 changed files with 443 additions and 729 deletions

View File

@ -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;

View File

@ -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,

View File

@ -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 {

View File

@ -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(

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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 '';

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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());

View File

@ -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 = [

View File

@ -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.'),
};
}
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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);

View File

@ -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.',
]);
}

View File

@ -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',

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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'));
}
}

View File

@ -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'));
}
}

View File

@ -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'];
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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,
) {
}
}

View 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,
) {
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace common\components\Authentication\Exceptions;
use Throwable;
interface AuthenticationException extends Throwable {
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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();
}
}

View 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;
}

View File

@ -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' => [

View File

@ -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;
}

View File

@ -6,7 +6,7 @@ namespace common\models;
use yii\db\ActiveQuery;
/**
* @see Account
* @extends \yii\db\ActiveQuery<\common\models\Account>
*/
class AccountQuery extends ActiveQuery {

View File

@ -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