Personal Maps: авторизация и аутентификация (с использованием Yii RBAC). Часть 9.

Владимир | | PHP, Web разработка, Yii.

personal maps rbac

В предыдущих статьях этого цикла мы практически завершили создание нашего web приложения (ссылки на предыдущие статьи вы найдёте внизу этой страницы). Мы можем создавать и редактировать объекты, отображать их на карте. Но для полноценной работы приложения нам необходимо создавать пользователей с различными уровнями доступа, а также проверять их права при обращении к приложению.

Прежде чем переходить к системе разграничения доступа, давайте разберемся, какие типы пользователей нужны для нашего приложения.

Предполагается, что каждый пользователь будет только со своими объектами, то нам будет достаточно двух уровней доступа:

  • user – обычный пользователь, может работать только со своими объектами;
  • admin – администратор, может работать со своими объектами, а также создавать, изменять и удалять других пользователей.

Т.е. администратор имеет те же самые права, что и обычный пользователь, плюс несколько дополнительных. Реализовать такую систему разграничения доступа удобнее всего с помощью RBAC (Role Based — Управление доступом на основе ролей).

Примечание. Исходный код приложения размещён на GitHub, также доступна демоверсия приложения.

Source Demo

Структура правил RBAC

В Yii фреймворк входит удобная библиотека для реализации такого управления доступом, которая поддерживает три элемента авторизации: операции (operations), задачи (tasks) и роли (roles). Роль может включать в себя несколько задач, а каждая задача – несколько операций.

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

rbac structure

В первую очередь мы определяем операции пользователей (левая колонка на рисунке). Т.к. приложение у нас достаточно простое, операций немного. Фактически мы определили только CRUD операции для объектов типа Place и User.

Авторизация пользователей

Процедура авторизации предполагает проверку прав пользователя на доступ к запрошенному ресурсу или выполнение какой-то операции.

Эта проверка выполняется в методах контроллера (до того как мы что-либо отправили пользователю) с помощью метода checkAccess:

public function actionIndex() {
    if (Yii::app()->user->checkAccess('viewPlaces')) {
        $this->render('index');
    }
    else {
        $this->redirect(array('site/login'));
    }
}

Если текущий пользователь имеет соответствующие права, метод checkAccess вернёт true и будет показана главная страница приложения, если нет – произойдёт редирект на страницу с формой ввода логина и пароля.

Немного сложнее использование задач. В нашем приложении у пользователя не должно быть возможности удалить чужие объекты. Т.е. операция удаления должна быть ему доступна, но при этом нужно проверить принадлежит ли объект ему. Для этого используются задачи (tasks). При их создании мы определяем так называемое «бизнес правило». В нём мы должны сравнить id текущего пользователя со значением поля p_user_id выбранного объекта.

Т.е. бизнес правило будет выглядеть так:

return Yii::app()->user->id==$params["place"]->p_user_id;

При этом $params содержит данные, которые передаются во втором параметре checkAccess. В нашем случае это будет объект, который нужно удалить. Теперь рассмотрим метод удаления объекта.

public function actionDelete($id)
{
	$place = Places::model()->findByPk($id);
	if (null === $place) {
		$this->_sendResponse(404, CJSON::encode(array('message'=>'Could not find place with id = '.$id)));
		return;
	}
	if (!Yii::app()->user->checkAccess('deletePlace', array('place'=>$place))) {
		$this->_sendResponse(403);
		return;
	}
	if ($place->delete()) {
		$this->_sendResponse(200, CJSON::encode($place));
	}
	else {
		$this->_sendResponse(500, CJSON::encode(array(
			'message'=>'Could not delete place',
			'errors'=>$place->getErrors(),
		)));
	}
}

Запрос на удаление выглядит следующим образом

/api/places/id

В первую очередь мы ищем объект, который имеет указанный id. Если объект не найден, отправляем 404-ую ошибку.

Затем выполняем проверку прав пользователя. Обратите внимание, в методе checkAccess мы проверяем права на выполнение операции deletePlace, но при этом передаём во втором параметре массив с найденным объектом. Т.к. для операции deletePlace определена задача viewOwnPlace при проверке доступа будет использовано бизнес правило этой задачи. И если значение p_user_id не совпадает с id пользователя, метод вернёт false.

Создание правил RBAC

  1. Правила, которые используются для проверок, должны где-то храниться. Это может быть как файл, так и база данных. Мы используем второй вариант.
  2. Правила нужно создать. Для этого можно реализовать web интерфейс, который позволит редактировать правила для разных групп пользователей. Но т.к. у нас не предполагается возможность изменения связей между ролями, операциями и задачами, мы создадим их с помощью консольной команды.

Сначала создадим таблицы, в которых будут храниться правила. Их схема хранится в файле framework/web/auth/schema-mysql.sql. Просто импортируем его в базу и в результате у нас появятся три новые таблицы: authassignment, authitem и authitemchild.

Теперь нам нужно указать Yii, что мы хотим хранить правила в базе. Для этого в config/main.php настроим компонент authManager.

'components'=>array(
	...
	'authManager'=>array(
		'class'=>'CDbAuthManager',
		'connectionID'=>'db',
	),
	...
),

Напишем консольную команду.

Для этого создаём файл protected/commands/AccessCommand.php с двумя методами.

class AccessCommand extends CConsoleCommand
{
    public function actionAddRules() {
	...
    }

    public function actionAddAdminUser() {
	...
    }
}

Первый метод создаёт правила, второй – добавляет пользователя с правами администратора. Для того чтобы их вызвать нужно из консоли (из папки protected) выполнить команды

yiic access addRules
yiic access addAdminUser

Рассмотрим создание правил

public function actionAddRules() {
	$auth=Yii::app()->authManager;

	$auth->createOperation('addPlace','create place');
	$auth->createOperation('viewPlace','view place');
	$auth->createOperation('updatePlace','update place');
	$auth->createOperation('deletePlace','delete place');
	$auth->createOperation('viewPlaces','view places');

	$auth->createOperation('addUser','create user');
	$auth->createOperation('viewUser','view user');
	$auth->createOperation('updateUser','update user');
	$auth->createOperation('deleteUser','delete user');
	$auth->createOperation('viewUsers','view users');

	$bizRule='return Yii::app()->user->id==$params["place"]->p_user_id;';
	$task=$auth->createTask('viewOwnPlace', 'view own place', $bizRule);
	$task->addChild('viewPlace');
	$task=$auth->createTask('updateOwnPlace', 'edit own place', $bizRule);
	$task->addChild('updatePlace');
	$task=$auth->createTask('deleteOwnPlace', 'delete own place', $bizRule);
	$task->addChild('deletePlace');

	$role=$auth->createRole('user');
	$role->addChild('addPlace');
	$role->addChild('viewOwnPlace');
	$role->addChild('updateOwnPlace');
	$role->addChild('deleteOwnPlace');
	$role->addChild('viewPlaces');

	$role=$auth->createRole('admin');
	$role->addChild('user');
	$role->addChild('addUser');
	$role->addChild('viewUser');
	$role->addChild('updateUser');
	$role->addChild('deleteUser');
	$role->addChild('viewUsers');
}

Сначала мы получаем authManager с помощью которого выполняется создание операций, задач и ролей. Каждая операция создаётся с помощью метода createOperation, в первом параметре которого мы указываем имя операции, во втором — описание.

Затем с помощью createTask создаём задачи, для каждой из них указываем бизнес правило. И с помощью метода addChild «привязываем» операцию к задаче. Теперь при проверке прав доступа к операции будет использована соответствующая задача. Т.е. в первом параметре checkAccess мы в любом случае указываем название операции, а не задачи.

Для создания роли используется метод createRole. После этого к роли с помощью метода addChild «привязываем» операции, задачи и другие роли.

Обратите внимание. К роли admin мы не «привязываем» операции вроде viewPlace, т.к. у нас администратор должен получить все права пользователя мы «привязываем» роль user. На рисунке каждая стрелка соответствует вызову метода addChild, т.е. «привязке» одного объекта к другому. В результате получается иерархическое наследование прав.

Аутентификация пользователей

Процедура аутентификации предполагает проверку подлинности пользователя. В нашем случае она заключается в проверке логина и пароля.

Прежде всего, создадим класс UserIdentity с методом authenticate (protected/components/UserIdentity.php)

class UserIdentity extends CUserIdentity
{
	private $_id;

	public function authenticate()
	{
		$record = Users::model()->findByAttributes(array('u_name' => $this->username));
		if ($record === null) {
			$this->errorCode = self::ERROR_USERNAME_INVALID;
		} else if ($record->u_pass !== crypt($this->password, $record->u_pass)) {
			$this->errorCode = self::ERROR_PASSWORD_INVALID;
		} else {
			$this->_id = $record->id;
			$this->errorCode = self::ERROR_NONE;
		}
		return !$this->errorCode;
	}

	public function getId() {
		return $this->_id;
	}

	/**
	 * Generate a random salt in the crypt(3) standard Blowfish format.
	 *
	 * @param int $cost Cost parameter from 4 to 31.
	 *
	 * @throws Exception on invalid cost parameter.
	 * @return string A Blowfish hash salt for use in PHP's crypt()
	 */
	public static function blowfishSalt($cost = 13)
	{
		if (!is_numeric($cost) || $cost < 4 || $cost > 31) {
			throw new Exception("cost parameter must be between 4 and 31");
		}
		$rand = array();
		for ($i = 0; $i < 8; $i += 1) {
			$rand[] = pack('S', mt_rand(0, 0xffff));
		}
		$rand[] = substr(microtime(), 2, 6);
		$rand = sha1(implode('', $rand), true);
		$salt = '$2a$' . sprintf('%02d', $cost) . '$';
		$salt .= strtr(substr(base64_encode($rand), 0, 22), array('+' => '.'));
		return $salt;
	}
}

В методе authenticate (строка 10) мы проверяем пароль с помощью функции crypt. На сегодняшний день, использование md5 для хеширование паролей считается не надёжным, поэтому необходимо генерировать хеши на основе более сложных алгоритмов.

Функция crypt поддерживает несколько алгоритмов хеширования, в том числе blowfish, который мы используем в данном приложении. В первом параметре crypt передаётся пароль, а во втором – соль. При этом, формат соли определяет алгоритм хеширования. Функция вернет хеш пароля, который мы сравниваем с хешем, который хранится в базе данных.

Также в данный класс мы добавили метод blowfishSalt, который формирует хеш пароля в по алгоритму blowfish.

Важно! В Yii 1.1.14 появился класс CPasswordHelper, который можно использовать для выполнения этой же задачи. Алгоритмы будут использоваться те же самые, но ваш код будет короче. В частности, не придётся реализовывать метод blowfishSalt. Подробности вы можете почитать в статье Use crypt() for password storage. Кстати, код blowfishSalt я взял из старой версии этой статьи.

Теперь добавим команду, которая будет создавать аккаунт администратора.

class AccessCommand extends CConsoleCommand
{
	...

    public function actionAddAdminUser() {
        $auth=Yii::app()->authManager;

        $user = new Users();
        $user->u_name = 'admin';
        $user->u_pass = 'admin';
        $user->u_email = 'admin@site.loc';
        $user->u_role = 'admin';
        $user->save();
    }
}

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

Контроллер и модель для работы с пользователями

Модель создаём с помощью gii и добавляем в неё два метода.

class Users extends CActiveRecord
{
	...
	
    public function beforeSave() {
        $this->u_pass = crypt($this->u_pass, UserIdentity::blowfishSalt());
        return parent::beforeSave();
    }

    public function afterSave() {
        $assignments = Yii::app()->authManager->getAuthAssignments($this->id);
        if (!empty($assignments)) {
            foreach ($assignments as $key => $assignment) {
                Yii::app()->authManager->revoke($key, $this->id);
            }
        }
        Yii::app()->authManager->assign($this->u_role, $this->id);
        return parent::afterSave();
    }
}

В методе beforeSave мы с помощью blowfishSalt хешируем пароль пользователя перед сохранением его в БД.

Метод afterSave используется для связей между пользователем и правами доступа. Это необходимо для того, чтобы администратор мог изменить права для уже созданных пользователей.

В первую очередь с помощью getAuthAssignments мы получаем массив всех связей между указанным пользователем и элементами авторизации RBAC. Далее удаляем все существующие связи и затем, с помощью метода assign, создаём новую связь. Новая связь устанавливается на основе значения поля u_role. Т.е. к конкретному пользователю мы привязываем роли, а не операции или задачи.

Обратите внимание. Пользователь с ролью администратор фактически имеет две роли: admin и user, т.к. роль admin наследует user. Поэтому мы храним «главную» роль пользователя в таблице pm_users. И именно к этой роли «привязываем» пользователя с помощью assign.

Контроллер и представления для модели Users мы также создаём с помощью gii.

Затем добавляем проверку прав пользователя в методы контроллера. Например,

public function actionView($id)
{
	if (Yii::app()->user->checkAccess('viewUser')) {
		$this->render('view', array(
			'model' => $this->loadModel($id),
		));
	}
	else {
		throw new CHttpException(403);
	}
}

И так далее для всех методов, названия которых начинаются с action. Таким образом, использовать этот контроллер сможет только администратор.

Соответственно в файле views/layouts/yiistrap.php настроим меню таким образом, чтобы пункт «Пользователи» отображался только для администратора.

<?php $this->widget('zii.widgets.CMenu',array(
	'items'=>array(
		array('label'=>Yii::t('app', 'PLACES'), 'url'=>array('/places/index'), 'visible'=>!Yii::app()->user->isGuest),
		array('label'=>Yii::t('app', 'USERS'), 'url'=>array('/users/index'), 'visible'=>Yii::app()->user->checkAccess('viewUsers')),
		array('label'=>Yii::t('app', 'ABOUT'), 'url'=>array('/site/page', 'view'=>'about')),
		array('label'=>Yii::t('app', 'CONTACT'), 'url'=>array('/site/contact')),
		array('label'=>Yii::t('app', 'LOGIN'), 'url'=>array('/site/login'), 'visible'=>Yii::app()->user->isGuest),
		array('label'=>Yii::t('app', 'LOGOUT').' ('.Yii::app()->user->name.')', 'url'=>array('/site/logout'), 'visible'=>!Yii::app()->user->isGuest)
	),
	'activeCssClass'=>'active',
	'htmlOptions'=>array(
		'class'=>'nav',
	),
)); ?>

В строке 4 мы с помощью checkAccess проверяем, может ли данный пользователь просматривать список пользователей (/users/index).

И в качестве завершающего штриха немного исправим форму создания (редактирования) пользователя (файл views/users/_form.php)

<?php echo $form->errorSummary($model); ?>

		<?php echo $form->textFieldControlGroup($model,'u_name',array('span'=>5,'maxlength'=>255)); ?>

		<?php echo $form->textFieldControlGroup($model,'u_email',array('span'=>5,'maxlength'=>255)); ?>

		<?php echo $form->passwordFieldControlGroup($model,'u_pass',array('span'=>5,'maxlength'=>255,'value'=>'')); ?>

		<?php echo $form->passwordFieldControlGroup($model,'u_pass_repeat',array('span'=>5,'maxlength'=>255,'value'=>'')); ?>

		<?php echo $form->dropDownListControlGroup($model, 'u_role', array('user'=>'user', 'admin'=>'admin'), array('class' => 'span5')); ?>

	<div class="form-actions">
	<?php echo TbHtml::submitButton($model->isNewRecord ? Yii::t('app', 'SAVE') : Yii::t('app', 'UPDATE'), array(
		'color'=>TbHtml::BUTTON_COLOR_PRIMARY,
		'size'=>TbHtml::BUTTON_SIZE_LARGE,
	)); ?>
</div>

<?php $this->endWidget(); ?>

В частности, мы использовали метод dropDownListControlGroup для того, чтобы создать выпадающий список с ролями пользователей. Gii создаёт обычное текстовое поле.

Заключение

Использования RBAC особой сложности не представляет. Самое главное, изначально продумать систему ролей таким образом, чтобы её было легко и удобно расширять. И помнить, что проверять в контроллерах нужно операции, а роли «привязывать» к пользователям. Тогда вы сможете создавать новые роли с любым набором прав, и при этом не нужно будет изменять проверки в контроллерах и другие роли.

Также советую почитать статью RBAC Авторизация в YII и LDAP. В ней более подробно описывается использование RBAC в Yii без привязки к конкретному приложению.

Если есть вопросы или замечания, пишите.
Успехов!

Содержание

  • Олег

    Выложи пожалуйста дамп базы данных на гитхабе.

    • Олег

      Для всего проекта.

    • Выложил. Логин/пароль администратора — admin/admin.

      • Ссылочку можно ?

  • Igor Grinev

    Дай бог здоровья автору, весь гугляндекс облазил в поисках правды об аутетнификации yii

  • Для нового проекта всё управление пользователями (профиля и т.д.) писать с нуля — жесть!
    Не смотрел расширение yii-user-management ?

    Потрогал чуток — вроде ничего, но по описанию должно быть гораздо круче — пока ковыряю

    http://gtalex.ru/yii-upravlenie-polzovatelyami-yii-user-management

    • Это учебная статья, поэтому в ней должен объясняться принцип работы с ролями. С yii-user-management не работал.

  • Flammm

    Статьи просто огонь! Спасибо

  • ANDREY

    Очень интересно, у меня вопрос, а как реализовать RDBS задачу, если не одна таблица, а 2 или 3. Например users, admins и masters?

    • В этом отношении библиотека Yii не накладывает ограничений, но работать будет не удобно.

      У Вас будет три модели для работы с каждой из таблиц. При этом нужно будет изменить метод authenticate класса UserIdentity таким образом, чтобы он работал со всеми тремя моделями. Т.е. с начала поиск пользователя выполняется в первой таблице, затем во второй и затем в третьей. Это не эффективно с точки зрения скорости и если Вы захотите добавить ещё одну роль, то нужно будет опять переписывать метод authenticate.

      Лучше добавить в таблицу users поле в котором хранится роль пользователя (admin, master и т.д.). В этом случае при добавлении новой роли нужно будет только добавить дополнительные правила RBAC.

  • Guest

    У вас путаница в терминологии или в переводе. Вы пишете «Роль может включать в себя несколько задач, а каждая задача – несколько операций». В то же время в коде вы создаёте задачи и наследуете от операций. В переводе официальной документации указано, что «Операция — разрешение на какое-либо действие (дальше не делится) «http://www.yiiframework.com/doc/guide/1.1/ru/topics.auth#sec5. Исправьте если не правы, что бы не путать людей или разъясните ситуацию с терминами, если правы.

    • >> Роль может включать в себя несколько задач, а каждая задача – несколько операций

      По-моему, в коде сделано именно так. Сначала создаём операцию с помощью метода createOperation. Затем создаём задачу (createTask). После этого, с помощью addChild добавляем операции в качестве дочерних объектов в задачи. Аналогично поступаем с ролями.
      Задачи можно не создавать если Вам не нужны бизнес правила для каких-то операций. В этом случае операции можно добавлять непосредственно в роли.

      P.S. Возможно, я не правильно понял вопрос. Как Вы предлагаете изменить текст?

  • Цикл статей хороший, для меня пролил свет на Angular. Спасибо! ))

    Вот только не могу понять как вы связали ежа с ужом — REST и авторизацию. На сколько я понимаю, каждый запрос к API — это атомарная операция, и сервер не должен знать ничего о предыдущих запросах к нему от этого пользователя.
    Т.е. авторизация должна происходить через токены.

    • Вы правы, классический REST именно так и должен работать. Но REST это не протокол, по сути это набор соглашений.

      Поэтому на практике его использование сильно зависит от задачи. Если Вы разрабатываете внешний API через который сторонние приложения будут получать данные от Вашего сервиса (например, аналог Flickr API), то удобнее использовать именно классический вариант, т.к. не нужно заморачиваться, например, с поддержкой cookie.

      Теперь о примере в этом цикле статей. В админке, где пользователь работает с картой и списком объектов, использовать REST удобно, т.к. AngularJS фактически поддерживает его «из коробки». Сделать эту часть с обычными html формами можно, но этот вариант будет работать медленнее из-за перезагрузок страницы, повторной инициализации карты и т.п. Но если Вы решите, например, добавить несколько информационных страниц вроде «О сайте», «Контакты», то их удобнее сделать именно обычными страницами, т.к. для доступа к ним не нужна авторизация и не нужно дополнительных усилий для того, чтобы их проиндексировали поисковики. В результате получается гибридное приложение, одна часть — обычные страницы, другая — одностраничные JavaScript приложения, использующие REST запросы. И использование классической авторизации в данном случае упрощает приложение, т.к. Вы с помощью одного и того же механизма контролируете доступ и к REST, и к обычным страницам.

      Если задача изменится и нужна будет, например, поддержка мобильных приложений, то конечно лучше реализовать полноценный REST API.

  • sfaniks

    Спасибо, полезная статтья.