Pull to refresh

Symfony2\SecurityBundle

Reading time 14 min
Views 41K
Original author: Dustin Dobervich
Аутентификация и Авторизация Автор рассказывает об устройстве бандла Security (на мой взгляд, самого трудного в понимании для symfony-новичков) и разбирает пример его применения. Статья будет особенно полезна для тех, кто желает знать, как работает их инструмент: Symfony2 Security в общем и FOSUserBundle в частности — однако не подходит для первого знакомства с фреймворком, поскольку требует знания некоторых из его компонент.
Статья была опубликована 21 марта 2011 года, когда ещё не вышла финальная версия symfony2.0, однако принципы работы бандла не изменились.

Оригинальная статья — «Symfony2 Blog Application Tutorial Part V: Intro to Security» — часть цикла обучающих статей на примере создания блога.
Есть прямое продолжение/дополнение статьи — «Symfony2 Blog Application Tutorial Part V-2: Testing Secure Pages», где даётся пример тестирования «закрытых» частей приложения.



Текст статьи


Компонент Security в Symfony2 очень мощен и сложен. Приведённый ниже пример будет простым, однако для вас не должно составить труда допилить его под свои нужды. В продакшн-версиях приложений настоятельно рекомендуется использовать бандл FOSUserBundle, который можно найти здесь. Его авторы входят в число разработчиков ядра Symfony2, и, скорее всего, бандл станет чем-то вроде «sfGuardPlugin», только уже для второй версии фреймворка. Те, кто знаком с symfony1, меня поймут. Поскольку у людей возникают проблемы именно с самим бандлом, то для ускорения процесса я опущу написание тестов. Возможно, напишу их позже и обновлю пост. [Update: я это сделал, см. здесь.]

Итак, наша цель — потребовать от любого, кто попытается зайти по адресу, начинающемуся с "/admin/", залогиниться через форму. Для этого нам понадобится сделать несколько вещей. Для начала нужно зарегистрировать SecurityBundle, который поставляется вместе с Symfony2. Для этого открываем AppKernel.php, расположенный в директории app, находим метод registerBundles и добавляем следующее к массиву с бандлами:

new Symfony\Bundle\SecurityBundle\SecurityBundle()

Теперь, когда бандл зарегистрирован, можно переходить к делу, однако прежде, чем начать писать код, необходимо разобраться в принципах работы компонента Security в Symfony2. Условно его можно разбить на три субкомпонента: Users (Пользователи), Authentication (Аутентификация) и Authorization (Авторизация). Users хранит в себе информацию о пользователе, который работает с приложением. Authentication проверяет, является ли пользователь тем, кем представился. Authorization разрешает и запрещает аутентифицированному пользователю выполнять определённые действия, например, просмотр какой-либо информации, её редактирование и др.

Теперь мы готовы к тому, чтобы настроить систему безопасности в нашем приложении. Для этого укажем в конфигурации Symfony2, что в качестве User provider (провайдера пользователей) следует использовать ранее созданную нами сущность Company\BlogBundle\Entity\User, а также определим: как шифровать пароли, какие части приложения должны быть защищены и какими ролями необходимо обладать пользователю, чтобы получить к ним доступ. Откроем файл config.yml из директории app/config и добавим следующее:

## Security Configuration
security:
    encoders:
        Company\BlogBundle\Entity\User:
            algorithm: sha512
            encode-as-base64: true
            iterations: 10

    providers:
        main:
            entity: { class: BlogBundle:User, property: username }

    firewalls:
        main:
            pattern: /.*
            form_login:
                check_path: /login_check
                login_path: /login
            logout: true
            security: true
            anonymous: true

    access_control:
        - { path: /admin/.*, role: ROLE_ADMIN }
        - { path: /.*, role: IS_AUTHENTICATED_ANONYMOUSLY }

Разберём каждый из разделов этого файла. В encoders определяется, как будут шифроваться пользовательские пароли для сущности Company\BlogBundle\Entity\User. Мы будем использовать MessageDigestPasswordEncoder, поставляемый с Symfony2, но, конечно же, можно написать свой шифратор. (Прим. пер. — класс MessageDigestPasswordEncoder расположен в vendor/symfony/src/Symfony/Component/Security/Core/Encoder.) Далее указано, что в качестве шифрования следует использовать 10 прогонов через sha512, а результат представить в виде строки base64. Более подробно про encoders см. здесь.

В разделе providers определяется, откуда следует извлекать данные пользователей. Мы выбрали Doctrine Entity Provider и для этого указали параметр entity. Другие разрешённые провайдеры — это In-Memory Provider и Chain Provider, про которые вы можете прочитать здесь. В строке entity необходимо указать свойства class и property. Class указывает на класс с сущностью, представляющей пользователя. В примере это класс User из бандла BlogBundle. Property является названием столбца таблицы, содержащего имена пользователей, и в нашем случае это username.

Авторизация в Symfony2 реализуется через систему Firewall. Она состоит из «слушателей» (англ. listeners), которые ожидают core.security событие и перенаправляют запрос, учитывая при этом то, какими правами наделен пользователь. В разделе firewalls определяются шаблоны маршрутов (англ. routes), на которые необходимо навесить «слушателей» (англ. listeners). На сегодня для защиты приложения рекомендуется указывать один firewall, обслуживающий все маршруты, а затем использовать раздел access_control для разрешения или запрещения доступа в зависимости от ролей пользователя. В нашем примере в поле pattern раздела firewalls определено, что необходимо «слушать» все маршруты. Поле form_login указывает на то, что аутентификация будет проходить через форму. О других методах аутентификации см. здесь. Внутри form_login мы указали маршруты для login_path (место расположения формы входа) и check_path (место расположения обработчика формы). Кроме того, form_login имеет много других настроек, о которых можно прочитать здесь.

Наконец, последний раздел — access_control. Записи в нём определяют шаблоны маршрутов и роли, необходимые для доступа к ним. В нашем примере для маршрутов, начинающихся с "/admin/" требуется роль ROLE_ADMIN, для всех остальных достаточно IS_AUTHENTICATED_ANONYMOUSLY (в Symfony2 этой ролью по умолчанию наделены все пользователи).

Итак, теперь, когда конфигурационный файл безопасности обновлён, необходимо изменить классы нашей сущности так, чтобы они реализовывали интерфейсы, которые требует SecurityBundle. В нашем примере необходимо добавить в сущность User реализацию интерфейса UserInterface. Также необходимо создать класс Role, реализующий интрефейс RoleInterface. После этого для загрузки в базу данных новой информации нужно изменить наши фикстуры.

Создадим в директории src/Company/BlogBundle/Entity файл Role.php со следующим содержимым:


namespace Company\BlogBundle\Entity;
 
use Symfony\Component\Security\Core\Role\RoleInterface;
use Doctrine\ORM\Mapping as ORM;
 
/**
 * @ORM\Entity
 * @ORM\Table(name="role")
 */
class Role implements RoleInterface
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     *
     * @var integer $id
     */
    protected $id;
 
    /**
     * @ORM\Column(type="string", length="255")
     *
     * @var string $name
     */
    protected $name;
 
    /**
     * @ORM\Column(type="datetime", name="created_at")
     *
     * @var DateTime $createdAt
     */
    protected $createdAt;
 
    /**
     * Геттер для id.
     *
     * @return integer The id.
     */
    public function getId()
    {
        return $this->id;
    }
 
    /**
     * Геттер для названия роли.
     *
     * @return string The name.
     */
    public function getName()
    {
        return $this->name;
    }
 
    /**
     * Сеттер для названия роли.
     *
     * @param string $value The name.
     */
    public function setName($value)
    {
        $this->name = $value;
    }
 
    /**
     * Геттер для даты создания роли.
     *
     * @return DateTime A DateTime object.
     */
    public function getCreatedAt()
    {
        return $this->createdAt;
    }
 
    /**
     * Конструктор класса
     */
    public function __construct()
    {
        $this->createdAt = new \DateTime();
    }
 
    /**
     * Реализация метода, требуемого интерфейсом RoleInterface.
     * 
     * @return string The role.
     */
    public function getRole()
    {
        return $this->getName();
    }
}

Сущность Role довольно проста. Она имеет всего одно свойство — name, которое содержит название роли. Также класс реализует интерфейс RoleInterface через поддержку метода getRole, который возвращает название роли. Теперь, после создания класса Role, необходимо подправить сущность User. Откроем файл User.php из
src/Company/BlogBundle/Entity и изменим код следующим образом:


namespace Company\BlogBundle\Entity;
 
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
 
/**
 * @ORM\Entity
 * @ORM\Table(name="user")
 */
class User implements UserInterface
{
    // ...
 
    /**
     * @ORM\Column(type="string", length="255")
     *
     * @var string username
     */
    protected $username;
 
    /**
     * @ORM\Column(type="string", length="255")
     *
     * @var string password
     */
    protected $password;
 
    /**
     * @ORM\Column(type="string", length="255")
     *
     * @var string salt
     */
    protected $salt;
 
    /**
     * @ORM\ManyToMany(targetEntity="Role")
     * @ORM\JoinTable(name="user_role",
     *     joinColumns={@ORM\JoinColumn(name="user_id", referencedColumnName="id")},
     *     inverseJoinColumns={@ORM\JoinColumn(name="role_id", referencedColumnName="id")}
     * )
     *
     * @var ArrayCollection $userRoles
     */
    protected $userRoles;
 
    // ... 
 
    /**
     * Геттер для имени пользователя.
     *
     * @return string The username.
     */
    public function getUsername()
    {
        return $this->username;
    }
 
    /**
     * Сеттер для имени пользователя.
     *
     * @param string $value The username.
     */
    public function setUsername($value)
    {
        $this->username = $value;
    }
 
    /**
     * Геттер для пароля.
     *
     * @return string The password.
     */
    public function getPassword()
    {
        return $this->password;
    }
 
    /**
     * Сеттер для пароля.
     *
     * @param string $value The password.
     */
    public function setPassword($value)
    {
        $this->password = $value;
    }
 
    /**
     * Геттер для соли к паролю.
     *
     * @return string The salt.
     */
    public function getSalt()
    {
        return $this->salt;
    }
 
    /**
     * Сеттер для соли к паролю.
     *
     * @param string $value The salt.
     */
    public function setSalt($value)
    {
        $this->salt = $value;
    }
 
    /**
     * Геттер для ролей пользователя.
     *
     * @return ArrayCollection A Doctrine ArrayCollection
     */
    public function getUserRoles()
    {
        return $this->userRoles;
    }
 
    /**
     * Конструктор класса User
     */
    public function __construct()
    {
        $this->posts = new ArrayCollection();
        $this->userRoles = new ArrayCollection();
        $this->createdAt = new \DateTime();
    }
 
    /**
     * Сброс прав пользователя.
     */
    public function eraseCredentials()
    {
 
    }
 
    /**
     * Геттер для массива ролей.
     * 
     * @return array An array of Role objects
     */
    public function getRoles()
    {
        return $this->getUserRoles()->toArray();
    }
 
    /**
     * Сравнивает пользователя с другим пользователем и определяет
     * один и тот же ли это человек.
     * 
     * @param UserInterface $user The user
     * @return boolean True if equal, false othwerwise.
     */
    public function equals(UserInterface $user)
    {
        return md5($this->getUsername()) == md5($user->getUsername());
    }
 
    // ...
 
}

Желающие могут скачать этот класс здесь. Как видите, в сущности User нет ничего особо сложного, лишь реализация интерфейса UserInterface и создание связи много-ко-многим с сущностью Role. Также существует интерфейс AdvancedUserInterface, который предоставляет большую функциональность, но его рассмотрение выходит за рамки данной статьи. Более подробно о нём можно прочитать здесь.

Теперь необходимо изменить фикстуры, с помощью которых мы сможем обновлять информацию в базе данных. Откроем файл FixtureLoader.php из директории src/Company/BlogBundle/DataFixtures/ORM и внесем следующие изменения:


namespace Company\BlogBundle\DataFixtures\ORM;
 
use Doctrine\Common\DataFixtures\FixtureInterface;
use Company\BlogBundle\Entity\Category;
use Company\BlogBundle\Entity\Post;
use Company\BlogBundle\Entity\Tag;
use Company\BlogBundle\Entity\User;
use Company\BlogBundle\Entity\Role;
use Symfony\Component\Security\Core\Encoder\MessageDigestPasswordEncoder;
 
class FixtureLoader implements FixtureInterface
{
    public function load($manager)
    {
        // создание роли ROLE_ADMIN
        $role = new Role();
        $role->setName('ROLE_ADMIN');
 
        $manager->persist($role);
 
        // создание пользователя
        $user = new User();
        $user->setFirstName('John');
        $user->setLastName('Doe');
        $user->setEmail('john@example.com');
        $user->setUsername('john.doe');
        $user->setSalt(md5(time()));
 
        // шифрует и устанавливает пароль для пользователя,
        // эти настройки совпадают с конфигурационными файлами
        $encoder = new MessageDigestPasswordEncoder('sha512', true, 10);
        $password = $encoder->encodePassword('admin', $user->getSalt());
        $user->setPassword($password);
 
        $user->getUserRoles()->add($role);
 
        $manager->persist($user);
 
        // ...
 
}

Таким образом была создана новая роль с именем ROLE_ADMIN. Также был добавлен пользователь с логином john.doe и произвольной солью для пароля. Единственно новая функциональность здесь — это шифрование пароля. Если помните, то в разделе security.encoders конфигурационного файла мы установили использование MessageDigestPasswordEncoder. Здесь мы создаём экземпляр этого класса и передаём в него те же параметры, что указаны в security.encoders, затем шифруем пароль «admin» и устанавливаем результат в качестве нового пароля создаваемого пользователя. При шифровании пароля необходимо указывать те же параметры, что и в конфигурации компонента Security, поскольку в противном случае мы не сможем корректно провести аутентификацию.

Перед тем, как обновить маршрутизацию и создать новые контроллеры и виды, необходимо запустить в консоли несколько команд. Откроем терминал и перейдём в корневую директорию нашего проекта. Во-первых, обновим структуру базы данных, чтобы она соответствовала сущностям.

php app/console doctrine:schema:update --force


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

php app/console doctrine:data:load

На этом этапе имеем установленные и обновленные сущности, данные и конфигурацию безопасности. Теперь приступим к изменению маршрутизации и созданию нескольких контроллеров и видов для реализации формы входа. Откроем файл routing.yml из директории src/Company/BlogBundle/Resources/config и добавим следующие маршруты в его начало:

_security_login:
    pattern:  /login
    defaults: { _controller: BlogBundle:Security:login }

_security_check:
    pattern:  /login_check

_security_logout:
    pattern:  /logout

admin_home:
    pattern:  /admin/
    defaults: { _controller: BlogBundle:Admin:index }

Этим мы добавили несколько специальных маршрутов, которые будут использоваться как нами, так и компонентом Security. Возможно, вы задались вопросом, почему мы не определили контроллеры для двух маршрутов. Помните, что я писал про работу компонента Security, ожидающего core.security событие? Из этого следует, что контроллеры для этих маршрутов никогда не выполнятся, поскольку компонент Security перехватывает запросы и сам их обрабатывает. Поэтому мы без проблем можем использовать маршруты _security_check и _security_logout в наших шаблонах. Также был добавлен маршрут admin_home, который представляет собой главную страницу администраторского раздела нашего приложения. Поскольку мы указали в security.access_control разделе конфигурации, что только пользователи с ролью ROLE_ADMIN могут получить доступ к маршрутам, начинающимся с "/admin/", то маршрут admin_home является защищённым. Скоро мы попробуем войти в него.

Наверное, вы заметили, что нам нужно создать два новых контроллера — SecurityController и AdminController, так давайте приступим. Создадим в директории src/Company/BlogBundle/Controller файл с названием AdminController.php и следующим содержанием:


namespace Company\BlogBundle\Controller;
 
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
 
class AdminController extends Controller
{
    public function indexAction()
    {
        return $this->render('BlogBundle:Admin:index.html.twig');
    }
}

Ничего необычного. Просто обработка шаблона index.html.twig, который мы сейчас опишем. Для этого создадим новую папку Admin в директории src/Company/BlogBunde/Resources/views, внутри которой создадим файл index.html.twig — шаблон главной страницы администраторского раздела.

{% extends "BlogBundle::layout.html.twig" %}
 
{% block title %}
    symfony2 Учебник | Админка | Главная
{% endblock %}
 
{% block content %}
    <h2>
        Добро пожаловать на администраторскую страницу, {{ app.user.username }}!
    </h2>
{% endblock %}

Единственно, на что следует обратить внимание, это переменная шаблона app.user, которая позволяет получить доступ к текущему, вошедшему в приложение пользователю. Так, в нашем примере переменная app.user соответствует экземпляру класса Company\BlogBundle\Entity\User.

Теперь, имея защищённые страницы, давайте создадим контроллер SecurityController. Для этого создадим в директории src/Company/BlogBundle/Controller файл SecurityController.php со следующим содержанием:


namespace Company\BlogBundle\Controller;
 
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Security\Core\SecurityContext;
 
class SecurityController extends Controller
{
    public function loginAction()
    {
        if ($this->get('request')->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {
            $error = $this->get('request')->attributes->get(SecurityContext::AUTHENTICATION_ERROR);
        } else {
            $error = $this->get('request')->getSession()->get(SecurityContext::AUTHENTICATION_ERROR);
        }
 
        return $this->render('BlogBundle:Security:login.html.twig', array(
            'last_username' => $this->get('request')->getSession()->get(SecurityContext::LAST_USERNAME),
            'error' => $error
        ));
    }
}

Мы создали экшн loginAction, который соответствует маршруту _security_login. Внутри него проводится проверка на наличие ошибок и затем в качестве ответа отдаётся шаблон login.html.twig. Проверка на ошибки может показаться немного странной, но фактически мы лишь пытаемся узнать, как попали в этот экшен — по прямой ссылке или были перенаправлены. В зависимости от этого получаем сгенерированное исключение.

Компонент Security берёт на себя всю проверку учётных данных пользователей, однако мы должны создать шаблон, принимающий определённые параметры. Для того, чтобы компонент Security корректно проводил валидацию данных, необходимо, чтобы форма имела поля _username и _password, а также была привязана к маршруту _security_check. Давайте реализуем это, создав в директории src/Company/BlogBunde/Resources/views папку Security, в которой, в свою очередь, создадим файл login.html.twig со следующим содержанием:

{% extends "BlogBundle::layout.html.twig" %}
 
{% block title %}
    symfony2 Учебник | Войти
{% endblock %}
 
{% block content %}
    {% if error %}
        <div class="error">{{ error.message }}</div>
    {% endif %}
 
    <form action="{{ path('_security_check') }}" method="POST">
        <table>
            <tr>
                <td>
                    <label for="username">Логин:</label>
                </td>
                <td>
                    <input type="text" id="username" name="_username" value="{{ last_username }}" />
                </td>
            </tr>
            <tr>
                <td>
                    <label for="password">Пароль:</label>
                </td>
                <td>
                    <input type="password" id="password" name="_password" />
                </td>
            </tr>
        </table>
        <input type="submit" name="login" value="Отправить" />
    </form>
{% endblock %}

Здесь должно быть всё понятно. При отправке данных из формы по маршруту _security_check компонент Security будет перехватывать запрос и самостоятельно проводить аутентификацию. После удачной аутентификации пользователь будет перенаправлен по первоначальному адресу, в противном случае — на страницу с формой входа.

Теперь давайте создадим ссылку «Выход», которая будет показываться только залогиненным пользователям. Откроем в директории src/Company/BlogBundle/Resources/views файл layout.html.twig и произведём следующие изменения:

// ...
 
{% block body %}
    <div id="container">
        <header class="clearfix">
            <h1>
                symfony2 Учебник
            </h1>
            <nav>
                <ul>
                    <li>
                        <a href="{{ path('show_page', { 'page' : 'about' }) }}">
                            О сайте
                        </a>
                    </li>
                    {% if is_granted('IS_AUTHENTICATED_FULLY') %}
                        <li>
                            <a href="{{ path('_security_logout') }}">
                                Выход
                            </a>
                        </li>
                    {% endif %}
                </ul>
            </nav>
        </header>
 
        // ...

Мы использовали twig функцию is_granted для того, чтобы проверить пользователя на обладанием определённой ролью. В нашем примере, это роль IS_AUTHENTICATED_FULLY — специальная роль, которой наделяются пользователи, аутентифицированные компонентом Security. Если пользователь обладает этой ролью, то добавляем в меню с навигацией ссылку на созданный выше маршрут _security_logout.

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

php app/console cache:clear

Теперь попробуйте зайти на "/admin/". Вас должно перенаправить на страницу с формой входа, которая выглядит примерно так (кликните для увеличения):



Введите john.doe в поле «Логин» и admin в поле «Пароль». После того, как вы отправите учётные данные, вас должно перенаправить на главную страницу администраторского раздела, которая выглядит примерно так (кликните для увеличения):



Также в меню с навигацией должна появиться ссылка «Выход». Если перейти по ней, то нас перебросит на главную страницу приложения, и при этом мы разлогинимся. Фух! Вы сделали большую работу! А я со своей стороны вложил в эту статью много сил и прошёл долгий путь проб и ошибок! В итоге мы имеем защищённый раздел приложения и соответствующий префикс маршрута (прим.пер. — "/admin/*"). Ещё раз повторю, что в продакш-версиях приложений настоятельно рекомендуется использовать FOSUserBundle, который гораздо функциональнее того, что мы создали в этой статье. Надеюсь, что всё было написано понятно, но если всё же что-то нуждается в дополнительных пояснениях, то дайте знать.

От переводчика


В рамках данной темы полезно почитать раздел Security официальной документации. Особого внимания заслуживают статьи из Cookbook: Access Control Lists (ACLs) и Advanced ACL Concepts.

Собираю информацию о том, что было или есть непонятно в Symfony2 лично для вас, а также ссылки на хорошие статьи о Symfony2.0 (желательно написанные после релиза фреймворка). Будем поднимать рейтинг хабраблога Symfony Frameworks вместе!

Книга жалоб и предложений по самому переводу находится здесь — pluseg. Буду рад любому отзыву!
Tags:
Hubs:
+43
Comments 18
Comments Comments 18

Articles