Go to main contentGo to page footer

Authentication and Authorisation in Phoenix Liveview

Lean Panda is currently developing a number of Elixir applications using Phoenix LiveView. Working with LiveView is a pleasure, but we still have some unsolved situations, or, at least, we don’t have a standard way of solving them. One of those problems is authentication and authorisation: authentication is where you prove who you are and authorisation is where the application decides what you can do.

On this article, we implement authentication and authorisation in Liveview, explaining the code and our decisions.

Estimate reading 100 minutes

Authentication and Authorisation in Liveview

Lean Panda is currently developing a number of Elixir applications using Phoenix LiveView. Working with LiveView is a pleasure, but we still have some unsolved situations, or, at least, we don’t have a standard way of solving them.

One of those problems is authentication and authorisation: authentication is where you prove who you are and authorisation is where the application decides what you can do.

The community has already come up with some excellent solutions for regular Phoenix applications. For authentication we can rely on libraries like Pow and Guardian, or use the library phx_gen_auth as proposed by José Valim, someone that surely understands a lot about authentication.  For authorisation, we don't have that many options, but we surely have some good ones, again, implemented for regular Phoenix applications, like Bodyguard

Most apps need to implement authentication similarly, and it's usually not a good idea to roll your authentication solution: there are a lot of tricky details, and if you miss a single one of them you might end up having a severe security problem. 

The authorisation, on the other hand, is something that varies a lot from application to application, and some developers prefer to implement themselves, as discussed in this thread. I feel the same, it’s sometimes harder to integrate an existing library than it is to implement your own code. The way standard Phoenix routing is implemented, with plugs and its pipelines, makes it really easy to understand how everything is glued together and where is the best place to ensure a given user has the roles we are asking for.

LiveView, on the other hand, doesn’t have a routing system. Although we can rely on the standard Phoenix Router when we change from one LiveView to another, sometimes we want to check if a user can access a given route inside the same LiveView or create for rules a given route, for example, allow a user to edit a property with a given ID but forbid the same user to edit a property with another ID.

We will implement our solution in three steps:

Step 1) Use the plug provided by phx_gen_auth to check if a given user is authenticated.

In this step, we will create a Phoenix setup, add phx_gen_auth and use its generator to create our authentication system. In our user database model, we will add an enum column called role

The login action will be performed on a regular Phoenix controller (not LiveView) since manipulating the session inside a LiveView, although supported, complicates our problem. The authenticated part of our app will be all LiveView, and only authenticated users will be able to access it.

Step 2) Develop a plug to ensure the user has the authorisation to access a given LiveView. 

We will implement a plug ourselves and use it on our controller. Later we will define some rules on our router, using plug pipelines that require a given role (or any role of a role list). 

Later we will test our plug by creating one route that can only be accessed by admins and another route that can be accessed by both admins and regular users.

Step 3) Update the live views generated with phx.gen.live to perform the checks, ensuring a user can really access a given route. 

On this step, we will implement some rules to be used by our LiveView and our template, and check, before each action, if a user is authorised.

Let’s use an example to help us understand the rules an application might need to implement: suppose we have an entity called properties, and we need to authorise users to access routes related to properties.

  • A user is authorised to access the index route for properties, seeing a list containing all properties or isn’t authorised to access the properties index route.
  • A user is authorised to access the show route for any property, is authorised to access the show route for a set of properties or isn’t authorised to access the show route for properties.
  • A user is authorised to access the edit route for any property, is authorised to access the edit route for a set of properties or isn’t authorised to access the edit route for properties.
  • A user is authorised to delete any property, is authorised to delete some properties or isn’t authorised to delete any property.

We will implement some nice helpers to provide some visual feedback to the user: the user won’t see buttons/links to routes he cannot access or actions he cannot perform. 

Step 4) Create a way to logout a user and close all his/her sessions. 

This function has many uses, for example: the user himself closing his sessions, or an admin, after promoting or demoting a user, force the user to login again. 

If you want to follow the steps of this tutorial with more details, please go to this repo and look at the commits.

Step 1: Use the plug provided by phx_gen_auth to check if a given user is authenticated

First, let’s create our setup. We are using Elixir 1.10 and Phoenix 1.5 and starting our application with the brand new --live option.

mix phx.new real_estate --live

Just follow the setup and you should be fine. Then, add the library phx_gen_auth as suggested on their documentation. 

Then run the generator:

mix phx.gen.auth Accounts User users

And migrate the database:

mix ecto.migrate

Now let’s add the role attribute to our users. We will use an Enum to define the roles we can set to the user through the use of the library Ecto Enum.  Start by adding the dependency on your mix.exs:

{:ecto_enum, “~> 1.4”}

Go to your User module and define our new enum:

  import EctoEnum

defenum(RolesEnum, :role, [
    :user,
    :admin
  ])

For this number of roles we could use simply a boolean, but let’s keep that way since usually we would have more than two roles.

Then create a new migration: 

mix ecto.gen.migration AddRoleToUsers

Also, on you User module add a new field to users schema: 

field :role, RolesEnum, default: :user

Let’s also create some functions to help us create an admin. We won’t create any special forms for creating admins on this tutorial, for that we will use just plain seeds. We will create a different function and a different changeset to create users and admins, that it's safer and easier to control.

On the accounts context, add the function:

  @doc “””
  Registers an admin.

  ## Examples
      iex> register_admin(%{field: value})
      {:ok, %User{}}

      iex> register_admin(%{field: bad_value})
      {:error, %Ecto.Changeset{}}
  “””

  def register_admin(attrs) do
    %User{}
    |> User.admin_registration_changeset(attrs)
    |> Repo.insert()
  end

On the User module add the following functions:

  @doc “””
  A user changeset for registering admins.
  “””
  def admin_registration_changeset(user, attrs) do
    user
    |> registration_changeset(attrs)
    |> prepare_changes(&set_admin_role/1)
  end

  defp set_admin_role(changeset) do
    changeset
    |> put_change(:role, :admin)
  end

We are just using the existing registration changeset and setting the role to admin. 

Just to ensure our code is working, we modified the existing tests a bit. Inside accounts_test.exs, in the test "register_admin/1: registers users with a hashed password", just add one extra line:

assert user.role == :admin

And add an extra test:

  describe “register_admin/1” do
    test “registers users with a hashed password and sets role to :admin” do
      email = unique_user_email()
      {:ok, user} = Accounts.register_admin(%{email: email, password: valid_user_password()})
      assert user.email == email
      assert is_binary(user.hashed_password)
      assert is_nil(user.confirmed_at)
      assert is_nil(user.password)
      assert user.role == :admin
    end
  end

Ok, we are good to go.

Now, let’s add some seeds:

RealEstate.Accounts.register_admin(%{
  email: "admin@company.com",
  password: "123456789abc",
  password_confirmation: "123456789abc"
})

RealEstate.Accounts.register_user(%{
  email: "user1@company.com",
  password: "123456789abc",
  password_confirmation: "123456789abc"
})

RealEstate.Accounts.register_user(%{
  email: "user2@company.com",
  password: "123456789abc",
  password_confirmation: "123456789abc"
})

Now, let’s go to our router and see what’s going on. The library phx_gen_auth just added some nice routes at the bottom of our file, just move the route to the index page  live “/“, PageLive, :index to the end of our  pipe_through [:browser, :require_authenticated_user] pipeline.

  scope “/“, RealEstateWeb do
    pipe_through [:browser, :redirect_if_user_is_authenticated]
get “/users/register”, UserRegistrationController, :new
    post “/users/register”, UserRegistrationController, :create
    get “/users/log_in”, UserSessionController, :new
    post “/users/log_in”, UserSessionController, :create
    get “/users/reset_password”, UserResetPasswordController, :new
    post “/users/reset_password”, UserResetPasswordController, :create
    get “/users/reset_password/:token”, UserResetPasswordController, :edit
    put “/users/reset_password/:token”, UserResetPasswordController, :update
  end

  scope “/“, RealEstateWeb do
    pipe_through [:browser, :require_authenticated_user]
    get “/users/settings”, UserSettingsController, :edit
    put “/users/settings/update_password”, UserSettingsController, :update_password
    put “/users/settings/update_email”, UserSettingsController, :update_email
    get “/users/settings/confirm_email/:token”, UserSettingsController, :confirm_email

    # This line was moved
    live "/", PageLive, :index
  end

  scope “/“, RealEstateWeb do
    pipe_through [:browser]
    delete “/users/log_out”, UserSessionController, :delete
    get “/users/confirm”, UserConfirmationController, :new
    post “/users/confirm”, UserConfirmationController, :create
    get “/users/confirm/:token”, UserConfirmationController, :confirm
  end

These routes mean two things:

  • If we are logged in (user role doesn’t matter here) we will be able to access the routes under pipe_through [:browser, :require_authenticated_user] pipeline. If we are not logged in we should be redirected to the login page.
  • If we are logged in and we try to access pages under pipe_through [:browser, :redirect_if_user_is_authenticated] we will be redirected to a default route. If we aren’t logged in, we will be able to access those routes.

So, let’s try it. Spin up your server and go to the url: http://localhost:4000.  You should see something like this:

Authentication required

Now, type the login and password for any of the users we have added on the seeds (remember to run the seeds code mix run priv/repo/seeds.exs). 

Email: admin@company.com
Password: 123456789abc

After logging in you should be redirected to the page you tried to access. Now that we logged in, just try the second part added in the router file. Let’s try to access this route: http//localhost:4000/users/log_in to see what happens. Ok, cool, we are redirected to the initial page of our app. 

Everything looks ok, but we need something else to secure our LiveViews according to Security considerations of the LiveView model — Phoenix LiveView v0.14.4.  But before doing that, let’s take a look at what phx_gen_auth  is doing under the hoods to keep our app secure.

You can see that phx_gen_auth added an import at the top of our router: 

import RealEstateWeb.UserAuth. 

Also, three functions were defined: :fetch_current_user, :redirect_if_user_is_authenticated and :require_authenticated_user. That’s what I like about this library: if anything is hidden from the developer, at least it is hidden in plain sight. 

So, let’s start with the first one:

  @doc “””
  Authenticates the user by looking into the session
  and remember me token.
  “””

  def fetch_current_user(conn, _opts) do
    {user_token, conn} = ensure_user_token(conn)
    user = user_token && Accounts.get_user_by_session_token(user_token)
    assign(conn, :current_user, user)
  end

  defp ensure_user_token(conn) do
    if user_token = get_session(conn, :user_token) do
      {user_token, conn}
    else
      conn = fetch_cookies(conn, signed: [@remember_me_cookie])
      if user_token = conn.cookies[@remember_me_cookie] do
        {user_token, put_session(conn, :user_token, user_token)}
      else
        {nil, conn}
      end
    end
  end

What this function is doing is basically if we have an opened session (a session with an existing token (phx_gen_auth implementation saves user tokens in a table on the database) or I have a signed remember_me cookie, we add current_user to the conn assigns. 

Remember our plug pipeline runs from top to bottom, so every route on our router will have that :current_user assign: it will be nil or a %User{}.

Let’s look at our next function: 

  @doc “””
  Used for routes that require the user to not be authenticated.
  “””

  def redirect_if_user_is_authenticated(conn, _opts) do
    if conn.assigns[:current_user] do
      conn
      |> redirect(to: signed_in_path(conn))
      |> halt()
    else
      conn
    end
  end

 defp signed_in_path(_conn), do: "/"

Remember this function will run after the previous one. So, we will always have a :current_user assign on our conn.  What this function is doing is, if our :current_user assign is true (remember nil is evaluated to false - so it will be a %User{}), we redirect to “/“ and halt.

And, at last:

  @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: Routes.user_session_path(conn, :new))
      |> halt()
    end
  end

  defp maybe_store_return_to(%{method: “GET”} = conn) do
    %{request_path: request_path, query_string: query_string} = conn
    return_to = if query_string == “”, do: request_path, else: request_path <> “?” <> query_string
    put_session(conn, :user_return_to, return_to)
  end

  defp maybe_store_return_to(conn), do: conn

Again, we are relying on the previous plug (:fetch_current_user). If we have our :current_user assign evaluated to true (not nil - so a %User{}) we proceed on our plug pipeline. Otherwise, we put an error flash message: “You must log in to access this page.”, we can then store a return_to path on the session and we redirect to the login page. 

The halt/1 function plays an important role. If we place a new plug after this one, we can be sure that our :current_user assign will be a %User{} because otherwise our plug wouldn’t be reached. 

Getting back to our original problem, the code on fetch_current_user/2 is what we will need to secure our live views. Let’s create an assign_defaults/2 function as suggested in the LiveView documentation since it is something we will reuse at the mount stage of all LiveViews we will create from now on.

Create a new file on lib/real_state_web/live called live_helpers.ex.

defmodule RealEstateWeb.LiveHelpers do
  import Phoenix.LiveView
  alias RealEstate.Accounts
  alias RealEstate.Accounts.User
  alias RealEstateWeb.Router.Helpers, as: Routes

  def assign_defaults(session, socket) do
    socket =
      assign_new(socket, :current_user, fn ->
        find_current_user(session)
      end)

    case socket.assigns.current_user do
      %User{} ->
        socket

      _other ->
        socket
        |> put_flash(:error, "You must log in to access this page.")
        |> redirect(to: Routes.user_session_path(socket, :new))
    end
  end

  defp find_current_user(session) do
    with user_token when not is_nil(user_token) <- session["user_token"],
         %User{} = user <- Accounts.get_user_by_session_token(user_token),
         do: user
  end
end

One nice detail about this code is the assign_new/3 function that lazily assigns values: on disconnected mount, getting them from conn assigns, and on connected mount, getting them from parent LiveView assigns. Quoting LiveView docs:

“When a LiveView is mounted in a disconnected state, the Plug.Conn assigns will be available for reference via  assign_new/3, allowing assigns to be shared for the initial HTTP request. The Plug.Conn assigns will not be available during the connected mount. Likewise, nested LiveView children have access to their parent's assigns on mount using assign_new, which allows assigns to be shared down the nested LiveView tree.”

Import the RealEstateWeb.LiveHelpers module in your live_view/0  function, inside on lib/real_state_web.ex (or lib/your_app_name_web.ex). 

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {RealEstateWeb.LayoutView, "live.html"}
      unquote(view_helpers())
      # Add this line
      import RealEstateWeb.LiveHelpers
    end
  end

On every mount/3 function on our LiveViews we will call this function as suggested by LiveView docs: 

socket = assign_defaults(session, socket)

So, update our mount/3  on  RealEstateWeb.PageLive module:

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    {:ok, assign(socket, query: “”, results: %{})}
  end

Remember that, after a recent LiveView update, all session assigns are passed by default to the LiveView mount/3 function. In previous versions, we would need to pass the keys we want from our router manually. 

Also, in the Plug implemented by phx_gen_auth library, we are setting :current_user in the connection assigns. So, the parent LiveView won’t need to reload that from the database on the disconnected mount/3. So, in the end, we will hit the database twice: First on the plug pipeline and later on the connected LiveView mount.

We finished our first step. Remember our goal was: Only authenticated users will be able to access our LiveViews. Most of our code was implemented by the phx_gen_auth library but, anyway, that was a good start.

Step 2: Develop a plug to ensure the user has the authorisation to access a given LiveView

In the previous step we understood a bit about how plug works and how phx_gen_auth authenticates our routes, so now let’s implement our own plug.

What we want to achieve is having something to what we have on our router.ex file right now: a pipeline for each role.

Example on how we would like to use our plug.

pipeline :admin do
  plug EnsureRolePlug, :admin
end

pipeline :manager do
  plug EnsureRolePlug, [:admin, :manager]
end

pipeline :user do
  plug EnsureRolePlug, [:admin, :manager, :user]
end

scope "/", ItimmAppWeb do
  pipe_through [:browser, :require_authenticated_agent, :user]

  # place here the user role routes
end

scope "/", ItimmAppWeb do
  pipe_through [:browser, :require_authenticated_agent, :manager]

  # place here the manager role routes
end

scope "/", ItimmAppWeb do
  pipe_through [:browser, :require_authenticated_agent, :admin]

  # place here the admin role routes
end

Remember that what we are proposing here is a hierarchical way of accessing the roles: so a manager will be able to access all manager and user routes and an admin will be able to access all admin, manager, and user routes. If that’s not the case, you can simply change the pipeline definition.

Also, right now we have only admin and user roles on our app, and all registered users have at least a :user role. So, the :user pipeline is redundant for our current implementation, but it would be nice to have for the example I showed above, so I will keep it for more complex cases.

Now let’s implement our plug.

defmodule RealEstateWeb.EnsureRolePlug do

  @moduledoc """
  This plug ensures that a user has a particular role before accessing a given route.

  ## Example
  Let's suppose we have three roles: :admin, :manager and :user.
  If you want a user to have at least manager role, so admins and managers are authorised to access a given route

  plug RealEstateWeb.EnsureRolePlug, [:admin, :manager]

  If you want to give access only to an admin:

  plug RealEstateWeb.EnsureRolePlug, :admin
  """

  import Plug.Conn
  alias RealEstate.Accounts
  alias RealEstate.Accounts.User
  alias Phoenix.Controller
  alias Plug.Conn

  @doc false
  @spec init(any()) :: any()
  def init(config), do: config

  @doc false
  @spec call(Conn.t(), atom() | [atom()]) :: Conn.t()
  def call(conn, roles) do
    user_token = get_session(conn, :user_token)
    
(user_token &&

       Accounts.get_user_by_session_token(user_token))
    |> has_role?(roles)
    |> maybe_halt(conn)
  end

  defp has_role?(%User{} = user, roles) when is_list(roles),
    do: Enum.any?(roles, &has_role?(user, &1))

  defp has_role?(%User{role: role}, role), do: true
  defp has_role?(_user, _role), do: false

  defp maybe_halt(true, conn), do: conn
  defp maybe_halt(_any, conn) do
    conn
    |> Controller.put_flash(:error, "Unauthorised")
    |> Controller.redirect(to: signed_in_path(conn))
    |> halt()
  end

  defp signed_in_path(_conn), do: "/"
end

Let’s take a look at our implementation. There are two ways of implementing plugs: Function plugs and Module plugs (read about it here): we chose the Module plug implementation.  

Our init/1 function is merely returning what it has received, and it’s not performing anything important. The important part of our plug is our call/2 function. 

On our router.ex file we are calling the plug like this:

plug EnsureRolePlug, [:admin, :manager]

or

plug EnsureRolePlug, :admin

The parameter roles on call/2 function will receive, then, a list of atoms or an atom. So what we are doing, in the end, is to find the user by the agent_token" key, present on the session, and passing it together with roles to our has_role/2 function.  If roles is a list of roles, we return true if the logged user role matches any of the roles in the list. If roles is a single role we return true if the logged user has precisely that role.

The result of our has_role/2 will be passed to maybe_halt/2. If that result is true, we will simply pass the conn we received. Otherwise, we will add an :error flash, redirect to the signed_in_path and halt the connection.

An important detail about the (user_token && Accounts.get_user_by_session_token(user_token)) is that, if we don’t add the parentheses and our user_token is nil, the next function (has_role/2) won’t be called, that way we will have a plug that returns nil and that will break our app.

Now let’s create two new LiveViews to test our functionality and add the restrictions on our router.  

Create two very simple LiveViews with embedded layouts just to make it easier to understand. First, create a new file on real_estate_web/lib/live called user_dashboard_live.ex:

defmodule RealEstateWeb.UserDashboardLive do

  use RealEstateWeb, :live_view

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~L”””
    <section class=“phx-hero”>
      <h1>Welcome to the user dashboard!</h1>
    </section>
    “””
  end
end

Now, create a new file on real_estate_web/lib/live called admin_dashboard_live.ex:

defmodule RealEstateWeb.AdminDashboardLive do
  use RealEstateWeb, :live_view

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    {:ok, socket}
  end

  @impl true
  def render(assigns) do
    ~L”””
    <section class=“phx-hero”>
      <h1>Welcome to the admin dashboard!</h1>
    </section>
    “””
  end
end

On our router.ex file add the following:

  alias RealEstateWeb.EnsureRolePlug

  ...

  pipeline :user do
    plug EnsureRolePlug, [:admin, :user]
  end

  pipeline :admin do
    plug EnsureRolePlug, :admin
  end

  ...

  scope "/", RealEstateWeb do
    pipe_through [:browser, :require_authenticated_user, :user]
    live "/user_dashboard", UserDashboardLive, :index
  end

  scope "/", RealEstateWeb do
    pipe_through [:browser, :require_authenticated_user, :admin]
    live "/admin_dashboard", AdminDashboardLive, :index
  end

Now, let’s add some tests, but first we need to create an admin_fixture. So, go to test/support/fixtures/accounts_fixtures.ex and add the following:

  def admin_fixture(attrs \\ %{}) do
    {:ok, user} =
      attrs
      |> Enum.into(%{
        email: unique_user_email(),
        password: valid_user_password()
      })
      |> RealEstate.Accounts.register_admin()

    user
  end

Now let’s create our tests. On test/real_estate_web/live add two new files, the first one is admin_dashboard_live_test.exs:

defmodule RealEstateWeb.AdminDashboardLiveTest do

  use RealEstateWeb.ConnCase
  import Phoenix.LiveViewTest
  import RealEstate.AccountsFixtures

  test “disconnected and connected render without authentication should redirect to login page”,
       %{conn: conn} do

    # If we don’t previously log in we will be redirected to the login page
    assert {:error, {:redirect, %{to: “/users/log_in”}}} = live(conn, “/admin_dashboard”)
  end

  test “disconnected and connected render authenticated with user role should redirect to index page”,
       %{
         conn: conn
       } do

    conn = conn |> log_in_user(user_fixture())
    assert {:error, {:redirect, %{to: “/“}}} = live(conn, “/admin_dashboard”)
  end

  test “disconnected and connected render authenticated with admin role should redirect to index page”,
       %{
         conn: conn
       } do

    conn = conn |> log_in_user(admin_fixture())
    {:ok, admin_dashboard, disconnected_html} = live(conn, “/admin_dashboard”)
    assert disconnected_html =~ “Welcome to the admin dashboard!”
    assert render(admin_dashboard) =~ “Welcome to the admin dashboard!”
  end
end

We are testing if someone that is not logged will be redirected to the login page, if someone that is logged with user role will be redirected to index page (“/“) and if someone that is logged with admin role will be able to see the dashboard.

Now add another file called user_dashboard_live_test.exs:

defmodule RealEstateWeb.UserDashboardLiveTest do

  use RealEstateWeb.ConnCase
  import Phoenix.LiveViewTest
  import RealEstate.AccountsFixtures

  test “disconnected and connected render without authentication should redirect to login page”,
       %{conn: conn} do

    # If we don’t previously log in we will be redirected to the login page
    assert {:error, {:redirect, %{to: “/users/log_in”}}} = live(conn, “/user_dashboard”)
  end

  test “disconnected and connected render authenticated with user role should redirect to index page”,
       %{
         conn: conn
       } do

    conn = conn |> log_in_user(user_fixture())
    {:ok, user_dashboard, disconnected_html} = live(conn, “/user_dashboard”)
    assert disconnected_html =~ “Welcome to the user dashboard!”
    assert render(user_dashboard) =~ “Welcome to the user dashboard!”
  end

  test “disconnected and connected render authenticated with admin role should redirect to index page”,
       %{
         conn: conn
       } do

    conn = conn |> log_in_user(admin_fixture())
    {:ok, user_dashboard, disconnected_html} = live(conn, “/user_dashboard”)
    assert disconnected_html =~ “Welcome to the user dashboard!”
    assert render(user_dashboard) =~ “Welcome to the user dashboard!”
  end
end

We are testing if someone that is not logged will be redirected to the login page, if someone that is logged with user role will be able to see the user dashboard and if someone that is logged with admin role will also be able to see the user dashboard. 

Now, run the tests and you will see everything is fine.

If you want, log in as a regular user and try to access this route http://localhost:4000/admin_dashboard.  You should see something like this:

Unauthorised

Ok, we finished Step 2, let’s start Step 3.

Step 3: Update the live views generated with phx.gen.live to perform the checks, ensuring a user can really access a given route.

Let’s start our step 3 by adding the Property entity using the phx.gen.live generators.  Open your terminal end type:

mix phx.gen.live Properties Property properties user_id:references:users name:string price:decimal description:text

Add the new routes to your router.ex file:

  scope “/“, RealEstateWeb do
    pipe_through [:browser, :require_authenticated_user, :user]

    live “/user_dashboard”, UserDashboardLive, :index

    # This was added
    live “/properties”, PropertyLive.Index, :index
    live “/properties/new”, PropertyLive.Index, :new
    live “/properties/:id/edit”, PropertyLive.Index, :edit
    live “/properties/:id”, PropertyLive.Show, :show
    live “/properties/:id/show/edit”, PropertyLive.Show, :edit
  end

And run your migrations:

mix ecto.migrate

Go to lib/real_estate_web/templates/layout/root.html.leex and replace the Get Start link with the properties link:

<nav role=“navigation”>
  <ul>
    <li><%= link “Properties”, to: Routes.property_index_path(@conn, :index) %></li>
    <%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
      <li><%= link “LiveDashboard”, to: Routes.live_dashboard_path(@conn, :home) %></li>
    <% end %>
  </ul>
  <%= render “_user_menu.html”, assigns %>
</nav>

Now you should see something like this page and both user and admin should be able to access the page:

Listing properties

Let’s update our tests since the generator doesn’t expect the need to login to access any of the generated pages.

To make the tests pass, just go to test/real_estate_web/live/property_live_test.exs and add:

  # This import was added
  import RealEstate.AccountsFixtures

...


  describe "Index" do
    setup [:create_property]

    # This was added
    setup %{conn: conn} do
      conn = log_in_user(conn, user_fixture())
      %{conn: conn}
    end

    ...

  end

  describe "Show" do
    setup [:create_property]

    # This was added
    setup %{conn: conn} do
      conn = log_in_user(conn, user_fixture())
      %{conn: conn}
    end

    ...

  end

Now our tests are passing. Since the tests were failing before, we already ensured that we need to login at least with the user role to access our Property LiveViews (Index and Show).

What we will do next is store the user who created a property, that way we will be able to apply some authorisation rules later.

Our property creation process happens on a LiveComponent called RealEstateWeb.PropertyLive.FormComponent . The creation happens on the function save_property/3, when the second parameter, action, is :new, so let’s start adding our new code on that function clause.

  defp save_property(socket, :new, property_params) do
    current_user = socket.assigns.current_user
    property_params = Map.put(property_params, “user_id”, current_user.id)

    case Properties.create_property(property_params) do
...

Remember property_params are coming from the form, so even though a malicious user may try to pass a different user_id on the form, our function will overwrite the current_user id.

The :current_user parameter is not available on that component yet, so we need it to be available when the assign :action is set to :new

The component is called two different LiveViews: RealEstateWeb.PropertyLive.Index and RealEstateWeb.PropertyLive.Show, but only on the PropertyLive.Index we might assign :action to :new

So let’s add the current_user assign to our FormComponent in the RealEstateWeb.PropertyLive.Index.

<%= if @live_action in [:new, :edit] do %>
  <%= live_modal @socket, RealEstateWeb.PropertyLive.FormComponent,
    id: @property.id || :new,
    title: @page_title,
    action: @live_action,
    property: @property,
  # this line was added
    current_user: @current_user,
    return_to: Routes.property_index_path(@socket, :index) %>
<% end %>

Now we need to add :current_user to our RealEstateWeb.PropertyLive.Index LiveView. 

  def mount(_params, session, socket) do
    # This line was added
    socket = assign_defaults(session, socket)
    {:ok, assign(socket, :properties, list_properties())}
  end

The next step is to create a new changeset only for property creation, in which we require a user_id.  So, on RealEstate.Properties.Property let’s create the create_changeset/2 function:

  def create_changeset(property, attrs) do
    property
    |> cast(attrs, [:name, :price, :description, :user_id])
    |> validate_required([:name, :price, :description, :user_id])
  end

We will use the create_changeset/2 function instead of the regular changeset/2 whenever we are creating a property, that way we can ensure we have a user_id

So now, let’s update our create_property/1 function on RealEstate.Properties context:

  def create_property(attrs \\ %{}) do
    %Property{}
    |> Property.create_changeset(attrs)
    |> Repo.insert()
  end

If you try to run the tests, you will see a lot of tests are failing. That’s because we didn’t add yet the user into the property tests while creating a property. So let’s add it real quick:

On RealEstateWeb.PropertyLiveTest update the fixture/1 function to:

  defp fixture(:property) do
    user = user_fixture()
    create_attributes = Enum.into(%{user_id: user.id}, @create_attrs)
    {:ok, property} = Properties.create_property(create_attributes)
    property
  end

The user_fixture/1 function comes from the module RealEstate.AccountsFixtures. Just import the module if you haven’t yet.

Fixing the property_fixture/1 function on the RealEstate.PropertiesTest module fix most of the tests, but not all:

    def property_fixture(attrs \\ %{}) do
      user = user_fixture()

      {:ok, property} =
        attrs
        |> Enum.into(@valid_attrs)
        |> Enum.into(%{user_id: user.id})
        |> Properties.create_property()

      property
    end

You need also to update these two other tests:

    test “create_property/1 with valid data creates a property” do
      user = user_fixture()
      create_attributes = Enum.into(%{user_id: user.id}, @valid_attrs)

      assert {:ok, %Property{} = property} = Properties.create_property(create_attributes)
      assert property.description == “some description”
      assert property.name == “some name”
      assert property.price == Decimal.new(“120.5”)
      assert property.user_id == user.id
    end

    test “create_property/1 with invalid data returns error changeset” do
      user = user_fixture()
      create_attributes = Enum.into(%{user_id: user.id}, @invalid_attrs)
      assert {:error, %Ecto.Changeset{}} = Properties.create_property(create_attributes)
    end

On the first test we are merging the user into the @valid_attrs we already have and passing the result of this to Properties.create_property/1.  We are also asserting that the property. user_id == user.id.  On the second test we are also merging the user but now with the @invalid_attrs, just to make sure our user isn’t the reason for the creation fail.

Right now, users are seeing the properties of each other and are also able to edit and delete a property created by others. We want to enforce some rules:

  • Any user can create new properties.
  • Any user can view (on index and show action) any property.
  • A user can edit and delete only his own properties.
  • An admin can edit and delete any property.
Let’s create a new module inside lib/real_estate_web/ called RealEstateWeb.Roles.

defmodule RealEstateWeb.Roles do

  @moduledoc """
  Defines roles related functions.
  """

  alias RealEstate.Accounts.User
  alias RealEstate.Properties.Property

  @type entity :: struct()
  @type action :: :new | :index | :edit | :show | :delete
  
@spec can?(%User{}, entity(), action()) :: boolean()

  def can?(user, entity, action)
  def can?(%User{role: :admin}, %Property{}, _any), do: true
  def can?(%User{}, %Property{}, :index), do: true
  def can?(%User{}, %Property{}, :new), do: true
  def can?(%User{}, %Property{}, :show), do: true
  def can?(%User{id: id}, %Property{user_id: id}, :edit), do: true
  def can?(%User{id: id}, %Property{user_id: id}, :delete), do: true
  def can?(_, _, _), do: false
end

Taking some existing libraries as inspiration, we implemented a function called can?/3 in which we pass a %User{}, an entity and an action among those defined above. 

From the code it’s easy to understand that an admin can do anything (related to Properties). Any user can access the :index, :new and :show action for any %Property{}. And regular users can only edit and delete properties they created. Anything other than that will be unauthorised by our can?/3 function.

Let’s update our seeds file to add some properties for each user.

{:ok, admin} =
  RealEstate.Accounts.register_admin(%{
    email: “admin@company.com”,
    password: “123456789abc”,
    password_confirmation: “123456789abc”
  })

{:ok, user_1} =
  RealEstate.Accounts.register_user(%{
    email: “user1@company.com”,
    password: “123456789abc”,
    password_confirmation: “123456789abc”
  })

{:ok, user_2} =
  RealEstate.Accounts.register_user(%{
    email: “user2@company.com”,
    password: “123456789abc”,
    password_confirmation: “123456789abc”
  })


Enum.each(1..10, fn I ->
  %{
    name: “Property #{i} - User 1”,
    price: :rand.uniform(5) * 100_000,
    description: “Property that belongs to user 1”,
    user_id: user_1.id
  }
  |> RealEstate.Properties.create_property()

  %{
    name: “Property #{i} - User 2”,
    price: :rand.uniform(5) * 100_000,
    description: “Property that belongs to user 2”,
    user_id: user_2.id
  }
  |> RealEstate.Properties.create_property()

  %{
    name: “Property #{i} - Admin”,
    price: :rand.uniform(5) * 100_000,
    description: “Property that belongs to admin”,
    user_id: admin.id
  }
  |> RealEstate.Properties.create_property()

end)

Now we need to update our Property LiveViews to enforce those rules.  The handle_params/3 function is called on the connected cycle of a LiveView. So, whenever handle_params/3 is called we will verify if a user has the proper authorisation to perform that action. 

Let’s start with the PropertyLive.Index

  alias RealEstateWeb.Roles

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    {:ok, assign(socket, :properties, [])}
  end

  @impl true
  def handle_params(params, _url, socket) do
    current_user = socket.assigns.current_user
    live_action = socket.assigns.live_action
    property = property_from_params(params)

    if Roles.can?(current_user, property, live_action) do
      {:noreply, apply_action(socket, live_action, params)}
    else
      {:noreply,
       socket
       |> put_flash(:error, "Unauthorised")
       |> redirect(to: "/")}
    end
  end

First we won’t load the properties before we check if the user has the rights to perform the action, so we are removing the load_properties/0 function from the mount.

Later, on the handle_params/3 function, before we apply_action we call our recently added Roles.can?/3 function, passing the user, the property (it’s an empty %Property{} struct or a real %Property{} loaded from the database) and the action.

That’s the property_from_params/1 implementation we were mentioning before:

  defp property_from_params(params)

  defp property_from_params(%{“id” => id}),
    do: Properties.get_property!(id)

  defp property_from_params(_params), do: %Property{}

So, the handle_params/3 will take care of three actions: :new, :edit and :index. Now we need to ensure a user is allowed to delete before proceeding. So let’s change also the handle_event/3 function that deals with deleting properties:

  @impl true
  def handle_event(“delete”, %{“id” => id}, socket) do
    current_user = socket.assigns.current_user
    property = Properties.get_property!(id)

    if RealEstateWeb.Roles.can?(current_user, property, :delete) do
      property = Properties.get_property!(id)
      {:ok, _} = Properties.delete_property(property)
      {:noreply, assign(socket, :properties, list_properties())}
    else
      {:noreply,
       socket
       |> put_flash(:error, “Unauthorised”)
       |> redirect(to: “/“)}
    end
  end

Again, before doing anything, we are ensuring the user can delete that given property.

Now, we need to apply the same rules on the  PropertyLive.Show LiveView.

defmodule RealEstateWeb.PropertyLive.Show do

  ...

  alias RealEstateWeb.Roles

  @impl true
  def mount(_params, session, socket) do
    socket = assign_defaults(session, socket)
    {:ok, socket}
  end

  @impl true
  def handle_params(%{“id” => id}, _, socket) do
    current_user = socket.assigns.current_user
    live_action = socket.assigns.live_action
    property = Properties.get_property!(id)

    if Roles.can?(current_user, property, live_action) do
      {:noreply,
       socket
       |> assign(:property, property)
       |> assign(:page_title, page_title(live_action))}
    else
      {:noreply,
       socket
       |> put_flash(:error, “Unauthorised”)
       |> redirect(to: “/“)}
    end
  end

  ...

end

First, we add the :current_user by using our existing assign_defaults/2 function.

Later we use the same strategy we used before, we tweak the handle_params/3 to enforce our rules before doing anything else.

Now, drop the database mix ecto.drop and setup mix ecto.setup again to update the seeds, and go and test manually. Log in with user_1 and try to edit or delete properties from user_2 or admin, you will see you will be redirected to the index page with the “Unauthorised” message.

The next step is hiding from the user the actions he/she cannot do. We have all the helpers we need, let’s place it on our templates. On lib/real_estate_web/live/index.html.leex update the actions column: 


      <td>
          <%= if Roles.can?(@current_user, property, :show) do %>
            <span><%= live_redirect “Show”, to: Routes.property_show_path(@socket, :show, property) %></span>
          <% end %>

          <%= if Roles.can?(@current_user, property, :edit) do %>
           <span><%= live_patch “Edit”, to: Routes.property_index_path(@socket, :edit, property)%></span>
          <% end %>

          <%= if Roles.can?(@current_user, property, :delete) do %>
            <span><%= link “Delete”, to: “#", phx_click: "d“lete",”phx_value_id: property.id, data: [confirm: "Are you sure?"]”%></span>
          <% end %>
        </td>

On lib/real_estate_web/live/show.html.leex update the place with the actions:

<%= if Roles.can?(@current_user, @property, :edit) do %>
 <span><%= live_patch “Edit”, to: Routes.property_show_path(@socket, :edit, @property), class: “button” %></span>
<% end %>

<%= if Roles.can?(@current_user, @property, :index) do %>
  <span><%= live_redirect “Back”, to: Routes.property_index_path(@socket, :index) %></span>
<% end %>

Now, if you open your browser you will see that the actions a user cannot do is hidden from him/her.

Listing properties for many users

But what if I’m a malicious user and I try to enter the edit route for editing a property I can’t edit?

Logged in as user1 you shouldn’t be able to access the edit action for property with id:2, so let’s try it http://localhost:4000/properties/2/edit

Unauthorised

Let’s write some tests to ensure everything is working (and that everything will continue to work in the future).

Open the module RealEstateWeb.PropertyLiveTest and update the property fixture, now it should receive a user so we can have a better control of who created a property:

  defp fixture(:property, user) do
    create_attributes = Enum.into(%{user_id: user.id}, @create_attrs)
    {:ok, property} = Properties.create_property(create_attributes)
    property
  end

On our setup for both index and show “describes” we now need to have a property_from_another_user assign to check on our tests later:

    setup %{conn: conn} do
      user = user_fixture()
      conn = log_in_user(conn, user)
      property = fixture(:property, user)
      property_from_another_user = fixture(:property, user_fixture())

      %{
        conn: conn,
        property: property,
        property_from_another_user: property_from_another_user,
        user: user
      }
    end

Inside index describe let’s add some tests:

    test "can see property from from other user in listing",
         %{
           conn: conn,
           property_from_another_user: property
         } do

      {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index))
      assert has_element?(index_live, "#property-#{property.id}")
    end

    test "can't see edit action for property from other user in listing",
         %{
           conn: conn,
           property_from_another_user: property
         } do

      {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index))
      refute has_element?(index_live, "#property-#{property.id} a", "Edit")
    end

    test "as an admin, I can update property from other user in listing", %{
      conn: conn,
      property_from_another_user: property
    } do

      conn = log_in_user(conn, admin_fixture())
      {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index))
      assert index_live |> element("#property-#{property.id} a", "Edit") |> render_click() =~ "Edit Property"
      assert_patch(index_live, Routes.property_index_path(conn, :edit, property))
      assert index_live
     |> form("#property-form", property: @invalid_attrs)
     |> render_change() =~ "can&apos;t be blank"

      {:ok, _, html} =
        index_live
        |> form("#property-form", property: @update_attrs)
        |> render_submit()
        |> follow_redirect(conn, Routes.property_index_path(conn, :index))

      assert html =~ "Property updated successfully"
      assert html =~ "some updated description"
    end

    test "can't see delete action for property from other user in listing", %{
      conn: conn,
      property_from_another_user: property
    } do

      {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index))
      refute has_element?(index_live, "#property-#{property.id} a", "Delete")
    end

    test "as an admin, I can delete property from others in listing", %{
      conn: conn,
      property_from_another_user: property
    } do

      conn = log_in_user(conn, admin_fixture())
      {:ok, index_live, _html} = live(conn, Routes.property_index_path(conn, :index))
      assert index_live |> element("#property-#{property.id} a", "Delete") |> render_click()
      refute has_element?(index_live, "#property-#{property.id}")
    end

    test "can't edit property from other user in listing",
         %{
           conn: conn,
           property_from_another_user: property
         } do

     assert {:error, {:redirect, %{to: "/"}}} =  live(conn, Routes.property_index_path(conn, :edit, property))
    end

The tests we added are pretty much self explanatory. Basically we are checking if a user can see other user’s properties. Later we check if the user is not seeing the delete and edit actions on other user's properties. Also, we check that an admin can edit and delete properties from other users normally. And, in the end, we check that, if a user types the url directly in the browser, he/she will be redirected to  ”/".

Inside the show describe, we do basically the same:

    test "can't see edit action for property from another user in show page", %{
      conn: conn,
      property_from_another_user: property
    } do

      {:ok, show_live, _html} = live(conn, Routes.property_show_path(conn, :show, property))
      refute has_element?(show_live, "a", "Edit")
    end

    test "as an admin, can updates property from others within modal", %{
      conn: conn,
      property_from_another_user: property
    } do

      conn = log_in_user(conn, admin_fixture())
      {:ok, show_live, _html} = live(conn, Routes.property_show_path(conn, :show, property))

     assert show_live |> element("a", "Edit") |> render_click() =~  "Edit Property"
      assert_patch(show_live, Routes.property_show_path(conn, :edit, property))
      assert show_live
     |> form("#property-form", property: @invalid_attrs)
     |> render_change() =~ "can&apos;t be blank"

      {:ok, _, html} =
        show_live
        |> form("#property-form", property: @update_attrs)
        |> render_submit()
        |> follow_redirect(conn, Routes.property_show_path(conn, :show, property))

      assert html =~ "Property updated successfully"
      assert html =~ "some updated description"
    end

    test "can't edit property from another user in show page", %{
      conn: conn,
      property_from_another_user: property
    } do

      assert {:error, {:redirect, %{to: "/"}}} =
      live(conn, Routes.property_show_path(conn, :edit, property))
    end

We check that a user can’t see the edit button for properties from another user, we check that an admin can normally perform updates on properties from another user and also we ensure that, if a user tries to type the url directly in the browser trying to edit properties from another user, he/she will be redirected to ”/".

And with that last part we finish our step 3.

Step 4) Create a way to logout a user and close all his/her sessions.

If we were relying only on regular sessions and weren't using LiveView, the only thing we could do to force a user to logout was to remove his/her sessions. The default implementation of phx.gen.auth searches for the user session on the database before every controller action, so, if we wanted to force a user to login again, we could simply remove all his/her sessions from our database.

But, if we choose this path with LiveView, even with our new authorisation rules, our user could stay logged in performing actions on a LiveView he/she was before we cleaned the sessions, since we only get the user from the token on mount/3.

To fix this problem in three ways (on LiveView): The first and most obvious one is to check before performing any action if the session still exists. The second is, on our LiveView, to periodically check the existence of the user session in the background, using a timer. The third is to communicate with all LiveView instances using pubsub to immediately disconnect the user when we remove the session.

The first option has the obvious problem of adding an overhead since we add an extra query to the database every time a user wants to perform any action. The second doesn't add a big overhead but still allows a user to perform actions for some time, until our system detects the user session doesn't exist anymore so that it might be a security problem for some systems. The third one solves the overhead problem and the security problem, immediately disconnecting the user whenever we remove his/her session through a very lightweight mechanism. Therefore, we will implement the third option.

On this step, we will use Phoenix PubSub to send a message to the LiveView process ensuring we properly disconnect a user if connected, forcing him to login again and update the session. The goal is to have the correct information about the user and his/her role 100% of the time.

To implement that we will subscribe all our LiveViews to a given Phoenix PubSub topic, and, whenever we receive a specific message, we compare the message payload with the current logged user, forcing the user to logout if they match. 

The first step is to define the name of the PubSub topic and make sure we are subscribing and publishing to that same topic: we decided to add a constant on our UserAuth module and implement a function to retrieve it from anywhere on our code.

defmodule RealEstateWeb.UserAuth do

...

 @pubsub_topic "user_updates"

...

 @doc """
 Returns the pubsub topic name for receiving  notifications when a user updated
 """

 def pubsub_topic, do: @pubsub_topic

...

end

Then, let's add a function to logout a %User{} on our Accounts context. 

defmodule RealEstate.Accounts do

...

 alias RealEstateWeb.UserAuth

...

 def logout_user(%User{} = user) do
   # Delete all user tokens
   Repo.delete_all(UserToken.user_and_contexts_query(user, :all))

   # Broadcast to all liveviews to immediately disconnect the user
   RealEstateWeb.Endpoint.broadcast_from(
     self(),
     UserAuth.pubsub_topic(),
     "logout_user",
     %{
       user: user
     }
   )
 end

...

end

The function logout_user works like this: First, we remove all sessions from the user from our database, second we send a message to logout the user to whoever is listening on the pubsub topic we previously defined.

Now, we need our LiveViews to receive this message and react to it properly. We already have a function called assign_defaults/3 defined on our LiveHelpers module, and this function is called on mount/3 for all our LiveViews, so let's update it to also subscribe to our topic.

defmodule RealEstateWeb.LiveHelpers do

...

 alias RealEstateWeb.UserAuth

...

  def assign_defaults(session, socket) do
    RealEstateWeb.Endpoint.subscribe(UserAuth.pubsub_topic())

    ...

  end

...

end

Now all our LiveViews are automatically subscribed to that topic and will receive the logout_message whenever we send it, but they aren't reacting to it. So let's implement that as well. Instead of adding the functionality on all our LiveViews manually, let's make use of the macro we already call for all our LiveViews, avoiding the need for the developer to remember all the time to add the code to react to the logout message. 

Go to RealEstateWeb module and add the following code:

defmodule RealEstateWeb do

...

 def live_view do
   quote do
     use Phoenix.LiveView,
       layout: {RealEstateWeb.LayoutView, "live.html"}

     unquote(view_helpers())
     import RealEstateWeb.LiveHelpers

     ### THIS WAS ADDED - FROM HERE
     alias RealEstate.Accounts.User

     @impl true
     def handle_info(%{event: "logout_user", payload: %{user: %User{id: id}}}, socket) do
       with %User{id: ^id} <- socket.assigns.current_user do
         {:noreply,
          socket
          |> redirect(to: Routes.user_session_path(socket, :force_logout))}
       else
         _any -> {:noreply, socket}
       end
     end
     ### TO HERE
   end
 end

 ...

end

So, whenever we add use RealEstateWeb, :live_view to our code, and this code is present on all our LiveViews, we will have that code automatically added to the module on compilation time.

What this code does should be pretty straightforward to understand: If we receive a message of type "logout_user" and the payload is the current_user we redirect the user to the force_logout route. Otherwise, we don't do anything. 

The force_logout route doesn't exist yet, so let's add it to our router:

defmodule RealEstateWeb.Router do

  ... 

    scope "/", RealEstateWeb do
      pipe_through [:browser]
      get "/users/force_logout", UserSessionController, :force_logout

      ... 

    end

  ... 

end

And to UserSessionController:

defmodule RealEstateWeb.UserSessionController do

  ... 

  def force_logout(conn, _params) do
    conn
    |> put_flash(
     :info,
     "You were logged out. Please login again to continue using our application."
    )
    |> UserAuth.log_out_user()
  end
end

Now, let's test it manually: 

Open multiple sessions with different browsers and login with user1@company.com (see details on seeds.exs file). And then go to your iex console and type:

RealEstate.Accounts.logout_user(RealEstate.Accounts.get_user_by_email("user1@company.com")

The animation above shows four opened browsers with different sessions. Three of them are for "user1@company.com" and the other for "user2@company.com". You can see that forcing the users to logout instantaneously disconnects the users.

We already did some manual tests, but let's also add some regular tests to ensure that everything is working properly and that we won't break this feature later when adding more features on our code. 

The test code is pretty similar and should be added to all liveviews with minor changes: we should only change the route we are accessing it, so here I'm just adding the test for one of the LiveViews, but you can find the code for the other LiveViews on our Github repository.

On PageLiveTest.exs add the following tests:

defmodule RealEstateWeb.PageLiveTest do

  ... 

  test "logs out when force logout on logged user", %{
   conn: conn
 } do

   user = user_fixture()
   conn = conn |> log_in_user(user)
   {:ok, page_live, disconnected_html} = live(conn, "/")
   assert disconnected_html =~ "Welcome to Phoenix!"
   assert render(page_live) =~ "Welcome to Phoenix!"
   RealEstate.Accounts.logout_user(user)

   # Assert our liveview process is down
   ref = Process.monitor(page_live.pid)
   assert_receive {:DOWN, ^ref, _, _, _}
   refute Process.alive?(page_live.pid)

   # Assert our liveview was redirected, following first to /users/force_logout, then to "/", and then to "/users/log_in"
   assert_redirect(page_live, "/users/force_logout")
   conn = get(conn, "/users/force_logout")
   assert "/" = redir_path = redirected_to(conn, 302)
   conn = get(recycle(conn), redir_path)
   assert "/users/log_in" = redir_path = redirected_to(conn, 302)
   conn = get(recycle(conn), redir_path)
  assert html_response(conn, 200) =~ "You were logged out. Please login again to continue using our application."
 end

 test "doesn't log out when force logout on another user", %{
   conn: conn
 } do

   user1 = user_fixture()
   user2 = user_fixture()
   conn = conn |> log_in_user(user2)

   {:ok, page_live, disconnected_html} = live(conn, "/")
   assert disconnected_html =~ "Welcome to Phoenix!"
   assert render(page_live) =~ "Welcome to Phoenix!"
   RealEstate.Accounts.logout_user(user1)

   # Assert our liveview is alive
   ref = Process.monitor(page_live.pid)
   refute_receive {:DOWN, ^ref, _, _, _}
   assert Process.alive?(page_live.pid)

   # If we are able to rerender the page it means nothing happened
   assert render(page_live) =~ "Welcome to Phoenix!"
 end
end

On the first test, we are checking that calling our logout function right after login stops our LiveView process and redirects to the force_logout route. That will trigger two redirects, the first one to "/" route and later to "/users/log_in" route with flash message "You were logged out. Please login again to continue using our application."

On the second test, we are checking that calling our logout function for another user, not the one logged, doesn't affect our current LiveView process.

We finished our last step. Hope you enjoyed this article. Let's summarize what we achieved:

  1. Installed phx_gen_auth on our application, making sure we all users that access our LiveViews are properly authenticated.
  2. Added role based authorisation, restricting access to our LiveViews according to the user roles. Our implementation relied on the existing Phoenix Router and Plug Pipeline to check whether a user is allowed to access a given route.
  3. Since LiveViews are very dynamic and we want to allow a user to perform some actions without reloading the page, we can't simply rely on Phoenix Router anymore. So, we implemented some helpers and added checks to ensure a user is authorised to perform actions like editing or removing a given entity.
  4. Relying on Phoenix PubSub we added the possibility to force a user to logout, cleaning all his/her sessions immediately, that way an admin can force a user to logout or a user himself could possibly close all his/her opened sessions.

Hope you enjoyed this article. Remember that you can find the source code on this repo.

Did you find this interesting?Know us better