diff --git a/api/controllers/AuthenticationController.php b/api/controllers/AuthenticationController.php index f6f5240..5d0ba23 100644 --- a/api/controllers/AuthenticationController.php +++ b/api/controllers/AuthenticationController.php @@ -36,10 +36,16 @@ class AuthenticationController extends Controller { $model = new LoginForm(); $model->load(Yii::$app->request->post()); if (($jwt = $model->login()) === false) { - return [ + $data = [ 'success' => false, 'errors' => $this->normalizeModelErrors($model->getErrors()), ]; + + if (ArrayHelper::getValue($data['errors'], 'login') === 'error.account_not_activated') { + $data['data']['email'] = $model->getAccount()->email; + } + + return $data; } return [ diff --git a/api/controllers/SignupController.php b/api/controllers/SignupController.php index db0853d..f29cff5 100644 --- a/api/controllers/SignupController.php +++ b/api/controllers/SignupController.php @@ -2,6 +2,7 @@ namespace api\controllers; use api\models\ConfirmEmailForm; +use api\models\RepeatAccountActivationForm; use api\models\RegistrationForm; use Yii; use yii\filters\AccessControl; @@ -12,13 +13,13 @@ class SignupController extends Controller { public function behaviors() { return ArrayHelper::merge(parent::behaviors(), [ 'authenticator' => [ - 'except' => ['index', 'confirm'], + 'except' => ['index', 'repeat-message', 'confirm'], ], 'access' => [ 'class' => AccessControl::class, 'rules' => [ [ - 'actions' => ['index', 'confirm'], + 'actions' => ['index', 'repeat-message', 'confirm'], 'allow' => true, 'roles' => ['?'], ], @@ -31,6 +32,7 @@ class SignupController extends Controller { return [ 'register' => ['POST'], 'confirm' => ['POST'], + 'new-message' => ['POST'], ]; } @@ -49,6 +51,31 @@ class SignupController extends Controller { ]; } + public function actionRepeatMessage() { + $model = new RepeatAccountActivationForm(); + $model->load(Yii::$app->request->post()); + if (!$model->sendRepeatMessage()) { + $response = [ + 'success' => false, + 'errors' => $this->normalizeModelErrors($model->getErrors()), + ]; + + if ($response['errors']['email'] === 'error.recently_sent_message') { + $activeActivation = $model->getActiveActivation(); + $response['data'] = [ + 'canRepeatIn' => $activeActivation->created_at - time() + RepeatAccountActivationForm::REPEAT_FREQUENCY, + 'repeatFrequency' => RepeatAccountActivationForm::REPEAT_FREQUENCY, + ]; + } + + return $response; + } + + return [ + 'success' => true, + ]; + } + public function actionConfirm() { $model = new ConfirmEmailForm(); $model->load(Yii::$app->request->post()); diff --git a/api/exceptions/Exception.php b/api/exceptions/Exception.php new file mode 100644 index 0000000..50972ae --- /dev/null +++ b/api/exceptions/Exception.php @@ -0,0 +1,6 @@ + 'error.key_is_required'], ['key', 'validateKey'], ]; diff --git a/api/models/LoginForm.php b/api/models/LoginForm.php index 3f9db42..a123560 100644 --- a/api/models/LoginForm.php +++ b/api/models/LoginForm.php @@ -68,7 +68,7 @@ class LoginForm extends BaseApiForm { /** * @return Account|null */ - protected function getAccount() { + public function getAccount() { if ($this->_account === NULL) { $attribute = strpos($this->login, '@') ? 'email' : 'username'; $this->_account = Account::findOne([$attribute => $this->login]); diff --git a/api/models/RegistrationForm.php b/api/models/RegistrationForm.php index f30f722..bde9c25 100644 --- a/api/models/RegistrationForm.php +++ b/api/models/RegistrationForm.php @@ -82,25 +82,7 @@ class RegistrationForm extends BaseApiForm { throw new ErrorException('Unable save email-activation model.'); } - /** @var \yii\swiftmailer\Mailer $mailer */ - $mailer = Yii::$app->mailer; - /** @var \yii\swiftmailer\Message $message */ - $message = $mailer->compose( - [ - 'html' => '@app/mails/registration-confirmation-html', - 'text' => '@app/mails/registration-confirmation-text', - ], - [ - 'key' => $emailActivation->key, - ] - ) - ->setTo([$account->email => $account->username]) - ->setFrom([Yii::$app->params['fromEmail'] => 'Ely.by Accounts']) - ->setSubject('Ely.by Account registration'); - - if (!$message->send()) { - throw new ErrorException('Unable send email with activation code.'); - } + $this->sendMail($emailActivation, $account); $transaction->commit(); } catch (ErrorException $e) { @@ -111,4 +93,24 @@ class RegistrationForm extends BaseApiForm { return $account; } + // TODO: подумать, чтобы вынести этот метод в какую-то отдельную конструкцию, т.к. используется и внутри NewAccountActivationForm + public function sendMail(EmailActivation $emailActivation, Account $account) { + /** @var \yii\swiftmailer\Mailer $mailer */ + $mailer = Yii::$app->mailer; + /** @var \yii\swiftmailer\Message $message */ + $message = $mailer->compose([ + 'html' => '@app/mails/registration-confirmation-html', + 'text' => '@app/mails/registration-confirmation-text', + ], [ + 'key' => $emailActivation->key, + ]) + ->setTo([$account->email => $account->username]) + ->setFrom([Yii::$app->params['fromEmail'] => 'Ely.by Accounts']) + ->setSubject('Ely.by Account registration'); + + if (!$message->send()) { + throw new ErrorException('Unable send email with activation code.'); + } + } + } diff --git a/api/models/RepeatAccountActivationForm.php b/api/models/RepeatAccountActivationForm.php new file mode 100644 index 0000000..7f7bf3f --- /dev/null +++ b/api/models/RepeatAccountActivationForm.php @@ -0,0 +1,101 @@ + 'trim'], + ['email', 'required', 'message' => 'error.email_required'], + ['email', 'validateEmailForAccount'], + ['email', 'validateExistsActivation'], + ]; + } + + public function validateEmailForAccount($attribute) { + if (!$this->hasErrors($attribute)) { + $account = $this->getAccount(); + if ($account === null) { + $this->addError($attribute, "error.{$attribute}_not_found"); + } elseif ($account->status === Account::STATUS_ACTIVE) { + $this->addError($attribute, "error.account_already_activated"); + } elseif ($account->status !== Account::STATUS_REGISTERED) { + // TODO: такие аккаунты следует логировать за попытку к саботажу + $this->addError($attribute, "error.account_cannot_resend_message"); + } + } + } + + public function validateExistsActivation($attribute) { + if (!$this->hasErrors($attribute)) { + if ($this->getActiveActivation() !== null) { + $this->addError($attribute, 'error.recently_sent_message'); + } + } + } + + public function sendRepeatMessage() { + if (!$this->validate()) { + return false; + } + + $account = $this->getAccount(); + $transaction = Yii::$app->db->beginTransaction(); + try { + EmailActivation::deleteAll([ + 'account_id' => $account->id, + 'type' => EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION, + ]); + + $activation = new EmailActivation(); + $activation->account_id = $account->id; + $activation->type = EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION; + $activation->key = UserFriendlyRandomKey::make(); + if (!$activation->save()) { + throw new ErrorException('Unable save email-activation model.'); + } + + $regForm = new RegistrationForm(); + $regForm->sendMail($activation, $account); + + $transaction->commit(); + } catch (ErrorException $e) { + $transaction->rollBack(); + throw $e; + } + + return true; + } + + /** + * @return Account|null + */ + public function getAccount() { + return Account::find() + ->andWhere(['email' => $this->email]) + ->one(); + } + + /** + * @return EmailActivation|null + */ + public function getActiveActivation() { + return $this->getAccount() + ->getEmailActivations() + ->andWhere(['type' => EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION]) + ->andWhere(['>=', 'created_at', time() - self::REPEAT_FREQUENCY]) + ->one(); + } + +} diff --git a/common/models/Account.php b/common/models/Account.php index b961a50..f9f0086 100644 --- a/common/models/Account.php +++ b/common/models/Account.php @@ -189,7 +189,7 @@ class Account extends ActiveRecord implements IdentityInterface { } public function getEmailActivations() { - return $this->hasMany(EmailActivation::class, ['id' => 'account_id']); + return $this->hasMany(EmailActivation::class, ['account_id' => 'id']); } public function getSessions() { @@ -206,9 +206,9 @@ class Account extends ActiveRecord implements IdentityInterface { * @return bool */ public function canAutoApprove(OauthClient $client, array $scopes = []) { - //if ($client->is_trusted) { - // return true; - //} + if ($client->is_trusted) { + return true; + } /** @var OauthSession|null $session */ $session = $this->getSessions()->andWhere(['client_id' => $client->id])->one(); diff --git a/tests/codeception/api/_pages/EmailConfirmRoute.php b/tests/codeception/api/_pages/EmailConfirmRoute.php deleted file mode 100644 index c664907..0000000 --- a/tests/codeception/api/_pages/EmailConfirmRoute.php +++ /dev/null @@ -1,19 +0,0 @@ -actor->sendPOST($this->getUrl(), [ - 'key' => $key, - ]); - } - -} diff --git a/tests/codeception/api/_pages/RegisterRoute.php b/tests/codeception/api/_pages/RegisterRoute.php deleted file mode 100644 index 89d166c..0000000 --- a/tests/codeception/api/_pages/RegisterRoute.php +++ /dev/null @@ -1,17 +0,0 @@ -actor->sendPOST($this->getUrl(), $registrationData); - } - -} diff --git a/tests/codeception/api/_pages/SignupRoute.php b/tests/codeception/api/_pages/SignupRoute.php new file mode 100644 index 0000000..a74c460 --- /dev/null +++ b/tests/codeception/api/_pages/SignupRoute.php @@ -0,0 +1,28 @@ +route = ['signup/index']; + $this->actor->sendPOST($this->getUrl(), $registrationData); + } + + public function sendRepeatMessage($email = '') { + $this->route = ['signup/repeat-message']; + $this->actor->sendPOST($this->getUrl(), ['email' => $email]); + } + + public function confirm($key = '') { + $this->route = ['signup/confirm']; + $this->actor->sendPOST($this->getUrl(), [ + 'key' => $key, + ]); + } + +} diff --git a/tests/codeception/api/functional/EmailConfirmationCest.php b/tests/codeception/api/functional/EmailConfirmationCest.php index c3d563d..0223d44 100644 --- a/tests/codeception/api/functional/EmailConfirmationCest.php +++ b/tests/codeception/api/functional/EmailConfirmationCest.php @@ -1,12 +1,12 @@ wantTo('see error.key_is_required expected if key is not set'); $route->confirm(); @@ -28,7 +28,7 @@ class EmailConfirmationCest { } public function testLoginByEmailCorrect(FunctionalTester $I) { - $route = new EmailConfirmRoute($I); + $route = new SignupRoute($I); $I->wantTo('confirm my email using correct activation key'); $route->confirm('HABGCABHJ1234HBHVD'); diff --git a/tests/codeception/api/functional/LoginCest.php b/tests/codeception/api/functional/LoginCest.php index adff510..73c6eb8 100644 --- a/tests/codeception/api/functional/LoginCest.php +++ b/tests/codeception/api/functional/LoginCest.php @@ -43,6 +43,7 @@ class LoginCest { 'login' => 'error.account_not_activated', ], ]); + $I->canSeeResponseJsonMatchesJsonPath('$.data.email'); $I->wantTo('don\'t see errors on login field if username is correct and exists in database'); $route->login('Admin'); diff --git a/tests/codeception/api/functional/RegisterCest.php b/tests/codeception/api/functional/RegisterCest.php index 8526afa..58c48bd 100644 --- a/tests/codeception/api/functional/RegisterCest.php +++ b/tests/codeception/api/functional/RegisterCest.php @@ -3,7 +3,7 @@ namespace tests\codeception\api\functional; use Codeception\Specify; use common\models\Account; -use tests\codeception\api\_pages\RegisterRoute; +use tests\codeception\api\_pages\SignupRoute; use tests\codeception\api\FunctionalTester; class RegisterCest { @@ -16,10 +16,10 @@ class RegisterCest { } public function testIncorrectRegistration(FunctionalTester $I) { - $route = new RegisterRoute($I); + $route = new SignupRoute($I); $I->wantTo('get error.you_must_accept_rules if we don\'t accept rules'); - $route->send([ + $route->register([ 'username' => 'ErickSkrauch', 'email' => 'erickskrauch@ely.by', 'password' => 'some_password', @@ -33,7 +33,7 @@ class RegisterCest { ]); $I->wantTo('don\'t see error.you_must_accept_rules if we accept rules'); - $route->send([ + $route->register([ 'rulesAgreement' => true, ]); $I->cantSeeResponseContainsJson([ @@ -43,7 +43,7 @@ class RegisterCest { ]); $I->wantTo('see error.username_required if username is not set'); - $route->send([ + $route->register([ 'username' => '', 'email' => '', 'password' => '', @@ -58,7 +58,7 @@ class RegisterCest { ]); $I->wantTo('don\'t see error.username_required if username is not set'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => '', 'password' => '', @@ -72,7 +72,7 @@ class RegisterCest { ]); $I->wantTo('see error.email_required if email is not set'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => '', 'password' => '', @@ -87,7 +87,7 @@ class RegisterCest { ]); $I->wantTo('see error.email_invalid if email is set, but invalid'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'invalid@email', 'password' => '', @@ -102,7 +102,7 @@ class RegisterCest { ]); $I->wantTo('see error.email_invalid if email is set, valid, but domain doesn\'t exist or don\'t have mx record'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'invalid@govnomail.com', 'password' => '', @@ -117,7 +117,7 @@ class RegisterCest { ]); $I->wantTo('see error.email_not_available if email is set, fully valid, but not available for registration'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'admin@ely.by', 'password' => '', @@ -132,7 +132,7 @@ class RegisterCest { ]); $I->wantTo('don\'t see errors on email if all valid'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'erickskrauch@ely.by', 'password' => '', @@ -142,7 +142,7 @@ class RegisterCest { $I->cantSeeResponseJsonMatchesJsonPath('$.errors.email'); $I->wantTo('see error.password_required if password is not set'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'erickskrauch@ely.by', 'password' => '', @@ -157,7 +157,7 @@ class RegisterCest { ]); $I->wantTo('see error.password_too_short before it will be compared with rePassword'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'correct-email@ely.by', 'password' => 'short', @@ -173,7 +173,7 @@ class RegisterCest { $I->cantSeeResponseJsonMatchesJsonPath('$.errors.rePassword'); $I->wantTo('see error.rePassword_required if password valid and rePassword not set'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'correct-email@ely.by', 'password' => 'valid-password', @@ -188,7 +188,7 @@ class RegisterCest { ]); $I->wantTo('see error.rePassword_does_not_match if password valid and rePassword donen\'t match it'); - $route->send([ + $route->register([ 'username' => 'valid_nickname', 'email' => 'correct-email@ely.by', 'password' => 'valid-password', @@ -205,10 +205,10 @@ class RegisterCest { } public function testUserCorrectRegistration(FunctionalTester $I) { - $route = new RegisterRoute($I); + $route = new SignupRoute($I); $I->wantTo('ensure that signup works'); - $route->send([ + $route->register([ 'username' => 'some_username', 'email' => 'some_email@example.com', 'password' => 'some_password', diff --git a/tests/codeception/api/functional/RepeatAccountActivationCest.php b/tests/codeception/api/functional/RepeatAccountActivationCest.php new file mode 100644 index 0000000..8f24d67 --- /dev/null +++ b/tests/codeception/api/functional/RepeatAccountActivationCest.php @@ -0,0 +1,71 @@ +wantTo('error.email_required on empty for submitting'); + $route->sendRepeatMessage(); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'email' => 'error.email_required', + ], + ]); + + $I->wantTo('error.email_not_found if email is not presented in db'); + $route->sendRepeatMessage('im-not@exists.net'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'email' => 'error.email_not_found', + ], + ]); + + $I->wantTo('error.account_already_activated if passed email matches with already activated account'); + $route->sendRepeatMessage('admin@ely.by'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'email' => 'error.account_already_activated', + ], + ]); + + $I->wantTo('error.recently_sent_message if last message was send too recently'); + $route->sendRepeatMessage('achristiansen@gmail.com'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson([ + 'success' => false, + 'errors' => [ + 'email' => 'error.recently_sent_message', + ], + ]); + $I->canSeeResponseJsonMatchesJsonPath('$.data.canRepeatIn'); + $I->canSeeResponseJsonMatchesJsonPath('$.data.repeatFrequency'); + } + + public function testSuccess(FunctionalTester $I) { + $route = new SignupRoute($I); + + $I->wantTo('successfully resend account activation message'); + $route->sendRepeatMessage('jon@ely.by'); + $I->canSeeResponseCodeIs(200); + $I->canSeeResponseIsJson(); + $I->canSeeResponseContainsJson(['success' => true]); + $I->cantSeeResponseJsonMatchesJsonPath('$.errors'); + } + +} diff --git a/tests/codeception/api/unit/models/RepeatAccountActivationFormTest.php b/tests/codeception/api/unit/models/RepeatAccountActivationFormTest.php new file mode 100644 index 0000000..07ebe07 --- /dev/null +++ b/tests/codeception/api/unit/models/RepeatAccountActivationFormTest.php @@ -0,0 +1,106 @@ +mailer; + $mailer->fileTransportCallback = function () { + return 'testing_message.eml'; + }; + } + + protected function tearDown() { + if (file_exists($this->getMessageFile())) { + unlink($this->getMessageFile()); + } + + parent::tearDown(); + } + + public function fixtures() { + return [ + 'accounts' => [ + 'class' => AccountFixture::class, + 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', + ], + 'activations' => [ + 'class' => EmailActivationFixture::class, + 'dataFile' => '@tests/codeception/common/fixtures/data/email-activations.php', + ], + ]; + } + + public function testValidateEmailForAccount() { + $this->specify('error.email_not_found if passed valid email, but it don\'t exists in database', function() { + $model = new RepeatAccountActivationForm(['email' => 'me-is-not@exists.net']); + $model->validateEmailForAccount('email'); + expect($model->getErrors('email'))->equals(['error.email_not_found']); + }); + + $this->specify('error.account_already_activated if passed valid email, but account already activated', function() { + $model = new RepeatAccountActivationForm(['email' => $this->accounts['admin']['email']]); + $model->validateEmailForAccount('email'); + expect($model->getErrors('email'))->equals(['error.account_already_activated']); + }); + + $this->specify('no errors if passed valid email for not activated account', function() { + $model = new RepeatAccountActivationForm(['email' => $this->accounts['not-activated-account']['email']]); + $model->validateEmailForAccount('email'); + expect($model->getErrors('email'))->isEmpty(); + }); + } + + public function testValidateExistsActivation() { + $this->specify('error.recently_sent_message if passed email has recently sent message', function() { + $model = new RepeatAccountActivationForm(['email' => $this->accounts['not-activated-account']['email']]); + $model->validateExistsActivation('email'); + expect($model->getErrors('email'))->equals(['error.recently_sent_message']); + }); + + $this->specify('no errors if passed email has expired activation message', function() { + $email = $this->accounts['not-activated-account-with-expired-message']['email']; + $model = new RepeatAccountActivationForm(['email' => $email]); + $model->validateExistsActivation('email'); + expect($model->getErrors('email'))->isEmpty(); + }); + } + + public function testSendRepeatMessage() { + $this->specify('no magic if we don\'t pass validation', function() { + $model = new RepeatAccountActivationForm(); + expect($model->sendRepeatMessage())->false(); + expect_file($this->getMessageFile())->notExists(); + }); + + $this->specify('successfully send new message if previous message has expired', function() { + $email = $this->accounts['not-activated-account-with-expired-message']['email']; + $model = new RepeatAccountActivationForm(['email' => $email]); + expect($model->sendRepeatMessage())->true(); + expect($model->getActiveActivation())->notNull(); + expect_file($this->getMessageFile())->exists(); + }); + } + + private function getMessageFile() { + /** @var \yii\swiftmailer\Mailer $mailer */ + $mailer = Yii::$app->mailer; + + return Yii::getAlias($mailer->fileTransportPath) . '/testing_message.eml'; + } + +} diff --git a/tests/codeception/common/fixtures/data/accounts.php b/tests/codeception/common/fixtures/data/accounts.php index eb70aef..fe8bf7a 100644 --- a/tests/codeception/common/fixtures/data/accounts.php +++ b/tests/codeception/common/fixtures/data/accounts.php @@ -38,5 +38,18 @@ return [ 'status' => \common\models\Account::STATUS_REGISTERED, 'created_at' => 1453146616, 'updated_at' => 1453146616, - ] + ], + 'not-activated-account-with-expired-message' => [ + 'id' => 4, + 'uuid' => '58a7bfdc-ad0f-44c3-9197-759cb9220895', + 'username' => 'Jon', + 'email' => 'jon@ely.by', + 'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0 + 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2, + 'password_reset_token' => null, + 'auth_key' => '45DsaEQ7U8lU9umIyCWk5iCnpdPvZ8Up', + 'status' => \common\models\Account::STATUS_REGISTERED, + 'created_at' => 1457890086, + 'updated_at' => 1457890086, + ], ]; diff --git a/tests/codeception/common/fixtures/data/email-activations.php b/tests/codeception/common/fixtures/data/email-activations.php index f0a859c..09c1aed 100644 --- a/tests/codeception/common/fixtures/data/email-activations.php +++ b/tests/codeception/common/fixtures/data/email-activations.php @@ -6,4 +6,10 @@ return [ 'type' => \common\models\EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION, 'created_at' => time(), ], + [ + 'key' => 'H23HBDCHHAG2HGHGHS', + 'account_id' => 4, + 'type' => \common\models\EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION, + 'created_at' => time() - \api\models\RepeatAccountActivationForm::REPEAT_FREQUENCY - 10, + ], ];