How I add auth to Phoenix LiveView apps

Phoenix LiveView is great for things like forms with validations. For a new user form, you'll probably want to log the user in as soon as they create an account. This is a walk-through of how you can add user auth to a LiveView app using login tokens. The same strategy also works for logging in with "magic" forgot password links.

Add password hashing libraries

We'll start by updating our mix.exs file by adding the following two libraries to our deps:

defp deps do
  [
      #...
      {:comeonin, "~> 4.0"},
      {:argon2_elixir, "~> 1.2"}
  ]
end

Add a login hash to the user

First create a migration:

defmodule Reactor.Repo.Migrations.AddLoginhashToUsers do
  use Ecto.Migration

  def change do
    alter table(:users) do
      add :login_token, :string
    end
  end
end

then update the user schema, then add a token changset and helpers:

defmodule Reactor.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset
  alias Comeonin.Argon2

  schema "users" do
    # ...
    field :password_hash, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true
    field :login_token, :string

    #...
  def token_changeset(struct, attrs \\ %{}) do
    struct
    |> changeset(attrs)
    |> cast(attrs, [:login_token])
    |> hash_login_token()
  end

  def generate_login_token do
    :crypto.strong_rand_bytes(40) |> Base.url_encode64()
  end

  defp hash_login_token(%{valid?: false} = changeset), do: changeset

  defp hash_login_token(%{changes: %{login_token: nil}} = changeset) do
    put_change(changeset, :login_token, nil)
  end

  defp hash_login_token(%{changes: %{login_token: token}} = changeset) do
    put_change(changeset, :login_token, Argon2.hashpwsalt(token))
  end

  defp hash_login_token(%{valid?: true} = cset), do: put_change(cset, :login_token, nil)

Add auth functions to the Accounts context

  # ...
  import Comeonin.Argon2, only: [checkpw: 2, dummy_checkpw: 0]

  def authenticate_by_email_password(email, given_pass) do
    user = get_user_by_email(email)

    cond do
      email in Reactor.Accounts.Email.banned() ->
        dummy_checkpw()
        {:error, :unauthorized}

      user && checkpw(given_pass, user.password_hash) ->
        {:ok, user}

      user ->
        {:error, :unauthorized}

      true ->
        dummy_checkpw()
        {:error, :not_found}
    end
  end

  def authenticate_by_email_token(email, token) do
    user = get_user_by_email(email)

    cond do
      email in Reactor.Accounts.Email.banned() ->
        dummy_checkpw()
        {:error, :unauthorized}

      user && user.login_token && checkpw(token, user.login_token) ->
        update_login_token(user, nil)
        {:ok, user}

      true ->
        dummy_checkpw()
        {:error, :unauthorized}
    end
  end

  def get_user_by_email(email) do
    Repo.get_by(User, email: email)
  end

  def update_login_token(%User{} = user, token) do
    user
    |> User.token_changeset(%{login_token: token})
    |> Repo.update()
  end

Add a session controller

This will be identical to that of a standard phoenix app, plus a function for token-based logins:

  def create_from_token(conn, %{"email" => email, "token" => token}) do
    case Accounts.authenticate_by_email_token(email, token) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "Welcome back!")
        |> put_session(:user_id, user.id)
        |> configure_session(renew: true)
        |> redirect(to: Routes.live_path(conn, ReactorWeb.UserLive.Show, user))

      _ ->
        conn
        |> put_flash(:error, "Invalid login")
        |> redirect(to: Routes.session_path(conn, :new))
    end
  end

Create an auth plug

defmodule ReactorWeb.Auth do
  import Plug.Conn
  import Phoenix.Controller
  alias ReactorWeb.Router.Helpers, as: Routes
  alias Reactor.Accounts

  @site_admins ~w[
    # add email addresses of site admins here
    alchemist.camp@gmail.com
  ]

  def init(opts), do: opts

  def call(conn, _opts) do
    user_id = get_session(conn, :user_id)

    user =
      cond do
        assigned = conn.assigns[:current_user] -> assigned
        true -> Accounts.get_user_from_id(user_id)
      end

    put_current_user(conn, user)
  end

  def authenticate_admin(conn = %{assigns: %{admin_user: true}}, _), do: conn

  def authenticate_admin(conn, _opts) do
    conn
    |> put_flash(:error, "You do not have access to that page")
    |> halt()
    |> redirect(to: Routes.page_path(conn, :index))
  end

  def is_admin?(%{email: email}), do: email in @site_admins
  def is_admin(_), do: false

  def login(conn, user) do
    conn
    |> put_current_user(user)
    |> put_session(:user_id, user.id)
    |> configure_session(renew: true)
  end

  def logout(conn) do
    drop_current_user(conn)
  end

  def logged_in_user(conn = %{assigns: %{current_user: %{}}}, _), do: conn

  def logged_in_user(conn, _opts) do
    conn
    |> put_flash(:error, "You must be logged in to access that page")
    |> redirect(to: Routes.page_path(conn, :index))
    |> halt()
  end

  def put_current_user(conn, user) do
    token = user && ReactorWeb.Token.sign(%{id: user.id})

    conn
    |> assign(:current_user, user)
    |> assign(
      :admin_user,
      (user && user.email) in @site_admins
    )
    |> assign(:user_token, token)
  end

  def drop_current_user(conn) do
    conn
    |> delete_req_header("authorization")
    |> configure_session(drop: true)
  end
end

Update the router

Add the following code to the router:

defmodule ReactorWeb.Router do
  use ReactorWeb, :router

  pipeline :browser do
    # ...
    plug :put_root_layout, {ReactorWeb.LayoutView, :root}
    plug ReactorWeb.Auth
  end

  # ...
  scope "/", ReactorWeb do
    pipe_through :browser

    # ...
    get("/forgot", SessionController, :forgot)
    get("/login", SessionController, :new)
    get("/login/:token/email/:email", SessionController, :create_from_token)
    get("/logout", SessionController, :delete)
    post("/forgot", SessionController, :reset_pass)

    resources(
      "/sessions",
      SessionController,
      only: [:new, :create, :delete],
      singleton: true
    )
    # ...
  end
end

Update UserLive.new

Then the final step is to update the save version of UserLive.new.handle_event/3 function to use all the utilities we've just added to log in new users once they're created.

def handle_event("save", %{"user" => params}, socket) do
  case Accounts.create_user(params) do
    {:ok, user} ->
      token = Accounts.User.generate_login_token()
      {:ok, user} = Accounts.update_login_token(user, token)

      {:noreply,
       socket
       |> put_flash(:info, "user created")
       |> redirect(
         to:
           Routes.session_path(
             socket,
             :create_from_token,
             token,
             user.email
           )
       )}

    {:error, %Ecto.Changeset{} = cset} ->
      {:noreply, assign(socket, changeset: cset)}
  end
end

(Source code available for premium members)

Next episode: Adding comments

Back to index

No Comments