Implémenter son propre SecurityController

Implémenter son propre SecurityController

J’ai récemment rédigé un article pour mon entreprise sur la gestion des utilisateurs et de leur authentification en utilisant nativement le Core de Symfony2, et donc en se passant de FOSUserBundle. Suite à ce premier article déjà riche, j’ai voulu vous décrire une deuxième partie tout aussi utile qui va nous permettre de mettre en place rapidement les fonctions indispensables à savoir, réinitialiser son mot de passe, changer son password, valider son compte ou encore s’enregistrer. Des actions tout aussi indispensable lorsqu’on possède un système gérant des utilisateurs.

Mettre en place un SecurityController

Dans un premier temps, si vous n’avez pas suivis le premier tutoriel sur comment mettre en place une gestion utilisateur, je vous conseille d’y jeter un coup d’œil. Si vous avez suivis la solution, vous devriez en toute logique avoir un contrôleur SecurityController ou avec un autre nom. Dans mon cas, je ne possède que trois méthodes dont une seule réellement utilisable.

  1. loginAction
    Cette méthode permet de connecter un utilisateur.
  2. checkAction
    Cette méthode permet simplement de déclarer une route pour le firewall, permettant d’effectuer la connexion d’un utilisateur côté serveur.
  3. logoutAction
    Cette méthode permet de déclarer une route pour le firewall permettant de déconnecter un utilisateur.

Ouvrir notre plateforme aux nouveaux utilisateurs

Il serait particulièrement intéressant que nos utilisateurs puissent se connecter, et par conséquence pouvoir s’inscrire au préalable.

Dans un premier temps, on génère le formulaire avec les informations que vous souhaitez demandé à votre utilisateur.

En ce qui concerne le formulaire que vous utiliserez pour inscrire votre utilisateur, sachez que le champs « password » ne doit pas être dans votre formulaire. Vous devrez par contre ajouter deux champs « no mapped » de manière à faire saisir deux fois le mot de passe qu’il souhaite.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * @Method({"GET"})
 * @Route("/register", name="register")
 * @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
 * @Template()
 */
public function register()
{
    $form = $this->createForm(new UserType(UserType::REGISTER), new User());
    return array(
        "form" => $form->createView(),
        );
}

Ensuite, c’est lorsque votre utilisateur va revenir avec son formulaire remplie que nous allons devoir l’inscrire ou refouler son dossier :p

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
/**
* @Method({"POST"})
* @Route("/register")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
* @Template()
*/
public function registerNow(Request $request)
{
$params = $request->request->all()["name_of_my_form"];
$form = $this->createForm(new UserType(UserType::REGISTER), new User());
$form->submit($request);
if (array_key_exists("plain_password", $params) && array_key_exists("plain_password2", $params) && $params["plain_password"] == $params["plain_password2"]) {
if ($form->isValid()) {
$data = $form->getData();
$data->setPassword($this->container->get("security.encoder_factory")->getEncoder($data)->encodePassword($params["plain_password"], $data->getSalt()));
$em->persist($data);
$em->flush();
return $this->redirect($this->generateUrl("login", array("message" => "You have received an email to validate your account.")));
}
}
return array(
"errors" => $params["plain_password"] == $params["plain_password2"] ? $form->getErrors() : array("The two password must be the sames"),
"form" => $form->createView(),
);
}

Ici nous allons rapidement détailler le workflow pour enregistrer un nouvel utilisateur.

  1. On regarde si tous les champs requis sont bien saisies, dont les deux champs « mot de passe », et si ces deux derniers sont identiques.
  2. On encode le mot de passe et on le « set » dans l’entité.
  3. En cas d’une quelconque erreur, on retourne le formulaire avec l’erreur qu’on pense bon de détailler.

Créer une fonctionnalité permettant le reset de son mot de passe

Désormais, votre utilisateur peut se connecter mais comment ferons-nous si il perd son mot de passe. Il est bien évident que nous n’allons pas mettre en place une adresse mail de contact dédié à ses opérations inutiles.

Comme vous avez pu le voir au dessus, j’ai pour habitude de déclarer deux méthodes pour chacune de mes fonctionnalités : une de mes méthodes s’occupent de gérer la vue créer suite à une requête GET et une suite à une requête POST. Vous pouvez tout à fait concaténer ces deux méthodes dans une et même méthodes.

1
2
3
4
5
6
7
8
9
/**
  * @Method({"GET"})
  * @Route("/reset", name="reset")
  * @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
  * @Template()
  */
  public function reset() {
   return array();
  }

Dans un deuxième temps, on va déclarer la méthode complémentaire gérant les requêtes POST.

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
/**
* @Method({"POST"})
* @Route("/reset")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
*/
public function resetNow(Request $request)
{
$params = $request->request->all();
if (!array_key_exists("login", $params)) {
throw new \Exception("No login given");
}
$login = &$params["login"];
$em = $this->container->get("doctrine.orm.default_entity_manager");
$user = $em->getRepository("NamespaceMyBundle:User")->findOneBy(array("login" => $login));
if ($user == null) {
return $this->redirect($this->generateUrl("login", array()));
}
$password = "myRandowPassword";
$user->setPassword($this->container->get("security.encoder_factory")->getEncoder($user)->encodePassword($password, $user->getSalt()));
$em->persist($user);
$em->flush();
// On envoie le mot de passe par mail
return $this->redirect($this->generateUrl("login", array()));
}

Cette méthode a été conçu pour resetter le mot de passe d’un utilisateur ayant fournis son login/username. Dans mon cas, le mot de passe était ensuite envoyé par mail. Je vous laisserai ajouter cette vaillante ligne.

  1. On va donc rechercher l’utilisateur.
  2. On génère un mot de passe qu’on vient renseigner dans l’utilisateur une fois qu’il a encodé selon les règles que vous aurez définit.

Mettre en place le changement de mot de passe

À ce point, notre utilisateur peut générer un nouveau mot de passe si celui-ci était perdu, mais dans le cas où il voudrait simplement le changer, il nous faut une porte définir une porte.

1
2
3
4
5
6
7
8
9
/**
* @Method({"GET"})
* @Route("/change", name="change-password")
* @Secure(roles="IS_AUTHENTICATED_FULLY")
* @Template()
*/
public function change() {
return array();
}

Voici le code permettant de générer la vue. Dans un premier temps, ou devra saisir son ancien mot de passe, puis saisir deux fois son nouveau mot de passe. La deuxième fois étant la confirmation.

Désormais nous allons voir le code permettant de ressetter le mot de passe. Le process est similaire à celui permettant de générer un nouveau mot de passe aléatoire.

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
/**
 * @Method({"POST"})
 * @Route("/change")
 * @Secure(roles="IS_AUTHENTICATED_FULLY")
 * @Template()
 */
public function changeNow(Request $request)
{
    $params = $request->request->all();
    if (!array_key_exists("current", $params)
        || !array_key_exists("new", $params)
        || !array_key_exists("new2", $params))
    {
        return array("error" => "Please fill all fields");
    }
    $em = $this->container->get("doctrine.orm.default_entity_manager");
    $user = $this->getUser();
    $user_encoders = $this->container->get("security.encoder_factory")->getEncoder($user);
    $user_repository = $em->getRepository("NamespaceMyBundle:User");
    $current_password_encoded = $user_encoders->encodePassword($params["current"], $user->getSalt());
    $new_password_encoded = $user_encoders->encodePassword($params["new"], $user->getSalt());
    if ($user_repository->findOneBy(array("password" => $current_password_encoded)) == null) {
        return array("error" => "The current password is wrong");
    } elseif ($params["new"] != $params["new2"]) {
        return array("error" => "The two fields password aren't the same");
    }
    $user->setPassword($new_password_encoded);
    $em->persist($user);
    $em->flush();
    return $this->redirect($this->generateUrl("logout", array()));
}

Si vous prenez 1 minute pour lire le code, vous verrez que celui-ci est particulièrement simple.

  1. Dans un premier temps, on regarde si les trois champs (ancien mot de passe, nouveau de passe et confirmation) ont bien été saisies.
  2. On encode le mot de passe actuel et on le compare avec le mot de passe actuellement en base de donnée pour voir si celui ci correspond avec l’ancien mot de passe saisie.
  3. On regarde si les « deux » nouveaux passwords sont identiques.
  4. On encode le nouveau mot de passe et on le push dans l’entité.

Activation de son compte

Cette fonctionnalité n’est pas détaillé parfaitement dans les autres snippets ci-dessus. Elle a pour but de débloquer un utilisateur qui viendrait de s’enregistrer, lorsqu’il aurait valider son email par exemple. Cette fonctionnalité est développé sur quasiment l’intégralité des plateformes que nous connaissons pour plusieurs raisons. Pour mettre en place un blocage d’un utilisateur, vous pourriez d’ailleurs avoir besoin d’implémenter un provider.

  • Bloquer/limiter les comptes fake et le spam.
  • Vérifier que l’utilisateur a remplie une adresse mail à première vue utilisable.
  • Supprimer les comptes qui n’auraient pas été validés après une certaine durée.

Workflow

  1. Un utilisateur s’enregistre. Son compte est alors bloqué via un champs propre à vous. Ce champs doit alors l’empêcher de se connecter tant que ce champs indiquent que le compte est désactivé.
1
2
3
4
5
6
7
8
// Namespace\MyBundle\Entity\User
class User {
public function __construct() {
$this->token = hash("sha512", uniqid());
}
}
1
$user->setEnabled(false);
  1. L’utilisateur a reçu un email lorsque son profil a été flusher en base de donnée. Ce mail doit faire part d’une adresse que vous générer.
    Dans cette route, un token ou identifiant unique doit être donnée permettant retrouver l’utilisateur concerné. Je vous conseille l’utilisation d’un UUID4 qui se veut aléatoire. Vous pouvez retrouver la liste des UUID ainsi que la description de toutes les versions.
1
2
3
4
/**
* @Route("/activate", name="activate")
*/
public function activate() {…}
1
$this->generateUrl("activate", array("token" => $user->getToken()), true);

Vous devriez avoir une URL comme celle-ci.

1
http://hostname/activate?token=myUniqueToken
  1. L’utilisateur ouvre son mail et tente d’activer son compte en cliquant sur le lien fournis. On entre alors dans le procesus ci-dessous.
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
/**
 * @Method({"GET"})
 * @Route("/activate", name="activate")
 * @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
 * @Template()
 */
public function activate(Request $request) {
    $params = array();
    $token = $request->query->get("token");
    $em = $this->container->get("doctrine.orm.default_entity_manager");
$user = $em->getRepository("NamespaceMyBundle:User")->findOneBy(array("token" => $token));
    if ($user != null) {
        $user->setEnabled(true);
        $em->persist($user);
        $em->flush();
        $params["activate"] = true;
    } else {
        $params["activate"] = false;
    }
    return $params;
}

Avec ce processus vous ne devriez pas avoir de problème pour permettre en place une validation d’un compte utilisateur.

Vous pouvez retrouver ce code équivalent sur ce Gist.

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
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
<?php
use JMS\SecurityExtraBundle\Annotation\Secure;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\SecurityContext;
class SecurityController extends Controller
{
/**
* @Method({"GET"})
* @Route("/login", name="login")
* @Template()
*/
public function login(Request $request)
{
$request = $this->getRequest();
$session = $request->getSession();
if ($request->attributes->has(SecurityContext::AUTHENTICATION_ERROR)) {
$error = $request->attributes->get(SecurityContext::AUTHENTICATION_ERROR);
} else {
$error = $session->get(SecurityContext::AUTHENTICATION_ERROR);
$session->remove(SecurityContext::AUTHENTICATION_ERROR);
}
$params = array(
"last_username" => $session->get(SecurityContext::LAST_USERNAME),
"error" => $error,
"message" => $request->get("message"),
);
if ($request->isXmlHttpRequest()) {
return $this->render("GCDirectoryMainBundle:Security:login-ajax.html.twig", $params);
}
return $params;
}
/**
* @Method({"POST"})
* @Route("/login_check", name="login_check")
*/
public function check()
{
throw new \RuntimeException('You must configure the check path to be handled by the firewall using form_login in your security firewall configuration.');
}
/**
* @Method({"GET"})
* @Route("/logout", name="logout")
*/
public function logout()
{
throw new \RuntimeException('You must activate the logout in your security firewall configuration.');
}
/**
* @Method({"GET"})
* @Route("/reset", name="reset")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
* @Template()
*/
public function reset() {
return array();
}
/**
* @Method({"POST"})
* @Route("/reset")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
*/
public function resetNow(Request $request)
{
$params = $request->request->all();
if (!array_key_exists("login", $params)) {
throw new \Exception("No login given");
}
$login = &$params["login"];
$em = $this->container->get("doctrine.orm.default_entity_manager");
$user = $em->getRepository("NamespaceMyBundle:User")->findOneBy(array("login" => $login));
if ($user == null) {
return $this->redirect($this->generateUrl("login", array()));
}
$password = "myRandowPassword";
$user->setPassword($this->container->get("security.encoder_factory")->getEncoder($user)->encodePassword($password, $user->getSalt()));
$em->persist($user);
$em->flush();
return $this->redirect($this->generateUrl("login", array()));
}
/**
* @Method({"GET"})
* @Route("/change", name="change-password")
* @Secure(roles="IS_AUTHENTICATED_FULLY")
* @Template()
*/
public function change() {
return array();
}
/**
* @Method({"POST"})
* @Route("/change")
* @Secure(roles="IS_AUTHENTICATED_FULLY")
* @Template()
*/
public function changeNow(Request $request)
{
$params = $request->request->all();
if (!array_key_exists("current", $params)
|| !array_key_exists("new", $params)
|| !array_key_exists("new2", $params))
{
return array("error" => "Please fill all fields");
}
$em = $this->container->get("doctrine.orm.default_entity_manager");
$user = $this->getUser();
$user_encoders = $this->container->get("security.encoder_factory")->getEncoder($user);
$user_repository = $em->getRepository("NamespaceMyBundle:User");
$current_password_encoded = $user_encoders->encodePassword($params["current"], $user->getSalt());
$new_password_encoded = $user_encoders->encodePassword($params["new"], $user->getSalt());
if ($user_repository->findOneBy(array("password" => $current_password_encoded)) == null) {
return array("error" => "The current password is wrong");
} elseif ($params["new"] != $params["new2"]) {
return array("error" => "The two fields password aren't the same");
}
$user->setPassword($new_password_encoded);
$em->persist($user);
$em->flush();
return $this->redirect($this->generateUrl("logout", array()));
}
/**
* @Method({"GET"})
* @Route("/register", name="register")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
* @Template()
*/
public function register()
{
$form = $this->createForm(new UserType(UserType::REGISTER), new User());
return array(
"form" => $form->createView(),
);
}
/**
* @Method({"POST"})
* @Route("/register")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
* @Template()
*/
public function registerNow(Request $request)
{
$params = $request->request->all()["name_of_my_form"];
$form = $this->createForm(new UserType(UserType::REGISTER), new User());
$form->submit($request);
if (array_key_exists("plain_password", $params) && array_key_exists("plain_password2", $params) && $params["plain_password"] == $params["plain_password2"]) {
if ($form->isValid()) {
$data = $form->getData();
$data->setPassword($this->container->get("security.encoder_factory")->getEncoder($data)->encodePassword($params["plain_password"], $data->getSalt()));
$em->persist($data);
$em->flush();
return $this->redirect($this->generateUrl("login", array("message" => "You have received an email to validate your account.")));
}
}
return array(
"errors" => $params["plain_password"] == $params["plain_password2"] ? $form->getErrors() : array("The two password must be the sames"),
"form" => $form->createView(),
);
}
/**
* @Method({"GET"})
* @Route("/activate", name="activate")
* @Secure(roles="IS_AUTHENTICATED_ANONYMOUSLY")
* @Template()
*/
public function activate(Request $request) {
$params = array();
$token = $request->query->get("token");
$em = $this->container->get("doctrine.orm.default_entity_manager");
$user = $em->getRepository("NamespaceMyBundle:User")->findOneBy(array("token" => $token));
if ($user != null) {
$user->setActive(User::ACTIVE_ACTIVE);
$em->persist($user);
$em->flush();
$params["activate"] = true;
} else {
$params["activate"] = false;
}
return $params;
}
}