Yii 1.1: Force a User to Change Their Password (ChangePasswordFilter)

9 followers

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
 
/**
 * 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
 
/**
 * 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 $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.

/**
     * 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 "-".

/**
     * @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
 
/**
 * 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
 
/**
 * 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

Total 2 comments

#16777 report it
prashant.tyagi at 2014/03/27 02:52am
file structure

can somebody tell me where to put the files?

#5692 report it
Trejder at 2011/11/02 07:17am
Loading whole user model to CWebUser?

I'm just wondering. You're loading whole model for currently logged-in user into WebUser. I understand that this includes user password. Is that safe? I've heard that storing sensitive data in Yii::app()->user is not considered to be the best idea.

Leave a comment

Please to leave your comment.

Write new article