I’ve been quite busy in the past week creating my own Pow-based session system I’ve followed the API guide at https://hexdocs.pm/pow/api.html and then modified it quite heavily to suite my own needs, meaning that sessions now have an ID that is stable for the life of the session, separate from the ID of the refresh token. The tokens themselves are created using Phoenix tokens.
One reason for this setup is that I want to make it possible for a user to see all their current sessions, and log out sessions other than the current one. To do so, I’ve extended Pow’s built-in MnesiaCache with some extra methods. Everything works fine, but now that I start to test things, it seems that the operations in it are not atomic. I’ve used transactions in the code I’ve added myself and I can see transactions being used in Pow’s MnesiaCache, so this does not make sense to me.
The following code is my implementation with doctests and the test module. I’ve hacked around the problem by inserting Process.sleep(30)
calls, which is obviously not ideal. Removing causes the tests to fail seemingly because things have not yet been inserted or deleted in mnesia.
Does anybody have an idea what the problem might be? Do I misunderstand mnesia transactions?
defmodule MyAppWeb.Sessions.MnesiaSessionStore do
@moduledoc """
Pow persistent session store using Mnesia.
"""
use Pow.Store.Base,
ttl: Application.get_env(:my_app_web, :auth)[:refresh_token_ttl],
namespace: "persistent_session"
alias MyAppWeb.Sessions.Session
# Mnesia table object format:
# {table, key, {session_id, session, [ttl: _, namespace: _], timestamp}}
@session_store Pow.Store.Backend.MnesiaCache
@store_config [backend: @session_store]
@doc """
Get a session by session id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session(session.id, session)
:ok
iex> Process.sleep(30)
iex> get_session(session.id)
session
"""
@spec get_session(binary) :: Session.t() | :not_found
def get_session(session_id), do: get(@store_config, session_id)
@doc """
Delete a session by session id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session(session.id, session)
:ok
iex> Process.sleep(30)
iex> delete_session(session.id)
:ok
iex> Process.sleep(30)
iex> get_session(session.id)
:not_found
"""
@spec delete_session(binary) :: :ok
def delete_session(session_id), do: delete(@store_config, session_id)
@doc """
Create or update a session by session id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session(session.id, session)
:ok
iex> Process.sleep(30)
iex> get_session(session.id)
session
"""
@spec put_session(binary, Session.t()) :: :ok
def put_session(session_id, session), do: put(@store_config, session_id, session)
@doc """
Get all sessions for the user identified by user_id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session("1", session)
:ok
iex> put_session("2", session)
:ok
iex> Process.sleep(30)
iex> get_all_sessions(session.user_id)
{:ok, [session, session]}
"""
@spec get_all_sessions(pos_integer) :: {:error, any} | {:ok, [Session.t()]}
def get_all_sessions(user_id) do
object_matcher = fn ->
:mnesia.match_object({@session_store, :_, {:_, %{user_id: user_id}, :_, :_}})
end
case :mnesia.transaction(object_matcher) do
{:atomic, results} -> {:ok, Enum.map(results, fn {_, _, {_, session, _, _}} -> session end)}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Delete all sessions for the user identified by user_id.
## Examples
iex> session = sample_session()
%MyAppWeb.Sessions.Session{created_at: 123, id: "the_session", last_known_ip: "127.0.0.1", refresh_token_id: nil, refreshed_at: 123, user_id: 1}
iex> put_session("1", session)
:ok
iex> put_session("2", session)
:ok
iex> Process.sleep(30)
iex> get_all_sessions(session.user_id)
{:ok, [session, session]}
iex> delete_all_sessions(session.user_id)
iex> Process.sleep(30)
iex> get_all_sessions(session.user_id)
{:ok, []}
"""
@spec delete_all_sessions(pos_integer) :: :ok | {:error, any}
def delete_all_sessions(user_id) do
object_deleter = fn ->
:mnesia.match_object({@session_store, :_, {:_, %{user_id: user_id}, :_, :_}})
|> Enum.each(fn {_, _, {id, _, _, _}} -> delete_session(id) end)
end
case :mnesia.transaction(object_deleter) do
{:atomic, _} -> :ok
{:aborted, reason} -> {:error, reason}
end
end
end
And the test module:
defmodule MyAppWeb.Sessions.MnesiaSessionStoreTest do
use ExUnit.Case
import MyAppWeb.Sessions.MnesiaSessionStore
setup do
case :mnesia.clear_table(Pow.Store.Backend.MnesiaCache) do
{:atomic, _} -> :ok
{:aborted, reason} -> {:error, reason}
end
end
def sample_session(),
do: %MyAppWeb.Sessions.Session{
id: "the_session",
user_id: 1,
last_known_ip: "127.0.0.1",
created_at: 123,
refreshed_at: 123
}
doctest MyAppWeb.Sessions.MnesiaSessionStore
end