Pull to refresh

RBAC Авторизация в YII и LDAP

Reading time 12 min
Views 97K

RBAC — это простой и мощный способ централизованного управления доступом в веб приложении. Основным его достоинством является то, что при правильном понимании и применении иерархии авторизации можно очень гибко управлять доступом не изменяя код контроллеров.

К сожалению стандартный мануал по RBAC в YII оставляет больше вопросов чем ответов. Эту ситуацию я и намереваюсь исправить.
Я расскажу о создании “правильной” иерархии: как делать не стоит. А в завершении я приберёг инструкцию, о том как подружить LDAP авторизацию (из ActiveDirectory ) с Yii и RBAC.

Все кто заинтересовался, добро пожаловать под кат!


RBAC (Role Based Access Control) Система доступа на основе ролей. В основу этой системы в Yii ложатся три основных звена:
  • Роль (Role)
  • Задача (Task)
  • Операция (Operaton)

Я предполагаю, что читатель уже вкратце ознакомился со страницей официального учебника YII и знает основные принципы механизма авторизации в Yii.

Поэтому перейдем сразу к построению правильной иерархии элементов авторизации.

Иерархия ролей.


Самое важное, и при этом самое запутанное для понимания — это иерархия элементов в RBAC. От того насколько правильно она продуманна зависит то, как гибко вы сможете управлять ролями в системе, и как часто вам придется изменять код контроллеров.

Рассмотрим подробнее каждый элемент авторизации:
  • Операция — это низший элемент авторизации. Именно его мы должны проверять в коде наших контроллеров. Другими словами: операция — это то, что цепляется к коду.
  • Роль — это наоборот высший элемент авторизации, группирующий в себе операции и задачи. И именно роль мы должны прикреплять к пользователям.
  • Задача — Это необязательный элемент, находящийся между операцией и ролью, и сужающий права операции с помощью bizRule. Что бы облегчить понимание, назовем ее фильтром.



На диаграмме выше указана типовая иерархия, где контроллеры проверяют операции, а к пользователям привязываются роли. Однако YII не запрещает вам проверять в контроллере что либо другое, например, роль пользователя.
Но вы должны помнить, что это НЕПРАВИЛЬНО и приводит к тому, что вы лишаетесь преимуществ централизованного управления.

Рассмотрим пример:
У нас есть Новости, к управлению которыми мы хотим разграничить доступ.
Первое, что мы должны сделать при проектировании RBAC это продумать возможные ОПЕРАЦИИ (а не роли пользователей, как кажется на первый взгляд).

Обычно новости можно удалять, создавать, читать и редактировать. Преобразуем эти действия в операции: deleteNews, createNews, readNews, updateNews.

В коде вы можете проверять любую из этих операций:

if(Yii::app()->user->checkAccess('createNews'))
{
    // создаём новость
}

//или 

if(Yii::app()->user->checkAccess('updateNews'))
{
    // обновляем новость
}

После того как продуманны операции, можно переходить к ролям (я намеренно пропускаю задачи, о них мы поговорим чуть позже):

Из имеющихся операций мы можем выделить следующие роли:
newsReader, newsManager, newsAuthor.

Иерархия элементов при этом будет такой:
  • newsReader
    • readNews
  • newsAuthor
    • readNews
    • createNews
  • newsManager
    • readNews
    • createNews
    • deleteNews
    • updateNews

Эти роли можно прикреплять к конкретным пользователям. Но лучше создать еще одну абстракцию ролей более обобщенную, и прикреплять к пользователям её, например:

  • guest
    • newsReader
  • authorized
    • newsAuthor
  • moderator
    • newsManager

Такая абстракция удобна, если у нас предстоит управление не только новостями, но еще, скажем, картинками в фотогалерее или товарами в магазине. Тогда для каждого такого раздела вашей системы необходимо создать свои “промежуточные” роли, например photoReader (showPhoto), photographer(showPhoto, addPhoto), photoManager (showPhoto, addPhoto, deletePhoto) и прикрепить их к обобщенным ролям:

  • guest
    • newsReader
    • photoReader
  • authorized
    • newsAuthor
    • photographer
  • moderator
    • newsManager
    • photoManager

Т.е. это можно прочитать как: гость может читать новости и смотреть фото; авторизованный пользователь может писать новости и добавлять фотографии; модератор может делать всё вышеперечисленное, а так же редактировать и удалять чужие фото и новости.

Вы наверняка заметили, что у ролей newsAuthor и photographer недоступна операция update. Правильно, потому что если на данном этапе мы дадим им операции updateNews или updatePhoto, то они смогут управлять всеми фото без разбора. А нам необходимо, что бы авторы могли отредактировать только свои элементы.

Именно для этого и созданы задачи. Задачи — это фильтры, которые могут уточнять права. Создадим задачу updateOwnNews, потомком которой назначим updateNews. Из названия задачи понятно, что она будет позволять редактировать собственные новости, и поможет нам в этом bizRule.

bizRule — это некий PHP код, результатом выполнения которого должен быть ответ: применять ли данное правило к этому юзеру или нет.

bizRule для нашей задачи updateOwnNews будет выглядеть следующим образом:
$bizRule='return Yii::app()->user->id==$params["news"]->authID;';

Где мы проверяем совпадает ли id автора новости с текущим авторизованным юзером.

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

$params=array('news'=>$post);
if(Yii::app()->user->checkAccess('updateNews',$params))
{
    // обновляем запись
}

Обратите внимание, мы проверяем не конкретную задачу (updateOwnNews), а операцию updateNews(самый низший элемент иерархии).

Благодаря иерархии, которая после создания задачи updateOwnNews стала такой:

  • newsAuthor
    • readNews
    • createNews
    • updateOwnNews
      • updateNews

Yii начнет проверку доступа с самого низа и пойдет по иерархии вверх, т.е. проверит updateNews, затем перейдет к updateOwnNews. На каждом этапе проверки Yii будет проверять: задано ли правило bizRule, и если задано, то будет передавать в него параметры, указанные в функции checkAccess.

Схематично проверку можно изобразить так:



На схеме изображено 3 сценария проверки:
Первый сценарий, это когда авторизованный пользователь пытается отредактировать свою новость. В этом случае проверка поднимаясь снизу вверху проходит через updateOwnNews. И поскольку идентификаторы пользователя совпадают — завершается успехом.

Во втором случае пользователь имеет роль модератора. В его иерархии задачи updateOwnNews нет, поэтому проверяется только наличие операции updateNews.
Проверка проходит успешно.

В третьем случае авторизованный пользователь пытается отредактировать чужую статью, и на этапе updateOwnNews проверка завершается отказом, т.к. не удовлетворено bizRule задачи.

Примеры указанные выше демонстрируют централизованность управления правами.
Написав один раз в контроллере проверку на выполнение операции и передав туда параметр, вся дальнейшая работа по разграничению доступа ложится на плечи RBAC.
Поэтому по возможности, вы должны всегда передавать в функцию checkAccess параметры (даже если проверяемый элемент не имеет bizRule), и проверять операцию, а не роль.

Если в контроллере в одном условии вы хотите написать более одной проверки — знайте, вы идете в неверном направлении — у вас проблемы с организацией иерархии.

К примеру:
if(Yii:app()->user->checkAccess('moderator') && Yii:app()->user->checkAccess('administrator')) {
	//delete smth
}

Это НЕправильно. При таком подходе вы не сможете централизованно управлять правами. Вам придется каждый раз редактировать код и добавлять сюда новое условие.

Способы проверки прав в контроллерах


Всего имеется 2 способа проверки прав.
Первый способ мы уже рассмотрели. Это метод checkAccess() компонента CWebUser.

Но для тех кто заботится о том, что бы его контроллеры не “толстели”, есть еще один аспектно-ориентированный способ проверки прав.

Способ заключается в подключении к контроллеру фильтра 'accessControl'.

Этот фильтр сделает за вас всю грязную работу: он проверит наличие прав доступа и, при необходимости, отправит пользователя на страницу 403. Таким образом вам не придется дублировать код проверок в каждом экшене.

Рассмотрим работу фильтра на примере контроллера новостей:

class NewsController extends CController
{
    …
    public function filters()
    {
        return array(
            'accessControl',
        );
    }

   public function accessRules()
    {
        return array(
            array('allow',
                'actions'=>array('create'),
                'roles'=>array('createNews'),
            ),
            array('allow',
                'actions'=>array('delete'),
                'roles'=>array('deleteNews'),
            ),
            array('allow',
                'actions'=>array('view'),
                'roles'=>array('readNews'),
            ),
            array('allow',
                'actions'=>array('update'),
                'roles'=>array('updateNews'),
            ),
         );
    }
 ...
}

В функции accessRules мы указываем 4 правила, каждое из которых является массивом. Где ключ actions указывает для каких Action’ов мы применяем правило, а ключ roles для каких ролей.

Следует отметить, что несмотря на то, что ключ называется 'roles', вписывать туда можно любой элемент авторизации, будь то операция или задача. Но как мы с вами знаем, в контроллерах мы должны проверять только операции, поэтому именно они и вписаны в примере выше.

Данный фильтр помогает избавиться нам от многих строк “сквозного” кода в экшенах. Однако есть проблема: нам необходимо в 'updateNews' передать текущую новость, что бы bizRule определенные в updateOwnNews корректно отработали.

Что бы понять, как можно передавать параметры в фильтр, пришлось залезть в код фрэймворка и подсмотреть там. К счастью с версии 1.1.11 такая возможность появилась.

Что бы передать параметры, необходимо написать правило вот так:

'roles'=>array('newsUpdate'=>array('news'=>$news))

Однако проблемы на этом не заканчиваются. Фильтр запускается ДО любого экшена, а значит мы еще не создали объект новости, который могли бы передать.

Решением может быть следующий подход:
protected $model;

    public function accessRules() {
        return array(
            ...
            array('allow',
                'actions' => array('update'),
                'roles' => array(
                    'updateNews' => array(
                        'news' => $this->news
                )),
            ),
            ...
            );
    }

    public function getNews() {
        if ($this->actionParams['id']) {
            return $this->loadModel($this->actionParams['id']);
        }
    }

    public function loadModel($id) {
      if ($this->model === null) 
        $this->model = News::model()->findByPk($id);

        if ($this->model === null)
            throw new CHttpException(404, 'The requested page does not exist.');
        return $this->model;
    }

Здесь я создаю геттер для поля news, в котором получаю модель новости, через функцию loadModel. Но что бы не дергать базу по несколько раз (в начале при проверке прав, а затем в самом действии), я создал закрытое поле $model, в которое закешировал модель.И при следующем обращении к функции loadModel, модель будет получена из свойства, а не из базы.

К сожалению этот способ не подходит, если в правило нужно отправить параметры, для которых требуется более сложная логика. Поэтому для таких случаев остается использовать checkAccess();

RBAC Yii и LDAP


LDAP это Lightweight Directory Access Protocol — «облегчённый протокол доступа к каталогам» — говорит нам Wikipedia. В нашем случае мы будем получать доступ к каталогу ActiveDirectory, с целью авторизации пользователей из корпоративной сети по своему логину и паролю.

PHP имеет встроенную поддержку LDAP, и поэтому изобретать ничего не придется, к тому же есть много готовых компонентов предоставляющий удобный интерфейс для доступа к каталогам.

Я использовал компонент adLdap, т.к. он заточен именно под ActiveDirectory, предоставляет простой и удобный ООП API и с ним просто приятно работать.

Для начала я подключил adLdap к Yii, как компонент приложения, т.е.:
//protected/config/main.php
‘components’ => array(
	...
	'ldap' => array(
            'class' => 'LdapComponent',
            'baseDn' => 'DC=example,DC=org', //example.org
            'accountSuffix' => '@example.org',
            'domainControllers' => array('dc.example.org'),
            'adminUsername' => 'username',
            'adminPassword' => 'password'
        ),
	..
)

Сам класс LdapComponent:

//protected/components/LdapComponent.php
Yii::import('application.vendors.adLDAP.adLDAP');

class LdapComponent extends adLDAP {

    public $baseDn;
    public $accountSuffix;
    public $domainControllers;
    public $adminUsername;
    public $adminPassword;

    public function __construct() {

    }

    public function init() {
        parent::__construct();
    }
}

Конфигурирование adLdap производится путем переопределение его свойств. Мне захотелось вынести настройки этого компонента в конфиг в привычном для Yii-программиста формате.Поэтому я переопределил нужные поля, изменив их атрибут видимости (что бы Yii мог сконфигурировать компонент) и перенес конструктор в метод init(), что бы конструктор вызывался ПОСЛЕ того как объект будет сконфигурирован (поля заполнены).

Далее мы можем просто использовать этот компонент как все другие в Yii:
Yii::app()->ldap

Для авторизация с помощью LDAP, необходимо создать стандартные компоненты, необходимые для авторизации в Yii: UserIdentity и WebUser:

//protected/components/LdapIdentity.php 
class LdapIdentity extends CUserIdentity
 {

    protected $_id;
    
    /**
     * Authenticates a user via LDAP.
     * @return boolean whether authentication succeeds.
     */
    public function authenticate() {

        $ldap = Yii::app()->ldap;

        $result = $ldap->authenticate($this->username, $this->password);
        $ldapUserInfo = $ldap->user()->infoCollection($this->username, array("mail", "displayname"));

        $this->setState('fullname', $ldapUserInfo->displayname);
        $this->setState('email', $ldapUserInfo->mail);

        if (!$result) {
            $this->errorCode = self::ERROR_USERNAME_INVALID;
        } else {
            $dbUser = User::model()->findByAttributes(array('ldap' => $this->username));

            if (!$dbUser) {
                $dbUser = new User();
                $dbUser->ldap = $this->username;
                $dbUser->save();
            }
            
            $this->_id = $dbUser->primaryKey;

            $this->errorCode = self::ERROR_NONE;
        }


        return !$this->errorCode;
    }

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

В коде выше мы переопределяем метод authenticate класса CUserIdentity, для того что бы реализовать свою логику авторизации. Мы пытаемся авторизовать этого юзера в AD через adLdap, и если успешно, то заносим его имя и email в постоянное хранилище.

Я решил помимо LDAP вести дополнительную информацию о пользователях в БД, поэтому после успешной авторизации, проверяется: есть ли уже на этого пользователя строка в базе, и если нет- то она создается.

//protected/components/LdapUser.php
class LdapUser extends CWebUser {

    protected $_groups = null;
    protected $_model;

    /**
     * 
     * @return type
     */
    public function getGroups() {
        if ($this->_groups === null) {
            if ($user = $this->getModel()) {
                $this->_groups = Yii::app()->ldap->user()->groups($user->ldap);
            }
        }
        return $this->_groups;
    }

    /**
     * 
     * @return User
     */
    public function getModel() {
        if (!$this->isGuest && $this->_model === null) {
            $this->_model = User::model()->findByPk($this->id);
        }
        return $this->_model;
    }

}

Класс LdapUser почти не отличается от стандартного, кроме важной для нас функции LdapUser::getGroups(). Эта функция, как не сложно догадаться, возвращает из AD все группы этого пользователя.

Я решил сделать группы пользователей в ActiveDirectory ролями в приложении Yii.
Т.е. роли мы будем назначать не конкретным пользователям, а группам. А какую группу кому присвоить будет управляться централизованно через AD.
Это очень удобно в корпоративных порталах и прочих внутренних ресурсах, т.к. вместе с правами на принтеры, папки, и прочую офисную инфраструктуру пользователю сразу будут даны права и на разделы в корпоративном сайте. При этом специалистам IT — отдела не нужно ничего объяснять, они просто делают свою работу “как обычно”.

Для того что бы присвоить пользователям роли, я переопределил класс CPhpAuthManager:

class PhpAuthManager extends CPhpAuthManager {

    public function init() {
        // Иерархию ролей расположим в файле auth.php в директории config приложения
        if ($this->authFile === null) {
            $this->authFile = Yii::getPathOfAlias('application.config.auth') . '.php';
        }

        parent::init();

        // Для гостей у нас и так роль по умолчанию guest.
        if (!Yii::app()->user->isGuest) {
            // Связываем группы из AD с ролями и юзерами
           
            $existingRoles = $this->getRoles();
          
            if (Yii::app()->user->groups) {
                foreach (Yii::app()->user->groups as $group) {
                    if ($existingRoles[$group]) {
                        $this->assign($group, Yii::app()->user->id);
                    }
                }
            }
            
           
        }
    }

}

В коде выше мы берем список групп, в которых состоит пользователь, проверяем: существует ли одноименная роль, и если существует, то присваиваем эту роль пользователю.

Пример конфигурационного файла авторизации с LDAP выглядит так:

	...
	
   /************************************
    ***************ROLES****************
    ************************************/
    'newsReader' => array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' => array(
            0 => 'readNews',
        ),
    ),
    'newsAuthor' => array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' => array(
            'newsReader',
            'createNews',
            'updateOwnNews',
            'deleteOwnNews'
        ),
    ),
    
    'newsManager' => array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' => array(
            'newsReader',
            'createNews',
            'updateNews',
            'deleteNews',
        ),
    ),
    
    //Заявки
    'requestCreator' => array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' => array(
            0 => 'createRequest',
        ),
    ),
    
    'requestManager' => array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' => array(
            'createRequest',
            'viewRequests',
            'manageRequests',
        ),
    ),


   /************************************
    **********ROLES ASSIGMENTS**********
    ************************************/
    'developers' => array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' =>  array(
            'newsManager',
            'requestManager',
        ),
    ),

    'departamentBoss'=> array(
        'type' => CAuthItem::TYPE_ROLE,
        'description' => '',
        'bizRule' => NULL,
        'data' => NULL,
        'children' =>  array(
            'requestCreator' ,
        ),
    ),


В разделе ROLES мы описываем “промежуточные” роли. Затем в разделе ROLES ASSIGMENTS мы описываем группы из AD, и присваиваем к ним промежуточные роли.

Приведенный выше конфиг можно читать так:
Для группы developers будут доступны все действия с новостями (newsManager) и с заявками (requestManager), а для группы departamentBoss будет доступно только создание заявок.

Заключение


Механизм ролей в Yii является по настоящему гибким, если его правильно использовать.
В планах на будущее есть создание или адаптация какого либо GUI решения по управлению ролями, т.к. даже с небольшим количеством действий система становится запутанной, а количество писанины неоправданным.
Призываю всех пользователей к обсуждению: как они реализовывали систему управления правами в Yii проектах, и какие советы из личного опыта могут быть полезны остальным?

Что еще почитать:


Tags:
Hubs:
+44
Comments 26
Comments Comments 26

Articles