Безопасное хранение пароля в модели Yii

Безопасность

Достаточно часто на тематических форумах встречаются вопросы новичков об организации хранения паролей пользователей в модели User. Вопрос звучит примерно так: «У меня в модели User есть поле password, в котором я храню хэш пароля. Как мне сделать так, чтобы пароль не перезаписывался при каждом сохранении модели?» Вопрос любопытный и для других фреймворков.

Действительно, многие начальные мануалы советуют хэшировать пароль перед первым сохранением модели (в методе beforeSave):

class User extends CActiveRecord
{
    // ...
 
    protected function beforeSave()
    {
        if (parent::beforeSave())
        { 
            // если это новая запись
            if ($this->isNewRecord)
            {            
                $this->salt = $this->generateSalt();
                $this->password = $this->hashPassowrd($this->password, $this->salt);
            }
 
            return true;
        }
        else
            return false;
    }      
 
    // ...
}

Это самый примитивный способ не позволяет легко менять пароль в дальнейшем без непосредственного присваивания хеша пароля в коде контроллера. Мы же стремимся сохранить контроллер примитивным:

protected function actionSettings()
{
    $model = $this->loadModel();    
    $model->scenario = 'settings'; // указываем нужный сценарий для правил валидации
 
    if (isset($_POST['User'])
    {
        $model->attributes = $_POST['User'];
 
        if ($model->save())
        {
            Yii::app()->user->setFlash('settings-form', 'Сохранено!');
            $this->refresh();
        }
    }
 
    $this->render('settings', array('model'=>$model));
}

Как вариант, можно запоминать старый пароль в приватной переменной и сравнивать с новым при каждом сохранении. Если пароль другой, то заменить его на хэш:

class User extends CActiveRecord
{
    private $_old_password; // для хранения хэша старого пароля
 
    // ...
 
    protected function afterFind() // при чтении из базы
    {
        $this->_old_password = $this->password;
        parent::afterFind();
    }     
 
    protected function beforeSave()
    {
        if (parent::beforeSave())
        {
            if ($this->password !== $this->_old_password)
            {
                $this->salt = $this->generateSalt();
                $this->password = $this->hashPassword($this->password, $this->salt);
            }            
            return true;
        }
        else
            return false;
    }    
 
    // ...
}

Вроде бы всё работает, но попробуйте вывести форму

<?php
<?php $form=$this->beginWidget('CActiveForm'); ?>
    <?php echo $form->textField($model,'username'); ?><br />        
    <?php echo $form->textField($model,'email'); ?><br />        
    <?php echo $form->passwordField($model,'password'); ?><br />
    <?php echo CHtml::submitButton('Сохранить'); ?>
<?php $this->endWidget(); ?>

...и Вы увидите, что поле пароля будет заполнено кучей точек. Там действительно будет содержимое поля $model->password. Таким образом, в поля формы будут выведены текущий логин, email и хэш пароля, а это небезопасно.

Мы должны иметь возможность задавать новый пароль и не должны иметь прямого доступа из формы к старому!

Добавим в модель пару полей:

class User extends CActiveRecord
{    
    public $new_password;
    public $new_confirm;
 
    public function rules()
    {
        return array(
            array('username, email', 'required'),
            array('username', 'match', 'pattern'=>'#^[a-zA-Z0-9_\.-]+$#', 'message'=>'Логин содержит запрещённые символы'),
            array('email', 'email', 'message'=>'Неверный формат E-mail адреса'),
            array('username, email', 'unique', 'caseSensitive'=>false),
            array('email, username, new_password, new_confirm', 'length', 'max'=>255),
            array('new_password', 'length', 'min'=>6, 'allowEmpty'=>true),
            array('new_confirm', 'compare', 'compareAttribute'=>'new_password', 'message'=>'Пароли не совпадают'),
 
            // Register
            array('new_password', 'required', 'on'=>'register'),
 
            // Settings
            array('username', 'unsafe', 'on'=>'settings'),
        );
    }
 
    public function attributeLabels()
    {
        return array(
            // ...
            'new_password' => 'Новый пароль',
            'new_confirm' => 'Подтвердите новый пароль',
        );
    }
 
    protected function beforeSave()
    {
        if (parent::beforeSave())
        {
            if ($this->new_password)
            {
                $this->salt = $this->generateSalt();
                $this->password = $this->hashPassword($this->new_password, $this->salt);
            }            
            return true;
        }
        else
            return false;
    } 
}

Здесь мы добавили отдельное поле для ввода нового пароля. Исходное поле password объявили небезопасным (его теперь нельзя перезаписать через безопасное присваивание атрибутов). В методе beforeSave() модели мы смотрим, введён ли новый пароль в поле new_password. Если введён, то записываем его хэш в поле password для записи в БД.

Теперь в формах регистрации (сценарий «register») и настроек профиля (сценарий «settings») нужно использовать поле new_password вместо password;

<?php
<?php $form=$this->beginWidget('CActiveForm'); ?>
    <?php echo $form->textField($model,'username'); ?><br />        
    <?php echo $form->textField($model,'email'); ?><br />        
    <?php echo $form->passwordField($model,'new_password'); ?><br /> 
    <?php echo $form->passwordField($model,'new_confirm'); ?><br /> 
    <?php echo CHtml::submitButton('Сохранить'); ?>
<?php $this->endWidget(); ?>

Если теперь ввести пароль в это поле, то он правильно сохранится в модели, а поле ввода пароля во всех формах будет пустым. Контроллер вообще не изменился. Всю работу делает модель.

Комментарии

 

Александр

Спасибо за эту и другие статьи, особенно по Yii! У Вас отличный блог: статьи, идеи, дизайн, изложение материала - все на высшем уровне. Большая редкость. Удачи Вам!

Ответить

 

Дмитрий Елисеев

Спасибо! Я долго к этому шёл.

Ответить

 

script

Дмитрий. Вопрос не в тему, но всеравно спрошу.
Как у Вас реализована подсветка синтаксиса кода?
Наверно с помощью CTextHighlighter, правильно?
Но меня интересует как работет сам вывод?
Вы сохраняете в базу уже отворматированый код с помощью класа, или сохраняете в базу с метками например

<pre code="php">

Если с метками то наверно парсите контент и пропускаете все что между этими тегами через CTextHighlighter. Правильно я понимаю?

Ответить

 

Дмитрий Елисеев

Использую в блоге Markdown синтаксис вместо HTML. Преобразование в HTML c подсветкой делает встроенный в Yii компонент CMarkdown. В базе имеется два поля: оригинальный текст и переконвертированный. Вывожу текст из второго поля, не забыв вручную в представлении вызвать CTextHighlighter::registerCssFile(). Для удобства использую поведение DPurifyTextBehavior с включенной опцией enableMarkdown. Оно конвертирует из Markdown в HTML при сохранении модели.

Ответить

 

script

Спасибо. Не знал.
Очень интересно, посмотрю.
Нужная статья была бы по интеграции такого решения, что скажете ;)
Аналогов вашему сайту вообще нет в рунете, спасибо.

Ответить

 

TwiX

И как всегда спасибо Дмитрий!

Ответить

 

Jazz – jazzyourweb.com

Дмитрий, спасибо, за статью. Там в rules наверное опечатка. Вместо array('username', 'unsafe', 'on'=>'settings') нужно 'password'.

Ответить

 

Дмитрий Елисеев

Нет, всё правильно. Password в правилах не уазан, поэтому он и так будет unsafe. А это правило сделано специально, чтобы пользователь не смог поменять свой ник.

Ответить

 

Антон

Скажите, а есть ли какие-то готовые рецепты для регистрации пользователя и восстановления пароля?
Было странно узнать, что решить эту задачу для меня оказалось гораздо сложнее, чем написать остальной сайт. Задача ведь, в принципе, стандартная.

Ответить

 

Антон

Лучше изменю свой вопрос (а то в предыдущем посте я, по сути, просил готовый код). Как лучше создать ссылку для восстановления пароля?

На ум приходит что-то следующее:

1) создавать отдельную таблицу с эл. адресами пользователей и кодами восстановления. Записи в таблицу будут производиться при запросе на восстановление пароля и введения эл. адреса существующего пользователя.

В таблице будет два столбца: введенный эл. адрес и случайный уникальный код.

2) после добавления записи в таблицу, по эл. адресу высылается письмо со ссылкой на соответствующее действие контроллера, передающей код и эл. адрес методом get.

3) если контроллер обнаруживает совпадение адреса и кода, то выводит форму для смены пароля.

Можно не создавать таблицу, а добавить столбец для кода восстановления к таблице с пользователями, но это мне кажется менее рациональным.


PS Надеюсь, что я хотя бы понятно изложил свою мысль. Я изучаю Yii меньше месяца, многое непонятно. Например код из статьи выше я слабо понял. Надеюсь на доброту к новичкам :)

Ответить

 

Дмитрий Елисеев

Для меня логичнее делать подтверждение регистрации и восстановление пароля дополнительными полями confirm_code и reset_code (со случайным md5 кодом) и отправкой простой ссылки с этим кодом. А в контроллере ищем прямо по этому коду, обновляем пароль и стираем код.

А так можно подсмотреть в расширении yii-user или yii2-user.

Ответить

 

Andrey

Добрый день! Очень интересно, хочу поинтересоваться - как изменять под необходимые экшены виджет в column2

$this->beginWidget('zii.widgets.CPortlet', array(
    'title'=>'Operations',
));
$this->widget('zii.widgets.CMenu', array(
    'items'=>$this->menu,
    'htmlOptions'=>array('class'=>'operations'),
));
$this->endWidget();

Например в некоторых экшенах я хочу чтобы его совсем не было, а в некоторых нужно, чтобы показывались определенные пункты, по умолчанию почему - то create и manage

Ответить

 

Дмитрий Елисеев

Пункты $this->menu задаются в каждом представлении экшэнов. Просто удалите ненужные.

Ответить

 

Сергей Скапа

Дмитрий, подскажите пожалуйста.
Есть проблема. В проекте реализовано так что админ самостоятельно регистрирует пользователя, и немного неудобно каждый раз придумывать пароли. Есть идея сделать генератор пароля с задаваемой длиной.
в модели:

class GenerateForm extends Model
{
    public $key;
    public $passwordHashCost;
    public $hash;
    public function rules()
    {
        return [
            [['key'], 'required'],
            [['passwordHashCost'], 'string', 'max' => 60],
            [['passwordHashCost'], 'default', 'value'=> 16],        
        ];
    }
}

в контролере

public function actionGenerate()
    {
        $model = new GenerateForm();
        if ($model->load(Yii::$app->request->post()) && $model->validate()) {
            // данные в $model удачно проверены
            function generatePasswordHash($key, $cost = null)
            {
                if ($cost === null) {
                    $cost = $this->passwordHashCost;
                }
                if (function_exists('password_hash')) {
                    /** @noinspection PhpUndefinedConstantInspection */
                    return password_hash($key, PASSWORD_DEFAULT, ['cost' => $cost]);
                }
                $salt = $this->generateSalt($cost);
                $hash = crypt($key, $salt);
                // strlen() is safe since crypt() returns only ascii
                if (!is_string($hash) || strlen($hash) !== 60) {
                    throw new Exception('Unknown error occurred while generating hash.');
                }
                return $hash;
            }
            return $this->render('generate-confirm', ['model' => $model]);
        } else {
            // либо страница отображается первый раз, либо есть ошибка в данных
            return $this->render('generate', ['model' => $model]);
        }
    }

в итоге: в $hash передается NULL. что я делаю не так?

Ответить

 

Дмитрий Елисеев

Что-то не понял, где у Вас какой код.

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>