pow-auth / pow

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

Instructions for WebSocket usage (e.g. Phoenix Channels and LiveView) #271

Open danschultzer opened 5 years ago

danschultzer commented 5 years ago

It's not obvious how to deal with Pow sessions and WebSockets. There are a few caveats to using WebSockets since browsers don't enforce CORS. Also, Phoenix LiveView won't run subsequent requests through the endpoint (so @current_user is not available).

Some details on WebSocket security: https://devcenter.heroku.com/articles/websocket-security https://gist.github.com/subudeepak/9897212

Support for pulling session data in WebSockets was added to Phoenix in 1.4.7:

socket "/socket", AppWeb.UserSocket,
  websocket: [
    connect_info: [:peer_data, :x_headers, :uri, session: [store: :cookie]]
  ]

A few questions I want to answer are:

  1. Should the session be fetched for requests after initial handshake?
  2. If so, should the session be renewed after timeout in the socket? This would require the reply to update the session cookie.
  3. If not, should the socket be signed somehow, e.g. like a signed url? Not sure if this even makes sense.
  4. What should happen if the session expires while a socket is open (e.g. someone logs out). Should it be aware, and close the socket (if possible)?

I haven't worked much with WebSockets, so I'll have to read up on this and experiment. I will see if I can find some best practices when it comes to sessions and WebSockets. Any comments are welcome πŸ˜„


Here's a few links that may be of interest:

https://www.owasp.org/index.php/Testing_WebSockets_(OTG-CLIENT-010) https://cheatsheetseries.owasp.org/cheatsheets/HTML5_Security_Cheat_Sheet.html#websockets https://spring.io/projects/spring-session https://github.com/spring-projects/spring-session https://www.christian-schneider.net/CrossSiteWebSocketHijacking.html https://abhirockzz.wordpress.com/2017/06/03/accessing-http-session-in-websocket-endpoint/ https://abhirockzz.wordpress.com/2017/06/03/accessing-http-session-in-websocket-endpoint/

joepstender commented 5 years ago

Could you rephrase the first question? Do you mean: "How should the session id be handled for requests after handshake?" ?

danschultzer commented 5 years ago

Rephrased, thanks!

danschultzer commented 5 years ago

From the elixir forum @LostKobrakai have used this:

https://gist.github.com/LostKobrakai/b51204a8de7ff463ee40bb6a3f6905b1

I would refactor it so:

  1. Rely on Pow for config e.g. session_key, cache_backend, :session_store, etc
  2. Use the same naming convention as in Pow (e.g. :current_user)
  3. Use Process.send_after/3 instead of :timer.send_interval/3 (no need to cancel then)
  4. Keep as little logic as possible in macros

Something like this:

defmodule LendingWeb.AuthHelper do
  @moduledoc """
  Handle pow user in LiveView.

  Will assign the current user and periodically check that the session is still
  active. `session_expired/1` will be called when session expires.

  Configuration options:

  * `:otp_app` - the app name
  * `:interval` - how often the session has to be checked, defaults 60s

      defmodule LendingWeb.SomeViewLive do
        use PhoenixLiveView
        use LendingWeb.AuthHelper, otp_app: lending

        def mount(session, socket) do
          socket = mount_user(socket, session)

          # ...
        end

        def session_expired(socket) do
          # handle session expiration

          {:noreply, socket}
        end
      end
  """
  require Logger

  import Phoenix.Socket, only: [assign: 3]

  defmacro __using__(opts) do
    config      = [otp_app: opts[:otp_app]]
    session_key = Pow.Plug.prepend_with_namespace(config, "auth")
    interval    = Keyword.get(opts, :interval, :timer.seconds(60))

    config = %{
      session_key: session_key,
      interval: interval,
      module: __MODULE__
    }

    quote do
      @config unquote(Macro.escape(config))

      def mount_user(socket, session), do: unquote(__MODULE__).mount_user(socket, self(), session, @config)

      def handle_info(:pow_auth_ttl, socket), do: unquote(__MODULE__).handle_auth_ttl(socket, self(), @config)
    end
  end

  @spec mount_user(Phoenix.Socket.t(), pid(), map(), map()) :: Phoenix.Socket.t()
  def mount_user(socket, pid, session, %{session_key: session_key} = config) do
    user       = Map.fetch!(session, session_key)
    assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user)

    socket
    |> assign_current_user(user, config)
    |> init_auth_check(pid, config)
  end

  defp init_auth_check(socket, pid, config) do
    case Phoenix.LiveView.connected?(socket) do
      true ->
        handle_auth_ttl(socket, pid, config)

      false ->
        socket
    end
  end

  @spec handle_auth_ttl(Phoenix.Socket.t(), pid(), map()) :: {:noreply, Phoenix.Socket.t()}
  def handle_auth_ttl(socket, pid, %{interval: interval, module: module} = config) do
    case pow_session_active?(config) do
      true ->
        Logger.info("[#{__MODULE__}] User session still active")

        Process.send_after(pid, :pow_auth_ttl, interval)

        {:noreply, socket}

      false ->
        Logger.info("[#{__MODULE__}] User session no longer active")

        socket
        |> assign_current_user(nil, config)
        |> module.session_expired()
    end
  end

  defp assign_current_user(socket, user, config) do
    assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user)

    assign(socket, assign_key, user)
  end

  defp pow_session_active?(config) do
    {store, store_config} = store(config)

    store_config
    |> store.get(key)
    |> case do
      :not_found            -> false
      {_user, _inserted_at} -> true
    end
  end

  defp store(config) do
    case Pow.Config.get(config, :session_store, default_store(config)) do
      {store, store_config} -> {store, store_config}
      store                 -> {store, []}
    end
  end

  defp default_store(config) do
    backend = Pow.Config.get(config, :cache_store_backend, Pow.Store.Backend.EtsCache)

    {Pow.Store.CredentialsCache, [backend: backend]}
  end
end

There's still an issue with sessions expiring after 30 minutes. The above doesn't keep sessions alive after that. The session id will also be rotated every 15 minutes. It's triggered if the user is visiting other pages while the socket is open.

The session could have a fingerprint, and that fingerprint can be used to look up the session info no matter if it has been rotated or not. This would make it possible to keep the socket open even after the session has been rotated.

If the cookie somehow can be updated in the session, then we can also prevent expiration after 30 min (since we'll then rotate within the socket).

anatoliyarkhipov commented 5 years ago

I tried the example in a real project and after a few changes it works fine! Here is my final version:

defmodule LendingWeb.AuthHelper do
  @moduledoc """
  Handle pow user in LiveView.

  Will assign the current user and periodically check that the session is still
  active. `session_expired/1` will be called when session expires.

  Configuration options:

  * `:otp_app` - the app name
  * `:interval` - how often the session has to be checked, defaults 60s

      defmodule LendingWeb.SomeViewLive do
        use PhoenixLiveView
        use LendingWeb.AuthHelper, otp_app: lending

        def mount(session, socket) do
          socket = mount_user(socket, session)

          # ...
        end

        def session_expired(socket) do
          # handle session expiration

          {:noreply, socket}
        end
      end
  """
  require Logger

# `Phoenix.Socket.assign` doesn't accept `LiveView.Socket` as its
# first argument, so we have to use `Phoenix.LiveView.assign` to
# work with sockets from LiveView.
-  import Phoenix.Socket, only: [assign: 3]
+  import Phoenix.LiveView, only: [assign: 3]

  defmacro __using__(opts) do
    config      = [otp_app: opts[:otp_app]]
    session_key = Pow.Plug.prepend_with_namespace(config, "auth")
    interval    = Keyword.get(opts, :interval, :timer.seconds(60))

# `config` is going to be passed to `Pow.Config.get` in several
# places, which uses `Keyword.get` under the hood, which expects
# the first argument to be a list, not a structure. So I changed
# the config to a list with keywords
-    config = %{
+    config = [
      session_key: session_key,
      interval: interval,
# I also moved module from here to the `quote` block, because as
# I understood it's supposed to point at the `*Live` module which
# is going to use `AuthHelper`, because `AuthHelper` attempts to
# call `module.session_expired(socket)` when the session expires,
# but outside of the `quote` block `__MODULE__` points at the helper
# itself.
-      module: __MODULE__,
    ]

    quote do
# This is where I moved the `module` assignment
-      @config unquote(Macro.escape(config))
+      @config unquote(Macro.escape(config)) ++ [module: __MODULE__]

      def mount_user(socket, session), do: unquote(__MODULE__).mount_user(socket, self(), session, @config)

      def handle_info(:pow_auth_ttl, socket), do: unquote(__MODULE__).handle_auth_ttl(socket, self(), @config)
    end
  end

  @spec mount_user(Phoenix.Socket.t(), pid(), map(), map()) :: Phoenix.Socket.t()
# Since `config` is a list with keywords now, I suppose we can't pattern 
# match it like this, and should work with as with a list
-  def mount_user(socket, pid, session, %{session_key: session_key} = config) do
-    user       = Map.fetch!(session, session_key)
+ def mount_user(socket, pid, session, config) do
+   user       = Map.fetch!(session, config[:session_key])

    assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user)

# That's kinda tricky, but the point is that `mount_user` is expected
# to return socket, but it's possible that `init_auth_check` returns
# `{:noreply, socket}`, because it uses `handle_auth_ttl` under the
# hook, which, in turn, is supposed to return the tuple, because it
# can also be called from `handle_info(:pow_auth_ttl)`. So, instead 
# of calling `handle_auth_ttl` from `mount_user` I decided to just 
# send the `:pow_auth_ttl` message immediately, and thus guarantee 
# that `mount_user` always returns a socket, and at the same time we
# still do an initial check.
-    socket
-   |> assign_current_user(user, config)
-   |> init_auth_check(pid, config)
+    socket = socket |> assign_current_user(user, config)
+    init_auth_check(socket, pid, config)
+    socket
  end

  defp init_auth_check(socket, pid, config) do
# That's how I changed `init_auth_check` from calling `handle_auth_ttl`
# directly, to sending a message and thus call it indirectly. Also, I'm
# not quite familiar with Elixir, so there is probably a better way to
# send a message immediately instead of using `send_after` with 0 interval
-    case Phoenix.LiveView.connected?(socket) do
-      true ->
-        handle_auth_ttl(socket, pid, config)
-
-      false ->
-        socket
-    end
+    if Phoenix.LiveView.connected?(socket) do
+      Process.send_after(pid, :pow_auth_ttl, 0)
+    en
  end

  @spec handle_auth_ttl(Phoenix.Socket.t(), pid(), map()) :: {:noreply, Phoenix.Socket.t()}
# This first thing here is to work with `config` as with list
-  def handle_auth_ttl(socket, pid, %{interval: interval, module: module} = config) do
+  def handle_auth_ttl(socket, pid, config) do
+    interval = Pow.Config.get(config, :interval)
+    module = Pow.Config.get(config, :module)

# And the second is to pass socket into the helper (you'll see why)
-    case pow_session_active?(config) do
+    case pow_session_active?(socket, config) do
      true ->
        Logger.info("[#{__MODULE__}] User session still active")

        Process.send_after(pid, :pow_auth_ttl, interval)

        {:noreply, socket}

      false ->
        Logger.info("[#{__MODULE__}] User session no longer active")

        socket
        |> assign_current_user(nil, config)
        |> module.session_expired()
    end
  end

  defp assign_current_user(socket, user, config) do
    assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user)

    assign(socket, assign_key, user)
  end

# A small helper to extract user from socket, similarly to the 
# `assign_current_user` above, which puts user to the socket.
+  defp get_current_user(socket, config) do
+    assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user)
+
+    socket.assigns |> Map.get(assign_key)
+  end

# Accepts `socket` now 
-  defp pow_session_active?(config) do
+  defp pow_session_active?(socket, config) do
    {store, store_config} = store(config)

    store_config
# And this is why we need the socket, because in the original
# example `key` wasn't defined, but supposed to be the auth ID
# extracted from session and put to the socket in `mount_user`.
# So here we need to extract that auth ID and run the check 
# against it.
-    |> store.get(key)
+    |> store.get(get_current_user(socket, config))
    |> case do
      :not_found            -> false
      {_user, _inserted_at} -> true
    end
  end

  defp store(config) do
# And two small changes, because `Config` isn't aliased in the example,
# and wee need to use the full name of the module.
-    case Config.get(config, :session_store, default_store(config)) do
+    case Pow.Config.get(config, :session_store, default_store(config)) do
      {store, store_config} -> {store, store_config}
      store                 -> {store, []}
    end
  end

  defp default_store(config) do
-    backend = Config.get(config, :cache_store_backend, Pow.Store.Backend.EtsCache)
+    backend = Pow.Config.get(config, :cache_store_backend, Pow.Store.Backend.EtsCache)

    {Pow.Store.CredentialsCache, [backend: backend]}
  end
end
danschultzer commented 5 years ago

Thanks @anatoliyarkhipov! I'm preparing the v1.0.14 release now that #287 has been merged in. After that I'll go through this to see how it can utilize the session fingerprint instead.

anatoliyarkhipov commented 5 years ago

Thanks @danschultzer!

Also, I encountered a pitfall - between the moment of first rendering and the moment when the live view has mounted, there is a time period when we don't have a user. And it can lead to glitches in parts of UI that conditionally depend on user. For example, one might want to render an "Edit" button only when user is logged in. In this case the button will not be rendered during the first render, but will appear immediately after the live view is mounted. And this is a noticeable delay.

But I think it's not related specifically to Pow, but to LiveView in general instead, because the same UI glitch can be encountered for any variable that exists only after mounting and doesn't on the first render.

UPD: I was wrong and the UI glitch happened not because of LiveView nature, but because I changed the example to assign user to the socket asynchronously, via sending message, instead of doing it directly in mount_user.

morgz commented 4 years ago

@anatoliyarkhipov Just having a play with the above code. Looks like you're assigning the current_user to just be the session key rather than the user itself? Is that a mistake? I'd have expected the value of 'current_user' to be the actual user


 def mount_user(socket, pid, session, config) do
    user       = Map.fetch!(session, config[:session_key])

    # That's kinda tricky, but the point is that `mount_user` is expected
    # to return socket, but it's possible that `init_auth_check` returns
    # `{:noreply, socket}`, because it uses `handle_auth_ttl` under the
    # hood, which, in turn, is supposed to return the tuple, because it
    # can also be called from `handle_info(:pow_auth_ttl)`. So, instead
    # of calling `handle_auth_ttl` from `mount_user` I decided to just
    # send the `:pow_auth_ttl` message immediately, and thus guarantee
    # that `mount_user` always returns a socket, and at the same time we
    # still do an initial check.
    socket = socket |> assign_current_user_session_key(user, config)
    init_auth_check(socket, pid, config)
    socket
  end
anatoliyarkhipov commented 4 years ago

@morgz Yep, your statement is correct, in the example I assigned the session key instead of the whole user. Technically, I wouldn't call it a mistake, since I just adopted the original example πŸ˜€, but practically having only the key wasn't convenient for me, so later in my app I changed the code to assign the key at current_user_key and the whole user at current_user.

morgz commented 4 years ago

@anatoliyarkhipov I found your example really helpful - so thanks. I've adapted parts of it to assign the current_user to the socket. I'm new to Elixir so I'm sure this code can be improved upon (and I welcome your feedback!) I decided to avoid calling the handle_info with a delay of 0 in favour of directly calling to get the current_user into the socket on mount.


defmodule WildeWeb.Live.AuthHelper do
  @moduledoc """
  Handle pow user in LiveView.

  Will assign the current user and periodically check that the session is still
  active. `session_expired/1` will be called when session expires.

  Configuration options:

  * `:otp_app` - the app name
  * `:interval` - how often the session has to be checked, defaults 60s

      defmodule LendingWeb.SomeViewLive do
        use PhoenixLiveView
        use LendingWeb.Live.AuthHelper, otp_app: :otp_app

        def mount(session, socket) do
          socket = mount_user(socket, session)

          # ...
        end

        def session_expired(socket) do
          # handle session expiration

          {:noreply, socket}
        end
      end
  """
  require Logger
  import Phoenix.LiveView, only: [assign: 3]

  defmacro __using__(opts) do
    config      = [otp_app: opts[:otp_app]]
    session_key = Pow.Plug.prepend_with_namespace(config, "auth") |> String.to_existing_atom
    interval    = Keyword.get(opts, :interval, :timer.seconds(60))

    config = [
      session_key: session_key,
      interval: interval
    ]

    quote do
      @config unquote(Macro.escape(config)) ++ [module: __MODULE__]

      def mount_user(socket, session), do: unquote(__MODULE__).mount_user(socket, self(), session, @config)

      def handle_info(:pow_auth_ttl, socket), do: unquote(__MODULE__).handle_auth_ttl(socket, self(), @config)
    end
  end

  @spec mount_user(Phoenix.Socket.t(), pid(), map(), map()) :: Phoenix.Socket.t()
  def mount_user(socket, pid, session, config) do
    user_session_key = Map.fetch!(session, config[:session_key])
    user = current_user(user_session_key, config)

    # Start our interval check to see if the current_user is still value
    init_auth_check(socket, pid, config)

    socket
      # Assigns the session key from the session to the assigns of the socket so it is persisted.
      |> assign_current_user_session_key(user_session_key, config)
      # Assigns the user into the :current_user_assigns_key defineed by POW. Default :current_user
      |> assign_current_user(user, config)
  end

  # initiates an Auth check every :interval
  defp init_auth_check(socket, pid, config) do
    interval = Pow.Config.get(config, :interval)

    if Phoenix.LiveView.connected?(socket) do
      Process.send_after(pid, :pow_auth_ttl, interval)
    end
  end

  # Called on interval
  @spec handle_auth_ttl(Phoenix.Socket.t(), pid(), map()) :: {:noreply, Phoenix.Socket.t()}
  def handle_auth_ttl(socket, pid, config) do
    interval = Pow.Config.get(config, :interval)
    module = Pow.Config.get(config, :module)
    session_key = get_current_user_session_key(socket, config)

    case current_user(session_key, config) do
      nil   ->  Logger.info("[#{__MODULE__}] User session no longer active")
                socket
                |> assign_current_user_session_key(nil, config)
                |> assign_current_user(nil, config)
                |> module.session_expired()
      _user  -> Logger.info("[#{__MODULE__}] User session still active")
                Process.send_after(pid, :pow_auth_ttl, interval)
                {:noreply, socket}
    end
  end

  defp assign_current_user(socket, user, config) do
    assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user)
    assign(socket, assign_key, user)
  end

  defp assign_current_user_session_key(socket, user, config) do
    assign_key = config[:session_key]
    assign(socket, assign_key, user)
  end

  # Helper to extract the session_key from socket
  defp get_current_user_session_key(socket, config) do
    assign_key = config[:session_key]
    socket.assigns |> Map.get(assign_key)
  end

  # Helper to extract the current_user from store
  defp current_user(session_key, config) do
    {store, store_config} = store(config)

    case store_config |> store.get(session_key) do
      :not_found -> nil
      {user, _inserted_at} -> user
    end
  end

  defp store(config) do
    case Pow.Config.get(config, :session_store, default_store(config)) do
      {store, store_config} -> {store, store_config}
      store                 -> {store, []}
    end
  end

  defp default_store(config) do
    backend = Pow.Config.get(config, :cache_store_backend, Pow.Store.Backend.MnesiaCache)
    {Pow.Store.CredentialsCache, [backend: backend]}
  end
end
dbi1 commented 4 years ago

@danschultzer Thank you for building Pow! Would you by any chance already have some guidance on how to use the session fingerprint (either as part of the above code, or in general)?

danschultzer commented 4 years ago

@dbi1 Thanks!

No, I haven't had time to dive into this, so I only have some light thoughts on it.

  1. Mount with current user struct and session fingerprint. Pow.Store.CredentialsCache.get/2 now returns {user, metadata} where metadata is a keyword list that in most cases as a :fingerprint key.
  2. Use the current user struct to check if the session is still available by fetching all sessions with Pow.Store.CredentialsCache.sessions/2 and search for a session that has the fingerprint.
  3. Maybe update TTL by using Pow.Store.CredentialsCache.put/3, storing the same session id, and {user, metadata} as was just fetched in 2. This could potentially open up for session attacks, but unless there's a way to set cookies through live view, I don't see any other way of keeping the session alive.
anatoliyarkhipov commented 4 years ago

@danschultzer Would be session kept alive if we periodically ping server from JS, making some AJAX requests? I mean, not a request through WebSocket, but a regular AJAX request.

danschultzer commented 4 years ago

Would be session kept alive if we periodically ping server from JS, making some AJAX requests?

Yeah it would since that would trigger renewal in Pow.Plug.Session when the endpoint is called. Then step 3 in the above is not necessary, all we need to know is the fingerprint and user to check if the session hasn't expired. It's the best solution I can think of.

anatoliyarkhipov commented 4 years ago

Honestly, I'm new to Elixir, and I'm constantly baffled by two questions: "what is what?" and "where do I get that what?". And this time is not an exception 😬.

  1. What is config in Pow.Store.CredentialsCache.get/2?
  2. Where do I get it (assuming we're working in the example above)?

Okay, I figured it out and here is my final version:

defmodule MyAppWeb.Live.AuthHelper do
  require Logger

  import Phoenix.LiveView, only: [assign: 3]

  defmacro __using__(opts) do
    config              = [otp_app: opts[:otp_app]]
    session_id_key      = Pow.Plug.prepend_with_namespace(config, "auth")
    auth_check_interval = Keyword.get(opts, :auth_check_interval, :timer.seconds(1))

    config = [
      session_id_key: session_id_key,
      auth_check_interval: auth_check_interval,
    ]

    quote do
      @config unquote(Macro.escape(config)) ++ [
        live_view_module: __MODULE__,
      ]

      def mount_user(socket, session),
          do: unquote(__MODULE__).mount_user(socket, self(), session, @config)

      def handle_info(:pow_auth_ttl, socket),
          do: unquote(__MODULE__).handle_auth_ttl(socket, self(), @config)
    end
  end

  def mount_user(socket, pid, session, config) do
    session_id  = Map.fetch!(session, config[:session_id_key])

    case credentials_by_session_id(session_id) do
      {user, meta} ->
        socket = socket |> assign(:credentials, {user, meta})

        if Phoenix.LiveView.connected?(socket) do
          init_auth_check(pid)
        end

        socket

      everything_else ->
        socket
    end

  end

  defp init_auth_check(pid) do
    Process.send_after(pid, :pow_auth_ttl, 0)
  end

  def handle_auth_ttl(socket, pid, config) do
    {user, meta} = socket.assigns[:credentials]
    live_view_module = Pow.Config.get(config, :live_view_module)
    auth_check_interval = Pow.Config.get(config, :auth_check_interval)

    case session_id_by_credentials(socket.assigns[:credentials]) do
      nil ->
        Logger.info("[#{__MODULE__}] User session no longer active")

        {:noreply, socket |> assign(:credentials, nil)}

      _session_id ->
        Logger.info("[#{__MODULE__}] User session still active")

        Process.send_after(pid, :pow_auth_ttl, auth_check_interval)

        {:noreply, socket}
    end
  end

  defp session_id_by_credentials(nil), do: nil
  defp session_id_by_credentials({user, meta}) do
    all_user_session_ids = Pow.Store.CredentialsCache.sessions(
      [backend: Pow.Store.Backend.EtsCache],
      user
    )

    all_user_session_ids |> Enum.find(fn session_id ->
      {_, session_meta} = credentials_by_session_id(session_id)

      session_meta[:fingerprint] == meta[:fingerprint]
    end)
  end

  defp credentials_by_session_id(session_id) do
    Pow.Store.CredentialsCache.get(
      [backend: Pow.Store.Backend.EtsCache],
      session_id
    )
  end
end

It works fine, but it feels wrong that I access CredentialsCache directly and provide the config with EtsCache backend when I already have it defined for Pow.Plug.Session in the endpoint.ex:

  plug Pow.Plug.Session,
       otp_app: :my_app,
       session_store: {Pow.Store.CredentialsCache,
                       ttl: :timer.minutes(30),
                       namespace: "credentials"},
       session_ttl_renewal: :timer.minutes(15)

I mean, what if I change Pow.Store.CredentialsCache to something else in this config? I'll have to remember to change it the live AuthHelper as well. right? Is there any way how I can access config passed to Pow.Plug.Session from AuthHelper? πŸ€”

danschultzer commented 4 years ago

@anatoliyarkhipov just a heads up, I’m traveling and don’t have any laptop with me. I’m waiting till I’m back as I would like to refactor the code, and be able to properly answer your questions. I’ll be back Tuesday.

frankdugan3 commented 4 years ago

With the release of LiveView 0.5.1 today, there have been a number of improvements regarding sessions in the socket. I'm not sure if that addresses any of the issues here, but thought I'd mention it since I'm needing to implement this as well.

https://github.com/phoenixframework/phoenix_live_view/blob/master/CHANGELOG.md#050-2020-01-15

morgz commented 4 years ago

Hey everyone - Just wanted to check in on this issue. Has anyone had any more thought on keeping the session alive? I'm going to be working on this in about a weeks time

anatoliyarkhipov commented 4 years ago

@morgz I settled with the latest version I posted in the thread. With an interval ping from JS.

morgz commented 4 years ago

@morgz I settled with the latest version I posted in the thread. With an interval ping from JS.

Thanks - It would be helpful to me if you could you share your implementation of the interval ping?

anatoliyarkhipov commented 4 years ago

That's a direct copy-n-paste from the codebase, including comments (which might be wrong, if I misinterpreted something at the moment I implemented that).

That function I call in the main JS file when it's loaded:

import {wait} from "./utils"

/**
 * If user doesn't request server long enough (few minutes),
 * his session expires and he has to re-login again. If user
 * manages to request server before the time has expired, his
 * cookie is updated and the timer is reset.
 *
 * The problem is that almost the whole website uses LiveView,
 * even for navigation, which means that most of the requests
 * go through WebSockets, where you can't update cookies, and
 * so the session inevitably expires, even if user is actively
 * using the website. More of that - it might expire during an
 * editing of a project, and user will be redirected, loosing
 * all its progress. What a shame!
 *
 * To work this around, we periodically ping the server via a
 * regular AJAX requests, which is noticed by the auth system
 * which, in turn, resets the cookie timer.
 */
export function keepAlive() {
  fetch('/keep-alive')
    .then(wait(60 * 1000 /*ms*/))
    .then(keepAlive)
}

The wait:

export function wait(ms) {
  return () => new Promise(resolve => {
    setTimeout(resolve, ms)
  })
}

The /keep-alive endpoint does nothing.

morgz commented 4 years ago

Excellent. Thanks @anatoliyarkhipov ✌️ I'll give it a go and report back

mithereal commented 4 years ago

where do you call authhelper? is it just a use statement in the liveview?

morgz commented 4 years ago

where do you call authhelper? is it just a use statement in the liveview?

Exactly, something like this:


defmodule AppWeb.Live.Index do

use AppWeb.Live.AuthHelper, otp_app: :app_name

 def mount(%{"id" => id} = params, session, socket) do
    socket = maybe_mount_user(socket, session)
end

end
metra-nimes commented 4 years ago

Ok, I mount user, check credentials in session , but what should i do with logout event in the live view? something like this Pow.Store.CredentialsCache.delete([backend: Pow.Store.Backend.MnesiaCache], session_id) ?

chrismccord commented 4 years ago

Note that by default since a few versions ago that you can broadcast "disconnect" to the LV socket id to invalidate/kill any active user connections on logout, as long as you add the :live_socket_id to the session on login:

https://github.com/phoenixframework/phoenix_live_view/blob/b49e828a15a0121d5cced8691089cadc122eb293/lib/phoenix_live_view.ex#L991-L1004

jeroenhouben commented 4 years ago

Maybe I'm overlooking something but for my use-case just adding some code to UserSocket works fine.

defmodule BlaBlaWeb.UserSocket do
  use Phoenix.Socket

  def connect(%{"authToken" => token}, socket, _connect_info) do
    result = Pow.Store.CredentialsCache.get([backend: Pow.Store.Backend.EtsCache], token)

    case result do
      :not_found -> :error
      {user, _metadata} -> {:ok, assign(socket, :current_user, user)}
    end
  end

  # we need an authToken
  def connect(_params, _socket, _connect_info), do: :error

  # ....
end

Use-case: mixed API and phoenix channels

I have a React app hosted on a different domain. It uses a mix of normal HTTP JSON API requests and WebSocket updates.

I made a API endpoint for token based auth using pretty much a copy and paste version of https://hexdocs.pm/pow/api.html

  1. The user logs in user via a webform or automatically using the renew_token (stored in localStorage).

  2. Then after succes the websockets tries to connect, the user/session is already known to Pow. Also the token is on the client so I just send that along with the connect(). After that it's just a matter of looking up the user and assigning it to the socket.

client code:

  const socket = new Socket(`${baseUrl.replace('http', 'ws')}/socket`, { params: { authToken: auth.token } });
trestrantham commented 4 years ago

Has anyone gotten this approach to work with the latest changes to pow (and live view)? I can no longer fetch the user from the session. This is now failing with :not_found in my auth helper:

Pow.Store.CredentialsCache.get([backend: Pow.Store.Backend.EtsCache], session_id)
danschultzer commented 4 years ago

@trestrantham the latest version of Pow (v1.0.19) signs and verifies the token, so you'll have to decode it before looking up:

defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  def connect(%{"authToken" => token}, socket, _connect_info) do
    case get_credentials(socket, token, otp_app: :my_app) do
      nil  -> :error
      user -> {:ok, assign(socket, :current_user, user)}
    end
  end

  # we need an authToken
  def connect(_params, _socket, _connect_info), do: :error

  defp get_credentials(socket, signed_token, config) do
    conn         = %Plug.Conn{secret_key_base: socket.endpoint.config(:secret_key_base)}
    store_config = [backend: Pow.Store.Backend.EtsCache]
    salt         = Atom.to_string(Pow.Plug.Session)

    with {:ok, token}        <- Pow.Plug.verify_token(conn, salt, signed_token, config),
         {_user, metadata}   <- Pow.Store.CredentialsCache.get(store_config, token) do
      user
    else
      _any -> nil
    end
  end

  # ....
end

Also now with LiveView built into Phoenix 1.5, it's time for official support in Pow. I'll get back to this ASAP. I think I'll add a Pow.Phoenix.Socket module with helpers.

trestrantham commented 4 years ago

@danschultzer Adding official helpers would be amazing!

I was originally referring to the solution here https://github.com/danschultzer/pow/issues/271#issuecomment-562953282 and was able to modify it to work with the example you provided. Thanks!

I did also try the UserSocket approach but could not get it working. This is for a LiveView so I tried to copy and modify Phoenix.LiveView.Socket to add the user lookup but couldn't get it to work.

morgz commented 4 years ago

Also now with LiveView built into Phoenix 1.5, it's time for official support in Pow. I'll get back to this ASAP. I think I'll add a Pow.Phoenix.Socket module with helpers

Made my day! πŸ‘ God speed....

simoncocking commented 4 years ago

I spent a long time figuring out the solution to this problem for Phoenix 1.5.1 and Pow 1.0.20, using this thread as a guide. To save others the same pain, this is what I ended up with:

First, a helper function to retrieve the currently logged-in user from the Pow cache:

defmodule MyAppWeb.Credentials do
  @moduledoc "Authentication helper functions"

  alias MyApp.Users.User
  alias Phoenix.LiveView.Socket
  alias Pow.Store.CredentialsCache

  @doc """
  Retrieves the currently-logged-in user from the Pow credentials cache.
  """
  @spec get_user(
          socket :: Socket.t(),
          session :: map(),
          config :: keyword()
        ) :: %User{} | nil

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

  def 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)

    with {:ok, token} <- Pow.Plug.verify_token(conn, salt, signed_token, config),
         {user, _metadata} <- CredentialsCache.get([backend: Pow.Store.Backend.EtsCache], token) do
      user
    else
      _any -> nil
    end
  end

  def get_user(_, _, _), do: nil
end

This then is used from your live view's mount/3:

defmodule MyAppWeb.SomethingLive do
  use MyAppWeb, :live_view
  alias MyAppWeb.Credentials

  .
  .

  def mount(_params, session, socket) do
    current_user = Credentials.get_user(socket, session)
    {:ok, assign(socket, current_user: current_user)}
  end
end

πŸŽ‰ Voila. Hope that saves someone some time.

morgz commented 4 years ago

@dbi1 Thanks!

No, I haven't had time to dive into this, so I only have some light thoughts on it.

  1. Mount with current user struct and session fingerprint. Pow.Store.CredentialsCache.get/2 now returns {user, metadata} where metadata is a keyword list that in most cases as a :fingerprint key.
  2. Use the current user struct to check if the session is still available by fetching all sessions with Pow.Store.CredentialsCache.sessions/2 and search for a session that has the fingerprint.
  3. Maybe update TTL by using Pow.Store.CredentialsCache.put/3, storing the same session id, and {user, metadata} as was just fetched in 2. This could potentially open up for session attacks, but unless there's a way to set cookies through live view, I don't see any other way of keeping the session alive.

I had a go at taking this approach to keep my LiveView sessions alive. I love the simplicity of @simoncocking code, but as far as I can tell this is not going to handle the expiration of the Liveview sessions?

I ended up, using the code from @simoncocking and following the quoted method from @danschultzer. The added complexity comes from the periodic renewal code, but I also assign the current_user into the socket in the helper.

Anyway... food for thought, and hopefully this will spur some better code for me to refactor with ;)

defmodule AppNameWeb.LiveViewPowHelper do
  @moduledoc """

    Will assign the current user from the session token.

    It will also renew the POW credential so it doesn't expire. Be cautious as according to @danshultzer -
    This could potentially open up for session attacks, but unless there's a way to set cookies through live view,
    I don't see any other way of keeping the session alive.

    I'd prefer not to use the __using__ macro stuff as I find it a little cryptic, but I needed it for the periodic handle_info unless someoe has a different suggestion?

    defmodule AppNameWeb.SomeViewLive do
        use PhoenixLiveView
        use AppNameWeb.LiveViewPowHelper

        def mount(session, socket) do
          socket = maybe_assign_current_user(socket, session)

          # ...
        end
    end

  """

  alias AppName.Accounts.User
  alias Phoenix.LiveView.Socket
  alias Pow.Store.CredentialsCache

  require Logger

  defmacro __using__(opts) do
    # Customise this for your app
    # You'll also need to replace the references to "app_name_auth"
    renewal_config      = [renew_session: true, interval: :timer.seconds(5)]
    pow_config  = [otp_app: :app_name, backend: Pow.Store.Backend.MnesiaCache]

    quote do

      @pow_config unquote(Macro.escape(pow_config)) ++ [module: __MODULE__]
      @renewal_config unquote(Macro.escape(renewal_config)) ++ [module: __MODULE__]

      def maybe_assign_current_user(socket, session), do: unquote(__MODULE__).maybe_assign_current_user(socket, self(), session, @pow_config, @renewal_config)
      def handle_info({:renew_pow_session, session}, socket), do: unquote(__MODULE__).handle_renew_pow_session(socket, self(), session, @pow_config, @renewal_config)
    end
  end

  @doc """
  Retrieves the currently-logged-in user from the Pow credentials cache.
  """
  def get_user(socket, session, pow_config) do
    with {:ok, token} <- verify_token(socket, session, pow_config),
         {user, metadata} = pow_credential <- CredentialsCache.get(pow_config, token) do
          user
    else
      _any -> nil
    end
  end

  # Convienience to assign straight into the socket
  def maybe_assign_current_user(socket, pid, %{"app_name_auth" => signed_token} = session, pow_config, renewal_config) do
    case get_user(socket, session, pow_config) do
      %User{} = user -> maybe_init_session_renewal(
                          socket,
                          pid,
                          session,
                          renewal_config |> Keyword.get(:renew_session),
                          renewal_config |> Keyword.get(:interval)
                        )
                        assign_current_user(socket, user)
      _ -> assign_current_user(socket, nil) #We didn't get a current_user for the token
    end
  end
  def maybe_assign_current_user(_, _, _), do: nil

  # assigns the current_user to the socket with the key current_user
  def assign_current_user(socket, user) do
    socket |> Phoenix.LiveView.assign(current_user: user)
  end

  # Session Renewal Logic
  def maybe_init_session_renewal(_, _, _, false, _), do: nil
  def maybe_init_session_renewal(socket, pid, session, true, interval) do
    if Phoenix.LiveView.connected?(socket) do
      Process.send_after(pid, {:renew_pow_session, session}, interval)
    end
  end

  def handle_renew_pow_session(socket, pid, session, pow_config, renewal_config) do
    with  {:ok, token} <- verify_token(socket, session, pow_config),
          {user, _metadata} = pow_credential <- CredentialsCache.get(pow_config, token),
          {:ok, _session_token} <- update_session_ttl(pow_config, token, pow_credential)  do

        # Successfully updates so queue up another renewal
        Process.send_after(pid, {:renew_pow_session, session}, renewal_config |> Keyword.get(:interval))
    else
      _any -> nil
    end

    {:noreply, socket}
  end

  # Verifies the session token
  defp verify_token(socket, %{"app_name_auth" => signed_token}, pow_config) do
    conn = struct!(Plug.Conn, secret_key_base: socket.endpoint.config(:secret_key_base))
    salt = Atom.to_string(Pow.Plug.Session)
    Pow.Plug.verify_token(conn, salt, signed_token, pow_config)
  end
  defp verify_token(_, _, _), do: nil

  # Updates the TTL on POW credential in the cache
  def update_session_ttl(pow_config, session_token, {%User{} = user, metadata} = pow_credential) do

    sessions = CredentialsCache.sessions(pow_config, user)

    # Do we have an available session which matches the fingerprint?
    case sessions |> Enum.find(& &1 == session_token) do
      nil -> Logger.debug("No Matching Session Found")
      available_session ->  # We have an available session. Now lets update it's TTL by passing the previously fetched credential
                            Logger.debug("Matching Session Found. Updating TTL")
                            CredentialsCache.put(pow_config, session_token, pow_credential)
    end
  end

end
vlarok commented 4 years ago

πŸŽ‰ Voila. Hope that saves someone some time.

Thank you, Sir!

scorsi commented 4 years ago

EDIT : As @Schultzer adviced, we should never put the current_user in the session to avoid cookie overflow. So, use my plug at your own risks.

I didn't achieved to make the other code example work... So here what I did before, really simple. Actually I managed to get my current_user by doing the following:

defmodule AppWeb.AssignUser do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _params) do
    user =
      conn
      |> Pow.Plug.current_user()
    conn
    |> put_session(:current_user, user)
    |> assign(:current_user, user)
  end
end
# in my router : 
pipeline :browser do
    # ... 
    plug AppWeb.AssignUser
end
# in my live view
def mount(_params, %{"current_user" => current_user} = _session, socket) do
    {:ok, assign(socket, current_user: current_user)}
end

Unfortunately, I don't have my session refreshed... This actual plug did the work to pass the current_user to the live view session but nothing else.

If it helps. ^^

EDIT: Ok the code given by @morgz works fine. Forgot something Line 134:

defp verify_token(socket, %{"YOUR_APP_auth" => signed_token}, pow_config) do

And also (but throws error), line 78:

%{"YOUR_APP_auth" => signed_token} = session,
scorsi commented 4 years ago

@morgz I just add some modification to handle some crashes. Here the fix:

  def maybe_assign_current_user(socket, _, _, _, _), do: assign_current_user(socket, nil)

  def maybe_assign_current_user(socket, _, _), do: assign_current_user(socket, nil)

It may arrives that the signed_token is unset (when user did logout) and the function maybe_assign_current_user/5 doesn't match. In addition, the both maybe_assign_current_user/5 and maybe_assign_current_user/3 did set a current_user to nil to allow performing such cases:

<%= if @current_user == nil do %>
...
<% end %>
Schultzer commented 4 years ago

You should never put the current_user which is a struct in the session, only the id You risk Cookie overflow!

scorsi commented 4 years ago

@Schultzer thanks for the advice, actually I don't do that anymore. =)

frankdugan3 commented 4 years ago
def assign_current_user(socket, user) do
    socket |> Phoenix.LiveView.assign(current_user: user)
  end

If you have a stateful component outside the main live view (e.g. in live.html.leex) that needs the current_user, you can use send_update to pass it along with no boiler plate.

def assign_current_user(socket, user) do
  Phoenix.LiveView.send_update(HsmWeb.AuthTMenuComponent,
    id: "autht-menu-component",
    current_user: user
  )

  socket |> Phoenix.LiveView.assign(current_user: user)
end
sekunho commented 4 years ago

I couldn't get the snippet of @simoncocking to work and I noticed that if you're not using the default backend for Pow, EtsCache then this wouldn't work.

defmodule MyAppWeb.Credentials do
  @moduledoc "Authentication helper functions"

  alias MyApp.Users.User
  alias Phoenix.LiveView.Socket
  alias Pow.Store.CredentialsCache

  @doc """
  Retrieves the currently-logged-in user from the Pow credentials cache.
  """
  @spec get_user(
          socket :: Socket.t(),
          session :: map(),
          config :: keyword()
        ) :: %User{} | nil

  def get_user(socket, session, config \\ [otp_app: :my_app])

  def get_user(socket, %{"my_app_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)

    with {:ok, token} <- Pow.Plug.verify_token(conn, salt, signed_token, config),
         # Replaced  `[backend: Pow.Store.Backend.EtsCache]` with `config`
         {user, _metadata} <- CredentialsCache.get(config, token) do
      user
    else
      _any -> nil
    end
  end

  def get_user(_, _, _), do: nil
end

Then in the live view

assign(socket, current_user: Credentials.get_user(socket, session, [backend: Pow.Store.Backend.MnesiaCache]))
inhji commented 4 years ago

@unsek I believe you can get the cache backend from the config, like this:

@cache_backend Application.compile_env!(:my_otp_app, [:pow, :cache_store_backend])

and the call CredentialsCache.get like this:

CredentialsCache.get([backend: @cache_backend], token)

I'm using Mnesia for the cache, so I did not try this with Ets.

sekunho commented 4 years ago

@inhji Oh awesome. I'm using Mnesia too so thanks for this!

LouisDelbosc commented 4 years ago

I used the snippet made by @morgz and it's working fine however I have an issue combining it with PowPersistentSession. After the token is expired, I get a 500 because I couldn't retrieve a user and I'm forced (as a user) to do an hard refresh.

Do you have an Idea how to fix it ?

fekle commented 4 years ago

I'm having the same issue that @LouisDelbosc is describing. After the token is expired, the user is nil, and leads to errors, as I don't do checks if @current_user is defined in every liveview that is in an authenticated route. A hard refresh fixes the issue, as the token is refreshed. Seems like we need to trigger the Persistent Session renewal in the liveview helper as well. I've looked into it a bit, but as I'm not all too familiar with the workings of Pow yet I wasn't able to find a solution so far - anyone got a suggestion on how to manually trigger a PowPersistentSession renewal?

morgz commented 4 years ago

Yeh, I can confirm I'm having the same problem. A next step could be looking at the source for the persistent session and copying over some of the renewal logic?... At the moment I've just extended the TTL times on the POW sessions, but this is only masking the problem...

sb8244 commented 4 years ago

Any luck on this @morgz ? I'm thinking about it now. I see the biggest issue with persistent session possibly being that you can't update the session with the new cookie value. This means that you will continually hit the persistent session flow until you finally do reload and get the cookie to be set.

I am not sure if WebSocket requests allow cookies to be set (my gut says possibly they will), although I don't think it's exposed at Phoenix level regardless.

Not all mount calls will be due to a new request. If a LiveView crashes, it will re-mount without re-initializing the WebSocket. This means that there would not be an opportunity to set the cookie. There could be some JavaScript client changes that look for a certain payload from the LiveSocket and make an HTTP request to refresh the HTTP session, but it's probably overly complex.

I was thinking of just extending the times to something I'm happy with, like you did.

morgz commented 4 years ago

@sb8244 currently I'm just living with extended session times. There was an Ajax solution above to send a request through plug and update the cookie. Something I haven't looked into - is necessary to modify the cookie to implement persistent session, or can we achieve it through just modifying the values in the store?

sb8244 commented 4 years ago

@morgz I have more info about this after a few discussions trying to wrap my head around it.

The biggest issue, as you've pointed out, is the lack of access to the cookie (both setting and reading). This means that the cookie value passed up by LiveView is going to be stale since it comes through a token. It works great, as long as the session hasn't changed.

There's a secondary issue that gets to the heart of what your question is. The session_ttl_renewal option (default 15min) basically signals "time has passed and a new session ID is required". This prevents session fixation where someone can grab your ID out of a log and use it. This is stored in the cookie, so unfortunately I think you would need to modify the cookie to properly implement persistent sessions.

One idea I had was to side-load the LiveView code with a JS handler that can make XHR requests to update the cookie as necessary (and is there a way to update LiveView's session payload??).

Currently, I have extended the cookies / TTLs to last for ~1 business day, so I'm thinking I can kick the can down the road. Because I'm kicking the can down the road, it means that this isn't a pressing issue for me currently.

morgz commented 4 years ago

Thanks for the simple description @sb8244 - That lines up with the limitations as I see them.

For this

This prevents session fixation where someone can grab your ID out of a log and use it. This is stored in the cookie, so unfortunately I think you would need to modify the cookie to properly implement persistent sessions.

Could a simple solution be some Liveview logic that acts as an inactivity/timeout feature? That can then disconnect the Liveview session and process a POW logout event.

I asked a question in Jose's PHX Auth pull request about Liveview auth. The general feeling I got was they don't touch the cookies and they have a multi-day (30?) session life.

My nervousness with using POW and Liveview at the moment is I feel I'm working on the edges of what it's designed for without fully understanding the complexity. It would be a shame, but for robustness over security, I'd be tempted to switch to PHX Auth where I know exactly how long the sessions last, and there isn't this session ttl logic. Basically, every time my session logouts, I'm left wondering why... was it a bug? Did POW do it? Did it expire? etc. With all the moving parts going on in my app, I don't want this concern.

morgz commented 4 years ago

An aside... we could whip up a bounty to reward a robust solution? πŸ›©πŸš€πŸ–

sekunho commented 4 years ago

I don't mind contributing to the bounty. This is way beyond my skill level but I really need this feature to work.