Код модели подтверждения через email теперь является первичным ключом тамблицы

Реализована форма подтверждения email, обмазана тестами
Слегка отрефакторена форма регистрации и авторизации в пользу выноса части логики в общего родителя
Проект зачищен от стандартных тестовых параметров
Пофикшены методы доступа к API
This commit is contained in:
ErickSkrauch 2016-01-21 00:14:29 +03:00
parent 44aaea2c08
commit 7e90f1838e
30 changed files with 430 additions and 107 deletions

View File

@ -34,11 +34,14 @@ return [
'urlManager' => [ 'urlManager' => [
'enablePrettyUrl' => true, 'enablePrettyUrl' => true,
'showScriptName' => false, 'showScriptName' => false,
'rules' => [], 'rules' => require __DIR__ . '/routes.php',
], ],
'reCaptcha' => [ 'reCaptcha' => [
'class' => 'api\components\ReCaptcha\Component', 'class' => 'api\components\ReCaptcha\Component',
], ],
'response' => [
'format' => \yii\web\Response::FORMAT_JSON,
],
], ],
'params' => $params, 'params' => $params,
]; ];

3
api/config/routes.php Normal file
View File

@ -0,0 +1,3 @@
<?php
return [
];

View File

@ -10,11 +10,10 @@ class AuthenticationController extends Controller {
public function behaviors() { public function behaviors() {
return array_merge(parent::behaviors(), [ return array_merge(parent::behaviors(), [
'access' => [ 'access' => [
'class' => AccessControl::className(), 'class' => AccessControl::class,
'only' => ['login'],
'rules' => [ 'rules' => [
[ [
'actions' => ['login', 'register'], 'actions' => ['login'],
'allow' => true, 'allow' => true,
'roles' => ['?'], 'roles' => ['?'],
], ],
@ -25,8 +24,7 @@ class AuthenticationController extends Controller {
public function verbs() { public function verbs() {
return [ return [
'login' => ['post'], 'login' => ['POST'],
'register' => ['post'],
]; ];
} }

View File

@ -1,6 +1,7 @@
<?php <?php
namespace api\controllers; namespace api\controllers;
use api\models\ConfirmEmailForm;
use api\models\RegistrationForm; use api\models\RegistrationForm;
use Yii; use Yii;
use yii\filters\AccessControl; use yii\filters\AccessControl;
@ -10,11 +11,10 @@ class SignupController extends Controller {
public function behaviors() { public function behaviors() {
return array_merge(parent::behaviors(), [ return array_merge(parent::behaviors(), [
'access' => [ 'access' => [
'class' => AccessControl::className(), 'class' => AccessControl::class,
'only' => ['register'],
'rules' => [ 'rules' => [
[ [
'actions' => ['register'], 'actions' => ['register', 'confirm'],
'allow' => true, 'allow' => true,
'roles' => ['?'], 'roles' => ['?'],
], ],
@ -23,6 +23,13 @@ class SignupController extends Controller {
]); ]);
} }
public function verbs() {
return [
'register' => ['POST'],
'confirm' => ['POST'],
];
}
public function actionRegister() { public function actionRegister() {
$model = new RegistrationForm(); $model = new RegistrationForm();
$model->load(Yii::$app->request->post()); $model->load(Yii::$app->request->post());
@ -38,4 +45,24 @@ class SignupController extends Controller {
]; ];
} }
public function actionConfirm() {
$model = new ConfirmEmailForm();
$model->load(Yii::$app->request->post());
if (!$model->confirm()) {
return [
'success' => false,
'errors' => $this->normalizeModelErrors($model->getErrors()),
];
}
// TODO: не уверен, что логин должен быть здесь + нужно разобраться с параметрами установки куки авторизации и сессии
$activationCode = $model->getActivationCodeModel();
$account = $activationCode->account;
Yii::$app->user->login($account);
return [
'success' => true,
];
}
} }

View File

@ -0,0 +1,12 @@
<?php
namespace api\models;
use yii\base\Model;
class BaseApiForm extends Model {
public function formName() {
return '';
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace api\models;
use common\models\EmailActivation;
class BaseKeyConfirmationForm extends BaseApiForm {
public $key;
private $model;
public function rules() {
return [
['key', 'required', 'message' => 'error.key_is_required'],
['key', 'validateKey'],
];
}
public function validateKey($attribute) {
if (!$this->hasErrors()) {
if ($this->getActivationCodeModel() === null) {
$this->addError($attribute, "error.{$attribute}_not_exists");
}
}
}
/**
* @return EmailActivation|null
*/
public function getActivationCodeModel() {
if ($this->model === null) {
$this->model = EmailActivation::findOne($this->key);
}
return $this->model;
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace api\models;
use common\models\Account;
use common\models\EmailActivation;
use Yii;
use yii\base\ErrorException;
class ConfirmEmailForm extends BaseKeyConfirmationForm {
public function confirm() {
if (!$this->validate()) {
return false;
}
$confirmModel = $this->getActivationCodeModel();
if ($confirmModel->type != EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION) {
$confirmModel->delete();
// TODO: вот где-то здесь нужно ещё попутно сгенерировать соответствующую ошибку
return false;
}
$transaction = Yii::$app->db->beginTransaction();
try {
$account = $confirmModel->account;
$account->status = Account::STATUS_ACTIVE;
if (!$confirmModel->delete()) {
throw new ErrorException('Unable remove activation key.');
}
if (!$account->save()) {
throw new ErrorException('Unable activate user account.');
}
$transaction->commit();
} catch (ErrorException $e) {
$transaction->rollBack();
throw $e;
}
return true;
}
}

View File

@ -3,9 +3,8 @@ namespace api\models;
use common\models\Account; use common\models\Account;
use Yii; use Yii;
use yii\base\Model;
class LoginForm extends Model { class LoginForm extends BaseApiForm {
public $login; public $login;
public $password; public $password;
@ -13,10 +12,6 @@ class LoginForm extends Model {
private $_account; private $_account;
public function formName() {
return '';
}
public function rules() { public function rules() {
return [ return [
['login', 'required', 'message' => 'error.login_required'], ['login', 'required', 'message' => 'error.login_required'],

View File

@ -7,9 +7,8 @@ use common\models\Account;
use common\models\EmailActivation; use common\models\EmailActivation;
use Yii; use Yii;
use yii\base\ErrorException; use yii\base\ErrorException;
use yii\base\Model;
class RegistrationForm extends Model { class RegistrationForm extends BaseApiForm {
public $username; public $username;
public $email; public $email;
@ -17,10 +16,6 @@ class RegistrationForm extends Model {
public $rePassword; public $rePassword;
public $rulesAgreement; public $rulesAgreement;
public function formName() {
return '';
}
public function rules() { public function rules() {
return [ return [
['rulesAgreement', 'required', 'message' => 'error.you_must_accept_rules'], ['rulesAgreement', 'required', 'message' => 'error.you_must_accept_rules'],

View File

@ -6,9 +6,8 @@ use yii\behaviors\TimestampBehavior;
/** /**
* Поля модели: * Поля модели:
* @property integer $id
* @property integer $account_id
* @property string $key * @property string $key
* @property integer $account_id
* @property integer $type * @property integer $type
* @property integer $created_at * @property integer $created_at
* *
@ -17,6 +16,10 @@ use yii\behaviors\TimestampBehavior;
* *
* Поведения: * Поведения:
* @mixin TimestampBehavior * @mixin TimestampBehavior
*
* TODO: у модели могут быть проблемы с уникальностью, т.к. key является первичным и не автоинкрементом
* TODO: мб стоит ловить beforeCreate и именно там генерировать уникальный ключ для модели.
* Но опять же нужно продумать, а как пробросить формат и обеспечить преемлемую уникальность.
*/ */
class EmailActivation extends \yii\db\ActiveRecord { class EmailActivation extends \yii\db\ActiveRecord {

View File

@ -0,0 +1,20 @@
<?php
use console\db\Migration;
class m160118_184027_email_activations_code_as_primary_key extends Migration {
public function safeUp() {
$this->dropColumn('{{%email_activations}}', 'id');
$this->dropIndex('key', '{{%email_activations}}');
$this->alterColumn('{{%email_activations}}', 'key', $this->string()->notNull() . ' FIRST');
$this->addPrimaryKey('key', '{{%email_activations}}', 'key');
}
public function safeDown() {
$this->dropPrimaryKey('key', '{{%email_activations}}');
$this->addColumn('{{%email_activations}}', 'id', $this->primaryKey() . ' FIRST');
$this->alterColumn('{{%email_activations}}', 'key', $this->string()->unique()->notNull() . ' AFTER `id`');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace tests\codeception\api\_pages;
use yii\codeception\BasePage;
/**
* @property \tests\codeception\api\FunctionalTester $actor
*/
class EmailConfirmRoute extends BasePage {
public $route = ['signup/confirm'];
public function confirm($key = '') {
$this->actor->sendPOST($this->getUrl(), [
'key' => $key,
]);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace tests\codeception\api;
use tests\codeception\api\_pages\EmailConfirmRoute;
class EmailConfirmationCest {
public function testLoginEmailOrUsername(FunctionalTester $I) {
$route = new EmailConfirmRoute($I);
$I->wantTo('see error.key_is_required expected if key is not set');
$route->confirm();
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'key' => 'error.key_is_required',
],
]);
$I->wantTo('see error.key_not_exists expected if key not exists in database');
$route->confirm('not-exists-key');
$I->canSeeResponseContainsJson([
'success' => false,
'errors' => [
'key' => 'error.key_not_exists',
],
]);
}
public function testLoginByEmailCorrect(FunctionalTester $I) {
$route = new EmailConfirmRoute($I);
$I->wantTo('confirm my email using correct activation key');
$route->confirm('HABGCABHJ1234HBHVD');
$I->canSeeResponseContainsJson([
'success' => true,
]);
$I->cantSeeResponseJsonMatchesJsonPath('$.errors');
}
}

View File

@ -5,14 +5,6 @@ use tests\codeception\api\_pages\LoginRoute;
class LoginCest { class LoginCest {
public function _before(FunctionalTester $I) {
}
public function _after(FunctionalTester $I) {
}
public function testLoginEmailOrUsername(FunctionalTester $I) { public function testLoginEmailOrUsername(FunctionalTester $I) {
$route = new LoginRoute($I); $route = new LoginRoute($I);

View File

@ -1,11 +1,9 @@
<?php <?php
namespace tests\codeception\api\unit; namespace tests\codeception\api\unit;
/**
* @inheritdoc class DbTestCase extends \yii\codeception\DbTestCase {
*/
class DbTestCase extends \yii\codeception\DbTestCase
{
public $appConfig = '@tests/codeception/config/api/unit.php'; public $appConfig = '@tests/codeception/config/api/unit.php';
} }

View File

@ -1,11 +1,9 @@
<?php <?php
namespace tests\codeception\api\unit; namespace tests\codeception\api\unit;
/**
* @inheritdoc class TestCase extends \yii\codeception\TestCase {
*/
class TestCase extends \yii\codeception\TestCase
{
public $appConfig = '@tests/codeception/config/api/unit.php'; public $appConfig = '@tests/codeception/config/api/unit.php';
} }

View File

@ -1,23 +0,0 @@
<?php
return [
[
'username' => 'okirlin',
'auth_key' => 'iwTNae9t34OmnK6l4vT4IeaTk-YWI2Rv',
'password_hash' => '$2y$13$CXT0Rkle1EMJ/c1l5bylL.EylfmQ39O5JlHJVFpNn618OUS1HwaIi',
'password_reset_token' => 't5GU9NwpuGYSfb7FEZMAxqtuz2PkEvv_' . time(),
'created_at' => '1391885313',
'updated_at' => '1391885313',
'email' => 'brady.renner@rutherford.com',
],
[
'username' => 'troy.becker',
'auth_key' => 'EdKfXrx88weFMV0vIxuTMWKgfK2tS3Lp',
'password_hash' => '$2y$13$g5nv41Px7VBqhS3hVsVN2.MKfgT3jFdkXEsMC4rQJLfaMa7VaJqL2',
'password_reset_token' => '4BSNyiZNAuxjs5Mty990c47sVrgllIi_' . time(),
'created_at' => '1391885313',
'updated_at' => '1391885313',
'email' => 'nicolas.dianna@hotmail.com',
'status' => '0',
],
];

View File

@ -0,0 +1,31 @@
<?php
namespace tests\codeception\api\models;
use api\models\BaseApiForm;
use Codeception\Specify;
use tests\codeception\api\unit\TestCase;
class BaseApiFormTest extends TestCase {
use Specify;
public function testLoad() {
$model = new DummyTestModel();
$this->specify('model should load data without ModelName array scope', function() use ($model) {
expect('model successful load data without prefix', $model->load(['field' => 'test-data']))->true();
expect('field is set as passed data', $model->field)->equals('test-data');
});
}
}
class DummyTestModel extends BaseApiForm {
public $field;
public function rules() {
return [
['field', 'safe'],
];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace tests\codeception\api\models;
use api\models\BaseKeyConfirmationForm;
use Codeception\Specify;
use tests\codeception\api\unit\DbTestCase;
use tests\codeception\common\fixtures\EmailActivationFixture;
use Yii;
/**
* @property array $emailActivations
*/
class BaseKeyConfirmationFormTest extends DbTestCase {
use Specify;
public function fixtures() {
return [
'emailActivations' => [
'class' => EmailActivationFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/email-activations.php',
],
];
}
protected function createModel($key = null) {
return new BaseKeyConfirmationForm([
'key' => $key,
]);
}
public function testEmptyKey() {
$model = $this->createModel();
$this->specify('get error.key_is_required with validating empty key field', function () use ($model) {
expect('model should don\'t pass validation', $model->validate())->false();
expect('error messages should be set', $model->errors)->equals([
'key' => [
'error.key_is_required',
],
]);
});
}
public function testIncorrectKey() {
$model = $this->createModel('not-exists-key');
$this->specify('get error.key_not_exists with validation wrong key', function () use ($model) {
expect('model should don\'t pass validation', $model->validate())->false();
expect('error messages should be set', $model->errors)->equals([
'key' => [
'error.key_not_exists',
],
]);
});
}
public function testCorrectKey() {
$model = $this->createModel($this->emailActivations[0]['key']);
$this->specify('no errors if key exists', function () use ($model) {
expect('model should pass validation', $model->validate())->true();
});
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace tests\codeception\api\models;
use api\models\ConfirmEmailForm;
use Codeception\Specify;
use common\models\Account;
use common\models\EmailActivation;
use tests\codeception\api\unit\DbTestCase;
use tests\codeception\common\fixtures\EmailActivationFixture;
use Yii;
/**
* @property array $emailActivations
*/
class ConfirmEmailFormTest extends DbTestCase {
use Specify;
public function fixtures() {
return [
'emailActivations' => [
'class' => EmailActivationFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/email-activations.php',
],
];
}
protected function createModel($key) {
return new ConfirmEmailForm([
'key' => $key,
]);
}
public function testValidInput() {
$fixture = $this->emailActivations[0];
$model = $this->createModel($fixture['key']);
$this->specify('expect true result', function() use ($model, $fixture) {
expect('model return successful result', $model->confirm())->true();
expect('email activation key is not exist', EmailActivation::find()->andWhere(['key' => $fixture['key']])->exists())->false();
/** @var Account $user */
$user = Account::findOne($fixture['account_id']);
expect('user status changed to active', $user->status)->equals(Account::STATUS_ACTIVE);
});
}
}

View File

@ -7,6 +7,9 @@ use tests\codeception\api\unit\DbTestCase;
use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\AccountFixture;
use Yii; use Yii;
/**
* @property array $accounts
*/
class LoginFormTest extends DbTestCase { class LoginFormTest extends DbTestCase {
use Specify; use Specify;
@ -17,8 +20,8 @@ class LoginFormTest extends DbTestCase {
public function fixtures() { public function fixtures() {
return [ return [
'account' => [ 'accounts' => [
'class' => AccountFixture::className(), 'class' => AccountFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php',
], ],
]; ];

View File

@ -9,6 +9,9 @@ use tests\codeception\api\unit\DbTestCase;
use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\AccountFixture;
use Yii; use Yii;
/**
* @property array $accounts
*/
class RegistrationFormTest extends DbTestCase { class RegistrationFormTest extends DbTestCase {
use Specify; use Specify;
@ -31,8 +34,8 @@ class RegistrationFormTest extends DbTestCase {
public function fixtures() { public function fixtures() {
return [ return [
'account' => [ 'accounts' => [
'class' => AccountFixture::className(), 'class' => AccountFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php',
], ],
]; ];

View File

@ -1,9 +1,9 @@
<?php <?php
namespace tests\codeception\common\_support; namespace tests\codeception\common\_support;
use Codeception\Module; use Codeception\Module;
use tests\codeception\common\fixtures\AccountFixture; use tests\codeception\common\fixtures\AccountFixture;
use tests\codeception\common\fixtures\EmailActivationFixture;
use yii\test\FixtureTrait; use yii\test\FixtureTrait;
use yii\test\InitDbFixture; use yii\test\InitDbFixture;
@ -61,6 +61,10 @@ class FixtureHelper extends Module {
'class' => AccountFixture::class, 'class' => AccountFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php', 'dataFile' => '@tests/codeception/common/fixtures/data/accounts.php',
], ],
'emailActivations' => [
'class' => EmailActivationFixture::class,
'dataFile' => '@tests/codeception/common/fixtures/data/email-activations.php',
],
]; ];
} }
} }

View File

@ -1,5 +1,4 @@
<?php <?php
namespace tests\codeception\common\fixtures; namespace tests\codeception\common\fixtures;
use common\models\Account; use common\models\Account;

View File

@ -0,0 +1,15 @@
<?php
namespace tests\codeception\common\fixtures;
use common\models\EmailActivation;
use yii\test\ActiveFixture;
class EmailActivationFixture extends ActiveFixture {
public $modelClass = EmailActivation::class;
public $depends = [
AccountFixture::class,
];
}

View File

@ -1,13 +0,0 @@
<?php
namespace tests\codeception\common\fixtures;
use yii\test\ActiveFixture;
/**
* User fixture
*/
class UserFixture extends ActiveFixture
{
public $modelClass = 'common\models\User';
}

View File

@ -6,10 +6,10 @@ return [
'username' => 'Admin', 'username' => 'Admin',
'email' => 'admin@ely.by', 'email' => 'admin@ely.by',
'password_hash' => '$2y$13$CXT0Rkle1EMJ/c1l5bylL.EylfmQ39O5JlHJVFpNn618OUS1HwaIi', # password_0 'password_hash' => '$2y$13$CXT0Rkle1EMJ/c1l5bylL.EylfmQ39O5JlHJVFpNn618OUS1HwaIi', # password_0
'password_hash_strategy' => 1, 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2,
'password_reset_token' => NULL, 'password_reset_token' => null,
'auth_key' => 'iwTNae9t34OmnK6l4vT4IeaTk-YWI2Rv', 'auth_key' => 'iwTNae9t34OmnK6l4vT4IeaTk-YWI2Rv',
'status' => 10, 'status' => \common\models\Account::STATUS_ACTIVE,
'created_at' => 1451775316, 'created_at' => 1451775316,
'updated_at' => 1451775316, 'updated_at' => 1451775316,
], ],
@ -19,11 +19,24 @@ return [
'username' => 'AccWithOldPassword', 'username' => 'AccWithOldPassword',
'email' => 'erickskrauch123@yandex.ru', 'email' => 'erickskrauch123@yandex.ru',
'password_hash' => '133c00c463cbd3e491c28cb653ce4718', # 12345678 'password_hash' => '133c00c463cbd3e491c28cb653ce4718', # 12345678
'password_hash_strategy' => 0, 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_OLD_ELY,
'password_reset_token' => NULL, 'password_reset_token' => null,
'auth_key' => 'ltTNae9t34OmnK6l4vT4IeaTk-YWI2Rv', 'auth_key' => 'ltTNae9t34OmnK6l4vT4IeaTk-YWI2Rv',
'status' => 10, 'status' => \common\models\Account::STATUS_ACTIVE,
'created_at' => 1385225069, 'created_at' => 1385225069,
'updated_at' => 1385225069, 'updated_at' => 1385225069,
], ],
'not-activated-account' => [
'id' => 3,
'uuid' => '86c6fedb-bffc-37a5-8c0f-62e8fa9a2af7',
'username' => 'howe.garnett',
'email' => 'achristiansen@gmail.com',
'password_hash' => '$2y$13$2rYkap5T6jG8z/mMK8a3Ou6aZxJcmAaTha6FEuujvHEmybSHRzW5e', # password_0
'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2,
'password_reset_token' => null,
'auth_key' => '3AGc12Q7U8lU9umIyCWk5iCnpdPvZ8Up',
'status' => \common\models\Account::STATUS_REGISTERED,
'created_at' => 1453146616,
'updated_at' => 1453146616,
]
]; ];

View File

@ -0,0 +1,9 @@
<?php
return [
[
'key' => 'HABGCABHJ1234HBHVD',
'account_id' => 3,
'type' => \common\models\EmailActivation::TYPE_REGISTRATION_EMAIL_CONFIRMATION,
'created_at' => time(),
],
];

View File

@ -1,14 +0,0 @@
<?php
return [
[
'username' => 'erau',
'auth_key' => 'tUu1qHcde0diwUol3xeI-18MuHkkprQI',
// password_0
'password_hash' => '$2y$13$nJ1WDlBaGcbCdbNC5.5l4.sgy.OMEKCqtDQOdQ2OWpgiKRWYyzzne',
'password_reset_token' => 'RkD_Jw0_8HEedzLk7MM-ZKEFfYR7VbMr_1392559490',
'created_at' => '1392559490',
'updated_at' => '1392559490',
'email' => 'sfriesen@jenkins.info',
],
];

View File

@ -7,11 +7,14 @@
$security = Yii::$app->getSecurity(); $security = Yii::$app->getSecurity();
return [ return [
'uuid' => $faker->uuid,
'username' => $faker->userName, 'username' => $faker->userName,
'email' => $faker->email, 'email' => $faker->email,
'auth_key' => $security->generateRandomString(),
'password_hash' => $security->generatePasswordHash('password_' . $index), 'password_hash' => $security->generatePasswordHash('password_' . $index),
'password_reset_token' => $security->generateRandomString() . '_' . time(), 'password_hash_strategy' => \common\models\Account::PASS_HASH_STRATEGY_YII2,
'password_reset_token' => NULL,
'auth_key' => $security->generateRandomString(),
'status' => \common\models\Account::STATUS_ACTIVE,
'created_at' => time(), 'created_at' => time(),
'updated_at' => time(), 'updated_at' => time(),
]; ];