Article écrit par Nicolas Cavigneaux
Dans ce nouvel article, notre but va être de mettre en place un système d’authentification basique.
Si on écrivait une application qui a pour but d’être utilisée en production, on opterait sûrement pour une solution éprouvée comme phx_gen_auth ou encore Pow.
Ici le but est d’apprendre et de s’approprier les différentes briques qui constituent Phoenix. On va donc créer notre propre solution.
Les utilisateurs vont pouvoir se créer un compte. Quand ils le feront, on enregistrera leur nom d’utilisateur et leur mot de passe en base de données. Évidemment, on chiffrera le mot de passe pour qu’il ne puisse pas être lu si la base de données venait à être compromise.
Une fois enregistré, un utilisateur pourra s’identifier et une session lui sera associée.
Gestion des changeset
avec mot de passe
Tout le code relatif à l’authentification sera placé dans le contexte Accounts
.
Pour chiffrer les mots de passe, nous allons utiliser une bibliothèque dédiée. On va donc commencer par l’installer, en modifiant mix.exs
:
defp deps do [ # … {:argon2_elixir, "~> 2.0"} ] end
On a ici choisi Argon qui à l’heure de l’écriture de cet article est considéré comme la meilleure implémentation de Comeonin, une spécification pour les bibliothèques de hachage de mots de passe, en termes d’efficacité.
Une fois cette dépendance ajoutée, on l’installe :
mix deps.get
Nous avons déjà une fonction de changeset
dans le fichier lib/commentator/accounts/user.ex
qui nous permet créer ou mettre à jour un utilisateur :
def changeset(user, attrs) do user |> cast(attrs, [:name, :username]) |> validate_required([:name, :username]) |> validate_length(:username, min: 1, max: 20) end
Cette fonction ne gère que le nom et le nom d’utilisateur avec une validation sur la longueur du nom d’utilisateur.
Il nous faut maintenant ajouter la gestion du mot de passe. Quand vous écrivez des fonctions de changeset, il est préférable d’écrire une fonction par cas d’usage.
Dans notre cas, on veut une fonction généraliste qui permet de mettre à jour les informations non sensibles, puis on en voudra une autre qui peut gérer le mot de passe. Ceci étant, il est tout à fait possible de tirer parti d’un changeset existant pour l’enrichir. C’est ce que nous ferons.
Avant de pouvoir gérer le mot de passe, il faut ajouter ce champ au schéma users
dans lib/commentator/accounts/user.ex
:
schema "users" do # … field :password, :string, virtual: true field :password_hash, :string # … end
On a ajouté deux champs, pourquoi ? Simplement parce que le champ password
est un champ virtuel, qui va exister dans la structure User
mais pas en base de données. Ce champ nous permet de stocker temporairement le mot de passe en clair, avant de le hacher puis de le persister dans le champ password_hash
de la base de données.
Maintenant que ces champs sont définis, on peut créer notre nouvelle fonction de changeset
:
def registration_changeset(user, params) do user |> changeset(params) |> cast(params, [:password]) |> validate_required([:password]) |> validate_length(:password, min: 8) |> put_pass_hash() end
Comme vous pouvez le voir, cette fonction réutilise notre fonction de changeset précédente pour traiter le travail qui est commun. Ensuite on cast
puis valide le mot de passe en s’assurant qu’il est présent et fait au moins 8 caractères de long.
Finalement, on fait appel à la fonction put_password_hash
qu’on doit encore écrire et dont le but est de hacher le mot de passe en clair puis de le stocker dans le champ password_hash
. Créons donc cette fonction privée, toujours dans le fichier lib/commentator/accounts/user.ex
:
defp put_pass_hash(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do change(changeset, Argon2.add_hash(password)) end defp put_pass_hash(changeset), do: changeset
On tire ici parti du pattern matching pour gérer le cas où le changeset passé est valide grâce à la première fonction. La deuxième gérera tous les autres cas (changeset invalide, paramètre qui n’est pas un Ecto.Changeset
, etc).
Si le changeset n’est pas valide, on le retourne tel quel. S’il est valide, on va hacher le mot de passe grâce à la fonction Argon2.add_hash/2
puis l’injecter dans le changeset (dans l’attribut password_hash
qui est l’attribut utilisé par défaut par Argon2.add_hash/2
).
On peut tester notre code dans une console iex
pour vérifier que tout fonctionne comme prévu :
iex> alias Commentator.Accounts.User Commentator.Accounts.User iex> changeset = User.registration_changeset(%User{}, %{username: "John", name: "Doe", password: "foo"}) #Ecto.Changeset< action: nil, changes: %{name: "Doe", password: "foo", username: "John"}, errors: [ password: {"should be at least %{count} character(s)", [count: 8, validation: :length, kind: :min, type: :string]} ], data: #Commentator.Accounts.User<>, valid?: false > iex> changeset.changes %{name: "Doe", password: "foo", username: "John"} iex> changeset.valid? false
Si on crée un changeset avec un mot de passe trop court, on voit qu’il n’est pas valide.
Essayons avec un mot de passe valide :
iex> changeset = User.registration_changeset(%User{}, %{username: "John", name: "Doe", password: "foobarbaz"}) #Ecto.Changeset< action: nil, changes: %{ name: "Doe", password: "foobarbaz", password_hash: "$argon2id$v=19$m=131072,t=8,p=4$hMYC7tV2qSXW3DPgNaylZw$t7iq1PSDUlnttv21xWo0svSu0wm7B/yyGXBNffoXeDo", username: "John" }, errors: [], data: #Commentator.Accounts.User<>, valid?: true >
Maintenant notre changeset est marqué comme valide et notre mot de passe haché a été généré et ajouté à la structure.
Sachant que notre code fonctionne, on va pouvoir ajouter un mot de passe par défaut à nos utilisateurs existants pour continuer à pouvoir les utiliser par la suite :
alias Commentator.Repo for u <- Repo.all(User) do Repo.update!(User.registration_changeset(u, %{password: "password"})) end
Grâce à cette boucle, tous nos utilisateurs ont un mot de passe haché en base de données.
Création de nouveaux utilisateurs
Il est temps d’utiliser ces nouvelles possibilités dans l’application Web. Nous avons déjà une action contrôleur qui permet de créer des utilisateurs. Cette action ne sait cependant pas gérer les mots de passe.
En effet, l’action create
utilise la fonction Accounts.create_user/1
qui ne gère pas du tout les mots de passe. Conservons cette fonction telle quelle, elle nous sera utile plus tard pour créer des utilisateurs dans les tests.
On va donc enrichir notre contexte Accounts
avec une nouvelle fonction Accounts.register_user/1
:
lib/commentator/accounts.ex
def register_user(attrs \ %{}) do %User{} |> User.registration_changeset(attrs) |> Repo.insert() end
Cette fonction est très similaire à la fonction create_user/1
qu’on avait créée par avant, la seule différence est l’utilisation de User.registration_changeset/1
à la place de User.changeset/1
.
Pour rester cohérent avec les fonctionnalités qu’expose déjà notre contexte Accounts
, on va également ajouter la fonction Accounts.change_registration/2
pour pouvoir facilement mettre à jour un utilisateur (avec son mot de passe) et obtenir un changeset :
def change_registration(%User{} = user, params) do User.registration_changeset(user, params) end
On peut maintenant passer à la mise à jour de notre contrôleur lib/commentator_web/controllers/user_controller.ex
:
def new(conn, _params) do changeset = Accounts.change_registration(%User{}, %{}) render(conn, "new.html", changeset: changeset) end def create(conn, %{"user" => user_params}) do case Accounts.register_user(user_params) do {:ok, user} -> conn |> put_flash(:info, "#{user.name} ajouté.") |> redirect(to: Routes.user_path(conn, :index)) {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) end end
Le seul changement appliqué dans les fonctions new
et create
est le passage de change_user
à change_registration
et de create_user
à register_user
.
Une fois encore, grâce à l’encapsulation de la logique dans les contextes, peu de changements sont à faire dans le code qui les utilise.
Il faut maintenant qu’on ajoute un champ pour le mot de passe dans notre formulaire d’inscription, on édite donc lib/commentator_web/templates/user/new.html.eex
:
<div> <%= password_input f, :password, placeholder: "Mot de passe" %> <%= error_tag f, :password %> </div>
J’ai simplement ajouté un nouveau div
qui contient un input
de type password
ainsi que l’affichage d’éventuelles erreurs de validation sur ce champ.
On peut se rendre sur la page de création d’un utilisateur pour constater le changement :
Et si on met un mot de passe trop court, le message d’erreur s’affiche :
Maintenant qu’on peut créer des utilisateurs avec mot de passe, il faut mettre en place un système d’authentification !
Gestion de l’authentification
Dans un des épisodes précédents, on avait vu qu’une requête dans Phoenix passe
dans un pipeline de fonctions (les plugs) qui traitent la connexion morceau par morceau avant de générer une réponse.
Pour gérer l’authentification, on va tirer parti des plugs et en écrire un qui permettra de vérifier si un utilisateur est identifié ou non.
Un plug doit nécessairement avoir une fonction init
et une fonction call
, le plug le plus basique qu’on peut écrire est le suivant :
defmodule BasicPlug do def init(opts) do opts end def call(conn, _opts) do conn end end
La fonction init
reçoit des options à son initialisation. Ensuite, chaque fois que le plug est appelé, on passe dans la fonction call
qui reçoit la connexion (Plug.Conn
) et d’éventuelles options.
Le but de cette fonction est d’appliquer des modifications à la connexion avant de la retourner.
La connexion nous fournit des informations sur la requête comme l’host
, le path
, les headers
mais aussi les cookies
, les params
, les assigns
, etc. La connexion nous permet également de définir des éléments de réponse le body
, les cookies
, les headers
ou encore le status
.
La documentation de Plug.Conn
est très bien faite et je vous invite à y jeter un œil.
Passons à la pratique ! On va vouloir stocker l’id
de l’utilisateur en session quand il va s’inscrire ou s’identifier. Une fois qu’on aura ce mécanisme on pourra, pour chaque requête, vérifier la session et s’il y a un id
, on le stockera dans conn.assigns
pour que les autres briques de l’application (comme les contrôleurs) puissent y accéder.
Créons donc notre plug d’authentification. On va créer un nouveau fichier lib/commentator_web/controllers/auth.ex
:
defmodule CommentatorWeb.Auth do import Plug.Conn def init(opts), do: opts def call(conn, _opts) do user_id = get_session(conn, :user_id) user = user_id && Commentator.Accounts.get_user(user_id) assign(conn, :current_user, user) end end
Ici rien de très compliqué si vous avez compris l’explication sur l’implémentation minimale d’un plug.
On importe Plug.Conn
, on déclare une fonction init
qui ne fait rien d’autre que retourner les options sans modification.
Le plus intéressant est ce qui se passe dans la fonction call
(qui je le rappelle sera appelée au runtime, à chaque requête). On essaie de récupérer en session l’élément user_id
, si on en obtient un et qu’on arrive à trouver en base un utilisateur qui correspond à cet id
alors on l’assigne dans la connexion courante sous la clé current_user
.
Si notre plug se trouve dans les premiers éléments du pipeline, alors tous les plug suivants pourront tirer parti de ce current_user
.
Évidemment pour le moment, ce plug ne sera pas très utile puisque jusqu’à maintenant on ne stocke pas l’utilisateur courant en session. On va tout de même ajouter ce plug dans notre pipeline et commencer à bloquer l’accès à certaines routes si l’utilisateur n’est pas identifié.
On commence par modifier lib/commentator_web/router.ex
:
pipeline :browser do plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_flash) plug(:protect_from_forgery) plug(:put_secure_browser_headers) plug CommentatorWeb.Auth end
On fait donc simplement référence dans le pipeline
:browser
à notre nouveau module qu’on appelle via la fonction plug
.
Toutes les routes qui utilisent ce pipeline
vont donc profiter de la mise en session de l’utilisateur courant.
Maintenant limitons l’accès à la liste des utilisateurs, ainsi qu’au détail d’un utilisateur, aux seuls utilisateurs authentifiés. Pour ce faire, on va modifier notre contrôleur lib/commentator_web/controllers/user_controller.ex
:
defp authenticate(conn, _opts) do if conn.assigns.current_user do conn else conn |> put_flash(:error, "Vous devez être authentifié") |> redirect(to: Routes.page_path(conn, :index)) |> halt() end end
Cette fonction privée prend la connexion en paramètre. Elle vérifie dans les assigns
si on a un current_user
. Si c’est le cas, la fonction laisse passer en retournant la connexion non modifiée.
Si par contre, current_user
n’est pas défini, alors la connexion va être modifiée pour y ajouter un message flash, faire une redirection vers la page d’accueil puis terminer la connexion pour éviter toute autre transformation de la connexion par la suite.
On pourrait faire appel à cette fonction dans chacune de nos actions contrôleur qu’on souhaite protéger. On peut aussi l’utiliser en tant que plug dans notre contrôleur ce qui nous facilite la tâche étant donné qu’on veut protéger plusieurs actions :
# lib/commentator_web/controllers/user_controller.ex plug :authenticate when action in [:index, :show]
J’ai ajouté cette ligne juste après les alias dans le contrôleur.
On peut maintenant essayer de se rendre sur une page protégée pour voir ce qu’il se passe :
Comme prévu, en tentant d’accéder à la liste des utilisateurs ou à la page de détail d’un utilisateur, on est redirigé sur la page d’accueil et un message nous informe qu’il faut être authentifié.
Je trouve qu’avec très peu de code on arrive à mettre en place un système élégant qui pourra être utilisé de manière transverse dans l’application. Le système de plug est vraiment un outil qu’il est pratique d’avoir à sa disposition.
Authentification
Il faut maintenant mettre à disposition un moyen de s’authentifier pour que nos utilisateurs puissent accéder aux pages protégées.
On va donc commencer par enrichir notre système d’authentification en le dotant d’une fonction de login
. On édite donc le fichier lib/commentator_web/controllers/auth.ex
:
def login(conn, user) do conn |> assign(:current_user, user) |> put_session(:user_id, user.id) |> configure_session(renew: true) end
On commence par assigner current_user
sur la connexion avec l’utilisateur passé en paramètre. On s’assure ensuite de renseigner son id
en session, puis on force un renouvellement de l’identifiant de cookie. Cette étape n’est pas nécessaire au fonctionnement de notre système mais elle est par contre très importante d’un point de vue sécurité puisqu’elle nous évite de nous retrouver face à des attaques de type « fixation » où une personne mal intentionnée arriverait à récupérer l’identifiant du cookie et pourrait s’en servir par la suite pour s’identifier.
On va maintenant se servir de cette nouvelle fonction pour identifier nos utilisateurs quand ils créent leur compte et aussi quand ils s’identifient à travers une page de login qui reste à faire.
On commence par la création de compte en éditant le fichier lib/commentator_web/controllers/user_controller.ex
:
def create(conn, %{"user" => user_params}) do case Accounts.register_user(user_params) do {:ok, user} -> conn |> CommentatorWeb.Auth.login(user) |> put_flash(:info, "#{user.name} ajouté.") |> redirect(to: Routes.user_path(conn, :index)) {:error, %Ecto.Changeset{} = changeset} -> render(conn, "new.html", changeset: changeset) end end
Une seule ligne change par rapport à l’implémentation précédente. On a, dans le cas d’une création de compte réussie, ajouté un appel à CommentatorWeb.Auth.login(user)
.
Désormais, si quelqu’un crée un compte, il sera automatiquement authentifié avec ce dernier.
Page d’authentification
Notre prochaine étape est de permettre aux utilisateurs existants de s’identifier.
Pour ce faire il va nous falloir agrémenter notre contexte Accounts
puis créer une page d’authentification.
Commençons par la fonction d’authentification qu’on va ajouter à lib/commentator/accounts.ex
:
def authenticate(username, password) do user = get_user_by(username: username) cond do user && Argon2.verify_pass(password, user.password_hash) -> {:ok, user} user -> {:error, :unauthorized} true -> Argon2.no_user_verify() {:error, :not_found} end end
Notre fonction d’authentification reçoit le nom d’utilisateur et le mot de passe fourni par l’utilisateur.
On essaie tout d’abord de récupérer le dit utilisateur grâce à la fonction get_user_by
que nous avons également écrit plus tôt dans notre contexte.
Ensuite, on vérifie différentes conditions.
D’abord si on a un utilisateur et qu’Argon nous confirme que le mot de passe fourni correspond à password_hash
une fois haché, alors on retourne un tuple qui confirme que les informations sont correctes et qui fournit l’utilisateur.
Si le mot de passe est erroné et qu’on a un user alors on répond en erreur en précisant que l’utilisateur n’est pas autorisé.
Finalement dans les autres cas, c’est-à-dire si l’utilisateur n’a pas pu être trouvé en base, on répond en erreur avec la précision :not_found
.
Vous aurez sûrement noté l’appel à Argon2.no_user_verify
dans ce cas. Ce n’est pas nécessaire au bon fonctionnement, mais une fois encore ça rend notre application plus robuste face aux tentatives d’abus. Si un utilisateur malveillant tente de détecter les comptes qui existent et ceux qui n’existent pas il ne pourra pas se baser sur le temps de réponse de l’application (timing attack) puisque l’appel à Argon simule un hachage de mot de passe qui retournera toujours false
. Il n’y a donc plus de réelle différence de temps de traitement entre un utilisateur qui existe et un qui n’existe pas.
On est maintenant prêts à utiliser cette fonction dans notre application. Il va falloir ajouter des routes, un contrôleur et des templates.
Commençons par les routes (lib/commentator_web/router.ex
) :
scope "/", CommentatorWeb do pipe_through(:browser) get("/", PageController, :index) resources("/users", UserController, only: [:index, :show, :new, :create]) resources("/sessions", SessionController, only: [:new, :create, :delete]) end
On a donc ajouté trois routes à travers l’utilisation de resources
:
new
présentera le formulaire d’authentification ;create
sera l’action qui vérifie le nom d’utilisateur et le mot de passe pour éventuellement créer la session ;delete
permettra de terminer sa session.
Nos routes à disposition, on peut maintenant créer nos actions. On va créer le fichier lib/commentator_web/controllers/session_controller.ex
. Tout d’abord l’action new
:
defmodule CommentatorWeb.SessionController do use CommentatorWeb, :controller def new(conn, _) do render(conn, "new.html") end end
Rien de nouveau ici, on va en profiter pour ajouter l’action create
qui sera un peu plus complexe :
defmodule CommentatorWeb.SessionController do # ... def create(conn, %{"session" => %{"username" => username, "password" => password}}) do case Commentator.Accounts.authenticate(username, password) do {:ok, user} -> conn |> CommentatorWeb.Auth.login(user) |> put_flash(:info, "Bienvenue !") |> redirect(to: Routes.page_path(conn, :index)) {:error, _} -> conn |> put_flash(:error, "Ce compte n'est pas valide") |> render("new.html") end end end
Comme à l’habitude, on fait usage du pattern matching
. D’abord pour récupérer les paramètres et les stocker dans les variables username
et password
. On l’utilise à nouveau pour vérifier si l’authentification est correcte ou non, grâce à notre fonction authenticate
créée plus tôt.
Si l’authentification est correcte, on stocke les infos de l’utilisateur dans la variable user
, puis on modifie la connexion pour créer la session (une fois encore grâce à la méthode login
créée plus tôt). Enfin, on ajoute un message flash et on redirige vers l’accueil.
En cas d’erreur d’authentification (tuple commençant par :error
), on va simplement ajouter un message flash d’erreur dans la connexion puis rendre à nouveau le formulaire d’authentification.
Passons à la création de la vue et du template associé. Pour la vue, on crée le fichier lib/commentator_web/views/session_view.ex
:
defmodule CommentatorWeb.SessionView do use CommentatorWeb, :view end
Ce module est tout ce qu’il y a de plus classique et basique pour une vue.
Passons maintenant au template en créant le fichier lib/commentator_web/templates/session/new.html.eex
:
<h1>Identification</h1> <%= form_for @conn, Routes.session_path(@conn, :create), [as: :session], fn f -> %> <div><%= text_input(f, :username, placeholder: "Nom d'utilisateur") %></div> <div><%= password_input(f, :password, placeholder: "Mot de passe") %></div> <%= submit("Connectez moi !") %> <% end %>
Le formulaire est des plus basiques, pas de fioritures. On utilise les classiques helpers form_for
, un text_input
, un password_input
et un submit
.
On devrait normalement pouvoir se rendre sur cette nouvelle page et tester le comportement :
On a donc bien tous nos cas d’usage qui fonctionnent ! On va maintenant pouvoir mettre en place des fonctionnalités liées aux utilisateurs authentifiés.
Pour commencer, selon que l’utilisateur soit authentifié ou non, on ne présentera pas les mêmes liens dans l’en-tête.
Un utilisateur authentifié verra son nom d’utilisateur affiché ainsi qu’un lien lui permettant de fermer sa session.
Un utilisateur anonyme verra quant à lui un lien pour s’authentifier et un autre pour s’inscrire.
Allons modifier ça, ça se passe dans le layout de l’application dans le fichier lib/commentator_web/templates/layout.html.eex
:
<nav role="navigation"> <ul> <%= if @current_user do %> <li><%= @current_user.username %></li> <li> <%= link("Fermer la session", to: Routes.session_path(@conn, :delete, @current_user), method: :delete) %> </li> <% else %> <li><%= link("S'identifier", to: Routes.session_path(@conn, :new)) %></li> <li><%= link("Créer un compte", to: Routes.user_path(@conn, :new)) %></li> <% end %> </ul> </nav>
On a donc simplement modifié la section navigation
qui, sur la base d’une condition sur la présence de @current_user
(mis à disposition par le plug développé plus haut), va afficher les liens adéquats.
Je suis actuellement authentifié, j’obtiens donc :
Pour tester en mode anonyme, il faut que je ferme ma session. Comme vous le voyez ci-dessus, le lien est présent, la route existe, mais l’action associée n’a pas encore été écrite. Mettons-la en place pour pouvoir clore notre session et tester les liens pour un utilisateur anonyme.
On retourne donc dans notre contrôleur de session pour y ajouter :
def delete(conn, _) do conn |> CommentatorWeb.Auth.logout() |> redirect(to: Routes.page_path(conn, :index)) end
Cette action est concise. Elle prend en argument la connexion, elle laisse tomber les éventuels paramètres puisque après tout c’est l’utilisateur courant qui veut clore sa session.
On appelle la fonction CommentatorWeb.Auth.logout/1
qui, si vous suivez, n’existe pas encore. Comme toujours, c’est une bonne pratique d’encapsuler la logique dans une fonction dédiée, dans le module qui va bien, pour éviter d’exposer la logique partout.
Ensuite on redirige sur l’accueil.
Ajoutons donc la fonction logout
à notre module Auth
:
def logout(conn) do configure_session(conn, drop: true) end
Ici on n’y va pas par quatre chemins puisqu’on efface complètement la session de l’utilisateur. Si d’autres infos étaient stockées en session, elles disparaissent également à la fin de la requête.
On devrait maintenant pouvoir ferme notre session et voir apparaître les autres liens :
Aujourd’hui on a donc pu monter un système d’authentification, certes basique, mais fonctionnel avec assez peu de code et qu’on peut facilement exploiter dans notre application pour afficher du contenu de manière conditionnelle ou encore pour restreindre l’accès à certaines pages.
L’implémentation par nos propres soins de ce système, plutôt que l’utilisation d’une brique toute faite, nous a permis d’utiliser une bibliothèque de hachage et d’améliorer notre connaissance des changesets et des plugs.