Безопасное хранение пароля в модели 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 $form=$this->beginWidget('CActiveForm'); echo $form->textField($model,'username'); <br /> echo $form->textField($model,'email'); <br /> echo $form->passwordField($model,'password'); <br /> echo CHtml::submitButton('Сохранить'); $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 $form=$this->beginWidget('CActiveForm'); echo $form->textField($model,'username'); <br /> echo $form->textField($model,'email'); <br /> echo $form->passwordField($model,'new_password'); <br /> echo $form->passwordField($model,'new_confirm'); <br /> echo CHtml::submitButton('Сохранить'); $this->endWidget();
Если теперь ввести пароль в это поле, то он правильно сохранится в модели, а поле ввода пароля во всех формах будет пустым. Контроллер вообще не изменился. Всю работу делает модель.
АлександрСпасибо за эту и другие статьи, особенно по Yii! У Вас отличный блог: статьи, идеи, дизайн, изложение материала - все на высшем уровне. Большая редкость. Удачи Вам!
Дмитрий ЕлисеевСпасибо! Я долго к этому шёл.
scriptДмитрий. Вопрос не в тему, но всеравно спрошу.
Как у Вас реализована подсветка синтаксиса кода?
Наверно с помощью CTextHighlighter, правильно?
Но меня интересует как работет сам вывод?
Вы сохраняете в базу уже отворматированый код с помощью класа, или сохраняете в базу с метками например
Если с метками то наверно парсите контент и пропускаете все что между этими тегами через 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. что я делаю не так?
Дмитрий ЕлисеевЧто-то не понял, где у Вас какой код.