Как и обещал ранее, продолжаю свою серию статей про создание API на Symfony2. Сегодня я бы хотел рассказать о авторизации. Из популярных бандлов есть JWTAuthenticationBundle и FOSOAuthServerBundle, у каждого есть свои плюсы и минусы, но мне хотелось бы рассказать как сделать авторизацию самому, чтобы понимать как это работает.
Для начала, создадим сущность UserAccessToken, в которой будем хранить токены доступа пользователей.
<?php

namespace App\CommonBundle\Entity;

use Doctrine\ORM\Mapping AS ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="user_access_tokens")
 */
class UserAccessToken
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     * @ORM\Column(name="id", type="integer")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     * @ORM\JoinColumn(name="user_id", referencedColumnName="id", onDelete="SET NULL")
     */
    protected $user;

    /**
     * Тут мы будем хранить наш токен. Токен необходимо генерировать самому и как можно сложнее и длиннее, чтобы исключить возможность подбора
     * 
     * @ORM\Column(name="access_token", type="string")
     */
    protected $accessToken;

    /**
     * Дата, после которой токен будет считаться не активным
     * 
     * @ORM\Column(name="expired_at", type="datetime")
     */
    protected $expiredAt;

    /**
     * @ORM\Column(name="created_at", type="datetime")
     */
    protected $createdAt;

}


Теперь создадим Listener, который будет слушать все запросы к вашему API и авторизировать пользователя.

<?php

namespace App\CommonBundle\Listener;

use App\CommonBundle as Common;
use Doctrine\ORM\EntityManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;

class AccessTokenListener
{
    private $entityManager;
    private $securityContext;
    private $exclude = [
        '/users/login', '/users/registration',
    ];

    const EMPTY_ACCESS_TOKEN = 'empty_access_token';
    const INVALID_ACCESS_TOKEN = 'invalid_access_token';
    const ACCESS_TOKEN_EXPIRED = 'access_token_expired';

    public function __construct(EntityManager $entityManager, SecurityContextInterface $securityContext)
    {
        $this->entityManager = $entityManager;
        $this->securityContext = $securityContext;
    }

    /**
     * @return Common\Entity\UserAccessToken
     */
    private function getByAccessToken($accessToken)
    {
        return $this->entityManager->getRepository('CommonBundle:UserAccessToken')->findOneByAccessToken($accessToken);
    }

    public function beforeController(GetResponseEvent $event)
    {
        // Делаем доступными без токена необходимые для нас URL
        // Это необходимо для того, чтобы открыть метод для авторизации и тд
        if (in_array($event->getRequest()->getPathInfo(), $this->exclude)) {
            return;
        }

        // Смотрим заголовок с названием X-Access-Token, если он пустой – возвращаем ошибку
        $accessToken = $event->getRequest()->headers->get('X-Access-Token');
        if (!$accessToken) {
            $event->setResponse(new JsonResponse(['error' => self::EMPTY_ACCESS_TOKEN], 403));
            return;
        }

        // Ищем в базе пользователя по токену
        $token = $this->getByAccessToken($accessToken);
        if (!$token) {
            $event->setResponse(new JsonResponse(['error' => self::INVALID_ACCESS_TOKEN], 403));
            return;
        }

        // Проверяем актуален ли все еще токен
        if ($token->getExpiredAt() <= new \DateTime('now')) {
            $event->setResponse(new JsonResponse(['error' => self::ACCESS_TOKEN_EXPIRED], 403));
            return;
        }

        // Устанавливаем пользователя, чтобы он был доступен в контроллерах через $this->getUser()
        $user = $token->getUser();

        $usernamePasswordToken = new UsernamePasswordToken($user, $user->getPassword(), "main", $user->getRoles());
        $this->securityContext->setToken($usernamePasswordToken);
    }
}


И подключим его в services.yml
    common.listener.access_token:
        class: App\CommonBundle\Listener\AccessTokenListener
        arguments: [@doctrine.orm.entity_manager, @security.context]
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: beforeController }


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

Комментарии (5)


  1. iborzenkov
    27.05.2015 23:53
    +4

    Хм, ожидал что вы сделаете через систему аутентификации, а не через события


  1. skobkin
    28.05.2015 06:05
    +5

    Это аутентификация. К тому же, надо сказать, не по канону сделанная.
    Более правильно было бы сделать через Security. Это сложнее (сам долго разбирался), но гибче. А для активации созданного провайдера в любом нужном разделе приложения достаточно в security.yml к файрволу, который закрывает ваш API дописать одну строчку вида:

        firewalls:
            main:
                pattern:             /api/(.*)
                # Та самая строчка:
                your_provider: ~
    


  1. mdv
    28.05.2015 22:32
    +1

    По api-keys есть пример на самом сайте симфони, через штатный компонент security: symfony.com/doc/current/cookbook/security/api_key_authentication.html
    В принципе он очень просто адаптируется, если нужны ограниченные по времени жизни токены — нужно просто вынести токены в отдельную сущность, как вы это сделали в статье, но саму аутентификацию все же лучше делать штатными средствами.


  1. hanovruslan
    01.06.2015 10:04

    В данном посте как бы аутентификация описана, а не авторизация. Причем, как правильно заметил skobkin, не по канону.

    [капитан]
    Вообще, в плане безопасности приложение должно обеспечить два процесса — авторизация (register/login/logout) и аутентификацию.
    [/капитан]

    С этой точки зрения было бы интересно увидеть изящную реализацию этих частей приложения в виде стороннего бандла (двух бандлов?) с этим фукнционалом, которое можно легко интегрировать в приложение _без_ авторизации и аутентификации, а также с возможностью подстроить процесс под свои нужды. symfony даёт возможность реализовать это достаточно топорно (через перегрузку вендорного решения) и через события. Сам приступал к этой идее, пока что похвастаться почти нечем, кроме — github.com/hanovruslan/api-key-project

    P.S.: карма не позволяет ссылки ставить :)


    1. skobkin
      03.06.2015 10:25

      авторизация (register/login/logout)

      Так-то, по сути, это и есть аутентификация. Авторизация — это работа с правами доступа, когда пользователь уже аутентифицирован. В Symfony это Voters, Roles.