From LiveView to "normal" Views

I am very new to Phoenix and Elixir, and thrilled by what can be achieved by LiveView without having to rely on JavaScript.
Here is my problem for a side project of mine.

  • I have a registration page, powered by LiveView
  • Registration page works properly, I am able to validate the user entries (like proper email address, …)
  • In terms of flows, once the user has registered with email address, password and password confirmation I want to enable them to edit their profile
  • The profile page is a normal page (without LiveView) and is protected by an authentication plug that requires the :current_user to be populated.

Below is what I have in the non LiveView version of this flow

router.ex

    get "/me", ConstituentController, :edit
    put "/me", ConstituentController, :update

    resources "/sessions", SessionController, only: [:new, :create, :delete]

    get "/signup", SignupController, :new
    post "/signup", SignupController, :create

Controller

  def create(conn, %{"user" => user_params}) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        conn
        |> FrogzWeb.Auth.login(user)
        |> redirect(to: Routes.constituent_path(conn, :edit))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

Auth.ex

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

In the LiveView version, I have the following event_handler that creates the user and tries to redirect to the same page

  def handle_event("save", %{"user" => user_params}, socket) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
        {:stop,
          socket
          |> assign(:current_user, user)
          |> assign(:user_id, user.id)
          |> redirect(to: Routes.constituent_path(socket, :edit, user: user))
        }

      {_, %Ecto.Changeset{} = changeset} ->
        IO.inspect(changeset)
        {:noreply, assign(socket, changeset: changeset)}
      end
  end

Trying to call the Auth.login function in my handler ends up with a pattern matching error.

My question is the following, how can I change the session in my liveview powered part so that the current_user is appended, so that I can safely show the update profile page.
I tried using the user_id in the session and fetching it from the “normal” page, but it does not feel like a very safe option as everybody would be able to change anybody’s profile.

Not sure if the solution is obvious, but would greatly appreciate any help.

1 Like

Continued to investigate the issue above and found a solution that seems viable and secure. Documenting this here to get your feedback.
I have abandoned the idea to try to store something in the session and went the long route (it seems).

Once the user register through the live view, the user is created in the database.

# SignupLive

  def handle_event("save", %{"user" => user_params}, socket) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
      {:stop,
        socket
        |> redirect(to: Routes.signup_path(socket, :create, user_id: user.id, hash: Argon2.Base.hash_password(user.email, Application.get_env(:frogz, FrogzWeb.Endpoint)[:secret_key_base])))
      }

      {_, %Ecto.Changeset{} = changeset} ->
          IO.inspect(changeset)
          {:noreply, assign(socket, changeset: changeset)}
    end
  end

the user id, alongside with the hash of the email address is sent to a “normal” view

in the controller for the normal view, the user is retrieved from the database and the user email retrieved from the database is also hashed and compared with the hash that was passed as a parameter in the URL. If the two hashes are identical, they are allowed to edit their profile.

# SignupController

  def create(conn, %{"user_id" => user_id, "hash" => email_hash}) do
    case user = Accounts.get_user(user_id) do
      nil -> redirect(conn, to: Routes.page_path(conn, :index))
      _ -> reroute_or_login(conn, user, email_hash)
    end
  end

  defp reroute_or_login(conn, user, email_hash) do
    if verify_email(user.email, email_hash) do
      conn
      |> FrogzWeb.Auth.login(user)
      |> render("login.html", user_id: user.id, email_hash: email_hash, hash: "hash")
    else
      redirect(conn, to: Routes.page_path(conn, :index))
    end
  end

  defp verify_email(email, hash) do
    hash == Argon2.Base.hash_password(email, Application.get_env(:frogz, FrogzWeb.Endpoint)[:secret_key_base])
  end

Feedback is welcome, from a security perspective as well as “idiomatic” elixir

1 Like

:wave:

Have you tried using pushState and update the page https://github.com/phoenixframework/phoenix_live_view/blob/957b7ebfb66034cb093a81d991c044f7e532aa5d/lib/phoenix_live_view.ex#L495 from /signup to /me?

And since the problem is that the user doesn’t have a cookie with user_id set after being redirected via live_view, you can also try writing the cookie with user_id via js before the live view’s redirect.

Quite frankly I haven’t considered this option: trying to stay away from Javascript as much as possible, which is one of the reason why I am using LiveView.
How would this look like?

The first approach (live_redirect) doesn’t involve writing any JS on your part.

  def handle_event("save", %{"user" => user_params}, socket) do
    case Accounts.register_user(user_params) do
      {:ok, user} ->
-    {:stop,
-       socket
-       |> redirect(to: Routes.signup_path(socket, :create, user_id: user.id, hash: Argon2.Base.hash_password(user.email, Application.get_env(:frogz, FrogzWeb.Endpoint)[:secret_key_base])))
+     {:noreply, live_redirect(socket, to: Routes.user_path(socket, FrogzWeb.UserLive.Edit, user_id: user.id))}
-     }

      {_, %Ecto.Changeset{} = changeset} ->
          IO.inspect(changeset)
          {:noreply, assign(socket, changeset: changeset)}
    end
  end