From e5f0e473d9632daca41418fe12802d5c2b064025 Mon Sep 17 00:00:00 2001 From: Dan Schultzer Date: Sat, 11 Apr 2020 11:54:55 -0700 Subject: [PATCH] Add plug to handle invalidated Pow session tokens (#36) * Add plug to handle invalidated Pow session tokens * Add token signing * Refactor for tests --- config/test.exs | 7 +- lib/philomena_web/endpoint.ex | 9 + .../plugs/pow_invalidated_session_plug.ex | 156 ++++++++++++++++++ .../pow_invalidated_session_plug_test.exs | 147 +++++++++++++++++ 4 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 lib/philomena_web/plugs/pow_invalidated_session_plug.ex create mode 100644 test/philomena_web/plug/pow_invalidated_session_plug_test.exs diff --git a/config/test.exs b/config/test.exs index 63ca1153..0f2d3d09 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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, diff --git a/lib/philomena_web/endpoint.ex b/lib/philomena_web/endpoint.ex index da9eae5b..06efb392 100644 --- a/lib/philomena_web/endpoint.ex +++ b/lib/philomena_web/endpoint.ex @@ -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 diff --git a/lib/philomena_web/plugs/pow_invalidated_session_plug.ex b/lib/philomena_web/plugs/pow_invalidated_session_plug.ex new file mode 100644 index 00000000..7e2cad9b --- /dev/null +++ b/lib/philomena_web/plugs/pow_invalidated_session_plug.ex @@ -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 diff --git a/test/philomena_web/plug/pow_invalidated_session_plug_test.exs b/test/philomena_web/plug/pow_invalidated_session_plug_test.exs new file mode 100644 index 00000000..e8a8b379 --- /dev/null +++ b/test/philomena_web/plug/pow_invalidated_session_plug_test.exs @@ -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" => "test@example.com", "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