Блог Андрея

 
 

Как сделать авторизацию пользователей в Symfony 3.4

Как написано в документации Симфони, система аутентификации мощная, но она может сбивать с толку.

Аутентификация - это идентификация пользователя в системе. Например, по логину и паролю, куки полученной после отправки логина и пароля или по openID.

Авторизация - это проверка, имеет ли аутентифицированный пользователь право на то или иное действие.

Если говорить об аутентификации и авторизации в Symfony, нельзя не упомянуть о пакете FOSUserBundle который всё весьма упрощает.

Но в этой статье я всё-таки решил снова заморочиться аутентификацией «из коробки», потому что меня расстроило то, что конфиги, которые работали в symfony 2 перестали работать в symfony 3.

Итак, прежде всего мы создаём класс сущности пользователей (или добавляем необходимые поля в существующую сущность). Это поля salt, password и username.


namespace App\Entity;

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

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 */
class User implements UserInterface //, \Symfony\Component\Security\Core\User\EquatableInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
	 * @Assert\NotBlank()
     * @ORM\Column(type="string", length=64, nullable=true, options={"comment"="User login"})
     */
    private $username;

    /**
	 * @Assert\NotBlank()
     * @ORM\Column(type="string", length=60, nullable=true)
     */
    private $password;

    /**
	 * @Assert\NotBlank()
     * @ORM\Column(type="string", length=64, nullable=true)
     */
    private $email;

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     */
    private $salt;

    public function getId(): ?int
    {
        return $this->id;
    }

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

    public function setUsername(?string $username): self
    {
        $this->username = $username;

        return $this;
    }

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

    public function setPassword(?string $password): self
    {
        $this->password = $password;

        return $this;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(?string $email): self
    {
        $this->email = $email;

        return $this;
    }

    public function getSalt(): ?string
    {
        return $this->salt;
    }

    public function setSalt(?string $salt): self
    {
        $this->salt = $salt;

        return $this;
    }
	
	public function getRoles()
	{
		return ['ROLE_USER'];
	}
	
	public function eraseCredentials()
	{
		;
	}
	
	
}

Также класс обязательно должен реализовывать интерфейс UserInterface, для этого в него добавлена реализация методов eraseCredentials и getRoles. Метод eraseCredentials нужен, чтобы при необходимости почистить в сессии такие данные как незашифрованный пароль, я его в сессии не сохраняю, поэтому тело метода пусто. Будет печально, если Symfony эти данные где-то сохраняет, но я не знаю, так ли это и где, пока тупо надеюсь что этого не происходит (а это реально тупо!).

Далее, правим файл config/packages/security.yaml. Секция main приводится к такому виду:

main:
            pattern: ^/
            security: true
            anonymous: ~
            logout_on_user_change: true
            form_login:
                provider: users
                csrf_token_generator: security.csrf.token_manager
            logout:
                target: /hello/anonymous
            remember_me:
                secret:   '%kernel.secret%'   /*'%secret%' for Symfony 3.4*/
                lifetime: 604800 /*# 1 week in seconds  #default 1 year in seconds*/
                path:     /

pattern: ^/ - значит что мы хотим чтобы весь сайт контролировался нашим файерволом.

security: true - говорит о том, что при обращении ко всем страницам сайта будут проверяться права доступа

anonymous: ~ говорит о том, что по умолчанию анонимные пользователи могут просматривать страницы сайта

logout_on_user_change: true добавлена потому что консоль разработчика Sуmfony предупредила, что скоро без этой опции ничего работать не будет.

form_login - интересная секция, в ней могут быть и другие ключи, но я пока ограничился минимально рабочей конфигурацией. Пока в ней только две строки. Строка provider: users говорит Symfony, что искать данные о том, какую сущность использовать для хранения данных об авторизованном пользователе следует в секции providers с именем users. Строка csrf_token_generator: security.csrf.token_manager укажет Symfony при авторизации (при действии логина) проверять csrf токен.

Секция logout также может быть сложнее (а может быть и проще, об этом ниже), но сейчас в ней только путь для редиректа после «разлогина».

Секция remember_me используется для реализации долгосрочного логина.

В некоторых примерах из документации Symfony 3.4 вместо секции main используется секция secured_area, тут я постарался внести ясность в этот вопрос.

Далее, приводим секцию providers в том же файле к такому виду:

providers:
        in_memory: { memory: null }
        users:
           entity:
                class: App\Entity\User
                property: username

Мы добавили секцию users, ту самую, на которую ссылается секция form_login. В секции entity (буквально переводится как «сущность») два ключа, в ключе class мы указываем полное имя класса, в экземпляре которого будут храниться данные об авторизованном пользователе. В секции property указано имя свойства (или поля) класса, по которому будет происходить поиск в базе данных.

Далее, также в файле security.yaml, надо указать алгоритм, которым будет кодироваться пароль:


encoders:
        App\Entity\User: { algorithm: bcrypt }

Далее, нам надо отобразить форму регистрации.

Код контроллера:



namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

use App\Entity\User;

class SecurityController extends Controller
{
    /**
     * @Route("/login", name="login")
    */
    public function loginAction(AuthenticationUtils $authenticationUtils, CsrfTokenManagerInterface $oCsrfTokenManager)
    {
		// Пока сообщение об ошибке аутентификацией всегда будет пустым
		$error ='';
        // Пока токен авторизации всегда будет пустым
        $csrfToken = '';
		// Пока последнее отправлонное имя пользователя всегда будет пустым
		$lastUsername = $authenticationUtils->getLastUsername();
	
                return $this->render('security/login.html.twig', [
            'controller_name' => 'SecurityController',
			 'last_username' => $lastUsername,
			'error'         => $error,
			'csrf_token'         => $csrfToken,
        ]);
    }
	
	/**
     * @Route("/register", name="register")
    */
    public function register(Request $oRequest, UserPasswordEncoderInterface $oEncoder)
    {
        return $this->render('security/register.html.twig', [
            'controller_name' => 'SecurityController',
        ]);
    }
}

Создадим шаблон templates/security/register.html.twig, он может быть например таким:

{% extends 'base.html.twig' %}

{% block title %}Hello SecurityController!{% endblock %}

{% block body %}


<div class="example-wrapper">
    <h1>Hello { controller_name }! ?</h1>

    This friendly message is coming from:
    <ul>
        <li>
			<form action="{ path('register') }" method="POST">
				<div>
					<input type="text" id="login" name="login" />
				</div>
				<div>
					<input type="password" id="" name="password" />
				</div>
				<div>
					<input type="password" id="" name="password2" />
				</div>
				<div>
					<input type="email" id="" name="email" />
				</div>
				<div style="text-align:right;">
					<input type="submit" id="bLogin" value="Enter" />
				</div>
			</form>
		</li>
		<li>Your template at <a href="{ path('login') }">Sign In</a></li>
    </ul>
</div>
		
{% endblock %}

И добавим в метод register приём и сохранение данных (этот метод стоит доработать, чтобы проверялось, свободен ли логин, но сейчас пока не хочется на этом останавливаться).

/**
     * @Route("/register", name="register")
    */
    public function register(Request $oRequest, UserPasswordEncoderInterface $oEncoder)
    {
		if ($oRequest->getMethod() == 'POST') {
			$sPassword = $oRequest->get('password');
			$sPassword2 = $oRequest->get('password2');
			$sEmail = $oRequest->get('email');
			$sUsername = $oRequest->get('login');
			
			if ($sPassword != $sPassword2) {
				$this->addFlash('notice', 'Passwords is different!');
				return $this->redirectToRoute('register');
			}
			$oUser = new User();
			$sPassword = $oEncoder->encodePassword($oUser, $sPassword);
			
			$oUser->setPassword($sPassword);
			$oUser->setEmail($sEmail);
			$oUser->setUsername($sUsername);
			
			$oEm = $this->getDoctrine()->getManager();
			$oEm->persist($oUser);
			$oEm->flush();
			return $this->redirectToRoute('login');
		}
        return $this->render('security/register.html.twig', [
            'controller_name' => 'SecurityController',
        ]);
    }

Попробуем отправить форму. Если в базе данных создалась запись, значит всё хорошо.

Создадим шаблон login.html.twig

{% extends 'base.html.twig' %}

{% block title %}Hello SecurityController!{% endblock %}

{% block body %}


<div class="example-wrapper">
    <h1>Hello { controller_name }! ?</h1>

    This friendly message is coming from:
    <ul>
        <li>
			{% if error %}
				<div>{ error.messageKey|trans(error.messageData, 'security') }</div>
			{% endif %}
		</li>
        <li>
			<form action="{ path('check_path') }" method="POST">
				<div>
					<input type="text" id="login" name="_username" />
				</div>
				<div>
					<input type="password" id="password" name="_password" />
				</div>
                <div>
					<input type="checkbox" id="remember_me" name="_remember_me" />
					<label for="remember_me" >Запомнить меня</label>
				</div>
				<div style="text-align:right;">
                    <input type="hidde" name="_csrf_token" value="{ csrf_token }" />
					<input type="submit" id="bLogin" value="Enter" />
				</div>
			</form>
		</li>
		<li><a href="{ path('register') }">Sign Up</a></li>
    </ul>
</div>
		
{% endblock %}

По сравнению с формой регистрации уже всё не так просто. Поле ввода имени пользователя должно называтьcя _username, а поле ввода пароля _password. То же касается и имени _csrf_token. Имя инпута _remember_me также должно называться именно так, если мы хотим, чтобы функционал долгосрочного логина работал.

Значение атрибута action path('check_path') также взялось не с потолка. Вспомним конфигурационный файл security.yaml, а конкретно секцию form_login. У нас она выглядит так:

form_login:
                provider: users

Если мы изменим её таким образом:

form_login:
                check_path: /login_check
                login_path: /login
                provider: users

то по сути ничего не изменится. То есть, существуют две настройки, check_path и login_path, значения которых по умолчанию равны /login_check и /login. Свою форму авторизации мы обязаны отправлять по маршруту check_path.

Чтобы запрос с данными формы мог быть обработан, надо создать соответствующий маршрут и соответствующий метод контроллера (для этого удобно использовать аннотацию @Route):

/**
	 * @Route("/login_check", name="check_path")
	*/
	public function check()
	{
		return $this->redirectToRoute('login');
	}

Обратите внимание, что в методе check ничего не происходит кроме редиректа на маршрут login. Это потому, что (основываясь на настройках form_login из файла security.yaml) при обращении по адресу /login_check Symfony сама считает данные из полей _username и _password, найдёт в таблице базы данных запись со значением поля username равным значению из поля формы _username (вспомните настройку секции provider), сравнит хэш переданного пароля с хэшем, сохранённым в базе данных и запишет в сессию авторизации факт, что пользователь авторизован если пароли совпадают.

Если же пароли не совпали, нам надо как-то сообщить об этом пользователю.

Для этого подключим use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; и изменим код метода login таким образом:

/**
     * @Route("/login", name="login")
    */
    public function loginAction(AuthenticationUtils $authenticationUtils, CsrfTokenManagerInterface $oCsrfTokenManager)
    {
		// сообщение об ошибке аутентификации
		$error = $authenticationUtils->getLastAuthenticationError();
		// имя пользователя, которое пытались ввести
		$lastUsername = $authenticationUtils->getLastUsername();
        //токен авторизации, без него авторизоваться невозможно
        $csrfToken = $oCsrfTokenManager
			? $oCsrfTokenManager->getToken('authenticate')->getValue()
			: null;
	
        return $this->render('security/login.html.twig', [
            'controller_name' => 'SecurityController',
			 'last_username' => $lastUsername,
			'error'         => $error,
        'csrf_token' => $csrfToken
        ]);
    }

Теперь мы будем видеть сообщение Invalid credentials в случае отправки неверных данных.

Из основных вещей нам остался только logout.

Напомню, что в security.yaml мы определили секцию

logout:
                target: /hello/anonymous

Кстати, она могла бы быть проще:

logout: ~ 

Но тогда после разлогина нас будет перенаправлять на главную страницу (/).

С помощью настройки target мы конфигурируем страницу, на которую нас перенаправит после разлогина.

Но чтобы всё работало нам необходимо создать маршрут logout. Контроллер нам не нужен, поэтому я не буду использовать аннотацию, а использую файл config/routes.yaml

Добавлю в него

logout:
    path: /logout

Всё, этого достаточно, чтобы logout заработал.

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