-
-
Notifications
You must be signed in to change notification settings - Fork 158
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
Comments
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:
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 |
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 |
@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
|
@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 |
@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.
|
@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. |
Yeah it would since that would trigger renewal in |
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 😬.
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 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 |
@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. |
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 |
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? |
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? |
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 |
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 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
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. |
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: |
If you don't need all the extra bells, and whistles that |
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.
I just did session authentication on a channel like this:
|
I think all you gotta do is get the user on the socket, and make a fake conn, then everything works nicely. |
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. |
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. |
One thing that I ran into with my setup is that I had to extend the |
@sb8244 I haven't tested this at all but this was posted earlier in this thread: #271 (comment) |
@danschultzer any plan on doing something official as part of Pow? I am a bit confused with different approaches in this thread. |
As of Liveview v0.16, there is now an |
Using @kieraneglin solution and @oliviasculley find 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 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 |
Thanks @christo-ph! For anyone else that may try copy/pasting this solution, you may need to add the following to import Phoenix.Component Without it Also do not forget to replace |
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, |
To answer my own question: using |
@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 |
@sb8244 Can you elaborate on that? What would reloading / verifying the current user on every socket connection look like? |
@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. |
I'm having a lot of trouble with setting up a test for liveview and pow. I have this method: def on_mount(:ensure_authenticated, _params, session, socket) do
IO.inspect(socket.private)
IO.inspect(session)
IO.puts("ensure_authenticated")
case get_user_from_session(socket, session) do
nil ->
IO.puts("halting on_mount")
{:halt, redirect_require_login(socket)}
user ->
IO.puts("NOT alting on_mount")
new_socket =
Phoenix.Component.assign_new(socket, :current_user, fn ->
Users.get_user!(user.id)
end)
%Users.User{} = new_socket.assigns.current_user
{:cont, new_socket}
end
rescue
Ecto.NoResultsError -> {:halt, redirect_require_login(socket)}
end Which attempts to get the user from the session using this method: defp get_user_from_session(socket, session, config \\ [otp_app: :airtunnel])
defp get_user_from_session(socket, %{"airtunnel_auth" => signed_token}, config) do
IO.puts("get_user_from_session")
IO.inspect(socket.endpoint.config(:secret_key_base))
IO.puts("get_user_from_session")
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(store_config(config), token) do
IO.puts("HERE!!!")
user
else
_ ->
IO.puts("NILLL!!!")
nil
end
end
defp get_user_from_session(_, _, _), do: nil This works fine in my running application, but when I setup a conn in my test like so (as described here): opts = Pow.Plug.Session.init(otp_app: :airtunnel)
%Plug.Conn{conn | secret_key_base: AirtunnelWeb.Endpoint.config(:secret_key_base)}
|> Pow.Plug.put_config(otp_app: :airtunnel)
|> Plug.Test.init_test_session(%{})
|> Pow.Plug.Session.call(opts)
|> Pow.Plug.Session.do_create(user, opts) get_session always returns nil because the pow_config =
Keyword.put([otp_app: :airtunnel, backend: EtsCacheMock], :plug, Pow.Plug.Session)
conn = Map.replace!(conn, :secret_key_base, AirtunnelWeb.Endpoint.config(:secret_key_base))
id = store_in_cache(conn, "token", {user, inserted_at: :os.system_time(:millisecond)})
conn
|> Pow.Plug.put_config(pow_config)
|> Plug.Test.init_test_session(%{})
|> Pow.Plug.authenticate_user(%{"email" => user.email, "password" => @password})
|> then(fn {:ok, conn} -> conn end)
|> UserAuth.log_in_user(user)
|> Plug.Conn.put_session("airtunnel_auth", id) But my application isn't accepting the config with my EtsCacheMock. At this point I've been struggling with this for 3 days and I'm close to giving up. If there's any guidance on what I'm doing wrong, I would really appreciate it! |
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/
The text was updated successfully, but these errors were encountered: