Skip to content
This repository has been archived by the owner on Dec 12, 2022. It is now read-only.

Commit

Permalink
Add helper to handle pow user in LiveViews.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
totorigolo committed Jan 31, 2021
1 parent 8897569 commit 7836566
Show file tree
Hide file tree
Showing 14 changed files with 392 additions and 11 deletions.
24 changes: 24 additions & 0 deletions assets/js/live_view.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
6 changes: 6 additions & 0 deletions assets/js/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
return () => new Promise(resolve => {
setTimeout(resolve, ms);
});
}
4 changes: 4 additions & 0 deletions lib/pixel_forum_web/controllers/page_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
168 changes: 168 additions & 0 deletions lib/pixel_forum_web/live/auth_helper.ex
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/pixel_forum_web/live/page_live.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule PixelForumWeb.PageLive do
defmodule PixelForumWeb.Live.PageLive do
use PixelForumWeb, :live_view

alias PixelForum.Lobbies
Expand Down
20 changes: 20 additions & 0 deletions lib/pixel_forum_web/live/profile_live.ex
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions lib/pixel_forum_web/live/profile_live.html.leex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<section>
<h1>Profile</h1>

<%= if @current_user do %>
<strong>User email:</strong> <%= @current_user.email %>
<% end %>
</section>
17 changes: 13 additions & 4 deletions lib/pixel_forum_web/router.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]

Expand Down
8 changes: 4 additions & 4 deletions lib/pixel_forum_web/templates/layout/root.html.leex
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
<%= live_title_tag assigns[:page_title] || "Home", suffix: " · Pixel Forum" %>
<link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
<script defer type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
<%= if Pow.Plug.current_user(@conn) do %><%= tag :meta, name: "logged-in" %><% end %>
</head>
<body>
<header>
<section class="container">
<nav role="navigation">
<ul>
<%= if Pow.Plug.current_user(@conn) do %>
<span><%= @current_user.email %></span>
<span><%= live_redirect @current_user.email, to: Routes.profile_path(@conn, :index) %></span>
<span><%= link "Sign out", to: Routes.pow_session_path(@conn, :delete), method: :delete %></span>
<% else %>
<%= for link <- PowAssent.Phoenix.ViewHelpers.provider_links(@conn),
Expand All @@ -26,10 +27,9 @@
<% end %>
</ul>
</nav>
<a href="/" class="phx-logo">
<%# <img src="<%= Routes.static_path(@conn, "/images/phoenix.png") % >" alt="Logo"/> %>
<%= live_redirect to: Routes.page_path(@conn, :index), class: "phx-logo" do %>
<h1>Pixel forum</h1>
</a>
<% end %>
</section>
</header>
<%= @inner_content %>
Expand Down
10 changes: 10 additions & 0 deletions test/pixel_forum_web/controllers/page_controller_test.exs
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions test/pixel_forum_web/live/auth_helper_test.exs
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions test/pixel_forum_web/live/page_live_test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
defmodule PixelForumWeb.PageLiveTest do
use PixelForumWeb.ConnCase
defmodule PixelForumWeb.Live.PageLiveTest do
use PixelForumWeb.ConnCase, async: true

import Phoenix.LiveViewTest

Expand Down
Loading

0 comments on commit 7836566

Please sign in to comment.