Skip to content
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

Add plug to handle invalidated Pow session tokens #36

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import Config

# Configure your database
config :philomena, Philomena.Repo,
username: System.get_env("PGUSER"),
password: System.get_env("PGPASSWORD"),
database: System.get_env("PGDATABASE"),
hostname: System.get_env("PGHOST"),
username: "postgres",
password: "postgres",
database: "philomena_test",
pool: Ecto.Adapters.SQL.Sandbox

# We don't run a server during test. If one is required,
Expand Down
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
156 changes: 156 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,156 @@
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"
@session_signing_salt Atom.to_string(Pow.Plug.Session)
@persistent_cookie_key "#{@otp_app}_persistent_session"
@persistent_cookie_signing_salt Atom.to_string(PowPersistentSession.Plug.Cookie)

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

def call(conn, type) do
conn
|> Plug.put_config(otp_app: @otp_app)
|> do_call(type)
end

defp do_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
defp do_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 client_store_fetch_session(conn) do
conn = Conn.fetch_session(conn)

with session_id when is_binary(session_id) <- Conn.get_session(conn, @session_key),
{:ok, session_id} <- Plug.verify_token(conn, @session_signing_salt, session_id) do
session_id
else
_any -> nil
end
end

@doc false
def client_store_fetch_persistent_cookie(conn) do
conn = Conn.fetch_cookies(conn)

with token when is_binary(token) <- conn.cookies[@persistent_cookie_key],
{:ok, token} <- Plug.verify_token(conn, @persistent_cookie_signing_salt, token) do
token
else
_any -> nil
end

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{authentication_token: "token", name: "John Doe", slug: "john-doe"}
|> 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