Как загружать пользователей безопасности из БД (поставщик сущностей)

Как загружать пользователей безопасности из БД (поставщик сущностей)

Система безопасности Symfony может загружать пользователей откуда угодно, например, из БД через Active Directory или сервер OAuth. Эта статья покажет вам, как загружать ваших пользователей из БД через сущность Doctrine.

Вступление

Tip

До того, как вы начнёте, вам стоит посмотреть FOSUserBundle. Этот внешний пакет позволяет вам загружать пользователей из БД (как вы узнаете далее) и даст вам встроенные маршруты и контроллеры для таких вещей, как вход в систему, регистрация и забытый пароль. Но, если вам нужно сильно настроить систему вашего пользователя или если вы хотите узнать, как это работает, то этот туториал даже лучше.

Загрузка пользователей через сущность Doctrine имеет 2 базовых шага:

  1. Создайте вашу сущность Пользователя
  2. Сконфигурируйте security.yml так, чтобы он загружал из вашей сущности

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

1) Создайте вашу сущность Пользователя

Для этой статьи, представьте, что у вас уже есть сущность User внутри AppBundle со следующими полями: id, username, password, email и isActive:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
// src/AppBundle/Entity/User.php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * @ORM\Table(name="app_users")
 * @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository")
 */
class User implements UserInterface, \Serializable
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=25, unique=true)
     */
    private $username;

    /**
     * @ORM\Column(type="string", length=64)
     */
    private $password;

    /**
     * @ORM\Column(type="string", length=60, unique=true)
     */
    private $email;

    /**
     * @ORM\Column(name="is_active", type="boolean")
     */
    private $isActive;

    public function __construct()
    {
        $this->isActive = true;
        // может не понадобиться, смотрите раздел о соли ниже
        // $this->salt = md5(uniqid(null, true));
    }

    public function getUsername()
    {
        return $this->username;
    }

    public function getSalt()
    {
        // вам *может* понадобиться настоящая соль, в зависимости от вашего кодировщика
        // смотрите раздел о соли ниже
        return null;
    }

    public function getPassword()
    {
        return $this->password;
    }

    public function getRoles()
    {
        return array('ROLE_USER');
    }

    public function eraseCredentials()
    {
    }

    /** @see \Serializable::serialize() */
    public function serialize()
    {
        return serialize(array(
            $this->id,
            $this->username,
            $this->password,
            // смотрите раздел о соли ниже
            // $this->salt,
        ));
    }

    /** @see \Serializable::unserialize() */
    public function unserialize($serialized)
    {
        list (
            $this->id,
            $this->username,
            $this->password,
            // смотрите раздел о соли ниже
            // $this->salt
        ) = unserialize($serialized);
    }
}

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

1
$ php bin/console doctrine:generate:entities AppBundle/Entity/User

Далее, обязательно создайте таблицу БД:

1
$ php bin/console doctrine:schema:update --force

Что это за UserInterface?

До этих пор, это просто обычная сущность. Но для того, чтобы использовать этот класс в системе безопасности, она должна реализовывать UserInterface. Это принуждает класс к тому, чтобы он имел пять следующих методов:

Чтобы узнать больше о каждом из них, смотрите UserInterface.

Caution

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

Что делают методы сериализации и десериализации?

В конце каждого запроса, объект User сериализуется в сессии. В следующем запросе он десериализуется. Чтобы помочь PHP делать это правильно, вам нужно реализовать Serializable. Но вам не нужно сериализовать всё: вам нужно только несколько полей (те, что показаны выше плюс несколько дополнительных, если вы решите реализовать AdvancedUserInterface). По каждому запросу используется id, чтобы запросить свежий объект User из БД.

Хотите знать больше? Смотрите Понимание сериализации и того, как сохраняется пользователь в сессии.

2) Сконфигурируйте безопасность так, чтобы она загружала из вашей сущности

Теперь, когда у вас есть сущность User, реализующая UserInterface, вам просто надо сказать системе безопасности Symfony о ней в security.yml.

В этом примере, пользователь будет вводить своё имя пользователя и пароль через базовую аутентификацию HTTP. Symfony запросит сущность User, совпадающую с этим именем пользователя и потом проверит парль (больше о паролях через секунду):

  • YAML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    # app/config/security.yml
    security:
        encoders:
            AppBundle\Entity\User:
                algorithm: bcrypt
    
        # ...
    
        providers:
            our_db_provider:
                entity:
                    class: AppBundle:User
                    property: username
                    # если вы используете несколько менеджеров сущностей
                    # manager_name: customer
    
        firewalls:
            main:
                pattern:    ^/
                http_basic: ~
                provider: our_db_provider
    
        # ...
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <!-- app/config/security.xml -->
    <?xml version="1.0" encoding="UTF-8"?>
    <srv:container xmlns="http://symfony.com/schema/dic/security"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:srv="http://symfony.com/schema/dic/services"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            http://symfony.com/schema/dic/services/services-1.0.xsd">
    
        <config>
            <encoder class="AppBundle\Entity\User" algorithm="bcrypt" />
    
            <!-- ... -->
    
            <provider name="our_db_provider">
                <!-- если вы используете несколько менеджеров сущностей, добавьте:
                     manager-name="customer" -->
                <entity class="AppBundle:User" property="username" />
            </provider>
    
            <firewall name="main" pattern="^/" provider="our_db_provider">
                <http-basic />
            </firewall>
    
            <!-- ... -->
        </config>
    </srv:container>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // app/config/security.php
    use AppBundle\Entity\User;
    
    $container->loadFromExtension('security', array(
        'encoders' => array(
            User::class => array(
                'algorithm' => 'bcrypt',
            ),
        ),
    
        // ...
    
        'providers' => array(
            'our_db_provider' => array(
                'entity' => array(
                    'class'    => 'AppBundle:User',
                    'property' => 'username',
                ),
            ),
        ),
        'firewalls' => array(
            'main' => array(
                'pattern'    => '^/',
                'http_basic' => null,
                'provider'   => 'our_db_provider',
            ),
        ),
    
        // ...
    ));
    

Во-первых, раздел encoders сообщает Symfony ожидать, что пароли в БД будут зашифрованы с использоваием bcrypt. Во-вторых, раздел providers создаёт "поставщика пользователя" под названием our_db_provider, который знает, что надо запросить из вашей сущности AppBundle:User по свойству username. Имя our_db_provider не важно: оно просто должно совпадать со значением ключа provider в вашем брандмауэре. Или, если вы не хотите устанавливать ключ provider в вашем брандмауэре, то будет автоматически использован первый "поставщик пользователя".

Создание вашего первого пользователя

Чтобы добавлять пользователей, вы можете реализовать форму регистрации или добавить некоторые fixtures. Это просто обычная сущность, так что нет ничего сложного кроме того, что вам нужно зашифровать пароль каждого пользователя. Но не волнуйтесь, Symfony предоставляет вам сервис, который сделает это за вас. Смотрите How to Manually Encode a Password, чтобы узнать детали.

Ниже вы можете увидеть экспорт таблицы app_users из MySQL с пользователем admin и паролем admin (который был зашифрован).

1
2
3
4
5
6
$ mysql> SELECT * FROM app_users;
+----+------------------+--------------------------------------------------------------+--------------------+-----------+
| id | имя пользователя | пароль                                                       | email              | is_active |
+----+------------------+--------------------------------------------------------------+--------------------+-----------+
|  1 | admin            | $2a$08$jHZj/wJfcVKlIwr5AvR78euJxYK7Ku5kURNhNx.7.CSIJ3Pq6LEPC | [email protected]  |         1 |
+----+------------------+--------------------------------------------------------------+--------------------+-----------+

Если вы используете bcrypt, то нет. В других случаях - да. Все пароли должны быть хешированы с солью, но bcrypt делает это внутренне. Так как этот туториал использует bcrypt, метод getSalt() в User может просто вернуть null (не используется). Если вы используете другой алгоритм, вам нужно будет убрать комментарии из строк salt в сущности User и добавить сохранённое свойство salt.

Запрет неактивных пользователей (AdvancedUserInterface)

Если свойство пользователя isActive установлено, как false (т.е. is_active равняется 0 в БД), то пользователь всё равно сможет заходить на сайт нормально. Это легко исправить.

Чтобы исключить неактивных пользователей, измените ваш класс User, чтобы он реализовывал AdvancedUserInterface. Это расширяет UserInterface, так что вам нужен только новый интерфейс:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// src/AppBundle/Entity/User.php

use Symfony\Component\Security\Core\User\AdvancedUserInterface;
// ...

class User implements AdvancedUserInterface, \Serializable
{
    // ...

    public function isAccountNonExpired()
    {
        return true;
    }

    public function isAccountNonLocked()
    {
        return true;
    }

    public function isCredentialsNonExpired()
    {
        return true;
    }

    public function isEnabled()
    {
        return $this->isActive;
    }

    // сериализация и десериализация должны быть обновлены - см. ниже
    public function serialize()
    {
        return serialize(array(
            // ...
            $this->isActive
        ));
    }
    public function unserialize($serialized)
    {
        list (
            // ...
            $this->isActive
        ) = unserialize($serialized);
    }
}

Интерфейс AdvancedUserInterface добавляет четыре дополнительных метода для валидации статуса учётной записи:

  • isAccountNonExpired() проверяет, не истёк ли срок годности учётной записи пользователя;
  • isAccountNonLocked() проверяет, не закрыт ли пользователь;
  • isCredentialsNonExpired() проверяет, не истёк ли срок годности аккредитации пользователя (пароля);
  • isEnabled() проверяет, включен ли пользователь.

Если любой из них вернёт false, ползователю нельзя будет выполнить вход. Выможете выбрать иметь сохранённые свойства для всех из них, или то, что вам надо (в этом примере, только isActive извлекает из БД).

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

Note

Если вы используете AdvancedUserInterface, вам также надо добавить любые из свойств, используемых этими методами (как isActive) к методам serialize() и unserialize(). Если вы не сделаете этого, то ваш пользователь может быть неправильно десериализован из сессии по каждому запросу.

Поздравляем! Ваша система безопаности загрузки из БД полностью настроена! Далее, добавьте настоящую форму входа вместо базового HTTP или продолжайте читать о других темах.

Исползование пользовательского запроса для загрузки пользователя

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

Чтобы сделать это, заставьте ваш UserRepository реализовывать специальный UserLoaderInterface. Этот интерфейс требует только одного метода: loadUserByUsername($username):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// src/AppBundle/Repository/UserRepository.php
namespace AppBundle\Repository;

use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Doctrine\ORM\EntityRepository;

class UserRepository extends EntityRepository implements UserLoaderInterface
{
    public function loadUserByUsername($username)
    {
        return $this->createQueryBuilder('u')
            ->where('u.username = :username OR u.email = :email')
            ->setParameter('username', $username)
            ->setParameter('email', $username)
            ->getQuery()
            ->getOneOrNullResult();
    }
}

Tip

Не забудьте добавить класс хранилища к определению маршрутизации вашей сущности.

Чтобы завершить это, просто удалите ключ property из поставщика пользователя в security.yml:

  • YAML
    1
    2
    3
    4
    5
    6
    7
    8
    # app/config/security.yml
    security:
        # ...
    
        providers:
            our_db_provider:
                entity:
                    class: AppBundle:User
    
  • XML
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    <!-- app/config/security.xml -->
    <?xml version="1.0" encoding="UTF-8"?>
    <srv:container xmlns="http://symfony.com/schema/dic/security"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:srv="http://symfony.com/schema/dic/services"
        xsi:schemaLocation="http://symfony.com/schema/dic/services
            http://symfony.com/schema/dic/services/services-1.0.xsd">
    
        <config>
            <!-- ... -->
    
            <provider name="our_db_provider">
                <entity class="AppBundle:User" />
            </provider>
        </config>
    </srv:container>
    
  • PHP
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    // app/config/security.php
    $container->loadFromExtension('security', array(
        // ...
    
        'providers' => array(
            'our_db_provider' => array(
                'entity' => array(
                    'class' => 'AppBundle:User',
                ),
            ),
        ),
    ));
    

Это сообщает Symfony не запрашивать Ползователя автоматически. Вместо этого, когда кто-то выполняет вход, будет вызван метод loadUserByUsername() в UserRepository.

Понимание сериализации и того, как сохраняется пользователь в сессии

Если вас интересует важность метода serialize() внутри класса User, или то, как объект Пользователя сериализуется или десериализуется, то этот раздел для вас. Если нет - просто пропустите его.

Когда пользователь выполнил вход, весь объект Пользователя сериализуется в сессию. По следующему запросу, объект Пользователя десериализуется. Потом, значение свойства id используется для повторного запроса свежего объекта пользователя из БД. Наконец, свежий объект Пользователя сравнивается с десериализованным объектом Пользователя, чтобы убедиться, что они представляют одного пользователя. Например, если username в объектах 2 Пользователя не совпадает по какой-либо причине, то ползователь будет выведен из системы из соображений безопасности.

Несмотря на то, что всё это происходит автоматически, существует несколько важных побочных эффектов.

Во-первых, интерфейс Serializable и его методы serialize() и unserialize() были добавлены, чтобы разрешить классу User быть сериализованым в сессии. Это может быть не нужно, в зависимости от ваших настроек, но скорее всего, это хорошая идея. В теории, сериализовать нужно только id, потому что метод refreshUser() обновляет пользователя по каждому запросу, используя id (как объяснялось выше). Это даёт нам "свежий" объект Пользователя.

Ео Symfony также использует username, salt, и password, чтобы верифицировать, что Пользователь не изменился между запросами (она также вызывает ваш метод AdvancedUserInterface, если вы его реализуете). Неудача сериализации может привести к тому, что вы будете выходить из системы покаждому запросу. Если ваш ползователь реализует EquatableInterface, то вместо проврки этих свойств, вызывается ваш isEqualTo(), и вы можете проверить те свойства, которые вы хотите. Кроме случаев, and you can check whatever properties you want. Если вы не понимаете это, то вы, скорее всего, не будете реализовывать этот интерфейс или беспокоиться о нём.

Эта документация является переводом официальной документации Symfony и предоставляется по свободной лицензии CC BY-SA 3.0.