pow-auth / pow

Robust, modular, and extendable user authentication system
https://powauth.com
MIT License
1.58k stars 152 forks source link

POW & LiveView - best way to implement `assigns.current_user` ? #706

Open goulvenclech opened 11 months ago

goulvenclech commented 11 months ago

Hi everyone,

What is the standard way to retrieve "current_user" in Liveview using POW?

I'm starting a project from scratch with LiveView and POW, and despite several topics discussing the problem, I can't find any documented solution or tutorial. Even less on recent versions.

My questions:

  1. To retrieve the current_user with POW, should I use Pow.Plug.current_user() or Pow.Store.CredentialsCache.get()? What are the differences, and what is the modern method?
  2. From what I understand, as these two functions require a conn, I have to create a plug with a put_session() function to add the current user ID -> Is there a recommended way to do this? Where should the plug be?
  3. Once the current user ID is in the session, do I have to assign_new(socket, :current_user, Users.get_user(session["current_user_id"]) in the mount() of each LiveView? Or there's a more practical way to do it?

A guide on the subject, particularly in the docs, would be welcome.

Thanks for any help :)

goulvenclech commented 11 months ago

Found this gist -> https://dev.to/oliverandrich/how-to-connect-pow-and-live-view-in-your-phoenix-project-1ga1

Really helpful, and answered my questions. But I still think we could have a guide about it in the doc or somewhere.

pandadefi commented 10 months ago

The link is

Found this gist -> https://dev.to/oliverandrich/how-to-connect-pow-and-live-view-in-your-phoenix-project-1ga1

Really helpful, and answered my questions. But I still think we could have a guide about it in the doc or somewhere.

This link gives a 404 now.

danschultzer commented 10 months ago

This is how I deal with it at the moment:

# TODO: Remove when upstream Pow can handle LiveView/socket auth
defmodule MyAppWeb.Pow.Phoenix.LiveView do
  @moduledoc false
  use MyAppWeb, :verified_routes

  def on_mount(:require_authenticated, _params, session, socket) do
    socket = mount_current_user(socket, session)

    if socket.assigns.current_user do
      {:cont, socket}
    else
      socket =
        socket
        |> Phoenix.LiveView.put_flash(:error, "You must be logged in to access this page.")
        |> Phoenix.LiveView.redirect(to: ~p"/")

      {:halt, socket}
    end
  end

  defp mount_current_user(socket, session) do
    Phoenix.Component.assign_new(socket, :current_user, fn ->
      pow_config = [otp_app: :my_app_web]

      {_conn, user} =
        %Plug.Conn{
          private: %{
            plug_session_fetch: :done,
            plug_session: session,
            pow_config: pow_config
          },
          owner: self(),
          remote_ip: {0, 0, 0, 0}
        }
        |> Map.put(:secret_key_base, MyAppWeb.Endpoint.config(:secret_key_base))
        |> Pow.Plug.Session.fetch(pow_config)

      user
    end)
  end
end
defmodule MyAppWeb.Router do
  # ...

  scop "/" do
    live_session :user_protected, on_mount: [
      {MyAppWeb.Pow.Phoenix.LiveView, :require_authenticated}
    ] do
      # ...
    end
  end
end

This is my temporary fix until I get Pow to work out-of-the-box with LiveView. Why it hasn't been done already is due to the million other things I have to tend to. There's a bunch of blockers and caveats to deal with with websockets in Phoenix:

  1. Phoenix takes total control over the request payload in websockets, this means no custom cookie (99% of Pow users do use Plug Session so nbd)
  2. Cookies can't be set safely over the web socket, which means a keep-alive REST request should be send for rotation of the session cookie
  3. Session store doesn't broadcast session expiration, which is necessary to close the socket if user logs out of session expires

I'm planning to solve three first, to make it safe to use Pow with websockets. This requires a refactor of how session stores currently work.


To answer directly the questions for anyone else following:

  1. To retrieve the current_user with POW, should I use Pow.Plug.current_user() or Pow.Store.CredentialsCache.get()? What are the differences, and what is the modern method?

Pow.Store.CredentialsCache.get/2 is too low-level. Pow.Plug.Current_user/1 could be used, but it expects the plug pipeline to have been run, so it's kinda implicit. I find it clearest to call Pow.Plug.Session.fetch/2, with an expectation that the Pow.Plug.Session has been called.

  1. From what I understand, as these two functions require a conn, I have to create a plug with a put_session() function to add the current user ID -> Is there a recommended way to do this? Where should the plug be?

This is handled on the first load when the request goes through the plug pipeline. Plug.Session is used by default so you will have access to the session there. You won't have to deal with setting the session at all. How I wish we could just have the whole initial conn instead, would make it much easier to make Pow work with websockets.

  1. Once the current user ID is in the session, do I have to assign_new(socket, :current_user, Users.get_user(session["current_user_id"]) in the mount() of each LiveView? Or there's a more practical way to do it?

I recommend using the live_session/2 as shown above. It's probably the cleanest approach.


A guide would be good, but this is really something Pow should provide out of the box now that Phoenix LiveView is included in Phoenix 1.7. The guide would need to cover some of the caveats above regarding security (session rotation, etc). If anyone wants to write one let me know, it could go up on https://powauth.com/guides/ along with general recommendations for websocket security.

goulvenclech commented 9 months ago

Hi @danschultzer ,

Thanks for your answer. I created a util UserLiveAuth defining an on_mount function to retrieve and then assign the user based on your comment (see code below). And everything works perfectly locally.

Sadly, this does not prove to be reliable in production (app and DB hosted on Fly): sometimes everything works, sometimes the function returns nil every other time, and often the user is disconnected for no reason. Here is an example in the monitoring logs, where I constantly update the page, with inconsistent responses:

[info] 18:46:37.986 request_id=F4oQLanygBo_-oMAAAbh [info] Sent 200 in 17ms

 [info] 18:46:38.335 request_id=F4oQLb_EKOAJCSUAAAcB [info] GET /

 [info] 18:46:38.342 request_id=F4oQLb_EKOAJCSUAAAcB [info] Elixir.MyappWeb.UserLiveAuth: No user found in session

 [info] 18:46:38.352 request_id=F4oQLb_EKOAJCSUAAAcB [info] Sent 200 in 17ms

 [info] 18:46:38.653 request_id=F4oQLdK1RR-SfJgAAAch [info] GET /

 [info] 18:46:38.660 request_id=F4oQLdK1RR-SfJgAAAch [info] Elixir.MyappWeb.UserLiveAuth: User goulvenclech found in session

 [info] 18:46:38.669 request_id=F4oQLdK1RR-SfJgAAAch [info] Sent 200 in 16ms

 [info] 18:46:38.967 request_id=F4oQLeVzuYH-qAAAAAdB [info] GET /

 [info] 18:46:38.975 request_id=F4oQLeVzuYH-qAAAAAdB [info] Elixir.MyappWeb.UserLiveAuth: No user found in session 

I tried fetching the current_user in two different ways (see code below), with CredentialsCache.get() and Session.fetch(), giving a similar result.

Do you think this is an error in the architecture of my code? In my way of using POW? Or could this be related to my hosting (Fly.Io)?

Thanks for any help, and your work on this library.

Source code

Utils module :

defmodule MyappWeb.UserLiveAuth do
  import Phoenix.Component

  alias Surface.Components.Context

  def on_mount(:default, _params, session, socket) do
    user = get_user(socket, session)

    {:cont,
     socket
     |> assign_new(:current_user, fn -> user end)
     |> Context.put(current_user: user)}
  end
end

First version of get_user :

  defp get_user(socket, session, config \\ [otp_app: :myapp])

  defp get_user(socket, %{"myapp_auth" => signed_token}, config) do
    conn = struct!(Plug.Conn, secret_key_base: socket.endpoint.config(:secret_key_base))
    salt = Atom.to_string(Pow.Plug.Session)

    Logger.info("#{__MODULE__}: get_user/3 called with #{signed_token}")

    with {:ok, token} <- Pow.Plug.verify_token(conn, salt, signed_token, config),
         {user, _metadata} <- CredentialsCache.get([backend: EtsCache], token) do
      Logger.info("#{__MODULE__}: get_user/3 found user #{user.username}")
      user
    else
      resp ->
        Logger.error("#{__MODULE__}: get_user/3 failed with error #{resp}")
        nil
    end
  end

  defp get_user(_, _, _), do: nil

Second version of get_user , based on your comment :

  defp get_user(socket, session, config \\ [otp_app: :myapp]) do
    case %Plug.Conn{
           private: %{
             plug_session_fetch: :done,
             plug_session: session,
             pow_config: config
           },
           secret_key_base: socket.endpoint.config(:secret_key_base),
           owner: self(),
           remote_ip: {0, 0, 0, 0}
         }
         |> Pow.Plug.Session.fetch(config) do
      {_conn, nil} ->
        Logger.error("#{__MODULE__}: No user found in session")
        nil

      {_conn, user} ->
        Logger.info("#{__MODULE__}: User #{user.username} found in session")
        user
    end
  end
danschultzer commented 9 months ago

@goulvenclech Looks like you are using the EtsCache. It will reset each time you restart/redeploy (or be fragmented if you are running in a cluster). You should instead use a persistent cache, either Mnesia, Redis, or Postgres as described here: https://github.com/pow-auth/pow#cache-store

goulvenclech commented 9 months ago

@danschultzer Hum I'm not sure that's the problem... Do you think I didn't configure Mnesia well ?

In mix.exs :

  def application do
    [
      mod: {Myapp.Application, []},
      extra_applications: [:logger, :runtime_tools, :mnesia]
    ]
  end

In config.exs:

config :myapp, :pow,
  web_module: MyappWeb,
  user: Myapp.Users.User,
  repo: Myapp.Repo,
  cache_store_backend: Pow.Store.Backend.MnesiaCache,
  extensions: [PowResetPassword, PowEmailConfirmation, PowPersistentSession]

in application.ex:

  def start(_type, _args) do
    children = [
      [...]
      # Start POW (authentication) cache
      Pow.Store.Backend.MnesiaCache
    ]

Did I forgot something?

danschultzer commented 9 months ago

Oh sorry, I was reading the first version you posted. Yeah it all looks correct. What setup do you have on fly.io? Are you running more than one node at any time? And how do you deal with deployment? That it returns nil every other time, sounds like competing nodes.