Index de l'article

Un début de système d'utilisateur

Projet d'exemple de création manuelle (hors FosUSer) d'un annuaire utilisateur sous Symfony (inspiré de dev-web.io).

À noter qu'au 19 juillet 2018, FosUser semblait encore incompatible avec SF4. Des packages adaptant SF4 à FosUser ou encore SonataAdmin étaient néanmoins disponibles.

1) Création du projet :

composer create-project symfony/skeleton UserDemo

2) Installez doctrine, security-bundle, twig, validator, annotations, form, swiftmailer, doctrine-fixtures-bundle, web-server-bundle et maker.

3) Dans le fichier .env :

DATABASE_URL=mysql://root:@127.0.0.1:3306/userdemo

Afin de pointer sur une BDD locale, sans mot de passe.

4) Puis créez la BDD :

php bin/console doctrine:database:create

5) Créez une 1ère entité User.

php bin/console make:entity

Sans propriété, nous allons les écrire à la main (encore merci à dev-web.io, site sur lequel vous trouverez d'autres tutos concernant Symfony 4) :

<?php
// /src/Entity/User.php
namespace App\Entity;

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

/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @ORM\Table(name="user")
* @UniqueEntity(fields="email", message="Email déjà pris")
* @UniqueEntity(fields="username", message="Username déjà pris")
*/
class User implements UserInterface, \Serializable
{
/**
* @var int
*
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;

/**
* @var string
*
* @ORM\Column(type="string")
* @Assert\NotBlank()
*/
private $fullName;

/**
* @var string
*
* @ORM\Column(type="string", unique=true)
* @Assert\NotBlank()
*/
private $username;

/**
* @var string
*
* @ORM\Column(type="string", unique=true)
* @Assert\NotBlank()
* @Assert\Email()
*/
private $email;

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

/**
* @var array
*
* @ORM\Column(type="json")
*/
private $roles = [];

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

public function setFullName(string $fullName): void
{
$this->fullName = $fullName;
}
// le ? signifie que cela peur aussi retourner null

public function getFullName(): ?string
{
return $this->fullName;
}

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

public function setUsername(string $username): void
{
$this->username = $username;
}

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

public function setEmail(string $email): void
{
$this->email = $email;
}

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

public function setPassword(string $password): void
{
$this->password = $password;
}

/**
* Retourne les rôles de l'user
*/
public function getRoles(): array
{
$roles = $this->roles;

// Afin d'être sûr qu'un user a toujours au moins 1 rôle
if (empty($roles)) {
$roles[] = 'ROLE_USER';
}
return array_unique($roles);
}
public function setRoles(array $roles): void
{
$this->roles = $roles;
}

/**
* Retour le salt qui a servi à coder le mot de passe
*
* {@inheritdoc}
*/
public function getSalt(): ?string
{
// See "Do you need to use a Salt?" at https://symfony.com/doc/current/cookbook/security/entity_provider.html
// we're using bcrypt in security.yml to encode the password, so
// the salt value is built-in and you don't have to generate one
return null;
}

/**
* Removes sensitive data from the user.
*
* {@inheritdoc}
*/
public function eraseCredentials(): void
{
// Nous n'avons pas besoin de cette methode car nous n'utilions pas de plainPassword
// Mais elle est obligatoire car comprise dans l'interface UserInterface
// $this->plainPassword = null;
}

/**
* {@inheritdoc}
*/
public function serialize(): string
{
return serialize([$this->id, $this->username, $this->password]);
}

/**
* {@inheritdoc}
*/
public function unserialize($serialized): void
{
[$this->id, $this->username, $this->password] = unserialize($serialized, ['allowed_classes' => false]);
}
}

6) Générez l'entité (créez la table) :

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

7) Ajoutez un encoder :

Dans config / packages / security.yaml :

...
encoders:
App\Entity\User: bcrypt
...

8) Créez le fichier des fixtures dans src / DataFixtures / AppFixtures.php :

<?php

namespace App\DataFixtures;

use App\Entity\User;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class AppFixtures extends Fixture
{
private $passwordEncoder;
public function __construct(UserPasswordEncoderInterface $passwordEncoder)
{
$this->passwordEncoder = $passwordEncoder;
}
public function load(ObjectManager $manager)
{
foreach ($this->getUserData() as [$fullname, $username, $password, $email, $roles]) {
$user = new User();
$user->setFullName($fullname);
$user->setUsername($username);
$user->setPassword($this->passwordEncoder->encodePassword($user, $password));
$user->setEmail($email);
$user->setRoles($roles);
$manager->persist($user);
$this->addReference($username, $user);
}

$manager->flush();
}

private function getUserData(): array
{
return [
// $userData = [$fullname, $username, $password, $email, $roles];
['Jane Doe', 'jane_admin', 'kitten', Cette adresse e-mail est protégée contre les robots spammeurs. Vous devez activer le JavaScript pour la visualiser.', ['ROLE_ADMIN']],
['Tom Doe', 'tom_admin', 'kitten', Cette adresse e-mail est protégée contre les robots spammeurs. Vous devez activer le JavaScript pour la visualiser.', ['ROLE_ADMIN']],
['John Doe', 'john_user', 'kitten', Cette adresse e-mail est protégée contre les robots spammeurs. Vous devez activer le JavaScript pour la visualiser.', ['ROLE_USER']],
];
}
}

9) Chargez les fixtures :

php bin/console doctrine:fixtures:load

10) Modification du fichier config / packages / security.yml (Attention à l'indentation) :

security:
encoders:
App\Entity\User: bcrypt

providers:
database_users:
entity: { class: App\Entity\User, property: username }

firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false

main:
# les urls auxquels s'appliquent ce firewall, dans ce cas, ce sont toutes les urls
pattern: ^/
# La connexion n'est pas requise sur toutes les pages
# par exemple la page d'accueil
anonymous: true

form_login:
# Le nom de la route de la page de connexion
check_path: security_login
# Le nom de la route où se trouve le formulaire de connexion
# Si un utilisateur tente d'acceder à une page protégée sans en avoir les droits
# il sera redirigé sur cette page
login_path: security_login
# Securisation des formulaires
csrf_token_generator: security.csrf.token_manager
# La page par defaut apres une connexion reussie
default_target_path: admin

logout:
# La route où se trouve le process de deconnexion
path: security_logout
# La route sur laquelle doit etre rediriger l'utilisateur apres une deconnexion
target: index

access_control:
# Les regles de securité
# Là dans ce cas seul les utilisateurs ayant le rôle ROLE_ADMIN
# peuvent acceder à toutes les pages commençant par /admin
- { path: '^/admin', roles: ROLE_ADMIN }

11) framework.yml :

framework:
secret: '%env(APP_SECRET)%'
#default_locale: en
csrf_protection: { enabled: true }
#http_method_override: true
# uncomment this entire section to enable sessions
session:
# With this config, PHP's native session handling is used
handler_id: ~
#esi: ~
#fragments: ~
php_errors:
log: true

12) SecurityController.php :

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

class SecurityController extends AbstractController
{
/**
* @Route("/login", name="security_login")
*/
public function login(AuthenticationUtils $helper): Response
{
return $this->render('Security/login.html.twig', [
// dernier username saisi (si il y en a un)
'last_username' => $helper->getLastUsername(),
// La derniere erreur de connexion (si il y en a une)
'error' => $helper->getLastAuthenticationError(),
]);
}

/**
* La route pour se deconnecter.
*
* Mais celle ci ne doit jamais être executé car symfony l'interceptera avant.
*
*
* @Route("/logout", name="security_logout")
*/
public function logout(): void
{
throw new \Exception('This should never be reached!');
}
}

13) Et son template donc, dans Security/login.html.twig :

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

{% block body %}
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey }}

</div>
{% endif %}
<div class="row">
<div class="col-sm-5">
<div class="well">
<form action="{{ path('security_login') }}" method="post">
<fieldset>
<legend><i class="fa fa-lock" aria-hidden="true"></i> Connexion</legend>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="_username" value="{{ last_username }}" class="form-control"/>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="_password" class="form-control" />
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}"/>
<button type="submit" class="btn btn-primary">
<i class="fa fa-sign-in" aria-hidden="true"></i> On entre
</button>
</fieldset>
</form>
</div>
</div>

{% endblock %}

À ce stade, la route http://localhost/UserDemo/public/login doit fonctionner.

14) Contrôleur IndexController.php pour la page index et admin

<?php

namespace App\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Response;

class IndexController extends Controller
{
/**
* @Route("/", name="index")
*/
public function index()
{
return $this->render('index.html.twig');
}

/**
* @Route("/admin", name="admin")
*/
public function admin()
{
return $this->render('Admin/index.html.twig');
}
}

15) Avec leurs templates dans templates / index.html.twig et templates / Admin / index.html.twig :

Index

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

{% block body %}
<a href="/{{ path('security_login')}}"> Connexion</a><br>
<a href="/{{ path('user_registration')}}"> Inscription</a>

{% endblock %}

Admin

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

{% block body %}
Bienvenue {{ app.user.username }} !

{% endblock %}

16) RegistrationController.php

<?php
// src/Controller/RegistrationController.php
namespace App\Controller;

use App\Form\UserType;
use App\Entity\User;
use App\Events;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;

class RegistrationController extends Controller
{
/**
* @Route("/register", name="user_registration")
*/
public function registerAction(Request $request, UserPasswordEncoderInterface $passwordEncoder, EventDispatcherInterface $eventDispatcher)
{
$user = new User();
$form = $this->createForm(UserType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$password = $passwordEncoder->encodePassword($user, $user->getPassword());
$user->setPassword($password);
// Par defaut l'utilisateur aura toujours le rôle ROLE_USER
$user->setRoles(['ROLE_USER']);
// On enregistre l'utilisateur dans la base
$em = $this->getDoctrine()->getManager();
$em->persist($user);
$em->flush();
//On déclenche l'event
$event = new GenericEvent($user);
$eventDispatcher->dispatch(Events::USER_REGISTERED, $event);
return $this->redirectToRoute('security_login');
}
return $this->render(
'register.html.twig',
array('form' => $form->createView())
);
}
}

17) Le form de registration

<?php
// /src/Form/UserType.php
namespace App\Form;

use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;

class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('fullName', TextType::class)
->add('email', EmailType::class)
->add('username', TextType::class)
->add('password', RepeatedType::class, array(
'type' => PasswordType::class,
'first_options' => array('label' => 'Password'),
'second_options' => array('label' => 'Repeat Password'),
))
;
}

public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => User::class,
));
}
}

Et son template register.html.twig :

{# templates/register.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}

{{ form_start(form) }}
{{ form_row(form.fullName) }}
{{ form_row(form.username) }}
{{ form_row(form.email) }}
{{ form_row(form.password.first) }}
{{ form_row(form.password.second) }}

<button type="submit">S'inscrire !</button>
{{ form_end(form) }}

{% endblock %}

À ce stade, la route http://localhost/UserDemo/public/register doit fonctionner.