diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a0eed6..39497a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Bug #133: Fix user search returning no results in admin page (phiurs) - 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) 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. diff --git a/src/User/Bootstrap.php b/src/User/Bootstrap.php index 48a6952..f166761 100644 --- a/src/User/Bootstrap.php +++ b/src/User/Bootstrap.php @@ -25,6 +25,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. @@ -91,6 +95,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); @@ -144,6 +149,18 @@ class Bootstrap implements BootstrapInterface $di->set(Search\RoleSearch::class); } + // Attach an event to check if the password has expired + 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 $di->set( diff --git a/src/User/Controller/AdminController.php b/src/User/Controller/AdminController.php index 7875629..8a7ec8b 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; @@ -328,4 +329,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/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); + } +} 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..2761688 --- /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}}', 'password_changed_at', $this->integer()->null()); + } + + public function safeDown() + { + $this->dropColumn('{{%user}}', 'password_changed_at'); + } +} diff --git a/src/User/Model/User.php b/src/User/Model/User.php index 567bf9e..8fcf040 100644 --- a/src/User/Model/User.php +++ b/src/User/Model/User.php @@ -22,6 +22,7 @@ use yii\base\InvalidParamException; 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; @@ -49,6 +50,8 @@ use yii\web\IdentityInterface; * @property int $created_at * @property int $updated_at * @property int $last_login_at + * @property int $password_changed_at + * @property int $password_age * * Defined relations: * @property SocialNetworkAccount[] $socialNetworkAccounts @@ -95,6 +98,7 @@ class User extends ActiveRecord implements IdentityInterface 'password_hash', $security->generatePasswordHash($this->password, $this->getModule()->blowfishCost) ); + $this->password_changed_at = time(); } return parent::beforeSave($insert); @@ -147,6 +151,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'), + 'password_changed_at' => Yii::t('usuario', 'Last password change'), + 'password_age' => Yii::t('usuario', 'Password age'), ]; } @@ -328,4 +334,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->password_changed_at)) { + return $this->getModule()->maxPasswordAge; + } + $d = new \DateTime($this->password_changed_at); + return $d->diff(new \DateTime(), true)->format("%a"); + } } diff --git a/src/User/Module.php b/src/User/Module.php index be4ac9b..c7bb564 100644 --- a/src/User/Module.php +++ b/src/User/Module.php @@ -132,4 +132,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', + ] + ); + }, ] ], ], diff --git a/tests/_data/schema.sql b/tests/_data/schema.sql index a700996..a282a51 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, + `password_changed_at` int(11) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `user_unique_email` (`email`),