TIP: Use Markdown or, <pre> for multi line code blocks / <code> for inline code.
These forums are read-only and for archival purposes only!
Please join our new forums at discourse.kohanaframework.org
Пишу систему авторизации. Хроники.
  • В этой теме хочу описывать процесс написания системы авторизации для Коханы. Очень хочется по ходу описания и написания услышать мнения и советы. Когда процесс будет закончен система оформится в виде модуля.

    Почему велосипед? Долгое время пользовался связкой A1/A2/Acl, очень неплохо для разграничения доступа к ресурсам, но есть важные НО. Библиотека давно не обновлялась. Acl - порт Zend Acl, просто взять новый Acl из ZF2 не получится, нужно переписывать. Разграничение только по связке ресурс + роль, нет проверки доступа по URL, с наскока не получится прикрутить другие проверки не укладывающиеся в "ресурс + роль". Ну и еще по мелочи. Есть отдельные модули для разграничения по URL, но городить зоопарк модулей тоже не хочется + все равно придется дописывать что-то свое. Итак, принято решение написать свой велосипед, при этом максимально используя готовые запчасти.

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

  • Часть 1. События.

    Вдохновившись привязкой симфониевской секьюрити к событиям ядра симфони, я решил что может быть не плохим способом привязать проверку доступа на освобождение определенных событий. Сказано - сделано.

    Я выбрал компонент Symfony EventDispatcher. Он полностью меня устраивает, хорошо спроектирован и документирован.

    Подключаем компоненты Symfony. В папку APPATH/vendor/Symfony/Component складываем два необходимых компонента ClassLoader и EventDispatcher. В bootstrap.php подключаем автозагрузчик Symfony:

    /**
     * Enable Symfony Components autoloading
     */
    if ($path = Kohana::find_file('vendor', 'Symfony/Component/ClassLoader/UniversalClassLoader'))
    {
        ini_set('include_path',
        ini_get('include_path').PATH_SEPARATOR.dirname(dirname($path)));
     
        require_once 'ClassLoader/UniversalClassLoader.php';
    
        $loader = new \Symfony\Component\ClassLoader\UniversalClassLoader();
    
        $loader->registerNamespaces(array(
            'Symfony\\Component'           => APPPATH.'/vendor/',
        ));
        $loader->register();
    }
    

    Дальше создаем базовый класс SecurityEvent наследующийся от Event класса компонента:

    namespace Event;
    
    //--
    defined('SYSPATH') or die('No direct script access.');
    //--
    
    use Symfony\Component\EventDispatcher\Event;
    
    class SecurityEvent extends Event{
    
        private $_allowed=FALSE;
    
        private $_exception = FALSE;
    
            public function __construct($exception = FALSE)
        {
            if($exception)      
            {
                 $this->_exception=$exception;
            }
        }
    
        public function get_exception()
        {
            return $this->_exception;
        }
    
        public function is_allowed()
        {
         return $this->_allowed;
        }
    
        public function allow()
        {
            $this->_allowed=TRUE;
        }
    
        public function deny()
        {
            $this->_allowed=FALSE;
        }
    
    
    }
    

    В будущем можно добавить коллбэки на разрешение / запрет доступа и что-то еще если что-то понадобится.

  • Часть 2. URL. Начало.

    При контроле доступа к закрытым частям сайта хотелось отсекать не авторизованных пользователей сразу, до контроллера. Есть модуль morgan/kohana-deputy но в нем можно использовать либо четкие пути, либо * для всего. Ну и использование отдельного модуля не входило в мои планы, чтобы как уже говорил, не создавать зоопарк разномастных модулей. После некоторых раздумий и чтения кода Коханы для вдохновения я подумал "а почему бы не использовать прекрасный роутинг Коханы для этих целей".

    Я взял класс Route и дополнил его несколькими свойствами и методами.

    <?php
    
    defined('SYSPATH') or die('No direct script access.');
    
    class Route extends Kohana_Route
    {
        protected static  $_secured_routes = array();
    
    
        /**
         * Stores a named route AS SECURED and returns it. The "action" will always be set to
         * "index" if it is not defined.
         *
         *     Route::set('default', '(< controller >(/< action >(/)))')
         *         ->defaults(array(
         *             'controller' => 'welcome',
         *         ));
         *
         * @param   string  $name           route name
         * @param   string  $uri_callback   URI pattern
         * @param   array   $regex          regex patterns for route keys
         * @return  Route
         */
        public static function set_secured($name, $uri_callback = NULL, $regex = NULL)
        {
            $secured_route = new Route($uri_callback, $regex);
    
            Route::$_secured_routes[$name]=$secured_route;
    
            $secured_route->secured(TRUE);
    
            return  $secured_route;
        }
    
        /**
         * Retrieves all named secured routes.
         *
         *     $routes = Route::all();
         *
         * @return  array  routes by name
         */
        public static function all_secured()
        {
            return Route::$_secured_routes;
        }
    
    
            protected $_roles;
    
        protected $_secured = FALSE;
    
    
        public function roles(array $roles = NULL)
        {
            if ($roles === NULL)
            {
                return $this->_roles;
            }
            $this->_roles = $roles;
    
            return $this;
        }
    
    
        
        public function secured($secured = NULL)
        {
            if ($secured === NULL)
            {
                return $this->_secured;
            }
            
            if(!$this->_secured)
            {
                $this->_secured = (boolean) $secured;       
            }
                    
            if(!array_search($this, Route::$_secured_routes ))
            {
                        Route::$_secured_routes[Route::name($this)]=$this;
            }
            return $this;
        }
    }
    

    теперь можно писать так:

    Route::set_secured('admin-login-logout', 'admin/< action >', array('action' => 'login|logout'))
            ->roles(array(
                'guest'
            ));
    
    Route::set_secured('admin-area', 'admin(< any >)', array('any' => '.*'))        
            ->roles(array(
                'admin'
            ));
    

    Такие роуты будут использованы только для контроля доступа. Более спецефичные роуты как всегда наверху и могут дать доступ к определенным частям закрытого раздела. Ну а дальше все ограничено только необходимостью и фантазией.

    или так:

    Route::set('manage-comments', 'manage/comment/< action >')
        ->defaults(array(
            'directory' => 'manage',        
            'controller' => 'comment',
            'action'     => 'show',
        ))
           ->secured(TRUE)
           ->roles(array(
                'moderator'
            ));
    

    Такой роут будет участвовать и в разборе uri и в процессе авторизации.

  • Часть 3. URL. События.

    Так как я решил использовать события для контроля доступа, необходимо выбрасывать событие при разборе uri в классе request.

    Так как Symfony вся очень "DI" а Кохана полна статики, я написал класс обертку для компонента Event используя Singletone паттерн.

    <? defined('SYSPATH') or die('No direct script access.');
    
    use Symfony\Component\EventDispatcher\EventDispatcher;
    
    class EDispatcher {
    
        /**
         * <a href="/profile/var">@var  EventDispatcher  Singleton static instance
         */
        protected static $_instance;
    
    
        /**
         * Get the singleton instance of EventDispatcher.
         *
         * @return  EventDispatcher;
         */
        public static function instance()
        {
        if (EDispatcher::$_instance === NULL)
        {
            // Create a new instance
            EDispatcher::$_instance = new EventDispatcher;
        }
    
        return EDispatcher::$_instance;
        }
    
        /**
         * Private constructor
         */
        private function __construct()
        {
        
        }
    
        /**
         * Private final clone method
         */
        final private function __clone()
        {
        
        }   
    }
    

    Это позволило не перелопачивать слишком сильно исходный код ядра.

    Создаем класс RequestUriProcess унаследованный от \Event\SecurityEvent

    namespace Event\SecurityEvent;
    
    //--
    defined('SYSPATH') or die('No direct script access.');
    //--
    
    use Event\SecurityEvent;
    
    class RequestUriProcess extends SecurityEvent{
    
        private $_uri = NULL;  
    
            public function __construct($uri)
            {
                $this->_uri = $uri;
        }
    
        public function get_uri()
        {
            return $this->_uri;
        }
    }
    

    Переопределяем метод __construct класса request внося в него лишь одну дополнительную строку:

    ...
                // Remove trailing slashes from the URI
                $uri = trim($uri, '/');
    
                //--EVENT--
                EDispatcher::instance()->dispatch('core.request.process_uri.before', new Event\SecurityEvent\RequestUriProcess($uri));                  
                //--EVENT--
                
                $processed_uri = Request::process_uri($uri, $this->_routes);
    ...
    
  • а такие измененные роуты будут кешироватся нормально? сложно как-то слишком вышло, с первого раза не осилил =\ почему бы просто с стандартному auth модулю не добавить метод для проверки доступа или расширить logged_in

    <?php defined('SYSPATH') OR die('No direct script access.');
    /**
     * Basic primary controller
     *
     * <a href="/profile/package">@package   CMS/Common
     * @category  Controller
     * @author    WinterSilence
     */ 
    abstract class Controller_CMS_Basic extends Controller
    {
        /**
         * Check user auth?
         * @var  bool
         */
        public $check_auth = TRUE;
    
        /**
         * List the roles required to access
         * @var  array
         */
        public $auth_roles = array();
    
    
        /**
         * Auth instance wrapper
         * 
         * @return Auth
         */
        public function auth()
        {
            return Auth::instance();
        }
    
        /**
         * User object wrapper
         * 
         * @return Model_User ORM
         */
        public function user()
        {
            return $this->auth()->get_user();
        }
    
    
        /**
         * Check user authentication and authorization
         *
         * @return  void
         * @throw   HTTP_Exception
         * @uses    Auth::logged_in
         * @uses    HTTP_Exception::factory
         */
        public function check_auth()
        {
            if ( ! $this->user())
            {
                throw HTTP_Exception::factory(401, 'Unauthorized user');
            }
            elseif ( ! $this->auth()->logged_in($this->auth_roles, $this->request))
            {
                throw HTTP_Exception::factory(403, 'Access forbidden');
            }
        }
    
        /**
         * Automatically executed before the controller action. Can be used to set
         * class properties, do authorization checks, and execute other custom code.
         *
         * @return  void
         */
        public function before()
        {
            parent::before();
            
            if ($this->check_auth)
            {
                $this->check_auth();
            }
            
            //..
        }
    
        //..
    
    } // End Controller_Basic
    
  • @WinterSilence роуты установленные через Route::set кэшируются без проблем. Те, которые установлены через Route::set_secured не кэшируются пока никак. Нужно дополнить класс методом cache_secured(). Спасибо за напоминание об этом механизме.

    Модуль проверки доступа это и есть Acl, а связка A1/A2/Acl как раз так и работает пользователи-роли и ресурсы завязаны в конфиге. Можно поменять A1 на Auth они родственники. Но тогда не получится отсекать по запрашиваемому пути например. Или если будет нужна специфическая проверка придется что-от где-то менять. Или вводить свой новый модуль/класс со своим интерфейсом и использовать параллельно несколько интерфейсов для проверки доступа. А так все секурные действия сопровождаются выбросом секурного события и все. А уже в одном месте все это разбирается, раскладывается и доступ либо разрешается, либо нет.

  • @WinterSilence предыдущий комментарий написал до того, как появился код. Да, я видел такую реализацию где-то здесь же на форуме. Но она решает как раз вопрос доступа к Controller\Action что у меня сейчас сделано через роуты. А вот более сложные проверки по ресурсам так сделать уже не получится. Ну и плюс через роуты можно ограничить или дать доступ используя несколько роутов для одного action Например запретить незалогиненным читать свежие новости:

    Route::set_secured('new-news', 'news(< any >)', array('any' => '.*'))        
            ->roles(array(
                'user'
            ));
    

    Но потом позволить им смотреть архив за 2011 год.

    Route::set_secured('2011-news', 'news/< date >', array('date' => '2011-\d{1,2}-\d{1,2}'))
            ->roles(array(
                'guest'
            ));
    

    Вообще это только начало. Дальше будет про:

    Разграничение доступа к ресурсам через Zend Acl. Что позволит легко задавать правила вроде "пользователь может удалять свои комментарии и любые комментарии расположенные под его работой"(это уже сложно сделать в контроллере).

    Сведение всех событий в одну точку обработки.

    Класс для работы с Zend Acl (под вдохновением от A2).

    В итоге должен получится расширяемый модуль.

  • @artad я просто опасаюсь проблем с кешированием роутов, а так по сути между расширением роута или расширением auth разницы особой нет. кроме того этот подход не решает проблему проверки доступа моделей. метод можно сделать многофункциональным, чтобы он принимал значения разного типа и преобразовывал в строку-ключ, который уже ищется в бд

        public static function path($class, $action = FALSE, $sep = DS)
        {
            if ($class instanceof Controller)
            {
                $name = substr(get_class($class), 11);
            }
            elseif ($class instanceof Request)
            {
                $name = ltrim($class->directory().$sep.$class->controller(), $sep);
            }
            elseif ($class instanceof ORM)
            {
                $name = $class->table_name().$sep.$class->pk();
            }
            elseif (is_object($class))
            {
                $name = get_class($class);
            }
            else
            {
                $name = (string) $class;
            }
            
            $name = str_replace('_', $sep, $name);
            
            if ($action)
            {
                $name .= $sep.($class instanceof Request ? $class->action() : $action);
            }
            
            return strtolower($name);
        }
    

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

  • @WinterSilence Роуты - это скорее вспомогательная вещь просто закрыть разделы до вызова контроллеров. Хотя и позволяет делать интересные вещи. Решать задачи со сложными условиями с помощью них я решать не предполагал. Просто хороший бонус за несколько дополнительных свойств и методов. Кэшируются, только что проверил. Для секурных свой метод напишу.

Howdy, Stranger!

It looks like you're new here. If you want to get involved, click one of these buttons!

In this Discussion