diff --git a/api/models/authentication/LoginForm.php b/api/models/authentication/LoginForm.php index c1ad5e6..004dc79 100644 --- a/api/models/authentication/LoginForm.php +++ b/api/models/authentication/LoginForm.php @@ -3,6 +3,7 @@ namespace api\models\authentication; use api\models\AccountIdentity; use api\models\base\ApiForm; +use api\validators\TotpValidator; use common\helpers\Error as E; use api\traits\AccountFinder; use common\models\Account; @@ -16,6 +17,7 @@ class LoginForm extends ApiForm { public $login; public $password; + public $token; public $rememberMe = false; public function rules() { @@ -28,6 +30,11 @@ class LoginForm extends ApiForm { }, 'message' => E::PASSWORD_REQUIRED], ['password', 'validatePassword'], + ['token', 'required', 'when' => function(self $model) { + return !$model->hasErrors() && $model->getAccount()->is_otp_enabled; + }, 'message' => E::OTP_TOKEN_REQUIRED], + ['token', 'validateTotpToken'], + ['login', 'validateActivity'], ['rememberMe', 'boolean'], @@ -51,6 +58,22 @@ class LoginForm extends ApiForm { } } + public function validateTotpToken($attribute) { + if ($this->hasErrors()) { + return; + } + + $account = $this->getAccount(); + if (!$account->is_otp_enabled) { + return; + } + + $validator = new TotpValidator(['account' => $account]); + if (!$validator->validate($this->token, $error)) { + $this->addError($attribute, $error); + } + } + public function validateActivity($attribute) { if (!$this->hasErrors()) { $account = $this->getAccount(); diff --git a/tests/codeception/api/_pages/AuthenticationRoute.php b/tests/codeception/api/_pages/AuthenticationRoute.php index 7ed0042..087bb55 100644 --- a/tests/codeception/api/_pages/AuthenticationRoute.php +++ b/tests/codeception/api/_pages/AuthenticationRoute.php @@ -8,15 +8,23 @@ use yii\codeception\BasePage; */ class AuthenticationRoute extends BasePage { - public function login($login = '', $password = '', $rememberMe = false) { + /** + * @param string $login + * @param string $password + * @param string|bool|null $rememberMeOrToken + * @param bool $rememberMe + */ + public function login($login = '', $password = '', $rememberMeOrToken = null, $rememberMe = false) { $this->route = ['authentication/login']; $params = [ 'login' => $login, 'password' => $password, ]; - if ($rememberMe) { + if ((is_bool($rememberMeOrToken) && $rememberMeOrToken) || $rememberMe) { $params['rememberMe'] = 1; + } elseif ($rememberMeOrToken !== null) { + $params['token'] = $rememberMeOrToken; } $this->actor->sendPOST($this->getUrl(), $params); diff --git a/tests/codeception/api/functional/LoginCest.php b/tests/codeception/api/functional/LoginCest.php index 4c39e63..ab11f50 100644 --- a/tests/codeception/api/functional/LoginCest.php +++ b/tests/codeception/api/functional/LoginCest.php @@ -1,6 +1,7 @@ wantTo('see token don\'t have errors if email, username or token not set'); + $route->login(); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see token don\'t have errors if username not exists in database'); + $route->login('non-exist-username', 'random-password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see token don\'t has errors if email not exists in database'); + $route->login('not-exist@user.com', 'random-password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see token don\'t has errors if email correct, but password wrong'); + $route->login('not-exist@user.com', 'random-password'); + $I->canSeeResponseContainsJson([ + 'success' => false, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors.token'); + + $I->wantTo('see error.token_required if username and password correct, but account have enable otp'); + $route->login('AccountWithEnabledOtp', 'password_0'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_required', + ], + ]); + + $I->wantTo('see error.token_incorrect if username and password correct, but token wrong'); + $route->login('AccountWithEnabledOtp', 'password_0', '123456'); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'token' => 'error.token_incorrect', + ], + ]); + } + public function testLoginByUsernameCorrect(FunctionalTester $I) { $route = new AuthenticationRoute($I); @@ -151,4 +202,16 @@ class LoginCest { $I->canSeeAuthCredentials(true); } + public function testLoginByAccountWithOtp(FunctionalTester $I) { + $route = new AuthenticationRoute($I); + + $I->wantTo('login into account with enabled otp'); + $route->login('AccountWithEnabledOtp', 'password_0', (new TOTP(null, 'secret-secret-secret'))->now()); + $I->canSeeResponseContainsJson([ + 'success' => true, + ]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + $I->canSeeAuthCredentials(false); + } + } diff --git a/tests/codeception/api/unit/models/authentication/LoginFormTest.php b/tests/codeception/api/unit/models/authentication/LoginFormTest.php index e32f243..0cf524e 100644 --- a/tests/codeception/api/unit/models/authentication/LoginFormTest.php +++ b/tests/codeception/api/unit/models/authentication/LoginFormTest.php @@ -6,6 +6,7 @@ use api\models\AccountIdentity; use api\models\authentication\LoginForm; use Codeception\Specify; use common\models\Account; +use OTPHP\TOTP; use tests\codeception\api\unit\TestCase; use tests\codeception\common\fixtures\AccountFixture; @@ -38,7 +39,7 @@ class LoginFormTest extends TestCase { 'account' => null, ]); $model->validateLogin('login'); - expect($model->getErrors('login'))->equals(['error.login_not_exist']); + $this->assertEquals(['error.login_not_exist'], $model->getErrors('login')); }); $this->specify('no errors if login exists', function () { @@ -47,7 +48,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(), ]); $model->validateLogin('login'); - expect($model->getErrors('login'))->isEmpty(); + $this->assertEmpty($model->getErrors('login')); }); } @@ -58,7 +59,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['password' => '12345678']), ]); $model->validatePassword('password'); - expect($model->getErrors('password'))->equals(['error.password_incorrect']); + $this->assertEquals(['error.password_incorrect'], $model->getErrors('password')); }); $this->specify('no errors if password valid', function () { @@ -67,7 +68,35 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['password' => '12345678']), ]); $model->validatePassword('password'); - expect($model->getErrors('password'))->isEmpty(); + $this->assertEmpty($model->getErrors('password')); + }); + } + + public function testValidateTotpToken() { + $account = new AccountIdentity(['password' => '12345678']); + $account->password = '12345678'; + $account->is_otp_enabled = true; + $account->otp_secret = 'mock secret'; + + $this->specify('error.token_incorrect if totp invalid', function() use ($account) { + $model = $this->createModel([ + 'password' => '12345678', + 'token' => '321123', + 'account' => $account, + ]); + $model->validateTotpToken('token'); + $this->assertEquals(['error.token_incorrect'], $model->getErrors('token')); + }); + + $totp = new TOTP(null, 'mock secret'); + $this->specify('no errors if password valid', function() use ($account, $totp) { + $model = $this->createModel([ + 'password' => '12345678', + 'token' => $totp->now(), + 'account' => $account, + ]); + $model->validateTotpToken('token'); + $this->assertEmpty($model->getErrors('token')); }); } @@ -77,7 +106,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['status' => Account::STATUS_REGISTERED]), ]); $model->validateActivity('login'); - expect($model->getErrors('login'))->equals(['error.account_not_activated']); + $this->assertEquals(['error.account_not_activated'], $model->getErrors('login')); }); $this->specify('error.account_banned if account has banned status', function () { @@ -85,7 +114,7 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['status' => Account::STATUS_BANNED]), ]); $model->validateActivity('login'); - expect($model->getErrors('login'))->equals(['error.account_banned']); + $this->assertEquals(['error.account_banned'], $model->getErrors('login')); }); $this->specify('no errors if account active', function () { @@ -93,36 +122,36 @@ class LoginFormTest extends TestCase { 'account' => new AccountIdentity(['status' => Account::STATUS_ACTIVE]), ]); $model->validateActivity('login'); - expect($model->getErrors('login'))->isEmpty(); + $this->assertEmpty($model->getErrors('login')); }); } public function testLogin() { - $this->specify('user should be able to login with correct username and password', function () { - $model = $this->createModel([ - 'login' => 'erickskrauch', + $model = $this->createModel([ + 'login' => 'erickskrauch', + 'password' => '12345678', + 'account' => new AccountIdentity([ + 'username' => 'erickskrauch', 'password' => '12345678', - 'account' => new AccountIdentity([ - 'username' => 'erickskrauch', - 'password' => '12345678', - 'status' => Account::STATUS_ACTIVE, - ]), - ]); - expect('model should login user', $model->login())->isInstanceOf(LoginResult::class); - expect('error message should not be set', $model->errors)->isEmpty(); - }); + 'status' => Account::STATUS_ACTIVE, + ]), + ]); + $this->assertInstanceOf(LoginResult::class, $model->login(), 'model should login user'); + $this->assertEmpty($model->getErrors(), 'error message should not be set'); } public function testLoginWithRehashing() { - $this->specify('user, that login using account with old pass hash strategy should update it automatically', function () { - $model = new LoginForm([ - 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], - 'password' => '12345678', - ]); - expect($model->login())->isInstanceOf(LoginResult::class); - expect($model->errors)->isEmpty(); - expect($model->getAccount()->password_hash_strategy)->equals(Account::PASS_HASH_STRATEGY_YII2); - }); + $model = new LoginForm([ + 'login' => $this->tester->grabFixture('accounts', 'user-with-old-password-type')['username'], + 'password' => '12345678', + ]); + $this->assertInstanceOf(LoginResult::class, $model->login()); + $this->assertEmpty($model->getErrors()); + $this->assertEquals( + Account::PASS_HASH_STRATEGY_YII2, + $model->getAccount()->password_hash_strategy, + 'user, that login using account with old pass hash strategy should update it automatically' + ); } /**