mirror of
https://github.com/philomena-dev/philomena.git
synced 2024-11-30 14:57:59 +01:00
Add plug to handle invalidated Pow session tokens (#36)
* Add plug to handle invalidated Pow session tokens * Add token signing * Refactor for tests
This commit is contained in:
parent
d247e01347
commit
e5f0e473d9
4 changed files with 315 additions and 4 deletions
|
@ -2,10 +2,9 @@ import Config
|
||||||
|
|
||||||
# Configure your database
|
# Configure your database
|
||||||
config :philomena, Philomena.Repo,
|
config :philomena, Philomena.Repo,
|
||||||
username: System.get_env("PGUSER"),
|
username: "postgres",
|
||||||
password: System.get_env("PGPASSWORD"),
|
password: "postgres",
|
||||||
database: System.get_env("PGDATABASE"),
|
database: "philomena_test",
|
||||||
hostname: System.get_env("PGHOST"),
|
|
||||||
pool: Ecto.Adapters.SQL.Sandbox
|
pool: Ecto.Adapters.SQL.Sandbox
|
||||||
|
|
||||||
# We don't run a server during test. If one is required,
|
# We don't run a server during test. If one is required,
|
||||||
|
|
|
@ -45,12 +45,21 @@ defmodule PhilomenaWeb.Endpoint do
|
||||||
signing_salt: "signed cookie",
|
signing_salt: "signed cookie",
|
||||||
encryption_salt: "authenticated encrypted 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 Pow.Plug.Session, otp_app: :philomena
|
||||||
|
|
||||||
plug PowPersistentSession.Plug.Cookie,
|
plug PowPersistentSession.Plug.Cookie,
|
||||||
otp_app: :philomena,
|
otp_app: :philomena,
|
||||||
persistent_session_cookie_opts: [extra: "SameSite=Lax"]
|
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.ReloadUserPlug
|
||||||
plug PhilomenaWeb.RenderTimePlug
|
plug PhilomenaWeb.RenderTimePlug
|
||||||
plug PhilomenaWeb.ReferrerPlug
|
plug PhilomenaWeb.ReferrerPlug
|
||||||
|
|
156
lib/philomena_web/plugs/pow_invalidated_session_plug.ex
Normal file
156
lib/philomena_web/plugs/pow_invalidated_session_plug.ex
Normal file
|
@ -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
test/philomena_web/plug/pow_invalidated_session_plug_test.exs
Normal file
147
test/philomena_web/plug/pow_invalidated_session_plug_test.exs
Normal file
|
@ -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
|
Loading…
Reference in a new issue