Mix phx.gen.auth stuck

def require_authenticated_user(conn, _opts) do
    user = conn.assigns[:current_user]
    if user do
      if user.confirmed_at do
        conn
      else
        conn
        |> put_flash(:error, "You must confirm your account first.")
        |> maybe_store_return_to()
        |> redirect(to: Routes.user_confirmation_path(conn, :new))
        |> halt()
      end
    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
  def require_authenticated_user(conn, _opts) do
    user = conn.assigns[:current_user]
    if user do
      if user.confirmed_at do
        conn
      else
        conn
        |> put_flash(:error, "You must confirm your account first.")
        |> maybe_store_return_to()
        |> redirect(to: Routes.user_confirmation_path(conn, :new))
        |> halt()
      end
    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

Trying to implement confirm enforcing. Did I get it right? Also wondering what the maybe_store_return_to() is supposed to do. And why do we call halt()?

I wrote a blog post awhile back where I made some changes to the default phx.gen.auth workflow that prevents sign in until the user is confirmed. You might find it helps you out: https://experimentingwithcode.com/phoenix-authentication-with-phx-gen-auth-part-1/

Instead of using if else in a nested way, it would be great if you think in Elixir way!! (i.e. Use Pipelines)

TL;DR:

I have a collection of functions, that I can call in a pipeline, so it won’t get unwieldy and unmaintainable!! (See my reply in the post I have linked below, for more details)

def on_mount(:only_admin, _params, session, socket) do
    {:cont, socket}
    |> assign_user(session)
    |> verify_user()
    |> verify_lock()
    |> verify_email()
    |> verify_role(~w(super_admin admin)a)
    |> subscribe_user()
  end

I wrote a long reply for somewhat similar question, see:

For instance, here’s how I implemented authorisation check in a sensible way:

Router:

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {DerpyCoderWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers, %{"Content-Security-Policy" => @content_security_policy}
    plug :fetch_current_user
    plug KickLockedUserOut
  end

  pipeline :authentication do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {DerpyCoderWeb.LayoutView, :minimalist}
    plug :protect_from_forgery
    plug :put_secure_browser_headers, %{"Content-Security-Policy" => @content_security_policy}
    plug :fetch_current_user
  end

  pipeline :any_user do
    plug :require_authenticated_user
    plug EnsureRolePlug, [:super_admin, :admin, :super_user, :user]
  end

  pipeline :only_admin do
    plug :require_authenticated_user
    plug EnsureRolePlug, [:super_admin, :admin]
  end

  # ==============================================================================
  # Following routes have Authentication mandatory
  # ==============================================================================
  scope "/", DerpyCoderWeb do
    pipe_through [:browser, :any_user]

    get "/users/settings", UserSettingsController, :edit
    put "/users/settings", UserSettingsController, :update
    get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
  end

  # ==============================================================================
  # Following routes may require Authentication & Authorization on some actions.
  # ==============================================================================
  live_session :visitor, on_mount: {DerpyCoderWeb.Permit, :anyone} do
    scope "/", DerpyCoderWeb do
      pipe_through [:browser]

      live "/", HomePageLive, :index

      live "/photos", PhotoLive.Index, :index
      live "/photos/new", PhotoLive.Index, :new
      live "/photos/:id", PhotoLive.Show, :show
    end
  end

  # ==============================================================================
  # Following routes have Authentication as well as Authorization mandatory
  # ==============================================================================
  live_session :user, on_mount: {DerpyCoderWeb.Permit, :any_user} do
    scope "/", DerpyCoderWeb do
      pipe_through [:browser, :any_user]

      live "/photos/:id/edit", PhotoLive.Index, :edit
      live "/photos/:id/show/edit", PhotoLive.Show, :edit
    end
  end

  scope "/admin", DerpyCoderWeb do
    pipe_through [:browser, :only_admin]

    live_dashboard "/live_dashboard", metrics: DerpyCoderWeb.Telemetry
  end

  # ==============================================================================
  # Below routes have custom root layout
  # ==============================================================================
  live_session :user_dashboard,
    on_mount: {DerpyCoderWeb.Permit, :any_user},
    root_layout: {DerpyCoderWeb.LayoutView, "user_dashboard.html"} do
    scope "/users", DerpyCoderWeb do
      pipe_through [:browser, :any_user]

      live "/dashboard", UserDashboardLive, :index
    end
  end

  live_session :admin_dashboard,
    on_mount: {DerpyCoderWeb.Permit, :only_admin},
    root_layout: {DerpyCoderWeb.LayoutView, "admin_dashboard.html"} do
    scope "/admin", DerpyCoderWeb do
      pipe_through [:browser, :only_admin]

      live "/dashboard", AdminDashboardLive, :index
    end
  end

Plugs:

Ensure Role Plug:

defmodule DerpyCoderWeb.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 authorized to access a given route

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

  If you want to give access only to an admin:

  plug DerpyCoderWeb.EnsureRolePlug, :admin
  """

  import Plug.Conn
  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
    conn.assigns.current_user
    |> has_role?(roles)
    |> maybe_halt(conn)
  end

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

  defp has_role?(%{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, "Unauthorized")
    |> Controller.redirect(to: signed_in_path(conn))
    |> halt()
  end

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

KickLockedUserOut

defmodule DerpyCoderWeb.KickLockedUserOut do
  @moduledoc """
  This plug ensures that a user isn't locked.

  ## Example

      plug DerpyCoderWeb.KickLockedUserOut
  """
  import Plug.Conn, only: [halt: 1]

  alias Phoenix.Controller
  alias Plug.Conn
  alias DerpyCoderWeb.UserAuth

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

  @doc false
  @spec call(Conn.t(), any()) :: Conn.t()
  def call(conn, _opts) do
    conn.assigns.current_user
    |> locked?()
    |> maybe_halt(conn)
  end

  defp locked?(%{id: id, locked_at: locked_at}) when not is_nil(locked_at),
    do: not DerpyCoder.Accounts.is_super_admin?(id)

  defp locked?(_user), do: false

  defp maybe_halt(true, conn) do
    conn
    |> Controller.put_flash(:error, "Your account is locked.")
    |> Controller.redirect(to: signed_in_path(conn))
    |> UserAuth.log_out_user()
    |> halt()
  end

  defp maybe_halt(_any, conn), do: conn

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

Permits:

defmodule DerpyCoderWeb.Permit do
  @moduledoc """
  Ensures current_user is assigned to LiveViews attaching this hook.
  """
  import Phoenix.LiveView

  alias DerpyCoder.Accounts
  import DerpyCoderWeb.LiveHelpers

  # ==============================================================================
  # Used for live view routes, that do not require authentication for first render.
  # Assigns current_user to the socket, if available.

  # Returns `socket`
  # ==============================================================================
  def on_mount(:anyone, _params, session, socket) do
    {:cont, socket}
    |> assign_user(session)
    |> maybe_verify_lock()
    |> maybe_subscribe_user()
  end

  # ==============================================================================
  # Used for live view routes, that do require authentication for first render.
  # Assigns current_user to the socket.

  # User must have confirmed their email.
  # If user is not authenticated, they are asked to login.

  # Returns `socket`
  # ==============================================================================
  def on_mount(:any_user, _params, session, socket) do
    {:cont, socket}
    |> assign_user(session)
    |> verify_user()
    |> verify_lock()
    |> verify_email()
    |> subscribe_user()
  end

  # ==============================================================================
  # Used for live view routes, that requires authentication and authorization for first render.
  # Assigns current_user to the socket.

  # User must have confirmed their email.
  # If user is not authenticated, they are asked to login.
  # Finally if user has no authorization, based on roles passed in, they are notified.

  # Returns `socket`
  # ==============================================================================
  def on_mount(:only_admin, _params, session, socket) do
    {:cont, socket}
    |> assign_user(session)
    |> verify_user()
    |> verify_lock()
    |> verify_email()
    |> verify_role(~w(super_admin admin)a)
    |> subscribe_user()
  end

  # ==============================================================================
  # Find and assign current user.
  # ==============================================================================
  defp assign_user({:cont, socket}, session) do
    socket =
      socket
      |> assign_new(:current_user, fn ->
        find_current_user(session)
      end)

    {:cont, socket}
  end

  # ==============================================================================
  # If current user exists, do something.
  # ==============================================================================
  defp maybe_verify_lock({:cont, socket}) do
    current_user = socket.assigns.current_user

    if current_user do
      {:cont, socket}
      |> verify_lock()
    end

    {:cont, socket}
  end

  defp maybe_subscribe_user({:cont, socket}) do
    current_user = socket.assigns.current_user

    if current_user do
      {:cont, socket}
      |> subscribe_user()
    end

    {:cont, socket}
  end

  # ==============================================================================
  # Subscribe to user_centric topic, for broadcasts, like:
  # Notifications, Account Lock, etc.
  # ==============================================================================
  defp subscribe_user({:cont, socket}) do
    current_user = socket.assigns.current_user

    if connected?(socket), do: Accounts.subscribe(current_user)

    {:cont, socket}
  end

  defp subscribe_user({:halt, _} = arg), do: arg

  defp find_current_user(%{"user_token" => user_token}) do
    Accounts.get_user_by_session_token(user_token)
  end

  defp find_current_user(_), do: nil
end

Helpers:

defmodule DerpyCoderWeb.LiveHelpers do
  @moduledoc """
  ALl helpers commonly used in LiveView, lives here.
  """
  import Phoenix.LiveView
  import Phoenix.LiveView.Helpers

  alias Phoenix.LiveView.JS
  alias DerpyCoderWeb.Router.Helpers, as: Routes

  def env(atom) when is_atom(atom), do: Application.get_env(:derpy_coder, :environment) == to_string(atom)
  def env(str) when is_binary(str), do: Application.get_env(:derpy_coder, :environment) == str
  def env(list) when is_list(list), do: Application.get_env(:derpy_coder, :environment) in list

  def inspect_source(path, line \\ 1) do
    System.cmd("code", ["--goto", "#{path}:#{line}"])
  end

  # ==============================================================================
  # Verify that user is there.
  # ==============================================================================
  def verify_user({:cont, socket}) do
    current_user = socket.assigns.current_user

    if current_user do
      {:cont, socket}
    else
      {:halt, socket |> ask_user_to_login()}
    end
  end

  def verify_user({:halt, _} = arg), do: arg

  # ==============================================================================
  # Verify that user's Account isn't locked.
  # ==============================================================================
  def verify_lock({:cont, socket}) do
    current_user = socket.assigns.current_user

    if current_user.locked_at && current_user.role != :super_admin &&
         not DerpyCoder.Accounts.is_super_admin?(current_user.id) do
      {:halt, socket |> kick_locked_user_out()}
    else
      {:cont, socket}
    end
  end

  def verify_lock({:halt, _} = arg), do: arg

  # ==============================================================================
  # Verify that user has confirmed their email.
  # ==============================================================================
  def verify_email({:cont, socket}) do
    current_user = socket.assigns.current_user

    if current_user.confirmed_at do
      {:cont, socket}
    else
      {:halt, socket |> ask_user_to_confirm_email()}
    end
  end

  def verify_email({:halt, _} = arg), do: arg

  # ==============================================================================
  # Verify that the current user has the roles required.
  # ==============================================================================
  def verify_role({:cont, socket}, roles) do
    current_user = socket.assigns.current_user

    if role_matches?(current_user.role, roles) do
      {:cont, socket}
    else
      {:halt, socket |> kick_unauthorized_user_out()}
    end
  end

  def verify_role({:halt, _} = arg, _), do: arg

  # ==============================================================================
  # Helpers
  # ==============================================================================
  def ask_user_to_login(socket) do
    socket
    |> put_flash(:error, "You must log in to access this page.")
    |> assign(redirect_to: Routes.user_session_path(socket, :new))
    |> halt()
  end

  def ask_user_to_confirm_email(socket) do
    socket
    |> put_flash(:error, "You must confirm your email address to proceed.")
    |> halt()
  end

  def kick_unauthorized_user_out(socket) do
    socket
    |> put_flash(:error, "Unauthorized.")
    |> halt()
  end

  def kick_locked_user_out(socket) do
    socket
    |> put_flash(:error, "Your account is locked.")
    |> halt()
  end

  # ==============================================================================
  # Misc
  # ==============================================================================
  defp halt(%{assigns: %{redirect_to: redirect_to, return_to: return_to}} = socket),
    do: socket |> redirect(to: "#{redirect_to}?return_to=#{return_to}")

  defp halt(%{assigns: %{redirect_to: redirect_to}} = socket),
    do: socket |> redirect(to: redirect_to)

  defp halt(%{assigns: %{return_to: return_to}} = socket),
    do: socket |> redirect(to: return_to)

  defp halt(%{assigns: _} = socket),
    do: socket |> redirect(to: "/")

  defp role_matches?(user_role, role) when is_atom(role), do: user_role === role
  defp role_matches?(user_role, roles) when is_list(roles), do: user_role in roles
end

P.S. I may not have the most experience, and this code isn’t close to complete. Once I am satisfied and Phoenix 1.7 is released, I will open source everything.

3 Likes

maybe_store_return() to usage:

Upon checking user’s login status, you might have to redirect them to login page.
For them to return back to where they were, we need to store their return to address!!

For example, if the user was going to like an image in a /photos/cute_cat_photo page, and you require them to login first to like something.

Then return_to_address will be /photos/cute_cat_photo page, and once the user is done login in /login page, they can be nicely escorted back to their original destination of interest!!

halt() usage:

I figured it out intuitively, I’m sure there are books to explain this.

In Elixir, Pipelines can be used to chain together functions and halt can break out of the pipeline, just like return statement in other languages can break out of a function!!:

Run the following code, in LiveBook:

defmodule Demo do
   alias Integer
   
   def add_two_to_odd_number({:cont, num}) do
      if is_odd(num) do
         {:cont, num + 2}
      else
         {:halt, num}
      end
   end
   def add_two_to_odd_number({:halt, _} = arg), do: arg
   
   def mul_two_to_odd_number({:cont, num}) do
      if is_odd(num) do
         {:cont, num * 2}
      else
         {:halt, num}
      end
   end
   def mul_two_to_odd_number({:halt, _} = arg), do: arg
end
   
   
{:cont, 3}
   |> Demo.add_two_to_odd_number() # Since 3 is odd, 2 will be added and 5 will be passed along
   |> Demo.mul_two_to_odd_number() # Since 5 is odd, 2 will be multiplied and 10 will be passed along
   |> Demo.add_two_to_odd_number() # Since 10 is even, halt will be passed along
   |> Demo.add_two_to_odd_number() # Since halt is passed along, this function call is skipped
   |> dbg()

Output:

{:cont, 3} #=> {:cont, 3}
|> Demo.add_two_to_odd_number() #=> {:cont, 5}
|> Demo.mul_two_to_odd_number() #=> {:cont, 10}
|> Demo.add_two_to_odd_number() #=> {:halt, 10}
|> Demo.add_two_to_odd_number() #=> {:halt, 10}


{:halt, 10}

As you can see, the pipeline skipped last two function calls as internally halt was introduced to the payload.

Similarly the halt() call in your example also does the same thing, it introduces a :halt atom into the conn payload, so that next in line function calls in the pipeline skip, by pattern matching in functions.

i.e. the def some_func({:halt, ...}) definition you see in the example above.`


P.S. I am new to Elixir, haven’t purchased any Elixir book yet, so I can’t point out some good example or blog post for this. :sweat_smile:

1 Like