Difference between #8 and #7 of Force a User to Change Their Password (ChangePasswordFilter)

unchanged
Title
Force a User to Change Their Password (ChangePasswordFilter)
unchanged
Category
Tutorials
unchanged
Tags
security, filters, password, user management
changed
Content
Sometimes you need to force a user to change their password after x number of
days. This article describes how to implement this using a filter,
ChangePasswordFilter.

Why can't I use afterLogin()? Sure, you could redirect a user to a change
password form after they login, but there's no mechanism to keep the user on the
change password form. I.e. they could easily browse to another page.


The Password Filter Class. This class calls
ChangePasswordRule->changePasswordRequired() to check the user's password
freshness.
~~~
[php]
<?php

/**
 * ChangePasswordFilter class file.
 *
 * @author Matt Skelton
 * @date 27-Jun-2011
 * 
 * Determines if a user needs to change their password. 
 * A user must change their password if:
 *      User->daysSincePasswordChange() >
ChangePasswordRule->passwordExpiry
 */
class ChangePasswordFilter extends CFilter
{
    private $rule;

    /**
     * Runs a check to see if the user is required to change the password. This 
     * method is called before controller actions are run.
     * @param CFilterChain $filterChain the filter chain that the filter is on.
     * @return boolean whether the filtering process should continue and the
action
     * should be executed.
     */
    public function preFilter($filterChain)
    {
        $allowed = true;

        if
($this->rule->changePasswordRequired(Yii::app()->user->getModel()))
        {
            $allowed = false;
            
            Yii::app()->user->setFlash('notice', 'You must change your
password before you can proceed.');
            Yii::app()->user->redirectToPasswordForm();
        }

        return $allowed;
    }

    /**
     * Builds the rule for the filter.
     * @param array $rules the list of rules as set in the controller
     */
    public function setRules($rule)
    {
        $passwordRule = new ChangePasswordRule();
        $passwordRule->passwordExpiry = $rule['days'];

        $this->rule = $passwordRule;
    }
}
?>
~~~


The Password Rule Class. This class returns a Boolean indicating if the user
needs to change their password.
~~~
[php]
<?php

/**
 * ChangePasswordRule class file.
 *
 * @author Matt Skelton
 * @date 27-Jun-2011
 * 
 * This class performs the actual validation and returns a boolean indicating if
 * the user is required to change their password.
 */
class ChangePasswordRule extends CComponent
{
    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)changePasswordRequired(User
$user)
    {
        $passwordChangeRequired = false;

        if ($user->daysSincePasswordChange() > $this->passwordExpiry)
            $passwordChangeRequired =return
Time::wasWithinLast($this->passwordExpiry, $user->password_update_time) ?
false : 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 methodsThe redirectToPasswordForm
method. I useinclude this in my User model.
They're usedWebUser. It is copied from CWebUser->loginRequired()
but redirects to calculate the days
sincepassword form, instead of the user has changed
his/her password.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;
    /**
     * Returns the number of days sinceHolds a reference
to the currently logged in user changed their
password.model.
     * @return integer the number of elapsed days@var User The
currently logged in User Model.
     */
    publicprivate $_model;
    public $changePasswordUrl = array('/user/password');

    public function daysSincePasswordChange()init()
    {
        return
$this->calculateInterval($this->password_update_time);parent::init();
    }

    /**
     * Returns the numberUser model 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:53currently logged in user
and null if
     * @param string $end the end time. Should be an English textual
description. Ex: 2011-06-28 08:06:53is user is not logged in.
     * 
     * @return integerUser The model of the
number of dayslogged in user.
     */
    public function calculateInterval($start, $end =
null)getModel()
    {
        $startTime = strtotime($start);
        $endTime = ($end == null) ? time() : $end;return
$this->loadUser(Yii::app()->user->id);
    }

        $elapsedTime/**
     * 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 = abs($endTime - $startTime);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 round($elapsedTime / 86400);$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);
        }

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        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.

Matt