Open danschultzer opened 5 years ago
Could you rephrase the first question? Do you mean: "How should the session id be handled for requests after handshake?" ?
Rephrased, thanks!
From the elixir forum @LostKobrakai have used this:
https://gist.github.com/LostKobrakai/b51204a8de7ff463ee40bb6a3f6905b1
I would refactor it so:
session_key
, cache_backend
, :session_store
, etc:current_user
)Process.send_after/3
instead of :timer.send_interval/3
(no need to cancel then)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).
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
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.
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
.
@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
@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
.
@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
@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)?
@dbi1 Thanks!
No, I haven't had time to dive into this, so I only have some light thoughts on it.
Pow.Store.CredentialsCache.get/2
now returns {user, metadata}
where metadata is a keyword list that in most cases as a :fingerprint
key.Pow.Store.CredentialsCache.sessions/2
and search for a session that has the fingerprint.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.@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.
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.
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 π¬.
config
in Pow.Store.CredentialsCache.get/2?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
? π€
@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.
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
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
@morgz I settled with the latest version I posted in the thread. With an interval ping from JS.
@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?
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.
Excellent. Thanks @anatoliyarkhipov βοΈ I'll give it a go and report back
where do you call authhelper? is it just a use statement in the liveview?
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
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)
?
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:
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
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
The user logs in user via a webform or automatically using the renew_token
(stored in localStorage).
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 } });
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)
@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.
@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.
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....
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.
@dbi1 Thanks!
No, I haven't had time to dive into this, so I only have some light thoughts on it.
- 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.- 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.- 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
π Voila. Hope that saves someone some time.
Thank you, Sir!
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,
@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 %>
You should never put the current_user
which is a struct
in the session, only the id
You risk Cookie overflow!
@Schultzer thanks for the advice, actually I don't do that anymore. =)
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
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]))
@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.
@inhji Oh awesome. I'm using Mnesia too so thanks for this!
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 ?
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?
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...
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.
@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?
@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.
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.
An aside... we could whip up a bounty to reward a robust solution? π©π€π
I don't mind contributing to the bounty. This is way beyond my skill level but I really need this feature to work.
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:
A few questions I want to answer are:
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/