Pull to refresh

Сделай сам ACL. История разработки и написания несложной системы контроля доступов

PHP *
Sandbox

Есть у нас в поддержке внутренний проект, где используется очень неудобная система контроля доступа.
Так называемая ACL.
Неудобная она тем, что для предоставления любого доступа администратору нужно совершать очень много действий для каждого отдельного ресурса требующего доступа.
Ну и после того, как жалобы пользователей начали достигать критической отметки мне была поставлена задача упростить, либо переделать систему.

Посмотрел я существующий код, желания в себе не почувствовал для переделки, — и решил подыскать готовое решение. Сразу скажу, — я большой противник «велосипедов», прежде чем что-то написать я всегда стараюсь найти готовое и подходящее решение. Так было и на этот раз, но достаточно безуспешно. Несколько решений есть, например, в коллекции PEAR, но подходящего я не выбрал. Много кода устарело (мы используем версию PHP 5.3), поэтому было принято решение писать ACL самостоятельно.


Об этом и хочу рассказать. Примеры кода постараюсь приводить минимально, — расскажу только идею и принципы работы.
Но весь код библиотеки желающие смогут взять upd-2011 ( utf8 ), посмотреть и, при желании, использовать.

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

КПП. (контрольно-пропускной пункт)
Сидит бабуська, перед ней журнал. В журнале есть список помещений, в которые проходят посетители через КПП.
На самом деле помещений на объекте много, в журнале же у бабуськи их меньше. Там только те, куда нужно контролировать доступ.
К каждому помещению, которое есть в журнале приложен список посетителей, которым разрешено туда входить,
где кроме конкретных людей указаны и группы. (Например, — в кабинет 303 разрешён вход всем сотрудникам тех. отдела).
Бабуська руководствуясь данным журналом, и документами на руках у посетителей осуществляет контроль доступа на объект.
Это клиентская часть системы.

Теперь административная часть:
Периодически на КПП приходит начальник охраны. Он вносит изменения в журнал.


Требования, которые я поставил перед будущей системой:


  1. Права на ресурсы могут быть предоставлены: пользователю, пользователям, группе, группам.
  2. Из пункта №1 следует, что пользователи могут состоять в группах.
  3. Система (по крайней мере в первой версии) будет простейшей, это значит, что она либо даёт определённому пользователю право на ресурс, либо нет.
  4. Права на ресурсы должны наследоваться.
    То-есть, если ресурсом, скажем example.com/someitem владеет только пользователь Пупкин, то автоматически он и только он (но смотри п.5) имеет доступ к ресурсам:

    Но только, если для какого-то подчинённого ресурса не переопределены права. Если же права переопределены, то они уже по новому наследуются далее по дереву.
  5. В системе должны быть обязательные (системные) группы:
    • не авторизованные пользователи
    • авторизованные пользователи
    • суперпользователь.

    При этом суперпользователь имеет право на любые ресурсы, в том числе и на ресурсы Пупкина из предыдущего пункта данных требований.
  6. Ну и в соответствии с пунктом 4 ресурсы могут быть подчинёнными и родительскими.
    Под ресурсом вообще здесь я понимаю любой путь на сайте.
    Родительский ресурс можно получить, если у данного стереть последний элемент в пути до косой черты (/).
    Например тот же example.com/someitem/item2 — стираем /item2, — получаем родительский:
    example.com/someitem.
  7. Из предыдущего пункта следует, что должен быть один общий предок у всех ресурсов сайта, — это корневой ресурс.
    В данном случае это, — example.com, но хранить мы его будем как '/', и он также для системы будет обязательным, то-есть системным.
  8. Системные элементы (группы пользователей и корневой ресурс) нельзя удалить из системы.
  9. Система должна быть максимально простой для использования в клиентском коде.
  10. Для начала по моему достаточно.


Требования определены, становится ясной и структура базы данных:

Таблицы: (указан минимум необходимых полей)



Пользователи
+--------------------------
+ id | имя
+--------------------------

Группы
+--------------------------
+ id | имя
+--------------------------

Ресурсы
+--------------------------
+ id | имя
+--------------------------

Пользователи в группах
+-------------------------------------------------
+ id | id_группы | id_пользователя
+-------------------------------------------------

Права пользователей на ресурсы
+-------------------------------------------------
+ id | id_ресурса | id_пользователя
+-------------------------------------------------

Права групп на ресурсы
+-------------------------------------------------
+ id | id_ресурса | id_группы
+-------------------------------------------------

Схема базы данных для MySQL находится в архиве с библиотекой в директории docs.

Здесь сразу нужно оговорить, что систему авторизации я написал отдельно, сначала вообще использовал PEAR::Auth, и именно под неё делал ACL, но в существующей версии PEAR::Auth тоже достаточно много устаревшего (для PHP 5.3) кода, поэтому и библиотеку для авторизации решил написать собственную. Она получилась вообще простейшей. В рамках данной статьи описывать не стану. Скажу лишь только, что я постарался от системы авторизации также абстрагироваться, может быть использована любая система, единственное условие, — она должна реализовывать интерфейс IAuth (он находится в архиве описываемой библиотеки)

Кроме того, — описанная схемы базы данных нужна была временно, только для организации тестирования разработки, так как сразу же решено было писать систему не зависящую от контейнеров хранения данных. То-есть реально данные с пользователями, группами, ресурсами, правами могут храниться в разных местах, в разных базах данных, у кого-то может просто в файлах, у кого-то даже непосредственно где-то в коде (ну мало ли, ресурс такой маленький, заведомо известно, что пара-тройка пользователей будут работать, может база данных и не нужна). Смысл только в том, что система ничего не знает, и ей не нужно знать где и как хранятся данные.

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

Поэтому, — необходимо описать интерфейс адаптера, и реализовать пока один адаптер, скажем для MySQL.

Интерфейс для адаптера примерно такой:



 // -- это только небольшая вырезка из интерфейса
 //  -- полностью его код находится в приложенном архиве

/**
     * Возвращает список ресурсов.
     * Может возвращать как чистый список, так и с пользователями 
     * и группами каждого ресурса
     *
     * @param string $orderBy Порядок сортировки списка с ресурсами.
     *        default: путь к ресурсу
     * @param bool $getUsers Нужно ли возвращать список пользователей ресурса.
     * @param bool $getGroups Нужно ли возвращать список групп ресурса.
     * 
     * @param bool $where Можно задать условие для выборки
     * 
     * @return array Список ресурсов.
     *         [список пользователей в каждом ресурсе]
     *         [список групп каждого ресурса]
     *
     */
    public function getResources($orderBy=null, $getUsers=true, $getGroups=true, $where=false);
    
    /**
     * Возвращает все группы
     *
     * @param string $orderBy Поле таблицы для сортировки.
     *                        По умолчанию - имя группы
     * @param bool $getUsers Включать ли пользователей группы
     * @param bool $getResources This Включать ли ресурсы группы
     * @return array Массив с информацией о группах
     *               (плюс пользователи и ресурсы каждой группы
     *                если нужны)
     *
     */
    public function getGroups($orderBy=null, $getUsers=false, $getResources=false);
    
    /**
     * Возвращает пользователей группы
     *
     * @param int $groupId Id группы
     * @return array Массив с информацией о 
     *               пользователях группы
     *
     */
    public function getUsersForGroup($groupId);
    
    /**
     * Возвращает ресурсы группы
     *
     * @param int $groupId Id группы
     * @return array Массив с информацией о 
     *               ресурсах группы
     *
     */
    public function getResourcesForGroup($groupId);


и так далее и тому подобное.
Должно быть ясно какие действия понадобятся от адаптеров.

Пример реализации одного метода в адаптере для MySQL


  public function getGroups($orderBy=null, $getUsers=false, $getResources=false)
  {
        if ( empty($orderBy) ) {
            $orderBy = $this->tables['group']['namecol'];
        }
        $query = "SELECT * FROM {$this->tables['group']['name']} ORDER BY {$orderBy}";
        $res = $this->_connection->query($query);
        if ( !$res ) {
            return false;
        }
        $groups = $res->fetchAll(PDO::FETCH_ASSOC);
        
        if ( !$getUsers && !$getResources ) {
            return $groups;
        }
        
        foreach ($groups as & $_g) {
            if ( $getUsers ) {
                $_g['users'] = $this->getUsersForGroup($_g[ $this->tables['group']['idcol'] ]);
            }
            
            if ( $getResources ) {
                $_g['resources'] = $this->getResourcesForGroup($_g[ $this->tables['group']['idcol'] ]);
            }
        }
        
        return $groups;
  }


Сама система имеет два класса, наследников общего предка:

1. Клиент
2. Администратор

Задача клиента:


Проверить права на запрашиваемый ресурс для текущего пользователя, и либо дать ему доступ, либо отправить на страницу «Доступ запрещён».
Кстати, — выходы из системы при наличии отсутствия доступа определяются в отдельных функциях, а в систему при конфигурации передаются только имена этих функций.
Есть необходимость только в двух функциях: отправить на страницу логина() и доступ запрещён().
Я, кстати, как правило в своих собственных проектах обычно делаю одну страницу для ошибок 403 и 404, — «Страница не найдена».
Мне кажется, — это более надёжный вариант, потому как при отображении «Доступ запрещён» (403 ошибка), — потенциальному злоумышленнику мы уже предоставляем лишнюю информацию о том, что "… такая страница существует, только тебе, Вася, туда не положено..."

Задача административной части:


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

Использование системы в клиентском коде.


Система в клиентском коде используется очень просто, например так:

index.php (точка входа приложения)

<?php

    $acl = new AuthAcl_Client($options);
    $acl->start();

    // далее работает приложение

?>


То-есть до работы клиентского приложения код либо вообще не дойдёт, в случае отсутствия прав у пользователя, запросившего ресурс (отобразится, например, страница 404), либо приложение продолжит работу.

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

Но обычно в этом массиве параметров присутствуют такие элементы как:
  1. Имена полей для ввода логина и пароля в форме авторизации (система передаёт их далее системе авторизации).
  2. Имя адаптера для доступа к данным (например 'MySQL')
  3. Сведения для подключения к базе данных.
  4. Имена таблиц хранящих пользователей, группы, ресурсы, права.
  5. Шифруется ли пароль, если да, то каким способом (например true, md5)
  6. И всё остальное, что можно настраивать и конфигурировать.


В методе start() происходит примерно следующая работа:

  1. Определяются права на запрошенный ресурс.
    Ресурс может принадлежать группе «Неавторизованные пользователи», то-есть быть доступен всем, тогда на этом работа системы прекращается и управление передаётся клиентскому приложению.
  2. Иначе, — пользователь должен быть авторизован.
  3. Создаётся объект системы авторизации и управление передаётся в него. На этом этапе код может уйти на страницу авторизации, и работа прекратится.
  4. Если объект авторизации сообщил, что пользователь авторизован, то проверяются права пользователя на данный ресурс.
    В случае отсутствия таковых код вызывает функцию «нет доступа()», которая отправляет пользователя куда подальше по своему усмотрению.
  5. Иначе, — метод завершает свою работу и передаёт управление клиентскому приложению.


Я всегда стараюсь по максимуму покрывать свой код тестами, так было и в этот раз. Были написаны
юнит-тесты (находятся в архиве), после чего был реализован весь задуманный функционал.

Вот, в принципе и всё, о чём хотелось рассказать.
Код всей библиотеки приложил к статье.
Буду очень рад, если кому-то пригодится подобный опыт. Также с удовольствием услышу критику слабых мест предложенного решения.

Код библиотеки AuthAcl (в zip архиве)


UPD: 31.07.2011
Версия в utf-8:
Код библиотеки AuthAcl-utf8
Tags:
Hubs:
Total votes 3: ↑3 and ↓0 +3
Views 11K
Comments 8
Comments Comments 8