Skip to content

Commit

Permalink
Add plug to handle invalidated Pow session tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
danschultzer committed Apr 11, 2020
1 parent 1836fef commit f6c165b
Show file tree
Hide file tree
Showing 3 changed files with 291 additions and 0 deletions.
9 changes: 9 additions & 0 deletions lib/philomena_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,21 @@ defmodule PhilomenaWeb.Endpoint do
signing_salt: "signed cookie",
encryption_salt: "authenticated encrypted cookie"

# This is used to capture tokens being invalidated to store for temporary
# reuse
plug PhilomenaWeb.PowInvalidatedSessionPlug, :pow_session
plug PhilomenaWeb.PowInvalidatedSessionPlug, :pow_persistent_session

plug Pow.Plug.Session, otp_app: :philomena

plug PowPersistentSession.Plug.Cookie,
otp_app: :philomena,
persistent_session_cookie_opts: [extra: "SameSite=Lax"]

# This is used as fallback to load user if the Pow session could not be
# loaded
plug PhilomenaWeb.PowInvalidatedSessionPlug, :load

plug PhilomenaWeb.ReloadUserPlug
plug PhilomenaWeb.RenderTimePlug
plug PhilomenaWeb.ReferrerPlug
Expand Down
135 changes: 135 additions & 0 deletions lib/philomena_web/plugs/pow_invalidated_session_plug.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
defmodule PhilomenaWeb.PowInvalidatedSessionPlug do
@moduledoc """
This plug ensures that invalidated sessions can still be used for a short
amount of time.
This MAY introduce a slight timing attack vector, but in practice would be
unlikely as all tokens expires after 60 seconds.
## Example
plug MyAppWeb.PowInvalidatedSessionPlug, :pow_session
plug MyAppWeb.PowInvalidatedSessionPlug, :pow_persistent_session
plug Pow.Plug.Session, otp_app: :my_app
plug PowPersistentSession.Plug.Cookie
plug MyAppWeb.PowInvalidatedSessionPlug, :load
"""
alias Plug.Conn
alias Pow.{Config, Plug, Store.Backend.EtsCache}

@store_ttl :timer.minutes(1)
@otp_app :philomena
@session_key "#{@otp_app}_auth"
@persistent_cookie_key "#{@otp_app}_persistent_session"

def init(:load), do: :load
def init(:pow_session) do
[
fetch_token: &__MODULE__.load_session_value/1,
namespace: :session
]
end
def init(:pow_persistent_session) do
[
fetch_token: &__MODULE__.load_cookie_value/1,
namespace: :persistent_session
]
end
def init({type, opts}) do
type
|> init()
|> Keyword.merge(opts)
end

def call(conn, :load) do
Enum.reduce(conn.private[:invalidated_session_opts], conn, fn opts, conn ->
maybe_load_from_cache(conn, Plug.current_user(conn), opts)
end)
end
def call(conn, opts) do
fetch_fn = Keyword.fetch!(opts, :fetch_token)
token = fetch_fn.(conn)

conn
|> put_opts_in_private(opts)
|> Conn.register_before_send(fn conn ->
maybe_put_cache(conn, Plug.current_user(conn), token, opts)
end)
end

defp maybe_load_from_cache(conn, nil, opts) do
fetch_fn = Keyword.fetch!(opts, :fetch_token)

case fetch_fn.(conn) do
nil -> conn
token -> load_from_cache(conn, token, opts)
end
end
defp maybe_load_from_cache(conn, _any, _opts), do: conn

defp put_opts_in_private(conn, opts) do
plug_opts = (conn.private[:invalidated_session_opts] || []) ++ [opts]

Conn.put_private(conn, :invalidated_session_opts, plug_opts)
end

defp maybe_put_cache(conn, nil, _old_token, _opts), do: conn
defp maybe_put_cache(conn, _user, nil, _opts), do: conn
defp maybe_put_cache(conn, user, old_token, opts) do
fetch_fn = Keyword.fetch!(opts, :fetch_token)

case fetch_fn.(conn) do
^old_token -> conn
_token -> put_cache(conn, user, old_token, opts)
end
end

defp put_cache(conn, user, token, opts) do
{store, store_config} = invalidated_cache(conn, opts)

store.put(store_config, token, user)

conn
end

defp load_from_cache(conn, token, opts) do
config = Plug.fetch_config(conn)
{store, store_config} = invalidated_cache(conn, opts)

case store.get(store_config, token) do
:not_found -> conn
user -> Plug.assign_current_user(conn, user, config)
end
end

@doc false
def load_session_value(conn) do
conn
|> Conn.fetch_session()
|> Conn.get_session(@session_key)
end

@doc false
def load_cookie_value(conn) do
Conn.fetch_cookies(conn).cookies[@persistent_cookie_key]
end

defp invalidated_cache(conn, opts) do
store_config = store_config(opts)
config = Plug.fetch_config(conn)
store = Config.get(config, :cache_store_backend, EtsCache)

{store, store_config}
end

defp store_config(opts) do
namespace = Keyword.fetch!(opts, :namespace)
ttl = Keyword.get(opts, :ttl, @store_ttl)

[
ttl: ttl,
namespace: "invalidated_#{namespace}",
]
end
end
147 changes: 147 additions & 0 deletions test/philomena_web/plug/pow_invalidated_session_plug_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
defmodule PhilomenaWeb.PowInvalidatedSessionPlugTest do
use PhilomenaWeb.ConnCase
doctest PhilomenaWeb.PowInvalidatedSessionPlug

alias PhilomenaWeb.PowInvalidatedSessionPlug
alias Philomena.{Users.User, Repo}

@otp_app :philomena
@config [otp_app: @otp_app, user: User, repo: Repo]
@session_key "#{@otp_app}_auth"
@cookie_key "#{@otp_app}_persistent_session"
@invalidated_ttl 250

alias Plug.{Conn, Test}
alias Plug.Session, as: PlugSession
alias Pow.Plug.Session
alias PowPersistentSession.Plug.Cookie

setup do
user =
%User{}
|> User.changeset(%{"email" => "[email protected]", "password" => "password", "password_confirmation" => "password"})
|> Repo.insert!()

{:ok, user: user}
end

test "call/2 session id is reusable for short amount of time", %{conn: init_conn, user: user} do
config = Keyword.put(@config, :session_ttl_renewal, 0)
init_conn = prepare_session_conn(init_conn, user, config)

assert session_id =
init_conn
|> init_session_plug()
|> Conn.fetch_session()
|> Conn.get_session(@session_key)

conn = run_plug(init_conn, config)

assert Pow.Plug.current_user(conn).id == user.id
assert Conn.get_session(conn, @session_key) != session_id

:timer.sleep(100)
conn = run_plug(init_conn, config)

assert Pow.Plug.current_user(conn).id == user.id
assert Conn.get_session(conn, @session_key) == session_id

:timer.sleep(@invalidated_ttl - 100)
conn = run_plug(init_conn)

refute Pow.Plug.current_user(conn)
end

test "call/2 persistent session id is reusable", %{conn: init_conn, user: user} do
init_conn = prepare_persistent_session_conn(init_conn, user)

assert persistent_session_id = init_conn.req_cookies[@cookie_key]

conn = run_plug(init_conn)

assert Pow.Plug.current_user(conn).id == user.id
assert conn.cookies[@cookie_key] != persistent_session_id

:timer.sleep(100)
conn = run_plug(init_conn)

assert Pow.Plug.current_user(conn).id == user.id
assert conn.cookies[@cookie_key] == persistent_session_id

:timer.sleep(@invalidated_ttl - 100)
conn = run_plug(init_conn)

refute Pow.Plug.current_user(conn)
assert conn.cookies[@cookie_key] == persistent_session_id
end

defp init_session_plug(conn) do
conn
|> Map.put(:secret_key_base, String.duplicate("abcdefghijklmnopqrstuvxyz0123456789", 2))
|> PlugSession.call(PlugSession.init(store: :cookie, key: "foobar", signing_salt: "salt"))
end

defp init_plug(conn, config) do
conn
|> init_session_plug()
|> PowInvalidatedSessionPlug.call(PowInvalidatedSessionPlug.init({:pow_session, ttl: @invalidated_ttl}))
|> PowInvalidatedSessionPlug.call(PowInvalidatedSessionPlug.init({:pow_persistent_session, ttl: @invalidated_ttl}))
|> Session.call(Session.init(config))
|> Cookie.call(Cookie.init([]))
|> PowInvalidatedSessionPlug.call(PowInvalidatedSessionPlug.init(:load))
end

defp run_plug(conn, config \\ @config) do
conn
|> init_plug(config)
|> Conn.send_resp(200, "")
end

defp create_persistent_session(conn, user, config) do
conn
|> init_plug(config)
|> Session.do_create(user, config)
|> Cookie.create(user, config)
|> Conn.send_resp(200, "")
end

defp prepare_persistent_session_conn(conn, user, config \\ @config) do
session_conn = create_persistent_session(conn, user, config)

:timer.sleep(100)

no_session_conn =
conn
|> Test.recycle_cookies(session_conn)
|> delete_session_from_conn(config)

:timer.sleep(100)

conn
|> Test.recycle_cookies(no_session_conn)
|> Conn.fetch_cookies()
end

defp delete_session_from_conn(conn, config) do
conn
|> init_plug(config)
|> Session.do_delete(config)
|> Conn.send_resp(200, "")
end


defp create_session(conn, user, config) do
conn
|> init_plug(config)
|> Session.do_create(user, config)
|> Conn.send_resp(200, "")
end

defp prepare_session_conn(conn, user, config) do
session_conn = create_session(conn, user, config)

:timer.sleep(100)

Test.recycle_cookies(conn, session_conn)
end
end

0 comments on commit f6c165b

Please sign in to comment.