Dans le précédent article, nous avons montré comment créer un blog avec Phoenix. Nous avons besoin maintenant de securiser le blog avec Phoenix.
Pour sécuriser le blog avec Phoenix, nous devons :
- associer les articles à leur auteur
- donner des droits exclusifs aux auteurs pour la modification et la suppression des articles
- créer une liste d’article par auteur pour aider les auteurs à administrer leur contenu
Nous mettons en place une sécurisation simple. Il est possible de compléter l’application en ajoutant la notion de rôle et d’attacher des rôles aux différents utilisateurs. Un article de leanpanda montre comment personnaliser les droits d’accès avec la notion de rôle à ajouter dans la table users.
Protéger l’accès aux articles pour sécuriser le blog Phoenix
Dans notre article précédent comment créer un blog avec Phoenix, les articles sont tous accessibles sans avoir besoin de s’identifier.
Phoenix permet très simplement de protéger l’accès aux pages en forçant l’identification des visiteurs. Cela se fait au niveau de BLOGSET/lib/blogset_web/router.ex.
Pour l’instant, nous avons mis les chemins vers les pages des articles dans :
- scope « / », BlogsetWeb do
- pipe_through :browser
- lignes 5 à 10 ci-dessous :
scope "/", BlogsetWeb do pipe_through :browser get "/", PageController, :home live "/stories", StoryLive.Index, :index live "/stories/new", StoryLive.Index, :new live "/stories/:id/edit", StoryLive.Index, :edit live "/stories/:id", StoryLive.Show, :show live "/stories/:id/show/edit", StoryLive.Show, :edit end
Nous allons déplacer ces routes dans :
- scope « / », BlogsetWeb do
- pipe_through [:browser, :require_authenticated_user]
La définition de require_authenticated_user se trouve dans user_auth.ex.
BLOGSET/lib/blogset_web/user_auth.ex :
@doc """ Used for routes that require the user to be authenticated. If you want to enforce the user email is confirmed before they use the application at all, here would be a good place. """ def require_authenticated_user(conn, _opts) do if conn.assigns[:current_user] do conn else conn |> put_flash(:error, "You must log in to access this page.") |> maybe_store_return_to() |> redirect(to: ~p"/users/log_in") |> halt() end end
Le principe est de tester si l’utilisateur est connecté dans la session (ligne 8) avec :
- if conn.assigns[:current_user] do
Si c’est le cas, nous pouvons continuer et la fonction retourne la connection conn. Sinon, nous forçons une redirection vers la page d’identification (ligne 14) en modifiant conn.
Dans le cadre du projet, nous aurons besoin de deux types de vue en mode liste pour les articles :
- la vue par auteur permettant à l’auteur de voir la liste de ses propres articles
- la vue consultation publique permettant à tout le monde de voir les articles du blog
Seule la vue par auteur doit contenir les liens vers les actions de modification ou de suppression d’article.
Vérifier que nous avons bien sécurisé le blog Phoenix
Nous déplaçons les routes :
- fichier : BLOGSET/lib/blogset_web/router.ex,
- fonction : scope « / », BlogsetWeb do
- filtrage : pipe_through [:browser, :require_authenticated_user]
scope "/", BlogsetWeb do pipe_through [:browser, :require_authenticated_user] live_session :require_authenticated_user, on_mount: [{BlogsetWeb.UserAuth, :ensure_authenticated}] do live "/users/settings", UserSettingsLive, :edit live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email live "/stories", StoryLive.Index, :index live "/stories/new", StoryLive.Index, :new live "/stories/:id/edit", StoryLive.Index, :edit live "/stories/:id", StoryLive.Show, :show live "/stories/:id/show/edit", StoryLive.Show, :edit end end
Nous vérifions cette fonctionnalité :
- cd C:\CarbonX1\Phoenix\Projets\blogset
- iex -S mix phx.server ‘execution en mode interactif
- http://localhost:4000/stories/ ‘aller sur la page de l’application depuis un navigateur
La page d’identification s’affiche lorsque l’utilisateur n’est pas identifé :
Après authentification, nous allons sur la page demandée :
Nous pouvons vérifier les conséquences du changement effectué sur les tests :
- cd C:\CarbonX1\Phoenix\Projets\blogset
- mix test
C:\CarbonX1\Phoenix\Projets\blogset>mix test Compiling 1 file (.ex) ......................................................................................................................... 1) test Show updates story within modal (BlogsetWeb.StoryLiveTest) test/blogset_web/live/story_live_test.exs:90 ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/users/log_in", flash: %{"error" => "You must log in to access this page."}}}} code: {:ok, show_live, _html} = live(conn, ~p"/stories/#{story}") stacktrace: test/blogset_web/live/story_live_test.exs:91: (test) 2) test Index saves new story (BlogsetWeb.StoryLiveTest) test/blogset_web/live/story_live_test.exs:26 ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/users/log_in", flash: %{"error" => "You must log in to access this page."}}}} code: {:ok, index_live, _html} = live(conn, ~p"/stories") stacktrace: test/blogset_web/live/story_live_test.exs:27: (test) 3) test Index deletes story in listing (BlogsetWeb.StoryLiveTest) test/blogset_web/live/story_live_test.exs:72 ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/users/log_in", flash: %{"error" => "You must log in to access this page."}}}} code: {:ok, index_live, _html} = live(conn, ~p"/stories") stacktrace: test/blogset_web/live/story_live_test.exs:73: (test) 4) test Show displays story (BlogsetWeb.StoryLiveTest) test/blogset_web/live/story_live_test.exs:83 ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/users/log_in", flash: %{"error" => "You must log in to access this page."}}}} code: {:ok, _show_live, html} = live(conn, ~p"/stories/#{story}") stacktrace: test/blogset_web/live/story_live_test.exs:84: (test) 5) test Index lists all stories (BlogsetWeb.StoryLiveTest) test/blogset_web/live/story_live_test.exs:19 ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/users/log_in", flash: %{"error" => "You must log in to access this page."}}}} code: {:ok, _index_live, html} = live(conn, ~p"/stories") stacktrace: test/blogset_web/live/story_live_test.exs:20: (test) 6) test Index updates story in listing (BlogsetWeb.StoryLiveTest) test/blogset_web/live/story_live_test.exs:49 ** (MatchError) no match of right hand side value: {:error, {:redirect, %{to: "/users/log_in", flash: %{"error" => "You must log in to access this page."}}}} code: {:ok, index_live, _html} = live(conn, ~p"/stories") stacktrace: test/blogset_web/live/story_live_test.exs:50: (test) ............... Finished in 2.2 seconds (1.1s async, 1.1s sync) 142 tests, 6 failures Randomized with seed 731497 C:\CarbonX1\Phoenix\Projets\blogset>
Nous notons l’échec des tests avec 6 erreurs.
Pour corriger ce point, nous avons besoin de permettre aux tests de s’identifier afin que les tests puissent accéder aux pages protégées (lignes 7, 16, 25, 34, 43, 52) : « You must log in to access this page. »
Le fichier de test contenant l’erreur est indiqué juste au dessus avec les numéros de ligne : test/blogset_web/live/story_live_test.exs:49
Pour permettre aux tests de s’identifier, nous ajoutons au début du fichier story_live_test.exs :
- setup :register_and_log_in_user ‘ajouté ligne 11 ci-dessous
BLOGSET/test/blogset_web/live/story_live_test.exs :
defmodule BlogsetWeb.StoryLiveTest do use BlogsetWeb.ConnCase import Phoenix.LiveViewTest import Blogset.StoriesFixtures @create_attrs %{title: "some title", body: "some body"} @update_attrs %{title: "some updated title", body: "some updated body"} @invalid_attrs %{title: nil, body: nil} setup :register_and_log_in_user defp create_story(_) do story = story_fixture() %{story: story} end
La fonction register_and_log_in_user est créé dans le fichier conn_case.ex
BLOGSET/test/support/conn_case.ex :
@doc """ Setup helper that registers and logs in users. setup :register_and_log_in_user It stores an updated connection and a registered user in the test context. """ def register_and_log_in_user(%{conn: conn}) do user = Blogset.AccountsFixtures.user_fixture() %{conn: log_in_user(conn, user), user: user} end
Nous pouvons maintenant relancer les tests :
- cd C:\CarbonX1\Phoenix\Projets\blogset
- mix test
Les tests peuvent maintenant accéder à nos pages protégées :
C:\CarbonX1\Phoenix\Projets\blogset>mix test .............................................................................................................................................. Finished in 2.7 seconds (1.3s async, 1.3s sync) 142 tests, 0 failures Randomized with seed 820743 C:\CarbonX1\Phoenix\Projets\blogset>
Nous avons protégé les pages et permis à nos tests de fonctionner.
Modifier le template de la création des articles
Nous constatons que la création des articles a une zone body beaucoup trop petite. Nous avons besoin d’élargir cette zone à une taille suffisante pour créer un article.
La page de création d’article est définie dans form_component.ex. Nous devons modifier dans render l’input field body en textarea (ligne 23) :
defmodule BlogsetWeb.StoryLive.FormComponent do use BlogsetWeb, :live_component alias Blogset.Stories @impl true def render(assigns) do ~H""" <div> <.header> <%= @title %> <:subtitle>Use this form to manage story records in your database.</:subtitle> </.header> <.simple_form for={@form} id="story-form" phx-target={@myself} phx-change="validate" phx-submit="save" > <.input field={@form[:title]} type="text" label="Title" /> <.input field={@form[:body]} type="textarea" label="Body" /> <:actions> <.button phx-disable-with="Saving...">Save Story</.button> </:actions> </.simple_form> </div> """ end
L’utilisation de textarea permet d’avoir une zone qui est ajustable par l’utilisateur grâce à la poignée en bas à droite de la zone :
Enregistrer l’identité des auteurs pour sécuriser le blog avec Phoenix
Nous avons vu que dans BLOGSET/lib/blogset_web/user_auth.ex, le current_user est disponible dans conn.assigns. C’est ce qui nous a permis de filtrer les utilisateurs identifiés.
Obtenir l’identifiant de l’utilisateur connecté
Nous pouvons donc prendre ce current_user pour affecter son identifiant id dans la fiche créé pour Story.
Dans la page avec la liste des Articles, nous pouvons tracer avec Logger le current_user.
Ouvrons VS Code :
- cd C:\CarbonX1\Phoenix\Projets\blogset ‘dossier du projet
- code . ‘ouvrir vs code
- iex -S mix phx.server ‘execution en mode interactif
- http://localhost:4000/stories ‘ page avec la liste des articles
BLOGSET/lib/blogset_web/live/index.ex :
defmodule BlogsetWeb.StoryLive.Index do use BlogsetWeb, :live_view require Logger alias Blogset.Stories alias Blogset.Stories.Story @impl true def mount(_params, _session, socket) do Logger.info(current_user_mount: socket.assigns[:current_user].id) {:ok, stream(socket, :stories, Stories.list_stories())} end @impl true def handle_params(params, _url, socket) do Logger.info(current_user_handle: socket.assigns[:current_user].id) Logger.info(params_handle: params) {:noreply, apply_action(socket, socket.assigns.live_action, params)} end
Et lorsque nous executons la page en étant identifié, nous voyons dans le log les informations demandées en lignes 1, 8 et 9 ci-dessous :
[info] [current_user_mount: 1] [debug] QUERY OK source="stories" db=0.2ms queue=0.1ms idle=990.9ms SELECT s0."id", s0."title", s0."body", s0."user_id", s0."inserted_at", s0."updated_at" FROM "stories" AS s0 [] ↳ BlogsetWeb.StoryLive.Index.mount/3, at: lib/blogset_web/live/story_live/index.ex:12 [debug] Replied in 5ms [debug] HANDLE PARAMS in BlogsetWeb.StoryLive.Index Parameters: %{} [info] [current_user_handle: 1] [info] [params_handle: %{}] [debug] Replied in 102µs iex(1)>
Maintenant si nous allons dans la partie render de la page, nous voyons notre form_component utilisé dans la page en mode modal, déclenché sur les actions :new et :edit :
BLOGSET/lib/blogset_web/live/index.html.heex :
<.modal :if={@live_action in [:new, :edit]} id="story-modal" show on_cancel={JS.patch(~p"/stories")}> <.live_component module={BlogsetWeb.StoryLive.FormComponent} id={@story.id || :new} title={@page_title} action={@live_action} story={@story} user_id={@current_user.id} patch={~p"/stories"} /> </.modal>
Nous avons le form_component utilisé pour créer et éditer l’article.
Ajout de l’identifiant de l’utilisateur dans le form_component
BLOGSET/lib/blogset_web/live/story_live/form_component.ex :
defmodule BlogsetWeb.StoryLive.FormComponent do use BlogsetWeb, :live_component require Logger alias Blogset.Stories @impl true def render(assigns) do ~H""" <div> <.header> <%= @title %> <:subtitle>Use this form to manage story records in your database.</:subtitle> </.header> <.simple_form for={@form} id="story-form" phx-target={@myself} phx-change="validate" phx-submit="save" > <.input field={@form[:title]} type="text" label="Title" /> <.input field={@form[:body]} type="textarea" label="Body" /> <:actions> <.button phx-disable-with="Saving...">Save Story</.button> </:actions> </.simple_form> </div> """ end .... def handle_event("save", %{"story" => story_params}, socket) do Logger.info(form_component_handle_event_save_user_id: socket.assigns[:user_id]) Logger.info(form_component_handle_event_save_story_params: story_params) save_story(socket, socket.assigns.action, story_params) end .... defp save_story(socket, :new, story_params) do case Stories.create_story(story_params) do {:ok, story} -> notify_parent({:saved, story}) {:noreply, socket |> put_flash(:info, "Story created successfully") |> push_patch(to: socket.assigns.patch)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign_form(socket, changeset)} end end
La fonction render compose la page avec le formulaire. Lorsque nous activons le bouton save, la fonction handle_event (ligne 32) est activée et sauvegarde dans la fonction save_story(…, :new, …). Pour pouvoir enregistrer la référence de l’auteur, nous avons besoin de son id dans story_params.
Nous avons ajouter l’id de l’auteur dans l’appel de FormComponent [index.html.heex ligne 8].
- user_id={@current_user.id}
Dans socket.params, nous pouvons récurpéré la valeur de l’id [form_component.ex ligne 35]
Pour que Logger soit actif, nous avons déclaré Logger dans [form_component.ex ligne 4].
- cd C:\CarbonX1\Phoenix\Projets\blogset
- iex -S mix phx.server ‘execution en mode interactif
- http://localhost:4000/stories/ ‘aller sur la page de l’application depuis un navigateur
[debug] HANDLE EVENT "save" in BlogsetWeb.StoryLive.Index Component: BlogsetWeb.StoryLive.FormComponent Parameters: %{"story" => %{"body" => "body 5", "title" => "un article 5"}} [info] [form_component_handle_event_save_user_id: 1] [info] [form_component_handle_event_save_story_params: %{"body" => "body 5", "title" => "un article 5"}] [debug] QUERY OK source="stories" db=1.8ms queue=0.5ms idle=1474.9ms INSERT INTO "stories" ("title","body","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["un article 5", "body 5", ~U[2023-12-04 17:26:42Z], ~U[2023-12-04 17:26:42Z]] ↳ BlogsetWeb.StoryLive.FormComponent.save_story/3, at: lib/blogset_web/live/story_live/form_component.ex:77 [debug] Replied in 2ms [info] [current_user_struct: nil] [debug] HANDLE PARAMS in BlogsetWeb.StoryLive.Index Parameters: %{} [info] [current_user_handle: 1] [info] [params_handle: %{}] [debug] Replied in 0µs iex(1)>
Nous voyons la valeur user_id passé au composant grace au Log : [info] ligne 4 [form_component_handle_event_save_user_id: 1].
Et nous voyons en ligne 5 le contenu du formulaire.
En résumé, les valeurs que nous mettons dans le composant sont disponibles dans les fonctions du composant. Nous devons maintenant enregistré la valeur id de l’auteur dans la base de données.
Enregistrement de l’id de l’auteur dans la table pour sécuriser le blog avec Phoenix
Nous pouvons maintenant ajouter dans story_param la valeur user_id (form_component.ex ligne 4) :
def handle_event("save", %{"story" => story_params}, socket) do Logger.info(form_component_handle_event_save_user_id: socket.assigns[:user_id]) Logger.info(form_component_handle_event_save_story_params: story_params) story_params = Map.put(story_params,"user_id", socket.assigns[:user_id]) Logger.info(form_component_handle_event_save_story_params2: story_params) save_story(socket, socket.assigns.action, story_params) end .... defp save_story(socket, :new, story_params) do case Stories.create_story(story_params) do {:ok, story} -> notify_parent({:saved, story}) {:noreply, socket |> put_flash(:info, "Story created successfully") |> push_patch(to: socket.assigns.patch)} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign_form(socket, changeset)} end end
Et nous voyons l’ajout dans la trace ligne 6 :
[debug] HANDLE EVENT "save" in BlogsetWeb.StoryLive.Index Component: BlogsetWeb.StoryLive.FormComponent Parameters: %{"story" => %{"body" => "body6", "title" => "article 6"}} [info] [form_component_handle_event_save_user_id: 1] [info] [form_component_handle_event_save_story_params: %{"body" => "body6", "title" => "article 6"}] [info] [form_component_handle_event_save_story_params2: %{"body" => "body6", "title" => "article 6", "user_id" => 1}] [debug] QUERY OK source="stories" db=2.4ms queue=0.8ms idle=1329.1ms INSERT INTO "stories" ("title","body","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["article 6", "body6", ~U[2023-12-04 17:33:33Z], ~U[2023-12-04 17:33:33Z]] ↳ BlogsetWeb.StoryLive.FormComponent.save_story/3, at: lib/blogset_web/live/story_live/form_component.ex:79 [debug] Replied in 3ms [info] [current_user_struct: nil] [debug] HANDLE PARAMS in BlogsetWeb.StoryLive.Index Parameters: %{} [info] [current_user_handle: 1] [info] [params_handle: %{}] [debug] Replied in 102µs iex(1)>
Pour que la valeur soit enregistrée dans la base de données, nous devons suivre le parcours. Dans form_component.ex ligne 6, nous appelons la fonction save_story. En ligne 10, save_story est filtré avec :new, puis appelle en ligne 11 Stories.create_story(story_params).
BLOGSET/lib/blogset/stories/story.ex :
defmodule Blogset.Stories.Story do use Ecto.Schema import Ecto.Changeset schema "stories" do field :title, :string field :body, :string field :user_id, :id timestamps(type: :utc_datetime) end @doc false def changeset(story, attrs) do story |> cast(attrs, [:title, :body]) |> validate_required([:title, :body]) end end
Nous devons ajouté user_id en ligne 16 :
@doc false def changeset(story, attrs) do story |> cast(attrs, [:title, :body, :user_id]) |> validate_required([:title, :body]) end
Regardons dands la bese de données
- cd C:\CarbonX1\Phoenix\Projets\blogset
- psql -U postgres ‘ouvrir psql avec l’utilisateur postgres
- \c blogset_dev ‘se connecter à la base blogset_dev
- \d ‘afficher la liste des tables de la base
- select * from stories; ‘afficher la table stories
- \q ‘pour quitter psql
Nous avons du relancer le serveur pour que la compilation des modifications soient prises en compte. Finalement nous avons bien l’identifiant de l’auteur enregistré dans la base (ligne 15) :
blogset_dev=# select * from stories; id | title | body | user_id | inserted_at | updated_at ----+------------------+-----------------------------------------------+---------+---------------------+--------------------- 2 | Article numÚro 2 | mon deuxiÞme article | | 2023-12-01 14:36:25 | 2023-12-01 14:36:25 3 | autre article | boy d'autre article | | 2023-12-04 17:02:38 | 2023-12-04 17:02:38 4 | autre article | body d'autre article | | 2023-12-04 17:04:43 | 2023-12-04 17:04:43 5 | article 3 | body 3 | | 2023-12-04 17:08:48 | 2023-12-04 17:08:48 6 | article 4 | body 4 | | 2023-12-04 17:13:45 | 2023-12-04 17:13:45 7 | un article 5 | body 5 | | 2023-12-04 17:26:42 | 2023-12-04 17:26:42 8 | article 6 | body6 | | 2023-12-04 17:33:33 | 2023-12-04 17:33:33 9 | article complet | est-ce que cela enregistre le user_id | | 2023-12-04 17:44:49 | 2023-12-04 17:44:49 10 | encore un essai | et maintenant est-ce que l'id est enregistrÚ? | | 2023-12-04 17:51:11 | 2023-12-04 17:51:11 11 | essai 10 | body 10 | | 2023-12-04 17:54:31 | 2023-12-04 17:54:31 12 | essai 11 | body 11 | | 2023-12-04 18:00:14 | 2023-12-04 18:00:14 13 | test 12 | body 12 | 1 | 2023-12-04 18:05:37 | 2023-12-04 18:05:37 (12 lignes) -- Suite --
Protéger les accès des pages articles en utilisant l’authentification
Les articles sont accessibles publiquement en mettant les liens dans la partie publique du routeur. Si nous souhaitons avoir une protection des pages articles, nous devons mettre les liens dans la partie require_authentificated_user.
Lorsque c’est fait, la demande des pages passe par une étape intermédiaire demandant à l’utilisateur de s’authentifier par son compte avant d’afficher la page.
le code de contrôle require_authentificated_user est défini dans user_auth.ex.
Maintenant que nous avons ajouté la référence à l’auteur qui a créé l’Article, nous pouvons filtrer les articles. La modification et la suppression d’articles peuvent être des actions réservées aux auteurs des articles.
Filtrer la liste avec le user_id pour sécuriser le blog avec Phoenix
Nous souhaitons avoir visible pour un utilisateur que ses propres articles. Nous réalisons le filtre dans :
- list_stories dans le module stories.ex en ajoutant user_id en paramètre de la fonction
- Repo.all(from s in Story, where: s.user_id == ^user_id)
Et pour l’appel nous devons fournir le user_id :
- dans mount de index.ex
- remplacer Stories.list_stories() par Stories.list_stories(socket.assigns[:current_user].id
Nous devons aussi protéger l’accès à chacun des articles par le get en passant le user_id en paramètre en plus de l’identifiant de l’article.
- dans stories.ex, nous ajoutons get_story!(id, user_id)
- avec Repo.one(from s in Story, where: s.id == ^id and s.user_id == ^user_id)
L’appel est fait dans la page principale index.ex lors de l’action sur le bouton edit :
- apply_action(socket, :edit, %(« id » => id)
- on met Stories.get_story!(id, socket.assigns.current_user.id)
Conclusion, nous avons sécurisé le blog avec Phoenix
Dans cet article, nous avons montré comment ajouter la sécurisation du blog avec Phoenix mix.gen.auth. Nous avons réalisé :
- une gestion de compte utilisateur permettant aux auteurs de gérer leurs articles
- une gestion d’article avec un titre, un corps de texte et un identifiant d’auteur
Nous allons dans les prochains articles :
- Ouvrir les commentaires pour le blog
- Compléter la création d’article en ajoutant la gestion des images.