From c5279eb13c3c2a4be7d8403f8087f0ad120e7f52 Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Tue, 6 Dec 2016 00:24:24 +0100 Subject: [PATCH] update models + added social network account services --- lib/User/Bootstrap.php | 39 +--- lib/User/Contracts/AuthClientInterface.php | 17 ++ lib/User/Contracts/ServiceInterface.php | 10 +- lib/User/Controller/AdminController.php | 3 + lib/User/Form/LoginForm.php | 128 +++++++++++ lib/User/Form/RecoveryForm.php | 79 ++++++- lib/User/Form/RegistrationForm.php | 140 ++++++++++++ lib/User/Form/ResendForm.php | 116 ++++++++++ lib/User/Form/SettingsForm.php | 206 ++++++++++++++++++ lib/User/Helper/GravatarHelper.php | 15 ++ lib/User/Model/Profile.php | 146 ++++++++++++- lib/User/Model/SocialNetworkAccount.php | 93 ++++++++ lib/User/Model/Token.php | 95 +++++++- lib/User/Model/User.php | 61 +++++- lib/User/Module.php | 9 + lib/User/Query/SocialNetworkAccountQuery.php | 11 +- lib/User/Query/UserQuery.php | 15 ++ lib/User/Service/PasswordRecoveryService.php | 15 ++ lib/User/Service/ResetPasswordService.php | 15 ++ .../SocialNetworkAccountCreateService.php | 85 ++++++++ .../SocialNetworkAccountUserLinkService.php | 68 ++++++ lib/User/Validator/TimeZoneValidator.php | 20 ++ 22 files changed, 1324 insertions(+), 62 deletions(-) create mode 100644 lib/User/Contracts/AuthClientInterface.php create mode 100644 lib/User/Form/LoginForm.php create mode 100644 lib/User/Form/RegistrationForm.php create mode 100644 lib/User/Form/ResendForm.php create mode 100644 lib/User/Form/SettingsForm.php create mode 100644 lib/User/Helper/GravatarHelper.php create mode 100644 lib/User/Service/PasswordRecoveryService.php create mode 100644 lib/User/Service/ResetPasswordService.php create mode 100644 lib/User/Service/SocialNetworkAccountCreateService.php create mode 100644 lib/User/Service/SocialNetworkAccountUserLinkService.php create mode 100644 lib/User/Validator/TimeZoneValidator.php diff --git a/lib/User/Bootstrap.php b/lib/User/Bootstrap.php index 71f3dc8..48a710e 100644 --- a/lib/User/Bootstrap.php +++ b/lib/User/Bootstrap.php @@ -4,6 +4,7 @@ namespace Da\User; use Da\User\Helper\ClassMapHelper; use Da\User\Model\Profile; +use Da\User\Validator\TimeZoneValidator; use Yii; use yii\authclient\Collection; use yii\base\Application; @@ -50,47 +51,26 @@ class Bootstrap implements BootstrapInterface $di->set(Strategy\InsecureEmailChangeStrategy::class); $di->set(Strategy\SecureEmailChangeStrategy::class); - // models + classMap + // class map + query models $modelClassMap = []; foreach ($map as $class => $definition) { - $di->set($class, $definition); $model = is_array($definition) ? $definition['class'] : $definition; $name = (substr($class, strrpos($class, '\\') + 1)); $modelClassMap[$name] = $model; + if(in_array($name, ['User', 'Profile', 'Token', 'Account'])) { + $di->set("Da\\User\\Query\\{$name}Query", function() use ($model) { + return $model::find(); + }); + } } - // query classes - $di->set( - Query\ProfileQuery::class, - function () { - return Model\Profile::find(); - } - ); - $di->set( - Query\SocialNetworkAccountQuery::class, - function () { - return Model\SocialNetworkAccount::find(); - } - ); - $di->set( - Query\TokenQuery::class, - function () { - return Model\Token::find(); - } - ); - $di->set( - Query\UserQuery::class, - function () { - return Model\User::find(); - } - ); - // search class - $di->set(Search\UserSearch::class, [$di->get('UserQuery')]); + $di->set(Search\UserSearch::class, [$di->get(Query\UserQuery::class)]); // helpers $di->set(Helper\AuthHelper::class); + $di->set(Helper\GravatarHelper::class); $di->setSingleton(ClassMapHelper::class, ClassMapHelper::class, [$modelClassMap]); if (php_sapi_name() !== 'cli') { @@ -120,6 +100,7 @@ class Bootstrap implements BootstrapInterface // validators $di->set(Validator\AjaxRequestModelValidator::class); + $di->set(TimeZoneValidator::class); } /** diff --git a/lib/User/Contracts/AuthClientInterface.php b/lib/User/Contracts/AuthClientInterface.php new file mode 100644 index 0000000..a6d8bf2 --- /dev/null +++ b/lib/User/Contracts/AuthClientInterface.php @@ -0,0 +1,17 @@ + - */ interface ServiceInterface { /** - * @return void + * @return bool */ public function run(); } diff --git a/lib/User/Controller/AdminController.php b/lib/User/Controller/AdminController.php index ca948bf..2454f70 100644 --- a/lib/User/Controller/AdminController.php +++ b/lib/User/Controller/AdminController.php @@ -28,6 +28,9 @@ class AdminController extends Controller use ModuleTrait; use ContainerTrait; + /** + * @var UserQuery + */ protected $userQuery; /** diff --git a/lib/User/Form/LoginForm.php b/lib/User/Form/LoginForm.php new file mode 100644 index 0000000..9dcb116 --- /dev/null +++ b/lib/User/Form/LoginForm.php @@ -0,0 +1,128 @@ +query = $query; + $this->securityHelper = $securityHelper; + parent::__construct($config); + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'login' => Yii::t('user', 'Login'), + 'password' => Yii::t('user', 'Password'), + 'rememberMe' => Yii::t('user', 'Remember me next time'), + ]; + } + + /** + * @inheritdoc + */ + public function rules() + { + return [ + 'requiredFields' => [['login', 'password'], 'required'], + 'loginTrim' => ['login', 'trim'], + 'passwordValidate' => [ + 'password', + function ($attribute) { + if ($this->user === null || + !$this->securityHelper->validatePassword($this->password, $this->user->password_hash) + ) { + $this->addError($attribute, Yii::t('user', 'Invalid login or password')); + } + } + ], + 'confirmationValidate' => [ + 'login', + function ($attribute) { + if ($this->user !== null) { + $module = $this->getModule(); + $confirmationRequired = $module->enableEmailConfirmation && !$module->allowUnconfirmedEmailLogin; + if ($confirmationRequired && !$this->user->getIsConfirmed()) { + $this->addError($attribute, Yii::t('user', 'You need to confirm your email address')); + } + if ($this->user->getIsBlocked()) { + $this->addError($attribute, Yii::t('user', 'Your account has been blocked')); + } + } + } + ], + 'rememberMe' => ['rememberMe', 'boolean'], + ]; + } + + /** + * Validates form and logs the user in. + * + * @return bool whether the user is logged in successfully + */ + public function login() + { + if ($this->validate()) { + $duration = $this->rememberMe ? $this->module->rememberLoginLifespan : 0; + return Yii::$app->getUser()->login($this->user, $duration); + } else { + return false; + } + } + + /** + * @inheritdoc + */ + public function beforeValidate() + { + if (parent::beforeValidate()) { + $this->user = $this->query->whereUsernameOrEmail(trim($this->login))->one(); + return true; + } + return false; + } +} diff --git a/lib/User/Form/RecoveryForm.php b/lib/User/Form/RecoveryForm.php index 7a1279e..c8faaee 100644 --- a/lib/User/Form/RecoveryForm.php +++ b/lib/User/Form/RecoveryForm.php @@ -1,15 +1,74 @@ - */ -class RecoveryForm -{ +use Da\User\Query\UserQuery; +use Da\User\Traits\ContainerTrait; +use Yii; +use yii\base\Model; +class RecoveryForm extends Model +{ + use ContainerTrait; + + const SCENARIO_REQUEST = 'request'; + const SCENARIO_RESET = 'reset'; + + /** + * @var string User's email + */ + public $email; + /** + * @var string User's password + */ + public $password; + /** + * @var UserQuery + */ + protected $query; + + /** + * @param UserQuery $query + * @param array $config + */ + public function __construct(UserQuery $query, array $config) + { + $this->query = $query; + parent::__construct($config); + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'email' => Yii::t('user', 'Email'), + 'password' => Yii::t('user', 'Password'), + ]; + } + + /** + * @inheritdoc + */ + public function scenarios() + { + return [ + self::SCENARIO_REQUEST => ['email'], + self::SCENARIO_RESET => ['password'], + ]; + } + + /** + * @inheritdoc + */ + public function rules() + { + return [ + 'emailTrim' => ['email', 'filter', 'filter' => 'trim'], + 'emailRequired' => ['email', 'required'], + 'emailPattern' => ['email', 'email'], + 'passwordRequired' => ['password', 'required'], + 'passwordLength' => ['password', 'string', 'max' => 72, 'min' => 6], + ]; + } } diff --git a/lib/User/Form/RegistrationForm.php b/lib/User/Form/RegistrationForm.php new file mode 100644 index 0000000..1738ff2 --- /dev/null +++ b/lib/User/Form/RegistrationForm.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace dektrium\user\models; + +use dektrium\user\traits\ModuleTrait; +use Yii; +use yii\base\Model; + +/** + * Registration form collects user input on registration process, validates it and creates new User model. + * + * @author Dmitry Erofeev + */ +class RegistrationForm extends Model +{ + use ModuleTrait; + /** + * @var string User email address + */ + public $email; + + /** + * @var string Username + */ + public $username; + + /** + * @var string Password + */ + public $password; + + /** + * @inheritdoc + */ + public function rules() + { + $user = $this->module->modelMap['User']; + + return [ + // username rules + 'usernameLength' => ['username', 'string', 'min' => 3, 'max' => 255], + 'usernameTrim' => ['username', 'filter', 'filter' => 'trim'], + 'usernamePattern' => ['username', 'match', 'pattern' => $user::$usernameRegexp], + 'usernameRequired' => ['username', 'required'], + 'usernameUnique' => [ + 'username', + 'unique', + 'targetClass' => $user, + 'message' => Yii::t('user', 'This username has already been taken') + ], + // email rules + 'emailTrim' => ['email', 'filter', 'filter' => 'trim'], + 'emailRequired' => ['email', 'required'], + 'emailPattern' => ['email', 'email'], + 'emailUnique' => [ + 'email', + 'unique', + 'targetClass' => $user, + 'message' => Yii::t('user', 'This email address has already been taken') + ], + // password rules + 'passwordRequired' => ['password', 'required', 'skipOnEmpty' => $this->module->enableGeneratingPassword], + 'passwordLength' => ['password', 'string', 'min' => 6, 'max' => 72], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'email' => Yii::t('user', 'Email'), + 'username' => Yii::t('user', 'Username'), + 'password' => Yii::t('user', 'Password'), + ]; + } + + /** + * @inheritdoc + */ + public function formName() + { + return 'register-form'; + } + + /** + * Registers a new user account. If registration was successful it will set flash message. + * + * @return bool + */ + public function register() + { + if (!$this->validate()) { + return false; + } + + /** @var User $user */ + $user = Yii::createObject(User::className()); + $user->setScenario('register'); + $this->loadAttributes($user); + + if (!$user->register()) { + return false; + } + + Yii::$app->session->setFlash( + 'info', + Yii::t( + 'user', + 'Your account has been created and a message with further instructions has been sent to your email' + ) + ); + + return true; + } + + /** + * Loads attributes to the user model. You should override this method if you are going to add new fields to the + * registration form. You can read more in special guide. + * + * By default this method set all attributes of this model to the attributes of User model, so you should properly + * configure safe attributes of your User model. + * + * @param User $user + */ + protected function loadAttributes(User $user) + { + $user->setAttributes($this->attributes); + } +} diff --git a/lib/User/Form/ResendForm.php b/lib/User/Form/ResendForm.php new file mode 100644 index 0000000..97e89d9 --- /dev/null +++ b/lib/User/Form/ResendForm.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace dektrium\user\models; + +use dektrium\user\Finder; +use dektrium\user\Mailer; +use yii\base\Model; + +/** + * ResendForm gets user email address and if user with given email is registered it sends new confirmation message + * to him in case he did not validate his email. + * + * @author Dmitry Erofeev + */ +class ResendForm extends Model +{ + /** + * @var string + */ + public $email; + + /** + * @var Mailer + */ + protected $mailer; + + /** + * @var Finder + */ + protected $finder; + + /** + * @param Mailer $mailer + * @param Finder $finder + * @param array $config + */ + public function __construct(Mailer $mailer, Finder $finder, $config = []) + { + $this->mailer = $mailer; + $this->finder = $finder; + parent::__construct($config); + } + + /** + * @inheritdoc + */ + public function rules() + { + return [ + 'emailRequired' => ['email', 'required'], + 'emailPattern' => ['email', 'email'], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'email' => \Yii::t('user', 'Email'), + ]; + } + + /** + * @inheritdoc + */ + public function formName() + { + return 'resend-form'; + } + + /** + * Creates new confirmation token and sends it to the user. + * + * @return bool + */ + public function resend() + { + if (!$this->validate()) { + return false; + } + + $user = $this->finder->findUserByEmail($this->email); + + if ($user instanceof User && !$user->isConfirmed) { + /** @var Token $token */ + $token = \Yii::createObject([ + 'class' => Token::className(), + 'user_id' => $user->id, + 'type' => Token::TYPE_CONFIRMATION, + ]); + $token->save(false); + $this->mailer->sendConfirmationMessage($user, $token); + } + + \Yii::$app->session->setFlash( + 'info', + \Yii::t( + 'user', + 'A message has been sent to your email address. It contains a confirmation link that you must click to complete registration.' + ) + ); + + return true; + } +} diff --git a/lib/User/Form/SettingsForm.php b/lib/User/Form/SettingsForm.php new file mode 100644 index 0000000..2b78fa1 --- /dev/null +++ b/lib/User/Form/SettingsForm.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace dektrium\user\models; + +use dektrium\user\helpers\Password; +use dektrium\user\Mailer; +use dektrium\user\Module; +use dektrium\user\traits\ModuleTrait; +use Yii; +use yii\base\Model; + +/** + * SettingsForm gets user's username, email and password and changes them. + * + * @property User $user + * + * @author Dmitry Erofeev + */ +class SettingsForm extends Model +{ + use ModuleTrait; + + /** @var string */ + public $email; + + /** @var string */ + public $username; + + /** @var string */ + public $new_password; + + /** @var string */ + public $current_password; + + /** @var Mailer */ + protected $mailer; + + /** @var User */ + private $_user; + + /** @return User */ + public function getUser() + { + if ($this->_user == null) { + $this->_user = Yii::$app->user->identity; + } + + return $this->_user; + } + + /** @inheritdoc */ + public function __construct(Mailer $mailer, $config = []) + { + $this->mailer = $mailer; + $this->setAttributes([ + 'username' => $this->user->username, + 'email' => $this->user->unconfirmed_email ?: $this->user->email, + ], false); + parent::__construct($config); + } + + /** @inheritdoc */ + public function rules() + { + return [ + 'usernameRequired' => ['username', 'required'], + 'usernameTrim' => ['username', 'filter', 'filter' => 'trim'], + 'usernameLength' => ['username', 'string', 'min' => 3, 'max' => 255], + 'usernamePattern' => ['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.@]+$/'], + 'emailRequired' => ['email', 'required'], + 'emailTrim' => ['email', 'filter', 'filter' => 'trim'], + 'emailPattern' => ['email', 'email'], + 'emailUsernameUnique' => [['email', 'username'], 'unique', 'when' => function ($model, $attribute) { + return $this->user->$attribute != $model->$attribute; + }, 'targetClass' => $this->module->modelMap['User']], + 'newPasswordLength' => ['new_password', 'string', 'max' => 72, 'min' => 6], + 'currentPasswordRequired' => ['current_password', 'required'], + 'currentPasswordValidate' => ['current_password', function ($attr) { + if (!Password::validate($this->$attr, $this->user->password_hash)) { + $this->addError($attr, Yii::t('user', 'Current password is not valid')); + } + }], + ]; + } + + /** @inheritdoc */ + public function attributeLabels() + { + return [ + 'email' => Yii::t('user', 'Email'), + 'username' => Yii::t('user', 'Username'), + 'new_password' => Yii::t('user', 'New password'), + 'current_password' => Yii::t('user', 'Current password'), + ]; + } + + /** @inheritdoc */ + public function formName() + { + return 'settings-form'; + } + + /** + * Saves new account settings. + * + * @return bool + */ + public function save() + { + if ($this->validate()) { + $this->user->scenario = 'settings'; + $this->user->username = $this->username; + $this->user->password = $this->new_password; + if ($this->email == $this->user->email && $this->user->unconfirmed_email != null) { + $this->user->unconfirmed_email = null; + } elseif ($this->email != $this->user->email) { + switch ($this->module->emailChangeStrategy) { + case Module::STRATEGY_INSECURE: + $this->insecureEmailChange(); + break; + case Module::STRATEGY_DEFAULT: + $this->defaultEmailChange(); + break; + case Module::STRATEGY_SECURE: + $this->secureEmailChange(); + break; + default: + throw new \OutOfBoundsException('Invalid email changing strategy'); + } + } + + return $this->user->save(); + } + + return false; + } + + /** + * Changes user's email address to given without any confirmation. + */ + protected function insecureEmailChange() + { + $this->user->email = $this->email; + Yii::$app->session->setFlash('success', Yii::t('user', 'Your email address has been changed')); + } + + /** + * Sends a confirmation message to user's email address with link to confirm changing of email. + */ + protected function defaultEmailChange() + { + $this->user->unconfirmed_email = $this->email; + /** @var Token $token */ + $token = Yii::createObject([ + 'class' => Token::className(), + 'user_id' => $this->user->id, + 'type' => Token::TYPE_CONFIRM_NEW_EMAIL, + ]); + $token->save(false); + $this->mailer->sendReconfirmationMessage($this->user, $token); + Yii::$app->session->setFlash( + 'info', + Yii::t('user', 'A confirmation message has been sent to your new email address') + ); + } + + /** + * Sends a confirmation message to both old and new email addresses with link to confirm changing of email. + * + * @throws \yii\base\InvalidConfigException + */ + protected function secureEmailChange() + { + $this->defaultEmailChange(); + /** @var Token $token */ + $token = Yii::createObject([ + 'class' => Token::className(), + 'user_id' => $this->user->id, + 'type' => Token::TYPE_CONFIRM_OLD_EMAIL, + ]); + $token->save(false); + $this->mailer->sendReconfirmationMessage($this->user, $token); + + // unset flags if they exist + $this->user->flags &= ~User::NEW_EMAIL_CONFIRMED; + $this->user->flags &= ~User::OLD_EMAIL_CONFIRMED; + $this->user->save(false); + + Yii::$app->session->setFlash( + 'info', + Yii::t( + 'user', + 'We have sent confirmation links to both old and new email addresses. You must click both links to complete your request' + ) + ); + } +} diff --git a/lib/User/Helper/GravatarHelper.php b/lib/User/Helper/GravatarHelper.php new file mode 100644 index 0000000..0ad2c04 --- /dev/null +++ b/lib/User/Helper/GravatarHelper.php @@ -0,0 +1,15 @@ +isAttributeChanged('gravatar_email')) { + + $this->setAttribute( + 'gravatar_id', + $this->make(GravatarHelper::class)->buildId(trim($this->getAttribute('gravatar_email'))) + ); + } + + return parent::beforeSave($insert); + } + + /** + * @inheritdoc + */ + public static function tableName() + { + return '{{%profile}}'; + } + + /** + * @inheritdoc + */ + public function rules() + { + return [ + 'bioString' => ['bio', 'string'], + 'timeZoneValidation' => [ + 'timezone', + function ($attribute) { + if ($this->make(TimeZoneValidator::class, [$attribute])->validate()) { + $this->addError($attribute, Yii::t('user', 'Time zone is not valid')); + } + } + ], + 'publicEmailPattern' => ['public_email', 'email'], + 'gravatarEmailPattern' => ['gravatar_email', 'email'], + 'websiteUrl' => ['website', 'url'], + 'nameLength' => ['name', 'string', 'max' => 255], + 'publicEmailLength' => ['public_email', 'string', 'max' => 255], + 'gravatarEmailLength' => ['gravatar_email', 'string', 'max' => 255], + 'locationLength' => ['location', 'string', 'max' => 255], + 'websiteLength' => ['website', 'string', 'max' => 255], + ]; + } + + /** + * @inheritdoc + */ + public function attributeLabels() + { + return [ + 'name' => Yii::t('user', 'Name'), + 'public_email' => Yii::t('user', 'Email (public)'), + 'gravatar_email' => Yii::t('user', 'Gravatar email'), + 'location' => Yii::t('user', 'Location'), + 'website' => Yii::t('user', 'Website'), + 'bio' => Yii::t('user', 'Bio'), + 'timezone' => Yii::t('user', 'Time zone'), + ]; + } + + /** + * Get the User's timezone. + * + * @return DateTimeZone + */ + public function getTimeZone() + { + try { + return new DateTimeZone($this->timezone); + } catch (Exception $e) { + return new DateTimeZone(Yii::$app->getTimeZone()); + } + } + + /** + * Set the User's timezone + * + * @param DateTimeZone $timezone + */ + public function setTimeZone(DateTimeZone $timezone) + { + $this->setAttribute('timezone', $timezone); + } + + /** + * Get User's local time + * + * @param DateTime|null $dateTime + * + * @return DateTime + */ + public function getLocalTimeZone(DateTime $dateTime = null) + { + return $dateTime === null ? new DateTime() : $dateTime->setTimezone($this->getTimeZone()); + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getUser() + { + return $this->hasOne($this->getClassMap()->get('User'), ['id' => 'user_id']); + } + + /** + * @param int $size + * + * @return mixed + */ + public function getAvatarUrl($size = 200) + { + return $this->make(GravatarHelper::class)->getUrl($this->gravatar_id, $size); + } + /** * @return ProfileQuery */ diff --git a/lib/User/Model/SocialNetworkAccount.php b/lib/User/Model/SocialNetworkAccount.php index 5a5050e..5738956 100644 --- a/lib/User/Model/SocialNetworkAccount.php +++ b/lib/User/Model/SocialNetworkAccount.php @@ -2,10 +2,103 @@ namespace Da\User\Model; use Da\User\Query\SocialNetworkAccountQuery; +use Da\User\Traits\ContainerTrait; +use Da\User\Traits\ModuleTrait; +use Yii; use yii\db\ActiveRecord; +use yii\helpers\Url; +/** + * /** + * @property integer $id Id + * @property integer $user_id User id, null if account is not bind to user + * @property string $provider Name of service + * @property string $client_id Account id + * @property string $data Account properties returned by social network (json encoded) + * @property string $decodedData Json-decoded properties + * @property string $code + * @property string $email + * @property string $username + * @property integer $created_at + * + * @property User $user User that this account is connected for. + */ class SocialNetworkAccount extends ActiveRecord { + use ModuleTrait; + use ContainerTrait; + + /** + * @var array json decoded properties + */ + protected $decodedData; + + /** + * @inheritdoc + */ + public static function tableName() + { + return '{{%social_account}}'; + } + + /** + * @return bool Whether this social account is connected to user. + */ + public function getIsConnected() + { + return $this->user_id != null; + } + + /** + * @return array json decoded properties + */ + public function getDecodedData() + { + if ($this->data !== null && $this->decodedData === null) { + $this->decodedData = json_decode($this->data); + } + + return $this->decodedData; + } + + /** + * @return string the connection url + */ + public function getConnectionUrl() + { + $code = Yii::$app->security->generateRandomString(); + $this->updateAttributes(['code' => md5($code)]); + + return Url::to(['/usr/registration/connect', 'code' => $code]); + } + + /** + * Connects account to a user + * + * @param User $user + * + * @return int + */ + public function connect(User $user) + { + return $this->updateAttributes( + [ + 'username' => null, + 'email' => null, + 'code' => null, + 'user_id' => $user->id, + ] + ); + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getUser() + { + return $this->hasOne($this->getClassMap()->get('User'), ['id' => 'user_id']); + } + /** * @return SocialNetworkAccountQuery */ diff --git a/lib/User/Model/Token.php b/lib/User/Model/Token.php index 4be910d..99d9fbe 100644 --- a/lib/User/Model/Token.php +++ b/lib/User/Model/Token.php @@ -1,17 +1,106 @@ '/user/registration/confirm', + self::TYPE_RECOVERY => '/usr/recovery/reset', + self::TYPE_CONFIRM_NEW_EMAIL => '/user/settings/confirm', + self::TYPE_CONFIRM_OLD_EMAIL => '/usr/settings/confirm' + ]; + + /** + * @inheritdoc + */ + public function beforeSave($insert) + { + if ($insert) { + $this->setAttribute('code', $this->make(SecurityHelper::class)->generateRandomString()); + static::deleteAll(['user_id' => $this->user_id, 'type' => $this->type]); + $this->setAttribute('created_at', time()); + } + + return parent::beforeSave($insert); + } + + /** + * @inheritdoc + */ + public static function tableName() + { + return '{{%token}}'; + } + + /** + * @inheritdoc + */ + public static function primaryKey() + { + return ['user_id', 'code', 'type']; + } + + /** + * @return \yii\db\ActiveQuery + */ + public function getUser() + { + return $this->hasOne($this->getClassMap()->get('User'), ['id' => 'user_id']); + } + + /** + * @return string + */ + public function getUrl() + { + return Url::to([$this->routes[$this->type], 'id' => $this->user_id, 'code' => $this->code], true); + } + + /** + * @return bool Whether token has expired. + */ + public function getIsExpired() + { + if ($this->type == static::TYPE_RECOVERY) { + $expirationTime = $this->getModule()->tokenRecoveryLifespan; + } elseif ($this->type >= static::TYPE_CONFIRMATION && $this->type <= static::TYPE_CONFIRM_OLD_EMAIL) { + $expirationTime = $this->getModule()->tokenConfirmationLifespan; + } else { + throw new RuntimeException(); + } + + return ($this->created_at + $expirationTime) < time(); + } + /** * @return TokenQuery */ diff --git a/lib/User/Model/User.php b/lib/User/Model/User.php index c349f74..07a25ca 100644 --- a/lib/User/Model/User.php +++ b/lib/User/Model/User.php @@ -1,6 +1,7 @@ make(SecurityHelper::class); + if ($insert) { + $this->setAttribute('auth_key', $security->generateRandomString()); + if (Yii::$app instanceof Application) { + $this->setAttribute('registration_ip', Yii::$app->request->getUserIP()); + } + } + + if (!empty($this->password)) { + $this->setAttribute( + 'password_hash', + $security->generatePasswordHash($this->password, $this->getModule()->blowfishCost) + ); + } + + return parent::beforeSave($insert); + } + + /** + * @inheritdoc + */ + public static function tableName() + { + return '{{%user}}'; + } + /** * @inheritdoc */ @@ -161,14 +195,6 @@ class User extends ActiveRecord implements IdentityInterface return static::findOne($id); } - /** - * @inheritdoc - */ - public static function findIdentityByAccessToken($token, $type = null) - { - throw new NotSupportedException('Method "' . __CLASS__ . '::' . __METHOD__ . '" is not implemented.'); - } - /** * @return bool whether is blocked or not. */ @@ -185,6 +211,14 @@ class User extends ActiveRecord implements IdentityInterface return $this->getAuth()->isAdmin($this->username); } + /** + * @return bool + */ + public function getIsConfirmed() + { + return $this->confirmed_at !== null; + } + /** * Checks whether a user has a specific role * @@ -214,10 +248,11 @@ class User extends ActiveRecord implements IdentityInterface /** @var SocialNetworkAccount[] $accounts */ $accounts = $this->hasMany($this->getClassMap()->get('Account'), ['user_id' => 'id'])->all(); - foreach($accounts as $account) { + foreach ($accounts as $account) { $this->connectedAccounts[$account->provider] = $account; } } + return $this->connectedAccounts; } @@ -228,4 +263,12 @@ class User extends ActiveRecord implements IdentityInterface { return new UserQuery(static::class); } + + /** + * @inheritdoc + */ + public static function findIdentityByAccessToken($token, $type = null) + { + throw new NotSupportedException('Method "' . __CLASS__ . '::' . __METHOD__ . '" is not implemented.'); + } } diff --git a/lib/User/Module.php b/lib/User/Module.php index 48895f8..4e2abca 100644 --- a/lib/User/Module.php +++ b/lib/User/Module.php @@ -58,6 +58,15 @@ class Module extends \yii\base\Module * @var array MailService configuration */ public $mailParams = []; + /** + * @var int the cost parameter used by the Blowfish hash algorithm. + * The higher the value of cost, + * the longer it takes to generate the hash and to verify a password against it. Higher cost + * therefore slows down a brute-force attack. For best protection against brute-force attacks, + * set it to the highest value that is tolerable on production servers. The time taken to + * compute the hash doubles for every increment by one of $cost. + */ + public $blowfishCost = 10; /** * @var array the url rules (routes) diff --git a/lib/User/Query/SocialNetworkAccountQuery.php b/lib/User/Query/SocialNetworkAccountQuery.php index 1df7978..95c9dc8 100644 --- a/lib/User/Query/SocialNetworkAccountQuery.php +++ b/lib/User/Query/SocialNetworkAccountQuery.php @@ -1,9 +1,18 @@ andWhere( + [ + 'provider' => $client->getId(), + 'client_id' => $client->getUserAttributes()['id'] + ] + ); + } } diff --git a/lib/User/Query/UserQuery.php b/lib/User/Query/UserQuery.php index 53661b2..452c734 100644 --- a/lib/User/Query/UserQuery.php +++ b/lib/User/Query/UserQuery.php @@ -6,5 +6,20 @@ use yii\db\ActiveQuery; class UserQuery extends ActiveQuery { + public function whereUsernameOrEmail($usernameOrEmail) + { + return filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL) + ? $this->whereEmail($usernameOrEmail) + : $this->whereUsername($usernameOrEmail); + } + public function whereEmail($email) + { + return $this->andWhere(['email' => $email]); + } + + public function whereUsername($username) + { + return $this->andWhere(['username' => $username]); + } } diff --git a/lib/User/Service/PasswordRecoveryService.php b/lib/User/Service/PasswordRecoveryService.php new file mode 100644 index 0000000..eed9202 --- /dev/null +++ b/lib/User/Service/PasswordRecoveryService.php @@ -0,0 +1,15 @@ + + */ +class PasswordRecoveryService +{ + +} diff --git a/lib/User/Service/ResetPasswordService.php b/lib/User/Service/ResetPasswordService.php new file mode 100644 index 0000000..99bd1b8 --- /dev/null +++ b/lib/User/Service/ResetPasswordService.php @@ -0,0 +1,15 @@ + + */ +class ResetPasswordService +{ + +} diff --git a/lib/User/Service/SocialNetworkAccountCreateService.php b/lib/User/Service/SocialNetworkAccountCreateService.php new file mode 100644 index 0000000..2f76896 --- /dev/null +++ b/lib/User/Service/SocialNetworkAccountCreateService.php @@ -0,0 +1,85 @@ +client = $client; + $this->query = $query; + } + + /** + * @return object + */ + public function run() + { + $data = $this->client->getUserAttributes(); + + /** @var SocialNetworkAccount $account */ + $account = Yii::createObject( + [ + 'class' => SocialNetworkAccount::class, + 'provider' => $this->client->getId(), + 'client_id' => $data['id'], + 'data' => json_encode($data), + 'username' => $this->client->getUserName(), + 'email' => $this->client->getEmail() + ] + ); + + if (($user = $this->getUser($account)) instanceof User) { + $account->user_id = $user->id; + } + + $account->save(false); + + return $account; + } + + protected function getUser(SocialNetworkAccount $account) + { + $user = $this->query->whereEmail($account->email)->one(); + if (null !== $user) { + return $user; + } + /** @var User $user */ + $user = Yii::createObject( + 'User', + [ + 'scenario' => 'connect', + 'username' => $account->username, + 'email' => $account->email + ] + ); + + if (!$user->validate(['email'])) { + $user->email = null; + } + + if (!$user->validate(['username'])) { + $user->username = null; + } + + return Yii::$container->get(UserCreateService::class, [$user])->run() ? $user : false; + } +} diff --git a/lib/User/Service/SocialNetworkAccountUserLinkService.php b/lib/User/Service/SocialNetworkAccountUserLinkService.php new file mode 100644 index 0000000..344f406 --- /dev/null +++ b/lib/User/Service/SocialNetworkAccountUserLinkService.php @@ -0,0 +1,68 @@ +client = $client; + $this->query = $query; + } + + public function run() + { + $account = $this->getSocialNetworkAccount(); + + if ($account->user === null) { + /** @var User $user */ + $user = Yii::$app->user->identity; + $account->link('user', $user); + + return true; + } + + return false; + } + + protected function getSocialNetworkAccount() + { + $account = $this->query->whereClient($this->client)->one(); + + if (null === $account) { + $data = $this->client->getUserAttributes(); + + $account = Yii::createObject( + [ + 'class' => SocialNetworkAccount::class, + 'provider' => $this->client->getId(), + 'client_id' => $data['id'], + 'data' => json_encode($data) + ] + ); + + $account->save(false); + } + + return $account; + } +} diff --git a/lib/User/Validator/TimeZoneValidator.php b/lib/User/Validator/TimeZoneValidator.php new file mode 100644 index 0000000..ae1b51f --- /dev/null +++ b/lib/User/Validator/TimeZoneValidator.php @@ -0,0 +1,20 @@ +timezone = $timezone; + } + + public function validate() + { + return in_array($this->timezone, timezone_identifiers_list()); + } + +}