FosUserBundle, c’est super, rapide à mettre en place et… magique. A moins d’aller voir le code du bundle, la plupart du temps, on l’utilise uniquement pour sécuriser rapidement un backoffice. C’est bien, ça suit un protocole standard classique, et si l’on veut sortir un peu des clous, il faut surcharger les contrôleurs. Rien de très compliqué en soi, à part qu’il faut aller voir un peu du côté des events car il y en a pas mal.
Mais si FosUserBundle nous simplifie la vie, cela ne doit pas nous dispenser de comprendre ce qui se passe à l’arrière et d’implémenter par nous-même un système de sécurité afin de bien comprendre toutes les rouages du mécanisme. Et surtout, parfois, sous prétexte d’une simple sécurisation du site, on va sortir l’artillerie lourde FosUser alors qu’un simple formulaire d’authentification aurait suffi.
C’est parti!
Le firewall
#app/config/security.yml
security:
encoders:
# Our user class and the algorithm we'll use to encode passwords
# http://symfony.com/doc/current/book/security.html#encoding-the-user-s-password
AppBundle\Entity\User: sha512
providers:
# in this example, users are stored via Doctrine in the database
# To see the users at src/AppBundle/DataFixtures/ORM/LoadFixtures.php
# To load users from somewhere else: http://symfony.com/doc/current/cookbook/security/custom_provider.html
database_users:
entity: { class: AppBundle:User, property: username }
firewalls:
dev:
pattern: ^/(_(profiler|wdt|error)|css|images|js)/
security: false
main:
# this firewall applies to all URLs
pattern: ^/
# but the firewall does not require login on every page
# denying access is done in access_control or in your controllers
anonymous: true
# This allows the user to login by submitting a username and password
# Reference: http://symfony.com/doc/current/cookbook/security/form_login_setup.html
form_login:
# The route name that the login form submits to
check_path: security_login_check
# The name of the route where the login form lives
# When the user tries to access a protected page, they are redirected here
login_path: security_login_form
# Secure the login form against CSRF
# Reference: http://symfony.com/doc/current/cookbook/security/csrf_in_login_form.html
csrf_provider: security.csrf.token_manager
default_target_path: user_dashboard
always_use_default_target_path: true
logout:
# The route name the user can go to in order to logout
path: security_logout
# The name of the route to redirect to after logging out
target: homepage
access_control:
- { path: ^/account/signup, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/account, roles: ROLE_USER }
Uniquement du classique ici:
- On définit sha512 comme encoder
- On indique que notre base d’utilisateurs va être associée à notre entité User (que nous allons bientôt créer) et que le login accepté sera le username (libre à vous de choisir un autre champ de votre table)
- un firewall main dans lequel on définit les chemins check_path et login_path
- La route du logout et celle de redirection
- les contrôles d’accès, c’est à dire les routes pour lesquelles on a défini des rôles obligatoires
L’entité User et son interface
Pourquoi une interface?
Pour respecter le principe O de SOLID. En effet, si nous voulons qu’une classe soit ouverte à l’extension mais fermée à la modification, il faut définir une interface qui sera l’argument de nos fonctions. Ainsi, si un jour nous voulons utiliser une autre entité que User, il n’y a pas de problème, il suffira qu’elle implémente notre UserInterface et il n’y aura pas besoin de revenir sur le service dans lequel nous aurions passé directement User (donc un code trop strict et sur lequel nous devrions repasser en cas de changement).
Ici, une seule méthode importante: encodePassword.
<?php
namespace AppBundle\Entity;
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
use Symfony\Component\Security\Core\User\AdvancedUserInterface as SecurityUserInterface;
interface UserInterface extends SecurityUserInterface
{
public function encodePassword(PasswordEncoderInterface $encoder);
}
Et voici notre entité User:
<?php
namespace AppBundle\Entity;
use AppBundle\Entity\UserInterface;
use Doctrine\ORM\Mapping as ORM;
use Serializable;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\Encoder\PasswordEncoderInterface;
use Symfony\Component\Security\Core\Role\Role;
use Symfony\Component\Validator\Constraints as Assert;
/**
* User
*
* @ORM\Table(name="user")
* @ORM\Entity()
* @UniqueEntity(fields="username", message="That username is taken!")
* @UniqueEntity(fields="email", message="That email is taken!")
*/
class User implements UserInterface, Serializable
{
/**
* @var integer
*
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @var string
*
* @ORM\Column(name="username", type="string", length=255)
* @Assert\NotBlank(message="Give us at least 3 characters")
* @Assert\Length(min=3, minMessage="Give us at least 3 characters!")
*/
private $username;
/**
* @var string
*
* @ORM\Column(name="password", type="string", length=255)
*/
private $password;
/**
* @ORM\Column(type="string", length=255)
* @Assert\NotBlank
* @Assert\Email
*/
private $email;
/**
* @ORM\Column(type="json_array")
*/
private $roles = array();
/**
* @var bool
*
* @ORM\Column(type="boolean")
*/
private $isActive = true;
/**
* @ORM\Column(type="string", length=255)
*/
private $salt;
/**
* @Assert\NotBlank
* @Assert\Regex(
* pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?!.*\s).*$/",
* message="Use 1 upper case letter, 1 lower case letter, and 1 number"
* )
*/
private $plainPassword;
/**
* Get id
*
* @return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set username
*
* @param string $username
* @return User
*/
public function setUsername($username)
{
$this->username = $username;
return $this;
}
/**
* Get username
*
* @return string
*/
public function getUsername()
{
return $this->username;
}
public function getPlainPassword()
{
return $this->plainPassword;
}
public function setPlainPassword($plainPassword)
{
$this->plainPassword = $plainPassword;
return $this;
}
/**
* Set password
*
* @param string $password
* @return User
*/
public function setPassword($password)
{
$this->password = $password;
return $this;
}
/**
* Get password
*
* @return string
*/
public function getPassword()
{
return $this->password;
}
/**
* @param PasswordEncoderInterface $encoder
*/
public function encodePassword(PasswordEncoderInterface $encoder)
{
if ($this->plainPassword) {
$this->salt = sha1(uniqid(mt_rand()));
$this->password = $encoder->encodePassword(
$this->plainPassword,
$this->salt
);
$this->eraseCredentials();
}
}
/**
* Returns the roles granted to the user.
*
* <code>
* public function getRoles()
* {
* return array('ROLE_USER');
* }
* </code>
*
* Alternatively, the roles might be stored on a ``roles`` property,
* and populated in any number of different ways when the user object
* is created.
*
* @return Role[] The user roles
*/
public function getRoles()
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles)
{
$this->roles = $roles;
// allows for chaining
return $this;
}
/**
* Removes sensitive data from the user.
*
* This is important if, at any given point, sensitive information like
* the plain-text password is stored on this object.
*/
public function eraseCredentials()
{
$this->setPlainPassword(null);
}
/**
* Returns the salt that was originally used to encode the password.
*
* This can return null if the password was not encoded using a salt.
*
* @return string|null The salt
*/
public function getSalt()
{
return $this->salt;
}
/**
* @return boolean
*/
public function getIsActive()
{
return $this->isActive;
}
/**
* @param boolean $isActive
*/
public function setIsActive($isActive)
{
$this->isActive = $isActive;
}
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->id,
$this->username,
$this->password,
));
}
public function unserialize($serialized)
{
list (
$this->id,
$this->username,
$this->password,
) = unserialize($serialized);
}
/**
* Set email
*
* @param string $email
* @return User
*/
public function setEmail($email)
{
$this->email = $email;
return $this;
}
/**
* Get email
*
* @return string
*/
public function getEmail()
{
return $this->email;
}
public function __toString()
{
return (string) $this->getUsername();
}
}
Ici, je n’implémente pas la classique UserInterface. J’ai envie d’aller plus loin et d’utiliser les fonctions de l’AdvancedUserInterface, plus intéressantes, pour interdire les utilisateurs non activés.
L’interface AdvancedUserInterface ajoute quatre méthodes supplémentaires pour valider le statut du compte :
- isAccountNonExpired() vérifie si le compte de l’utilisateur a expiré,
- isAccountNonLocked() vérifie si l’utilisateur est verrouillé,
- isCredentialsNonExpired() vérifie si les informations de connexion de l’utilisateur (mot de passe) ont expiré,
- isEnabled() vérifie si l’utilisateur est activé.
L’interface Serializable ainsi que ses méthodes serialize et unserialize ont été ajoutées pour permettre à la classe User d’être sérialisable dans la session. Cela peut ou non être nécessaire en fonction de votre configuration.
Le plainPassword n’est pas toujours présent dans les tutos, et il ne l’est pas dans celui de Sensio Labs. Ce champ vous sera utile lors de l’enregistrement (nous y reviendrons).
La fonction encodePassword nous servira plus tard mais sachez que nous préparons le terrain pour correctement découpler toutes les fonctions et créer une application maintenable, testable et réutilisable.
Le contrôleur de sécurité
Ce contrôleur est généralement appelé SecurityController mais ce n’est pas une obligation. Vous pouvez très bien l’appeler AuthenticationController si vous préférez, l’important reste le routing qui est renseigné dans security.yml pour check_path, login_path et path (pour logout).
#src/AppBundle/Controller
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
/**
* Controller used to manage the application security.
* See http://symfony.com/doc/current/cookbook/security/form_login_setup.html.
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class SecurityController extends Controller
{
/**
* @Route("/login", name="security_login_form")
*/
public function loginAction()
{
$helper = $this->get('security.authentication_utils');
return $this->render('security/login.html.twig', array(
// last username entered by the user (if any)
'last_username' => $helper->getLastUsername(),
// last authentication error (if any)
'error' => $helper->getLastAuthenticationError(),
));
}
/**
* This is the route the login form submits to.
*
* But, this will never be executed. Symfony will intercept this first
* and handle the login automatically. See form_login in app/config/security.yml
*
* @Route("/login_check", name="security_login_check")
*/
public function loginCheckAction()
{
throw new \Exception('This should never be reached!');
}
/**
* This is the route the user can use to logout.
*
* But, this will never be executed. Symfony will intercept this first
* and handle the logout automatically. See logout in app/config/security.yml
*
* @Route("/logout", name="security_logout")
*/
public function logoutAction()
{
throw new \Exception('This should never be reached!');
}
}
Chargement en base d’un utilisateur
Il ne reste plus qu’à enregistrer un utilisateur en créant le répertoire DataFixtures/ORM et en y plaçant le fichier loadFixtures.php:
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AppBundle\DataFixtures\ORM;
use AppBundle\Entity\User;
use Doctrine\Common\DataFixtures\FixtureInterface;
use Doctrine\Common\Persistence\ObjectManager;
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Defines the sample data to load in the database when running the unit and
* functional tests. Execute this command to load the data:
*
* $ php app/console doctrine:fixtures:load
*
* See http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html
*
* @author Ryan Weaver <weaverryan@gmail.com>
* @author Javier Eguiluz <javier.eguiluz@gmail.com>
*/
class LoadFixtures implements FixtureInterface, ContainerAwareInterface
{
/** @var ContainerInterface */
private $container;
public function load(ObjectManager $manager)
{
$this->loadUsers($manager);
}
private function loadUsers(ObjectManager $manager)
{
$user = new User();
$user->setUsername('user1');
$user->setEmail('user1@symfony.com');
$user->setPlainPassword('pass');
$user->setIsActive(true);
$factory = $this->container->get('security.encoder_factory');
$encoder = $factory->getEncoder($user);
$user->encodePassword($encoder);
$manager->persist($user);
$manager->flush();
}
public function setContainer(ContainerInterface $container = null)
{
$this->container = $container;
}
}
Le template de connexion
Il ne nous reste plus qu’à mettre en place le template de connexion:
{# app/Ressources/view/user/login.html.twig #}
{% extends 'base.html.twig' %}
{% block body_id 'login' %}
{% block main %}
{% if error %}
<div class="alert alert-danger">
{{ error.messageKey|trans(error.messageData) }}
</div>
{% endif %}
<div class="row">
<div class="col-sm-5">
<div class="well">
<form action="{{ path('security_login_check') }}" method="post">
<fieldset>
<legend><i class="fa fa-lock"></i> Secure Sign in</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">Password:</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"></i> Sign in
</button>
</fieldset>
</form>
</div>
</div>
</div>
{% endblock %}
puis le template du dashboard de l’utilisateur:
{# app/Ressources/view/user/dashboard.html.twig #}
{% extends 'base.html.twig' %}
{% block title 'Member Area' %}
{% block main %}
<h1>Dashboard</h1>
<p>
Welcome {{ app.user.username }}
</p>
{% endblock %}
Le contrôleur lié aux actions du user connecté
<?php
namespace AppBundle\Controller;
use AppBundle\Entity\ChangePassword;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
/**
* @Route("/account")
*/
class UserController extends Controller
{
/**
* @Route("/dashboard", name="user_dashboard")
* @Method("GET|POST")
*/
public function dashboardAction()
{
return $this->render('user/dashboard.html.twig');
}
}
Et la connexion est possible.
Dans le prochain article, nous verrons comment mettre en place l’enregistrement et comment découpler correctement son code en mettant en place plusieurs services et events afin de minimiser le code dans les contrôleurs et découpler au maximum les fonctions.
Le code github se trouve ici: https://github.com/jpsymfony/authentication-demo
Sa version mise à jour (il faudra chercher un peu pour retrouver les classes car l’arborescence a changé) en Symfony 3.2 est ici: https://github.com/jpsymfony/symfony3-generic-project-symfony3-architecture

Commentaires