lib/ecto_sessions.ex

defmodule EctoSessions do
  @default_table_name "sessions"
  @default_extra_fields [{:user_id, :string}]

  @moduledoc """
  This lib implements a set of methods to help you handle the storage and
  access to databse-backend sessions.

  In your application, use `EctoSessions`:

  ```elixir
  defmodule MyApp.Sessions do
    use EctoSessions,
      repo: MyApp.Repo, # required
      prefix: nil, # optional
      table_name: #{inspect(@default_table_name)}, # optional
      extra_fields: #{inspect(@default_extra_fields)} # optional
  ```

  See `EctoSessions.Migrations` for instructions on how to migrate your
  database.

  """

  defmacro __using__(opts) do
    ecto_sessions_module = __CALLER__.module

    repo = Keyword.fetch!(opts, :repo)
    prefix = Keyword.get(opts, :prefix, nil)
    table_name = Keyword.get(opts, :table_name, @default_table_name)
    extra_fields = Keyword.get(opts, :extra_fields, @default_extra_fields)

    quote do
      defmodule Config do
        use EctoSessions.Config,
          ecto_sessions_module: unquote(ecto_sessions_module)
      end

      defmodule Session do
        use EctoSessions.Session,
          config_module: unquote(Module.concat([ecto_sessions_module, Config])),
          table_name: unquote(table_name),
          extra_fields: unquote(Macro.escape(extra_fields))
      end

      import Ecto.Query
      alias EctoSessions.AuthToken

      @repo unquote(repo)

      def create_session(attrs \\ %{}) do
        Session.new(attrs)
        |> @repo.insert(prefix: unquote(prefix))
      end

      def create_session!(attrs \\ %{}) do
        Session.new(attrs)
        |> @repo.insert!(prefix: unquote(prefix))
      end

      def create_auth_token(attrs \\ %{}) do
        %{plaintext_auth_token: plaintext_auth_token} = create_session!(attrs)
        plaintext_auth_token
      end

      def get_expired_sessions_query() do
        from(
          session in Session,
          where:
            not is_nil(session.expires_at) or
              session.expires_at <= ^DateTime.utc_now()
        )
      end

      def get_valid_sessions_query() do
        from(
          session in Session,
          as: :session,
          where:
            is_nil(session.expires_at) or
              session.expires_at > ^DateTime.utc_now()
        )
      end

      def filter_session_query_by(query, :auth_token, plaintext_auth_token) do
        auth_token_digest =
          AuthToken.get_digest(
            plaintext_auth_token,
            Config.get_hashing_algorithm(),
            Config.get_secret_salt()
          )

        from(
          session in query,
          where: session.auth_token == ^auth_token_digest
        )
      end

      def filter_session_query_by(query, field_name, value) do
        from(
          session in query,
          where: field(session, ^field_name) == ^value
        )
      end

      def get_session(field_name, value) do
        get_valid_sessions_query()
        |> filter_session_query_by(field_name, value)
        |> @repo.one(prefix: unquote(prefix))
      end

      def get_session!(field_name, value) do
        get_valid_sessions_query()
        |> filter_session_query_by(field_name, value)
        |> @repo.one!(prefix: unquote(prefix))
      end

      def renovate_session(session) do
        Session.changeset(session)
        |> @repo.update!(prefix: unquote(prefix))
      end
    end
  end

  @doc """
  Creates a session. `attrs` is a map that contains `EctoSessions.Session` attributes,
  where the keys are atoms.

  Uses `Ecto.Repo.insert/2`

  ## Examples

      iex> create_session(%{user_id: "1234", data: %{device_name: "Samle Browser"}})
      %Session{
        user_id: "1234",
        data: %{
          device_name: "Sample Browser",
          plaintext_auth_token: "plaintext-auth-token"
          auth_token: "hashed-token"
        }
      }
  """
  @callback create_session(attrs :: map) :: Ecto.Schema.t()

  @doc """
  Same as `create_session/1` but using `Ecto.Repo.insert!/2`.
  """
  @callback create_session!(attrs :: map) :: Ecto.Schema.t()

  @doc """
  Creates a session using `create_session!/1` returing only the plaintext auth token.

  ## Examples

      iex> create_session(%{user_id: "1234", data: %{device_name: "Samle Browser"}})
      "plaintext-auth-token"

  """
  @callback create_auth_token(attrs :: map()) :: Ecto.Schema.t()

  @doc """
  Returns an ecto query for sessions which have expired:
  Whenever expires_at is in the past.
  """
  @callback get_expired_sessions_query() :: Ecto.Queryable.t()

  @doc """
  Returns an ecto query for sessions which have not expired:
  Whenever expires_at is either null or in the future.
  """
  @callback get_valid_sessions_query() :: Ecto.Queryable.t()

  @doc """
  Filters a sessions query by the given field.

  When `:auth_token` is passed, hashing will be automatically handled according to
  the configuration.
  """
  @callback filter_session_query_by(query :: Ecto.Queryable.t(), field_name :: atom, value :: any) ::
              Ecto.Queryable.t()

  @doc """
  Returns a valid session given the field name and the desired value to check.

  Uses `Ecto.Repo.one/1`
  """
  @callback get_session(field_name :: atom, value :: any) :: Ecto.Schema.t()

  @doc """
  Same as `get_session/2` but using `Ecto.Repo.one!/1`.
  """
  @callback get_session!(field_name :: atom, value :: any) :: Ecto.Schema.t()

  @doc """
  Renovates a session expiration following the configuration in `EctoSession.Config`.
  """
  @callback renovate_session!(session :: Ecto.Schema.t()) :: Ecto.Schema.t()
end