Revision #8 has been created by waterloomatt on Nov 1, 2011, 2:12:41 AM with the memo:
Added WebUser class, added Time helper class, refactored ChangePasswordRule->changePasswordRequired to use the Time helper class
« previous (#7)
Changes
Title
unchanged
Force a User to Change Their Password (ChangePasswordFilter)
Category
unchanged
Tutorials
Yii version
unchanged
Tags
unchanged
security, filters, password, user management
Content
changed
[...]
{
public $passwordExpiry;
/**
* Checks if a user is required to change their password.
* @param type $user the user whose password needs to be validated
* @return boolean if the user must change their password
*/
public function changePasswordRequired($user)
{
$passwordChangeRequired = false;
if ($user->daysSincePasswordChange() > $this->passwordExpiry)
$passwordChangeRequired = true;
return $passwordChangeRequired;
}
}
?>
```
Controller Class (Parent Controller - client controllers should extend this one). These two methods instantiate the filter and link it to all controllers that inherit from this one.
```php
/**
* Creates a rule to validate user's password freshness.
* @return array the array of rules to validate against
*/
public function changePasswordRules()
{
return array(
'days' => 30,
);
}
/**
* Runs the Password filter
* @param type $filterChain
*/
public function filterChangePassword($filterChain)
{
$filter = new ChangePasswordFilter();
$filter->setRules($this->changePasswordRules());
$filter->filter($filterChain);
}
```
Client Controller Class. This is an example of my SiteController. The changePasswordFilter applies to all actions except for the ones listed after "-".
```php
/**
* @return array action filters
*/
public function filters()
{
return array(
'accessControl', // perform access control for CRUD operations
'changePassword - logout, login, autoLogin, password, securityCode, passwordVerify, verify, autoGeneratePassword',
'https',
);
}
```
These are two convenience methods I use in my User model. They're used to calculate the days since the user has changed his/her password.
```php
/**
* Returns the number of days since the user changed their password.
* @return integer the number of elapsed days
*/
public function daysSincePasswordChange()
{
return $this->calculateInterval($this->password_update_time);
}
/**
* Returns the number of days between two time intervals. If $end is null, 'now' will be used.
* @param string $start the start time. Should be an English textual description. Ex: 2011-06-28 08:06:53
* @param string $end the end time. Should be an English textual description. Ex: 2011-06-28 08:06:53
* @return integer the number of days
*/
public function calculateInterval($start, $end = null)
{
$startTime = strtotime($start);
$endTime = ($end == null) ? time() : $end;
$elapsedTime = abs($endTime - $startTime);
return round($elapsedTime / 86400);
}
```
The redirectToPasswordForm method. I include this in WebUser. It is copied from CWebUser->loginRequired() but redirects to the password form, instead of the login form.
```php
public function redirectToPasswordForm()
{
if (!Yii::app()->request->getIsAjaxRequest())
$this->setReturnUrl(Yii::app()->request->getUrl());
if (($url = $this->changePasswordUrl) !== null)
{
if (is_array($url))
{
$route = isset($url[0]) ? $url[0] : $app->defaultController;
$url = Yii::app()->createUrl($route, array_splice($url, 1));
}
Yii::app()->request->redirect($url);
}
else
throw new CHttpException(403, Yii::t('yii', 'Change Password Required'));
}
```
* @param $user the user whose password needs to be validated
* @return boolean if the user must change their password
*/
public function changePasswordRequired(User $user)
{
return Time::wasWithinLast($this->passwordExpiry, $user->password_update_time) ? false : true;
}
}
?>
```
Controller Class (Parent Controller - client controllers should extend this one). These two methods instantiate the filter and link it to all controllers that inherit from this one.
```php
/**
* Creates a rule to validate user's password freshness.
* @return array the array of rules to validate against
*/
public function changePasswordRules()
{
return array(
'days' => 30,
);
}
/**
* Runs the Password filter
* @param type $filterChain
*/
public function filterChangePassword($filterChain)
{
$filter = new ChangePasswordFilter();
$filter->setRules($this->changePasswordRules());
$filter->filter($filterChain);
}
```
Client Controller Class. This is an example of my SiteController. The changePasswordFilter applies to all actions except for the ones listed after "-".
```php
/**
* @return array action filters
*/
public function filters()
{
return array(
'accessControl', // perform access control for CRUD operations
'changePassword - logout, login, autoLogin, password, securityCode, passwordVerify, verify, autoGeneratePassword',
'https',
);
}
```
The redirectToPasswordForm method. I include this in WebUser. It is copied from CWebUser->loginRequired() but redirects to the password form, instead of the login form.
```php
<?php
/**
* WebUser class file.
*
* @author Matt Skelton
* @date 8-Jun-2011
*/
/**
* Provides additional properties and functionality to CWebUser.
*/
class WebUser extends CWebUser
{
// User access error codes
const ERROR_NONE = 1;
const ERROR_NEW_USER = 2;
const ERROR_PASSWORD_EXPIRED = 3;
private $accessError = self::ERROR_NONE;
/**
* Holds a reference to the currently logged in user model.
* @var User The currently logged in User Model.
*/
private $_model;
public $changePasswordUrl = array('/user/password');
public function init()
{
parent::init();
}
/**
* Returns the User model of the currently logged in user and null if
* is user is not logged in.
*
* @return User The model of the logged in user.
*/
public function getModel()
{
return $this->loadUser(Yii::app()->user->id);
}
/**
* Returns a boolean indicating if the currently logged in user is an Admin user.
* @return boolean whether the current application user is an admin.
*/
public function getIsAdmin()
{
$isAdmin = false;
if (strtolower($this->loadUser(Yii::app()->user->id)->role->name) == 'admin' ||
strtolower($this->loadUser(Yii::app()->user->id)->role->name) == 'super admin')
{
$isAdmin = true;
}
return $isAdmin;
}
/**
* Retrieves a User model from the database
* @param integer $id the id of the User to be retrieved
* @return User the user model
*/
protected function loadUser($id=null)
{
if ($this->_model === null)
{
if ($id !== null)
$this->_model = User::model()->findByPk($id);
}
return $this->_model;
}
public function redirectToPasswordForm()
{
if (!Yii::app()->request->getIsAjaxRequest())
$this->setReturnUrl(Yii::app()->request->getUrl());
if (($url = $this->changePasswordUrl) !== null)
{
if (is_array($url))
{
$route = isset($url[0]) ? $url[0] : $app->defaultController;
$url = Yii::app()->createUrl($route, array_splice($url, 1));
}
Yii::app()->request->redirect($url);
}
else
throw new CHttpException(403, Yii::t('yii', 'Change Password Required'));
}
}
?>
```
Time helper class used to calculate if the password has been updated within the specified time.
```php
<?php
/**
* Time class file.
*
* @author Matt Skelton
* @date 29-Sep-2011
*
* Provides convenience methods for date and time functionality.
*/
class Time
{
/**
* Returns a nicely formatted date string for given Datetime string.
*
* @param string $dateString Datetime string
* @param int $format Format of returned date
* @return string Formatted date string
*/
public static function nice($dateString = null, $format = 'D, M jS Y, H:i')
{
$date = ($dateString == null) ? time() : strtotime($dateString);
return date($format, $date);
}
/**
* Returns a formatted descriptive date string for given datetime string.
*
* If the given date is today, the returned string could be "Today, 6:54 pm".
* If the given date was yesterday, the returned string could be "Yesterday, 6:54 pm".
* If $dateString's year is the current year, the returned string does not
* include mention of the year.
*
* @param string $dateString Datetime string or Unix timestamp
* @return string Described, relative date string
*/
public static function niceShort($dateString = null)
{
$date = ($dateString == null) ? time() : strtotime($dateString);
$y = (self::isThisYear($date)) ? '' : ' Y';
if (self::isToday($date))
{
$ret = sprintf('Today, %s', date("g:i a", $date));
}
elseif (self::wasYesterday($date))
{
$ret = sprintf('Yesterday, %s', date("g:i a", $date));
}
else
{
$ret = date("M jS{$y}, H:i", $date);
}
return $ret;
}
/**
* Returns true if given date is today.
*
* @param string $date Unix timestamp
* @return boolean True if date is today
*/
public static function isToday($date)
{
return date('Y-m-d', $date) == date('Y-m-d', time());
}
/**
* Returns true if given date was yesterday
*
* @param string $date Unix timestamp
* @return boolean True if date was yesterday
*/
public static function wasYesterday($date)
{
return date('Y-m-d', $date) == date('Y-m-d', strtotime('yesterday'));
}
/**
* Returns true if given date is in this year
*
* @param string $date Unix timestamp
* @return boolean True if date is in this year
*/
public static function isThisYear($date)
{
return date('Y', $date) == date('Y', time());
}
/**
* Returns true if given date is in this week
*
* @param string $date Unix timestamp
* @return boolean True if date is in this week
*/
public static function isThisWeek($date)
{
return date('W Y', $date) == date('W Y', time());
}
/**
* Returns true if given date is in this month
*
* @param string $date Unix timestamp
* @return boolean True if date is in this month
*/
public static function isThisMonth($date)
{
return date('m Y', $date) == date('m Y', time());
}
/**
* Returns either a relative date or a formatted date depending
* on the difference between the current time and given datetime.
* $datetime should be in a <i>strtotime</i>-parsable format, like MySQL's datetime datatype.
*
* Options:
* 'format' => a fall back format if the relative time is longer than the duration specified by end
* 'end' => The end of relative time telling
*
* Relative dates look something like this:
* 3 weeks, 4 days ago
* 15 seconds ago
* Formatted dates look like this:
* on 02/18/2004
*
* The returned string includes 'ago' or 'on' and assumes you'll properly add a word
* like 'Posted ' before the function output.
*
* @param string $dateString Datetime string
* @param array $options Default format if timestamp is used in $dateString
* @return string Relative time string.
*/
public static function timeAgoInWords($dateTime, $options = array())
{
$now = time();
$inSeconds = strtotime($dateTime);
$backwards = ($inSeconds > $now);
$format = 'j/n/y';
$end = '+1 month';
if (is_array($options))
{
if (isset($options['format']))
{
$format = $options['format'];
unset($options['format']);
}
if (isset($options['end']))
{
$end = $options['end'];
unset($options['end']);
}
}
else
{
$format = $options;
}
if ($backwards)
{
$futureTime = $inSeconds;
$pastTime = $now;
}
else
{
$futureTime = $now;
$pastTime = $inSeconds;
}
$diff = $futureTime - $pastTime;
// If more than a week, then take into account the length of months
if ($diff >= 604800)
{
$current = array();
$date = array();
list($future['H'], $future['i'], $future['s'], $future['d'], $future['m'], $future['Y']) = explode('/', date('H/i/s/d/m/Y', $futureTime));
list($past['H'], $past['i'], $past['s'], $past['d'], $past['m'], $past['Y']) = explode('/', date('H/i/s/d/m/Y', $pastTime));
$years = $months = $weeks = $days = $hours = $minutes = $seconds = 0;
if ($future['Y'] == $past['Y'] && $future['m'] == $past['m'])
{
$months = 0;
$years = 0;
}
else
{
if ($future['Y'] == $past['Y'])
{
$months = $future['m'] - $past['m'];
}
else
{
$years = $future['Y'] - $past['Y'];
$months = $future['m'] + ((12 * $years) - $past['m']);
if ($months >= 12)
{
$years = floor($months / 12);
$months = $months - ($years * 12);
}
if ($future['m'] < $past['m'] && $future['Y'] - $past['Y'] == 1)
{
$years--;
}
}
}
if ($future['d'] >= $past['d'])
{
$days = $future['d'] - $past['d'];
}
else
{
$daysInPastMonth = date('t', $pastTime);
$daysInFutureMonth = date('t', mktime(0, 0, 0, $future['m'] - 1, 1, $future['Y']));
if (!$backwards)
{
$days = ($daysInPastMonth - $past['d']) + $future['d'];
}
else
{
$days = ($daysInFutureMonth - $past['d']) + $future['d'];
}
if ($future['m'] != $past['m'])
{
$months--;
}
}
if ($months == 0 && $years >= 1 && $diff < ($years * 31536000))
{
$months = 11;
$years--;
}
if ($months >= 12)
{
$years = $years + 1;
$months = $months - 12;
}
if ($days >= 7)
{
$weeks = floor($days / 7);
$days = $days - ($weeks * 7);
}
}
else
{
$years = $months = $weeks = 0;
$days = floor($diff / 86400);
$diff = $diff - ($days * 86400);
$hours = floor($diff / 3600);
$diff = $diff - ($hours * 3600);
$minutes = floor($diff / 60);
$diff = $diff - ($minutes * 60);
$seconds = $diff;
}
$relativeDate = '';
$diff = $futureTime - $pastTime;
if ($diff > abs($now - strtotime($end)))
{
$relativeDate = sprintf('on %s', date($format, $inSeconds));
}
else
{
if ($years > 0)
{
// years and months and days
$relativeDate .= ($relativeDate ? ', ' : '') . $years . ' ' . ($years == 1 ? 'year' : 'years');
$relativeDate .= $months > 0 ? ($relativeDate ? ', ' : '') . $months . ' ' . ($months == 1 ? 'month' : 'months') : '';
$relativeDate .= $weeks > 0 ? ($relativeDate ? ', ' : '') . $weeks . ' ' . ($weeks == 1 ? 'week' : 'weeks') : '';
$relativeDate .= $days > 0 ? ($relativeDate ? ', ' : '') . $days . ' ' . ($days == 1 ? 'day' : 'days') : '';
}
elseif (abs($months) > 0)
{
// months, weeks and days
$relativeDate .= ($relativeDate ? ', ' : '') . $months . ' ' . ($months == 1 ? 'month' : 'months');
$relativeDate .= $weeks > 0 ? ($relativeDate ? ', ' : '') . $weeks . ' ' . ($weeks == 1 ? 'week' : 'weeks') : '';
$relativeDate .= $days > 0 ? ($relativeDate ? ', ' : '') . $days . ' ' . ($days == 1 ? 'day' : 'days') : '';
}
elseif (abs($weeks) > 0)
{
// weeks and days
$relativeDate .= ($relativeDate ? ', ' : '') . $weeks . ' ' . ($weeks == 1 ? 'week' : 'weeks');
$relativeDate .= $days > 0 ? ($relativeDate ? ', ' : '') . $days . ' ' . ($days == 1 ? 'day' : 'days') : '';
}
elseif (abs($days) > 0)
{
// days and hours
$relativeDate .= ($relativeDate ? ', ' : '') . $days . ' ' . ($days == 1 ? 'day' : 'days');
$relativeDate .= $hours > 0 ? ($relativeDate ? ', ' : '') . $hours . ' ' . ($hours == 1 ? 'hour' : 'hours') : '';
}
elseif (abs($hours) > 0)
{
// hours and minutes
$relativeDate .= ($relativeDate ? ', ' : '') . $hours . ' ' . ($hours == 1 ? 'hour' : 'hours');
$relativeDate .= $minutes > 0 ? ($relativeDate ? ', ' : '') . $minutes . ' ' . ($minutes == 1 ? 'minute' : 'minutes') : '';
}
elseif (abs($minutes) > 0)
{
// minutes only
$relativeDate .= ($relativeDate ? ', ' : '') . $minutes . ' ' . ($minutes == 1 ? 'minute' : 'minutes');
}
else
{
// seconds only
$relativeDate .= ($relativeDate ? ', ' : '') . $seconds . ' ' . ($seconds == 1 ? 'second' : 'seconds');
}
if (!$backwards)
{
$relativeDate = sprintf('%s ago', $relativeDate);
}
}
return $relativeDate;
}
/**
* Returns true if specified datetime was within the interval specified, else false.
*
* @param mixed $timeInterval the numeric value with space then time type.
* Example of valid types: 6 hours, 2 days, 1 minute.
* @param mixed $dateString the datestring or unix timestamp to compare
* @param int $userOffset User's offset from GMT (in hours)
* @return bool whether the $dateString was withing the specified $timeInterval
*/
public static function wasWithinLast($timeInterval, $dateString, $userOffset = null)
{
$tmp = str_replace(' ', '', $timeInterval);
if (is_numeric($tmp))
{
$timeInterval = $tmp . ' ' . __('days', true);
}
$date = self::fromString($dateString, $userOffset);
$interval = self::fromString('-' . $timeInterval);
if ($date >= $interval && $date <= time())
{
return true;
}
return false;
}
/**
* Returns true if the specified date was in the past, else false.
*
* @param mixed $date the datestring (a valid strtotime) or unix timestamp to check
* @return boolean if the specified date was in the past
*/
public static function wasInThePast($date)
{
return self::fromString($date) < time() ? true : false;
}
/**
* Returns a UNIX timestamp, given either a UNIX timestamp or a valid strtotime() date string.
*
* @param string $dateString Datetime string
* @param int $userOffset User's offset from GMT (in hours)
* @return string Parsed timestamp
*/
public static function fromString($dateString, $userOffset = null)
{
if (empty($dateString))
{
return false;
}
if (is_integer($dateString) || is_numeric($dateString))
{
$date = intval($dateString);
}
else
{
$date = strtotime($dateString);
}
if ($userOffset !== null)
{
return $this->convert($date, $userOffset);
}
if ($date === -1)
{
return false;
}
return $date;
}
}
?>
```
The rule setup in Controller.php, _'days' => 30,_, is obviously very simple. It can be easily extended. For example, users of different roles might have different freshness requirements - Admin users - 10 days, regular users - 30 days, super admins - never etc. You'll just need to modify _ChangePasswordFilter->setRules()_ and _ChangePasswordRule->changePasswordRequired()_ to loop over the list of rules and perform your needed logic.
Hope this helps - feedback welcome.[...]