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.

2 Likes

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

3 Likes

: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
1 Like

In this approach, I guess you changed the route from post to get for the :create action or else how can the redirection work?

@rakr Just wanted to show my solution using Guardian in case you feel interested on using it eventually:

 def handle_event("sign_in", %{"credential"=> params}, socket) do
    case Auth.validate_credentials(params["email"], params["password"]) do
      {:ok, user} ->
        # once live view will not keep state when the page refreshes, we pass the
        # auth token via url so that we can verify the token in the next view.
        {:ok, jwt, _full_claims} = Turing.Auth.Guardian.encode_and_sign(user)

        {:stop,
         socket
         |> put_flash(:info, "User signed in successfull!")
         |> redirect(to: Routes.page_path(TuringWeb.Endpoint, :sign_in_from_live_view, jwt: jwt))
        }

      {:error, errors} ->
        %Phoenix.LiveView.Socket{
          assigns: %{changeset: changeset}
        } = socket

        {:noreply, assign(socket, changeset: Map.put(changeset, :errors, errors))}

    end
def sign_in_from_live_view(conn, %{"jwt"=> jwt}) do
    case Turing.Auth.Guardian.resource_from_token(jwt) do
      {:ok, user, _claims} ->
        conn
        |> Turing.Auth.Guardian.Plug.sign_in(user)
        |> redirect(to: Routes.page_path(conn, :index))

      _ ->
        conn
        |> redirect(to: Routes.session_path(conn, :sign_in))

    end
  end

It’s mostly encoding the resource, passing the JWT and in the normal view we decode the JWT and sign it in which should create the session and set the cookies properly. We also have the function {:ok, claims} = MyApp.Guardian.decode_and_verify(token) but maybe the way it is, it looks a bit cleaner.

1 Like

Guardian’s JWT’s are for server-to-server-via-client token passing, it’s rather heavy for just frontend-backend communication. Why not just use Phoenix.Token then?

1 Like

Good point. I never used it before but it seems a very simple approach to consider:

user_id = 1
token = Phoenix.Token.sign(MyApp.Endpoint, "user salt", user_id)
Phoenix.Token.verify(MyApp.Endpoint, "user salt", token, max_age: 86400)
{:ok, 1}

I am using Guardian mostly to manage session creation, logout, token tracker, and all authentication layer. I think I can just sign the user ID on the event handler and get the user through it in the normal view.

1 Like

Exactly, that’s the point of Phoenix.Tokens. :slight_smile:

For note, use sign/verify for publicly visible but cryptographically signed data (so they can’t change it without corrupting the signature, although it’s still hard to see, they have to know erlang’s term format). Use encrypt/decrypt to fully encrypt it so they can’t even see it. Encrypt/decrypt is slightly more expensive to calculate than sign/verify I ‘think’ but I’ve not actually benchmarked it.

At least I hope I’m right on the above, that’s how I’ve been treating it. ^.^;

2 Likes