From 78365662db729782de8a0d8caf45263a7cfdd7e8 Mon Sep 17 00:00:00 2001 From: Thomas Lacroix Date: Sun, 31 Jan 2021 23:57:32 +0100 Subject: [PATCH] Add helper to handle pow user in LiveViews. The solution is based on suggestions in https://github.com/danschultzer/pow/issues/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. --- assets/js/live_view.ts | 24 +++ assets/js/utils.ts | 6 + .../controllers/page_controller.ex | 4 + lib/pixel_forum_web/live/auth_helper.ex | 168 ++++++++++++++++++ lib/pixel_forum_web/live/page_live.ex | 2 +- lib/pixel_forum_web/live/profile_live.ex | 20 +++ .../live/profile_live.html.leex | 7 + lib/pixel_forum_web/router.ex | 17 +- .../templates/layout/root.html.leex | 8 +- .../controllers/page_controller_test.exs | 10 ++ .../pixel_forum_web/live/auth_helper_test.exs | 47 +++++ test/pixel_forum_web/live/page_live_test.exs | 4 +- .../live/profile_live_test.exs | 29 +++ test/support/auth_helper.ex | 57 ++++++ 14 files changed, 392 insertions(+), 11 deletions(-) create mode 100644 lib/pixel_forum_web/live/auth_helper.ex create mode 100644 lib/pixel_forum_web/live/profile_live.ex create mode 100644 lib/pixel_forum_web/live/profile_live.html.leex create mode 100644 test/pixel_forum_web/controllers/page_controller_test.exs create mode 100644 test/pixel_forum_web/live/auth_helper_test.exs create mode 100644 test/pixel_forum_web/live/profile_live_test.exs create mode 100644 test/support/auth_helper.ex diff --git a/assets/js/live_view.ts b/assets/js/live_view.ts index 6266043..e6ecbc3 100644 --- a/assets/js/live_view.ts +++ b/assets/js/live_view.ts @@ -1,4 +1,5 @@ import "phoenix_html"; +import { wait } from "./utils"; import { Socket } from "phoenix"; import * as nProgress from "nprogress"; import { LiveSocket } from "phoenix_live_view"; @@ -33,3 +34,26 @@ export interface LiveViewWindow extends Window { } declare let window: LiveViewWindow; window.liveSocket = liveSocket; + +/** + * 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. + */ +function keepAlive() { + void fetch("/keep-alive") + .then(wait(2 * 60 * 1000 /* ms */)) // 2 minutes + .then(keepAlive); +} +if (document.querySelector("meta[name='logged-in']") != null) keepAlive(); diff --git a/assets/js/utils.ts b/assets/js/utils.ts index 5d38ccc..af37835 100644 --- a/assets/js/utils.ts +++ b/assets/js/utils.ts @@ -12,3 +12,9 @@ export function getUint40(dataView: DataView, byteOffset: number): number { const right = dataView.getUint32(byteOffset + 1); return 2 ** 32 * left + right; } + +export function wait(ms: number): () => Promise { + return () => new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/lib/pixel_forum_web/controllers/page_controller.ex b/lib/pixel_forum_web/controllers/page_controller.ex index 81dcd92..28ef183 100644 --- a/lib/pixel_forum_web/controllers/page_controller.ex +++ b/lib/pixel_forum_web/controllers/page_controller.ex @@ -8,4 +8,8 @@ defmodule PixelForumWeb.PageController do render(conn, "index.html", lobbies: lobbies) end + + def keep_alive(conn, _params) do + text(conn, "ok") + end end diff --git a/lib/pixel_forum_web/live/auth_helper.ex b/lib/pixel_forum_web/live/auth_helper.ex new file mode 100644 index 0000000..93063da --- /dev/null +++ b/lib/pixel_forum_web/live/auth_helper.ex @@ -0,0 +1,168 @@ +defmodule PixelForumWeb.Live.AuthHelper do + @moduledoc """ + This module helps assigning the current user from the session. Doing so will + setup periodical checks that the session is still active. `session_expired/1` + will be called when session expires. + + Will not renew the session though, this has to be done separately, by + periodically fetching a dummy page from for instance. + + Configuration options: + + * `:otp_app` - the app name + * `:interval` - how often the session has to be checked, defaults 60s + + defmodule MyAppWeb.Live.SomeViewLive do + use MyAppWeb, :live_view + use MyAppWeb.Live.AuthHelper, otp_app: my_app + + def mount(params, session, socket) do + socket = maybe_assign_current_user(socket, session) + # or + socket = assign_current_user!(socket, session) + + # ... + end + + def session_expired(socket) do + # handle session expiration + + {:noreply, socket} + end + end + """ + require Logger + + alias Pow.Config + + import Phoenix.LiveView, only: [assign: 3] + + @callback session_expired(Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + + defmacro __using__(opts) do + config = [otp_app: opts[:otp_app]] + session_token = Pow.Plug.prepend_with_namespace(config, "auth") + default_interval_duration = if Mix.env() == :test, do: 100, else: :timer.seconds(60) + interval = Keyword.get(opts, :interval, default_interval_duration) + cache_store_backend = Pow.Config.get(config, :cache_store_backend, Pow.Store.Backend.EtsCache) + + config = [ + session_token: session_token, + interval: interval, + cache_store_backend: cache_store_backend + ] + + quote do + @behaviour unquote(__MODULE__) + + @config unquote(Macro.escape(config)) ++ [module: __MODULE__] + + def maybe_assign_current_user(socket, session), + do: unquote(__MODULE__).maybe_assign_current_user(socket, self(), session, @config) + + def assign_current_user!(socket, session), + do: unquote(__MODULE__).assign_current_user!(socket, self(), session, @config) + + def handle_info(:pow_auth_ttl, socket), + do: unquote(__MODULE__).handle_auth_ttl(socket, self(), @config) + end + end + + @spec maybe_assign_current_user(Phoenix.LiveView.Socket.t(), pid(), map(), Config.t()) :: + Phoenix.LiveView.Socket.t() + def maybe_assign_current_user(socket, pid, session, config) do + user_session_token = get_user_session_token(socket, session, config) + user = get_current_user(user_session_token, config) + + # Start the interval check to see if the current user is still connected. + init_auth_check(socket, pid, config) + + socket + |> assign_current_user_session_token(user_session_token, config) + |> assign_current_user(user, config) + end + + @spec assign_current_user!(Phoenix.LiveView.Socket.t(), pid(), map(), Config.t()) :: + Phoenix.LiveView.Socket.t() + def assign_current_user!(socket, pid, session, config) do + socket = maybe_assign_current_user(socket, pid, session, config) + assign_key = Pow.Config.get(config, :current_user_assigns_key, :current_user) + + if is_nil(socket.assigns[assign_key]) do + raise "There is no current user in the session." + end + + socket + 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 when receiving :pow_auth_ttl. + @spec handle_auth_ttl(Phoenix.LiveView.Socket.t(), pid(), Config.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + def handle_auth_ttl(socket, pid, config) do + interval = Pow.Config.get(config, :interval) + module = Pow.Config.get(config, :module) + session_token = get_current_user_session_token(socket, config) + + case get_current_user(session_token, config) do + nil -> + Logger.debug("[#{__MODULE__}] User session no longer active") + + socket + |> assign_current_user_session_token(nil, config) + |> assign_current_user(nil, config) + |> module.session_expired() + + _user -> + Logger.debug("[#{__MODULE__}] User session still active") + Process.send_after(pid, :pow_auth_ttl, interval) + {:noreply, socket} + end + end + + defp get_user_session_token(socket, session, config) do + conn = struct!(Plug.Conn, secret_key_base: socket.endpoint.config(:secret_key_base)) + salt = Atom.to_string(Pow.Plug.Session) + + with {:ok, signed_token} <- Map.fetch(session, config[:session_token]), + {:ok, session_token} <- Pow.Plug.verify_token(conn, salt, signed_token, config) do + session_token + else + _ -> nil + 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 session_token_assign_key(config) do + current_user_key = Pow.Config.get(config, :current_user_assigns_key, :current_user) + String.to_atom("#{Atom.to_string(current_user_key)}_session_token") + end + + defp assign_current_user_session_token(socket, user, config) do + assign(socket, session_token_assign_key(config), user) + end + + defp get_current_user_session_token(socket, config) do + Map.get(socket.assigns, session_token_assign_key(config)) + end + + defp get_current_user(session_token, config) do + case Pow.Store.CredentialsCache.get([backend: config[:cache_store_backend]], session_token) do + :not_found -> nil + {user, _inserted_at} -> user + end + end +end diff --git a/lib/pixel_forum_web/live/page_live.ex b/lib/pixel_forum_web/live/page_live.ex index 42cb1ab..8d6c7e2 100644 --- a/lib/pixel_forum_web/live/page_live.ex +++ b/lib/pixel_forum_web/live/page_live.ex @@ -1,4 +1,4 @@ -defmodule PixelForumWeb.PageLive do +defmodule PixelForumWeb.Live.PageLive do use PixelForumWeb, :live_view alias PixelForum.Lobbies diff --git a/lib/pixel_forum_web/live/profile_live.ex b/lib/pixel_forum_web/live/profile_live.ex new file mode 100644 index 0000000..943642b --- /dev/null +++ b/lib/pixel_forum_web/live/profile_live.ex @@ -0,0 +1,20 @@ +defmodule PixelForumWeb.Live.ProfileLive do + use PixelForumWeb, :live_view + + use PixelForumWeb.Live.AuthHelper, otp_app: :pixel_forum + + @impl true + def mount(_params, session, socket) do + socket = assign_current_user!(socket, session) + + {:ok, socket} + end + + @impl true + def session_expired(socket) do + {:noreply, + socket + |> put_flash(:info, "Session expired.") + |> push_redirect(to: Routes.page_path(socket, :index))} + end +end diff --git a/lib/pixel_forum_web/live/profile_live.html.leex b/lib/pixel_forum_web/live/profile_live.html.leex new file mode 100644 index 0000000..dfbde19 --- /dev/null +++ b/lib/pixel_forum_web/live/profile_live.html.leex @@ -0,0 +1,7 @@ +
+

Profile

+ + <%= if @current_user do %> + User email: <%= @current_user.email %> + <% end %> +
diff --git a/lib/pixel_forum_web/router.ex b/lib/pixel_forum_web/router.ex index e67037e..ca27481 100644 --- a/lib/pixel_forum_web/router.ex +++ b/lib/pixel_forum_web/router.ex @@ -1,7 +1,7 @@ defmodule PixelForumWeb.Router do use PixelForumWeb, :router - use Pow.Phoenix.Router + use Pow.Phoenix.Router use PowAssent.Phoenix.Router pipeline :browser do @@ -35,10 +35,13 @@ defmodule PixelForumWeb.Router do scope "/" do pipe_through :browser - # pow_routes() -> Using only Pow Assent for sessions + # Not using pow_routes() because we don't want registration using Pow, only + # sessions. pow_session_routes() - pow_assent_routes() + + # Call this to keep the session alive, which is essential in LiveViews. + get "/keep-alive", PixelForumWeb.PageController, :keep_alive end scope "/" do @@ -49,7 +52,7 @@ defmodule PixelForumWeb.Router do scope "/", PixelForumWeb do pipe_through :browser - live "/", PageLive, :index + live "/", Live.PageLive, :index get "/lobby/:id/image", LobbyController, :get_image end @@ -58,6 +61,12 @@ defmodule PixelForumWeb.Router do # pipe_through :api # end + scope "/", PixelForumWeb do + pipe_through [:browser, :browser_authenticated] + + live "/profile", Live.ProfileLive, :index + end + scope "/admin", PixelForumWeb do pipe_through [:browser, :protected, :admin] diff --git a/lib/pixel_forum_web/templates/layout/root.html.leex b/lib/pixel_forum_web/templates/layout/root.html.leex index 36608ef..a196a1f 100644 --- a/lib/pixel_forum_web/templates/layout/root.html.leex +++ b/lib/pixel_forum_web/templates/layout/root.html.leex @@ -8,6 +8,7 @@ <%= live_title_tag assigns[:page_title] || "Home", suffix: " ยท Pixel Forum" %> "/> + <%= if Pow.Plug.current_user(@conn) do %><%= tag :meta, name: "logged-in" %><% end %>
@@ -15,7 +16,7 @@ - + <% end %>
<%= @inner_content %> diff --git a/test/pixel_forum_web/controllers/page_controller_test.exs b/test/pixel_forum_web/controllers/page_controller_test.exs new file mode 100644 index 0000000..34a6e82 --- /dev/null +++ b/test/pixel_forum_web/controllers/page_controller_test.exs @@ -0,0 +1,10 @@ +defmodule PixelForumWeb.PageControllerTest do + use PixelForumWeb.ConnCase, async: true + + describe "keep_alive" do + test "Returns an ok response", %{conn: conn} do + conn = get(conn, Routes.page_path(conn, :keep_alive)) + assert text_response(conn, 200) == "ok" + end + end +end diff --git a/test/pixel_forum_web/live/auth_helper_test.exs b/test/pixel_forum_web/live/auth_helper_test.exs new file mode 100644 index 0000000..5d29233 --- /dev/null +++ b/test/pixel_forum_web/live/auth_helper_test.exs @@ -0,0 +1,47 @@ +defmodule PixelForumWeb.Live.AuthHelperTest do + use PixelForumWeb.ConnCase, async: true + import Phoenix.LiveViewTest + import PixelForumWeb.Test.AuthHelper + + # TODO: Ideally, we should use a LiveView made only for testing, instead of a + # real page. + + describe "when not logged in" do + test "redirects to the index", %{conn: conn} do + index_path = Routes.page_path(conn, :index) + + assert {:error, {:redirect, %{to: ^index_path}}} = + live(conn, Routes.profile_path(conn, :index)) + end + end + + describe "when logged in" do + setup [:log_as_user] + + test "shows current user info", %{conn: authed_conn, user: user} do + {:ok, page_live, disconnected_html} = + live(authed_conn, Routes.profile_path(authed_conn, :index)) + + assert disconnected_html =~ user.email + assert render(page_live) =~ user.email + end + + test "properly redirects when session expires", %{conn: authed_conn, user: user} do + {:ok, page_live, _disconnected_html} = + live(authed_conn, Routes.profile_path(authed_conn, :index)) + + assert render(page_live) =~ user.email + + expire_user_session(authed_conn) + assert_redirect(page_live, "/", 200) + end + end + + defp expire_user_session(conn) do + token = Plug.Conn.get_session(conn, session_token_key() <> "_unsigned") + cache_store_backend = Pow.Config.get([], :cache_store_backend, Pow.Store.Backend.EtsCache) + Pow.Store.CredentialsCache.delete([backend: cache_store_backend], token) + end + + defp session_token_key(), do: Pow.Plug.prepend_with_namespace([otp_app: :pixel_forum], "auth") +end diff --git a/test/pixel_forum_web/live/page_live_test.exs b/test/pixel_forum_web/live/page_live_test.exs index a94e29a..c1cfe26 100644 --- a/test/pixel_forum_web/live/page_live_test.exs +++ b/test/pixel_forum_web/live/page_live_test.exs @@ -1,5 +1,5 @@ -defmodule PixelForumWeb.PageLiveTest do - use PixelForumWeb.ConnCase +defmodule PixelForumWeb.Live.PageLiveTest do + use PixelForumWeb.ConnCase, async: true import Phoenix.LiveViewTest diff --git a/test/pixel_forum_web/live/profile_live_test.exs b/test/pixel_forum_web/live/profile_live_test.exs new file mode 100644 index 0000000..b57fec6 --- /dev/null +++ b/test/pixel_forum_web/live/profile_live_test.exs @@ -0,0 +1,29 @@ +defmodule PixelForumWeb.Live.ProfileLiveTest do + use PixelForumWeb.ConnCase, async: true + import Phoenix.LiveViewTest + import PixelForumWeb.Test.AuthHelper + + describe "when not logged in" do + test "redirects to the index", %{conn: conn} do + index_path = Routes.page_path(conn, :index) + + assert {:error, {:redirect, %{to: ^index_path}}} = + live(conn, Routes.profile_path(conn, :index)) + end + end + + describe "when logged in" do + setup [:log_as_user] + + test "shows user email", %{conn: authed_conn, user: user} do + {:ok, page_live, disconnected_html} = live(authed_conn, Routes.profile_path(authed_conn, :index)) + + assert disconnected_html =~ "

Profile

" + assert disconnected_html =~ "User email: #{user.email}" + + rendered = render(page_live) + assert rendered =~ "

Profile

" + assert rendered =~ "User email: #{user.email}" + end + end +end diff --git a/test/support/auth_helper.ex b/test/support/auth_helper.ex new file mode 100644 index 0000000..042cb55 --- /dev/null +++ b/test/support/auth_helper.ex @@ -0,0 +1,57 @@ +defmodule PixelForumWeb.Test.AuthHelper do + @otp_app :pixel_forum + @default_user %PixelForum.Users.User{id: 0, email: "user@example.com"} + + @doc """ + Logs as a user, properly putting the user inside the conn assigns as well as + into Pow's credentials cache. Works for both classical controllers and + LiveViews. If there is a :user in the test context, it will be used instead of + the default one. + """ + def log_as_user(%{conn: conn} = params) do + user = Map.get(params, :user, @default_user) + + conn = + conn + |> put_user_in_session(user) + |> assign_current_user(user) + + {:ok, conn: conn, user: user} + end + + defp sign_token(token) do + salt = Atom.to_string(Pow.Plug.Session) + secret_key_base = Application.get_env(@otp_app, PixelForumWeb.Endpoint)[:secret_key_base] + + crypt_conn = + struct!(Plug.Conn, secret_key_base: secret_key_base) + |> Pow.Plug.put_config(otp_app: @otp_app) + + Pow.Plug.sign_token(crypt_conn, salt, token) + end + + defp session_token_key(), do: Pow.Plug.prepend_with_namespace([otp_app: @otp_app], "auth") + + defp put_user_in_credentials_cache(token, user) do + cache_store_backend = Pow.Config.get([], :cache_store_backend, Pow.Store.Backend.EtsCache) + Pow.Store.CredentialsCache.put([backend: cache_store_backend], token, {user, []}) + end + + defp put_user_in_session(conn, user) do + token = "test-token-#{Pow.UUID.generate()}" + signed_token = sign_token(token) + + put_user_in_credentials_cache(token, user) + + conn + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_session(session_token_key(), signed_token) + |> Plug.Conn.put_session(session_token_key() <> "_unsigned", token) + end + + defp assign_current_user(conn, user) do + conn + |> Pow.Plug.put_config(otp_app: @otp_app) + |> Pow.Plug.assign_current_user(user, []) + end +end