Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
danschultzer opened this issue Sep 11, 2019 · 67 comments
Open
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@danschultzer
Copy link
Collaborator

danschultzer commented Sep 11, 2019

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/

@danschultzer danschultzer added enhancement New feature or request help wanted Extra attention is needed labels Sep 11, 2019
@joepstender
Copy link
Contributor

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

@danschultzer
Copy link
Collaborator Author

Rephrased, thanks!

@danschultzer
Copy link
Collaborator Author

danschultzer commented Sep 24, 2019

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
Copy link

anatoliyarkhipov commented Oct 21, 2019

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
Copy link
Collaborator Author

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
Copy link

anatoliyarkhipov commented Oct 23, 2019

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
Copy link

morgz commented Nov 27, 2019

@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
Copy link

anatoliyarkhipov commented Nov 27, 2019

@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
Copy link

morgz commented Nov 27, 2019

@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
Copy link

dbi1 commented Dec 5, 2019

@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
Copy link
Collaborator Author

danschultzer commented Dec 5, 2019

@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
Copy link

anatoliyarkhipov commented Dec 5, 2019

@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
Copy link
Collaborator Author

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
Copy link

anatoliyarkhipov commented Dec 8, 2019

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
Copy link
Collaborator Author

@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
Copy link

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
Copy link

morgz commented Feb 17, 2020

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
Copy link

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

@morgz
Copy link

morgz commented Feb 18, 2020

@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
Copy link

anatoliyarkhipov commented Feb 18, 2020

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
Copy link

morgz commented Feb 18, 2020

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

@mithereal
Copy link

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

@morgz
Copy link

morgz commented Feb 18, 2020

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
Copy link

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
Copy link

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

@morgz
Copy link

morgz commented May 27, 2020

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
Copy link

sb8244 commented Jun 25, 2020

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
Copy link

morgz commented Jun 25, 2020

@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
Copy link

sb8244 commented Jul 14, 2020

@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
Copy link

morgz commented Jul 14, 2020

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
Copy link

morgz commented Jul 14, 2020

An aside... we could whip up a bounty to reward a robust solution? 🛩🚤🏖

@sekunho
Copy link

sekunho commented Jul 28, 2020

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

@pierre-pretorius
Copy link

pierre-pretorius commented Oct 8, 2020

I'm new to Phoenix and had a good experience with building the entire app with LiveView so far. Tried POW and it looks awesome but got stuck at getting it to work with LiveView. I just tried phx_gen_auth and it worked with LiveView without any adjustments.

https://github.com/aaronrenner/phx_gen_auth

At 35mins in this video they cover how to get the user from the socket:
https://www.youtube.com/watch?v=YlDO07P3oL0&ab_channel=Groxio

@sekunho
Copy link

sekunho commented Oct 9, 2020

I'm new to Phoenix and had a good experience with building the entire app with LiveView so far. Tried POW and it looks awesome but got stuck at getting it to work with LiveView. I just tried phx_gen_auth and it worked with LiveView without any adjustments.

https://github.com/aaronrenner/phx_gen_auth

If you don't need all the extra bells, and whistles that pow provides then yes that is a good alternative. Although you'll be stuck implementing your own OAuth solutions, and updating phx_gen_auth would have to be done manually through diffing.

eliknebel added a commit to Simon-Initiative/oli-torus that referenced this issue Oct 22, 2020
totorigolo added a commit to totorigolo/pixel-forum that referenced this issue Feb 7, 2021
The solution is based on suggestions in
pow-auth/pow#271
and seems to tick all the boxes:
- access the connected user in LiveViews.
- (callback to) handle session expiration.
- keeping sessions alive.

This could be imprived by disconnecting all the current user LiveViews
on sign out, but this would require custom Pow controllers. A at-most 60
seconds delay seems to be a good compromise for the time being.
@johns10
Copy link

johns10 commented Aug 15, 2021

I just did session authentication on a channel like this:

  def connect(%{"token" => token}, socket, _connect_info) do
    salt = Atom.to_string(Pow.Plug.Session)
    config = Application.get_env(:userdocs_web, :pow)
    conn = %Plug.Conn{secret_key_base: UserDocsWeb.Endpoint.config(:secret_key_base)}
           |> Pow.Plug.put_config(otp_app: :userdocs_web)

    case Pow.Plug.verify_token(conn, salt, token, config) do
      {:ok, _token} -> {:ok, socket}
      _ -> {:error, :not_authenticated}
    end
  end

@johns10
Copy link

johns10 commented Aug 15, 2021

I think all you gotta do is get the user on the socket, and make a fake conn, then everything works nicely.

@johns10
Copy link

johns10 commented Aug 15, 2021

The only gotcha that's screwing me up is inconsistently signing the token. In this case, I'd signed it with UserDocsWeb.API.Auth.Plug and was trying to decode with Pow.Plug.Session. Apparently you just gotta be careful about how you sign it.

@kieraneglin
Copy link

For those who end up here through Google, this is my solution for LiveView auth with Pow including a test helper.

I'm not fully aware of the security implications in using Pow this way so use at your own risk.

@sb8244
Copy link

sb8244 commented Sep 1, 2021

One thing that I ran into with my setup is that I had to extend the ttl of credentials_cache_store because LiveView is not extending credentials between requests. Does anyone have advice for this? The latest version of pow has an IO.warn about the high ttl that I cannot get around.

@kieraneglin
Copy link

@sb8244 I haven't tested this at all but this was posted earlier in this thread: #271 (comment)

@strzibny
Copy link

strzibny commented Oct 6, 2021

@danschultzer any plan on doing something official as part of Pow? I am a bit confused with different approaches in this thread.

@oliviasculley
Copy link

As of Liveview v0.16, there is now an on_mount/1 callback and attach_hook/4 that could allow for Pow to hook into the Liveview lifecycle

@christo-ph
Copy link

christo-ph commented Jun 23, 2022

Using @kieraneglin solution and @oliviasculley on_mount suggestion it is quite easy to get the user into your liveviews:

find def live_view in your_app_web.ex to call the on_mount hook:

def live_view do
    quote do
      use Phoenix.LiveView, layout: {YourAppWeb.LayoutView, "live.html"}

      # Add User auth (Pow user) to all liveviews
      on_mount YourAppWeb.UserLiveAuth # <- to be added
      unquote(view_helpers())
    end
end

Add i.e. a file user_live_auth.ex to /live:

defmodule YourAppWeb.UserLiveAuth do
  import Phoenix.LiveView

  alias Pow.Store.CredentialsCache
  alias Pow.Store.Backend.EtsCache

  def on_mount(:default, _params, session, socket) do
    socket =
      assign_new(socket, :current_user, fn ->
        get_user(socket, session)
      end)

    if socket.assigns.current_user do
      {:cont, socket}
    else
      {:halt, redirect(socket, to: "/login")}
    end
  end

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

  defp get_user(socket, %{"your_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),
         {user, _metadata} <- CredentialsCache.get([backend: EtsCache], token) do
      user
    else
      _ -> nil
    end
  end

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

@benjaminwood
Copy link

Thanks @christo-ph! For anyone else that may try copy/pasting this solution, you may need to add the following to UserLiveAuth.

import Phoenix.Component

Without it assign_new was not available in Phoenix 1.7.

Also do not forget to replace "your_app_auth" as appropriate. I had missed this at first and it caused some confusion!

@slondr
Copy link

slondr commented May 15, 2023

Does this mean that Pow does not have a general way to access the current user which can be used in both controllers and liveviews?

I want to render a user's username in the nav header which is defined in a layout, components/layouts/app.html.heex. Using @current_user works in controller-rendered pages but fails in LiveViews. With @christo-ph's solution, that would still fail, because the get_user method needs a socket which does not exist in controller-rendered pages, right?

@slondr
Copy link

slondr commented May 15, 2023

To answer my own question: using @current_user actually succeeds in LiveViews. If you followed the documentation which says to pull current_user using the current_user function which takes @conn as a parameter, that won't work, but dropping in @current_user as a replacement seems to work for me.

@sb8244
Copy link

sb8244 commented May 15, 2023

@slondr one note there is to make sure that you're reloading / verifying the current user on every socket connection.

You have to be careful that the current_user is not used directly from the LiveView session, because that means it could hold stale data, or the user could have logged out but your LiveView will still give access.

@slondr
Copy link

slondr commented May 15, 2023

one note there is to make sure that you're reloading / verifying the current user on every socket connection.

You have to be careful that the current_user is not used directly from the LiveView session, because that means it could hold stale data, or the user could have logged out but your LiveView will still give access.

@sb8244 Can you elaborate on that? What would reloading / verifying the current user on every socket connection look like?

@sb8244
Copy link

sb8244 commented May 17, 2023

@slondr it's a bit old now, and it involved some custom macros that I made for the project. but here's the gist and notated a bit:

  # Where you put this depends on your app. Mine was entirely custom built macros that replaced mount/3 with a custom function. I'm not sharing that here because it's way too complex and probably not something you should do.
  def load_user_mount(_params, session, socket, _opts) do
    socket = assign(socket, tz_name: get_tz_name(socket))

    # This line will verify the user, then some billing stuff is done as it applies globally to all LiveViews
    case UserService.get_user_by_socket(socket, session) do
      {:ok, user = %{active: true}} ->
        billing = CloveBilling.lookup_billing_state(user)
        {:ok, assign(socket, billing_state: billing, user: user, organization: user.organization)}

      _ ->
        {:ok, assign(socket, user: nil)}
    end
  end
# I'm not sure where this code came from. Probably a mix of stuff I found online and my own debugging.
defmodule UserServiceWeb.Credentials do
  @moduledoc "Authentication helper functions"

  alias Pow.Store.CredentialsCache

  @otp_app :clove

  @doc """
  Retrieves the currently-logged-in user from the Pow credentials cache.
  """
  def get_user(socket, session, config \\ [otp_app: @otp_app])

  def get_user(socket, %{"clove_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),
         {%{id: id}, _metadata} <- CredentialsCache.get([backend: backend()], token),
         {:ok, user} <- UserService.Users.get_user(id, preload_org?: true) do
      {:ok, user}
    else
      e = {:error, _} -> e
      e -> {:error, e}
    end
  end

  def get_user(_, _, _), do: {:error, :missing_credentials}

  defp backend do
    Application.get_env(:clove, :pow, [])
    |> Keyword.fetch!(:cache_store_backend)
  end
end

The important thing here is that CredentialsCache is checked on every connect. This is what verifies that the session is still allowed access. Once the user logs out, that session is automatically invalidated because it's removed from the CredentialsCache implementation (mine was postgres backed) and then future requests would require user to login again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

No branches or pull requests