From 3281169b86ebcf3028b24d47c31c97760acee9ee Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Sun, 26 Nov 2017 20:09:09 +0100 Subject: [PATCH 1/9] Password expiration feature #102 It's still missing an enforcement which redirects all actions to profile update until the password is changed --- src/User/Bootstrap.php | 18 +++++++++++ src/User/Controller/AdminController.php | 17 +++++++++++ ...0000_000007_enable_password_expiration.php | 27 +++++++++++++++++ src/User/Model/User.php | 19 ++++++++++++ src/User/Module.php | 4 +++ src/User/Service/PasswordExpireService.php | 30 +++++++++++++++++++ src/User/resources/views/admin/index.php | 22 ++++++++++++-- 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/User/Migration/m000000_000007_enable_password_expiration.php create mode 100644 src/User/Service/PasswordExpireService.php diff --git a/src/User/Bootstrap.php b/src/User/Bootstrap.php index 0f36307..b1b69ac 100644 --- a/src/User/Bootstrap.php +++ b/src/User/Bootstrap.php @@ -24,6 +24,10 @@ use yii\console\Application as ConsoleApplication; use yii\i18n\PhpMessageSource; use yii\web\Application as WebApplication; +use yii\base\Event; +use Da\User\Event\FormEvent; +use Da\User\Controller\SecurityController; + /** * Bootstrap class of the yii2-usuario extension. Configures container services, initializes translations, * builds class map, and does the other setup actions participating in the application bootstrap process. @@ -88,6 +92,7 @@ class Bootstrap implements BootstrapInterface // services $di->set(Service\AccountConfirmationService::class); $di->set(Service\EmailChangeService::class); + $di->set(Service\PasswordExpireService::class); $di->set(Service\PasswordRecoveryService::class); $di->set(Service\ResendConfirmationService::class); $di->set(Service\ResetPasswordService::class); @@ -141,6 +146,19 @@ class Bootstrap implements BootstrapInterface $di->set(Search\RoleSearch::class); } + // Attach an event to check if the password has expired + Event::on(SecurityController::class, FormEvent::EVENT_AFTER_LOGIN, function (FormEvent $event) { + if (is_null(Yii::$app->getModule('user')->maxPasswordAge)) { + return; + } + $user = $event->form->user; + if ($user->password_age >= Yii::$app->getModule('user')->maxPasswordAge) { + // Force password change + Yii::$app->session->setFlash('warning', Yii::t('usuario', 'Your password has expired, you must change it now')); + Yii::$app->response->redirect(['/user/settings/account'])->send(); + } + }); + if ($app instanceof WebApplication) { // override Yii $di->set( diff --git a/src/User/Controller/AdminController.php b/src/User/Controller/AdminController.php index f0ddf23..9af1113 100644 --- a/src/User/Controller/AdminController.php +++ b/src/User/Controller/AdminController.php @@ -18,6 +18,7 @@ use Da\User\Model\Profile; use Da\User\Model\User; use Da\User\Query\UserQuery; use Da\User\Search\UserSearch; +use Da\User\Service\PasswordExpireService; use Da\User\Service\PasswordRecoveryService; use Da\User\Service\SwitchIdentityService; use Da\User\Service\UserBlockService; @@ -329,4 +330,20 @@ class AdminController extends Controller return $this->redirect(['index']); } + + /** + * Forces the user to change password at next login + * @param integer $id + */ + public function actionForcePasswordChange($id) + { + /** @var User $user */ + $user = $this->userQuery->where(['id' => $id])->one(); + if ($this->make(PasswordExpireService::class, [$user])->run()) { + Yii::$app->session->setFlash("success", Yii::t('usuario', 'User will be required to change password at next login')); + } else { + Yii::$app->session->setFlash("danger", Yii::t('usuario', 'There was an error in saving user')); + } + $this->redirect(['index']); + } } diff --git a/src/User/Migration/m000000_000007_enable_password_expiration.php b/src/User/Migration/m000000_000007_enable_password_expiration.php new file mode 100644 index 0000000..dcee448 --- /dev/null +++ b/src/User/Migration/m000000_000007_enable_password_expiration.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Migration; + +use yii\db\Migration; + +class m000000_000007_enable_password_expiration extends Migration +{ + public function safeUp() + { + $this->addColumn('{{%user}}', 'last_password_change', $this->timestamp()->null()); + } + + public function safeDown() + { + $this->dropColumn('{{%user}}', 'last_password_change'); + } +} diff --git a/src/User/Model/User.php b/src/User/Model/User.php index 7fc3771..b85147c 100644 --- a/src/User/Model/User.php +++ b/src/User/Model/User.php @@ -19,6 +19,7 @@ use Yii; use yii\base\NotSupportedException; use yii\behaviors\TimestampBehavior; use yii\db\ActiveRecord; +use yii\db\Expression; use yii\helpers\ArrayHelper; use yii\web\Application; use yii\web\IdentityInterface; @@ -46,6 +47,8 @@ use yii\web\IdentityInterface; * @property int $created_at * @property int $updated_at * @property int $last_login_at + * @property int $last_password_change + * @property int $password_age * * Defined relations: * @property SocialNetworkAccount[] $socialNetworkAccounts @@ -88,6 +91,7 @@ class User extends ActiveRecord implements IdentityInterface 'password_hash', $security->generatePasswordHash($this->password, $this->getModule()->blowfishCost) ); + $this->last_password_change = new Expression("NOW()"); } return parent::beforeSave($insert); @@ -138,6 +142,8 @@ class User extends ActiveRecord implements IdentityInterface 'created_at' => Yii::t('usuario', 'Registration time'), 'confirmed_at' => Yii::t('usuario', 'Confirmation time'), 'last_login_at' => Yii::t('usuario', 'Last login'), + 'last_password_change' => Yii::t('usuario', 'Last password change'), + 'password_age' => Yii::t('usuario', 'Password age'), ]; } @@ -312,4 +318,17 @@ class User extends ActiveRecord implements IdentityInterface { throw new NotSupportedException('Method "' . __CLASS__ . '::' . __METHOD__ . '" is not implemented.'); } + + /** + * Returns password age in days + * @return integer + */ + public function getPassword_age() + { + if (is_null($this->last_password_change)) { + return $this->getModule()->maxPasswordAge; + } + $d = new \DateTime($this->last_password_change); + return $d->diff(new \DateTime(), true)->format("%a"); + } } diff --git a/src/User/Module.php b/src/User/Module.php index 53b54b5..f6ee68f 100644 --- a/src/User/Module.php +++ b/src/User/Module.php @@ -128,4 +128,8 @@ class Module extends BaseModule * @var string the session key name to impersonate users. Please, modify it for security reasons! */ public $switchIdentitySessionKey = 'yuik_usuario'; + /** + * @var integer If != NULL sets a max password age in days + */ + public $maxPasswordAge = null; } diff --git a/src/User/Service/PasswordExpireService.php b/src/User/Service/PasswordExpireService.php new file mode 100644 index 0000000..6a7e75f --- /dev/null +++ b/src/User/Service/PasswordExpireService.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Service; + +use Da\User\Contracts\ServiceInterface; +use Da\User\Model\User; + +class PasswordExpireService implements ServiceInterface +{ + protected $model; + + public function __construct(User $model) + { + $this->model = $model; + } + + public function run() + { + return $this->model->updateAttributes(['last_login_at' => time()]); + } +} diff --git a/src/User/resources/views/admin/index.php b/src/User/resources/views/admin/index.php index cf2bf3c..8827ecc 100644 --- a/src/User/resources/views/admin/index.php +++ b/src/User/resources/views/admin/index.php @@ -92,6 +92,7 @@ $module = Yii::$app->getModule('user'); 'format' => 'raw', 'visible' => Yii::$app->getModule('user')->enableEmailConfirmation, ], + 'password_age', [ 'header' => Yii::t('usuario', 'Block status'), 'value' => function ($model) { @@ -121,7 +122,7 @@ $module = Yii::$app->getModule('user'); ], [ 'class' => 'yii\grid\ActionColumn', - 'template' => '{switch} {reset} {update} {delete}', + 'template' => '{switch} {reset} {force-password-change} {update} {delete}', 'buttons' => [ 'switch' => function ($url, $model) use ($module) { if ($model->id != Yii::$app->user->id && $module->enableSwitchIdentities) { @@ -158,7 +159,24 @@ $module = Yii::$app->getModule('user'); } return null; - } + }, + 'force-password-change' => function ($url, $model) use ($module) { + if (is_null($module->maxPasswordAge)) { + return null; + } + return Html::a( + '', + ['/user/admin/force-password-change', 'id' => $model->id], + [ + 'title' => Yii::t('usuario', 'Force password change at next login'), + 'data-confirm' => Yii::t( + 'usuario', + 'Are you sure you wish the user to change their password at next login?' + ), + 'data-method' => 'POST', + ] + ); + }, ] ], ], From 5484074d8e551327c12e8dbe1af50fb13222c397 Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Tue, 23 Jan 2018 05:15:01 +0100 Subject: [PATCH 2/9] Enforce password change event only if set #102 --- src/User/Bootstrap.php | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/User/Bootstrap.php b/src/User/Bootstrap.php index b1b69ac..18fb8d2 100644 --- a/src/User/Bootstrap.php +++ b/src/User/Bootstrap.php @@ -147,17 +147,16 @@ class Bootstrap implements BootstrapInterface } // Attach an event to check if the password has expired - Event::on(SecurityController::class, FormEvent::EVENT_AFTER_LOGIN, function (FormEvent $event) { - if (is_null(Yii::$app->getModule('user')->maxPasswordAge)) { - return; - } - $user = $event->form->user; - if ($user->password_age >= Yii::$app->getModule('user')->maxPasswordAge) { - // Force password change - Yii::$app->session->setFlash('warning', Yii::t('usuario', 'Your password has expired, you must change it now')); - Yii::$app->response->redirect(['/user/settings/account'])->send(); - } - }); + if (!is_null(Yii::$app->getModule('user')->maxPasswordAge)) { + Event::on(SecurityController::class, FormEvent::EVENT_AFTER_LOGIN, function (FormEvent $event) { + $user = $event->form->user; + if ($user->password_age >= Yii::$app->getModule('user')->maxPasswordAge) { + // Force password change + Yii::$app->session->setFlash('warning', Yii::t('usuario', 'Your password has expired, you must change it now')); + Yii::$app->response->redirect(['/user/settings/account'])->send(); + } + }); + } if ($app instanceof WebApplication) { // override Yii From ad0c6c86ba52f297723e6012d66357eb1881fe72 Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Tue, 23 Jan 2018 06:01:39 +0100 Subject: [PATCH 3/9] Password age check filter #102 --- src/User/Filter/PasswordAgeEnforceFilter.php | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/User/Filter/PasswordAgeEnforceFilter.php diff --git a/src/User/Filter/PasswordAgeEnforceFilter.php b/src/User/Filter/PasswordAgeEnforceFilter.php new file mode 100644 index 0000000..2da75dc --- /dev/null +++ b/src/User/Filter/PasswordAgeEnforceFilter.php @@ -0,0 +1,39 @@ + + * @author Lorenzo Milesi + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Da\User\Filter; + +use Yii; +use yii\base\ActionFilter; + +class PasswordAgeEnforceFilter extends ActionFilter +{ + public function beforeAction($action) + { + $maxPasswordAge = Yii::$app->getModule('user')->maxPasswordAge; + // If feature is not set do nothing (or raise a configuration error?) + if (is_null($maxPasswordAge)) { + return parent::beforeAction($action); + } + if (Yii::$app->user->isGuest) { + // Not our business + return parent::beforeAction($action); + } + if (Yii::$app->user->identity->password_age >= $maxPasswordAge) { + // Force password change + Yii::$app->getSession()->setFlash('warning', Yii::t('usuario', 'Your password has expired, you must change it now')); + return Yii::$app->response->redirect(['/user/settings/account'])->send(); + } + + return parent::beforeAction($action); + } +} From 0362c9bc047add06e66b09dc7dc4e0d68176377f Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Tue, 23 Jan 2018 06:01:43 +0100 Subject: [PATCH 4/9] Document the maxPasswordAge config parameter #102 --- docs/installation/configuration-options.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/installation/configuration-options.md b/docs/installation/configuration-options.md index 2519ef2..673b1d2 100644 --- a/docs/installation/configuration-options.md +++ b/docs/installation/configuration-options.md @@ -48,6 +48,25 @@ If `true` it will enable password recovery process. If `true` and `allowPasswordRecovery` is false, it will enable administrator to send a password recovery email to a user. +#### maxPasswordAge (type: `integer`, default: `null`) + +If set to an integer value it will check user password age. If the days since last password change are greater than this configuration value +user will be forced to change it. This enforcement is done only at login stage. In order to perform the check in every action you must configure +a filter into your controller like this: +``` +use Da\User\Filter\PasswordAgeEnforceFilter; +class SiteController extends Controller +{ + public function behaviors() + { + return [ + [...] + 'enforcePasswordAge' => [ + 'class' => PasswordAgeEnforceFilter::className(), + ], +``` +This will redirect the user to their account page until the password has been updated. + #### allowAccountDelete (type: `boolean`, default: `true`) If `true` users will be able to remove their own accounts. From 9605f4fa6cfc510fb316ec0adcd5810dce49d990 Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Tue, 30 Jan 2018 22:17:40 +0100 Subject: [PATCH 5/9] Updated CHANGELOG #102 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41856fb..cf36194 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 1.1.2 - Work in progress - Bug #125: Fix validation in non-ajax requests (faenir) - Bug #122: Fix wrong email message for email address change (liviuk2) +- Bug #102: Implemented password expiration feature (maxxer) ## 1.1.1 - November 27, 2017 - Bug #115: Convert client_id to string because pgsql fail with type convertion (Dezinger) From c004a7c4c117f76ccc0f529caffd50a727d09cc8 Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Wed, 31 Jan 2018 15:05:15 +0100 Subject: [PATCH 6/9] Fix tests --- tests/_data/schema.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/_data/schema.sql b/tests/_data/schema.sql index a700996..0c25fa7 100644 --- a/tests/_data/schema.sql +++ b/tests/_data/schema.sql @@ -73,6 +73,7 @@ CREATE TABLE `user` ( `auth_tf_enabled` tinyint(1) DEFAULT '0', `flags` int(11) NOT NULL DEFAULT '0', `last_login_at` int(11) DEFAULT NULL, + `last_password_change` timestamp, PRIMARY KEY (`id`), UNIQUE KEY `user_unique_email` (`email`), From 4583f147ffa3f2f6ee2f3153ce5d8c3d583d5f1c Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Tue, 6 Feb 2018 19:04:27 +0100 Subject: [PATCH 7/9] Renamed field to `password_changed_at` and type INT #102 --- .../m000000_000007_enable_password_expiration.php | 4 ++-- src/User/Model/User.php | 10 +++++----- tests/_data/schema.sql | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/User/Migration/m000000_000007_enable_password_expiration.php b/src/User/Migration/m000000_000007_enable_password_expiration.php index dcee448..f5d70e0 100644 --- a/src/User/Migration/m000000_000007_enable_password_expiration.php +++ b/src/User/Migration/m000000_000007_enable_password_expiration.php @@ -17,11 +17,11 @@ class m000000_000007_enable_password_expiration extends Migration { public function safeUp() { - $this->addColumn('{{%user}}', 'last_password_change', $this->timestamp()->null()); + $this->addColumn('{{%user}}', 'password_changed_at', $this->int()->null()); } public function safeDown() { - $this->dropColumn('{{%user}}', 'last_password_change'); + $this->dropColumn('{{%user}}', 'password_changed_at'); } } diff --git a/src/User/Model/User.php b/src/User/Model/User.php index b765171..4aacbea 100644 --- a/src/User/Model/User.php +++ b/src/User/Model/User.php @@ -50,7 +50,7 @@ use yii\web\IdentityInterface; * @property int $created_at * @property int $updated_at * @property int $last_login_at - * @property int $last_password_change + * @property int $password_changed_at * @property int $password_age * * Defined relations: @@ -98,7 +98,7 @@ class User extends ActiveRecord implements IdentityInterface 'password_hash', $security->generatePasswordHash($this->password, $this->getModule()->blowfishCost) ); - $this->last_password_change = new Expression("NOW()"); + $this->password_changed_at = time(); } return parent::beforeSave($insert); @@ -151,7 +151,7 @@ class User extends ActiveRecord implements IdentityInterface 'created_at' => Yii::t('usuario', 'Registration time'), 'confirmed_at' => Yii::t('usuario', 'Confirmation time'), 'last_login_at' => Yii::t('usuario', 'Last login'), - 'last_password_change' => Yii::t('usuario', 'Last password change'), + 'password_changed_at' => Yii::t('usuario', 'Last password change'), 'password_age' => Yii::t('usuario', 'Password age'), ]; } @@ -341,10 +341,10 @@ class User extends ActiveRecord implements IdentityInterface */ public function getPassword_age() { - if (is_null($this->last_password_change)) { + if (is_null($this->password_changed_at)) { return $this->getModule()->maxPasswordAge; } - $d = new \DateTime($this->last_password_change); + $d = new \DateTime($this->password_changed_at); return $d->diff(new \DateTime(), true)->format("%a"); } } diff --git a/tests/_data/schema.sql b/tests/_data/schema.sql index 0c25fa7..a282a51 100644 --- a/tests/_data/schema.sql +++ b/tests/_data/schema.sql @@ -73,7 +73,7 @@ CREATE TABLE `user` ( `auth_tf_enabled` tinyint(1) DEFAULT '0', `flags` int(11) NOT NULL DEFAULT '0', `last_login_at` int(11) DEFAULT NULL, - `last_password_change` timestamp, + `password_changed_at` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `user_unique_email` (`email`), From de543058a3b83b121d1f60fdb2ca04036f5dc13c Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Tue, 6 Feb 2018 23:26:44 +0100 Subject: [PATCH 8/9] Fix typo in column add #102 --- .../Migration/m000000_000007_enable_password_expiration.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/User/Migration/m000000_000007_enable_password_expiration.php b/src/User/Migration/m000000_000007_enable_password_expiration.php index f5d70e0..2761688 100644 --- a/src/User/Migration/m000000_000007_enable_password_expiration.php +++ b/src/User/Migration/m000000_000007_enable_password_expiration.php @@ -17,7 +17,7 @@ class m000000_000007_enable_password_expiration extends Migration { public function safeUp() { - $this->addColumn('{{%user}}', 'password_changed_at', $this->int()->null()); + $this->addColumn('{{%user}}', 'password_changed_at', $this->integer()->null()); } public function safeDown() From b9dc39b3a6cf158057f21994e80edf3f9baab49a Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Wed, 7 Feb 2018 22:17:49 +0100 Subject: [PATCH 9/9] Allow + sign in username --- src/User/Model/User.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/User/Model/User.php b/src/User/Model/User.php index f6340ad..567bf9e 100644 --- a/src/User/Model/User.php +++ b/src/User/Model/User.php @@ -175,7 +175,7 @@ class User extends ActiveRecord implements IdentityInterface return [ // username rules 'usernameRequired' => ['username', 'required', 'on' => ['register', 'create', 'connect', 'update']], - 'usernameMatch' => ['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.@]+$/'], + 'usernameMatch' => ['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.@\+]+$/'], 'usernameLength' => ['username', 'string', 'min' => 3, 'max' => 255], 'usernameTrim' => ['username', 'trim'], 'usernameUnique' => [