lib/pow/store/credentials_cache.ex

defmodule Pow.Store.CredentialsCache do
  @moduledoc """
  Default module for credentials session storage.

  A key (session id) is used to store, fetch, or delete credentials. The
  credentials are expected to take the form of
  `{credentials, session_metadata}`, where session metadata is data exclusive
  to the session id.

  This module also adds two utility methods:

    * `users/2` - to list all current users
    * `sessions/2` - to list all current sessions

  The `:ttl` should be maximum 30 minutes per
  [OWASP recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-expiration).
  A warning will be output for any sessions created with a longer TTL.

  ## Custom credentials cache module

  Pow may use the utility methods in this module. To ensure all required
  methods has been implemented in a custom credentials cache module, the
  `@behaviour` of this module should be used:

      defmodule MyApp.CredentialsStore do
        use Pow.Store.Base,
          ttl: :timer.minutes(30),
          namespace: "credentials"

        @behaviour Pow.Store.CredentialsCache

        @impl Pow.Store.CredentialsCache
        def users(config, struct) do
          # ...
        end

        @impl Pow.Store.CredentialsCache
        def put(config, key, value) do
          # ...
        end
      end

  ## Configuration options

    * `:reload` - boolean value for whether the user object should be loaded
      from the context. Defaults false.

  """
  alias Pow.{Config, Operations, Store.Base}

  @callback users(Base.config(), module()) :: [any()]
  @callback sessions(Base.config(), map()) :: [binary()]
  @callback put(Base.config(), binary(), {map(), list()}) :: :ok

  # Per https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-expiration
  @recommended_max_idle_timeout :timer.minutes(30)

  use Base,
    ttl: :timer.minutes(30),
    namespace: "credentials"

  @doc """
  List all user for a certain user struct.

  Sessions for a user can be looked up with `sessions/3`.
  """
  @spec users(Base.config(), module()) :: [any()]
  def users(config, struct) do
    config
    |> Base.all(backend_config(config), [struct, :user, :_])
    |> Enum.map(fn {[^struct, :user, _id], user} ->
      user
    end)
  end

  @doc """
  List all existing sessions for the user fetched from the backend store.
  """
  @spec sessions(Base.config(), map()) :: [binary()]
  def sessions(config, user), do: fetch_sessions(config, backend_config(config), user)

  # TODO: Refactor by 1.1.0
  defp fetch_sessions(config, backend_config, user) do
    {struct, id} = user_to_struct_id!(user, [])

    config
    |> Base.all(backend_config, [struct, :user, id, :session, :_])
    |> Enum.map(fn {[^struct, :user, ^id, :session, session_id], _value} ->
      session_id
    end)
  end

  @doc """
  Add user credentials with the session id to the backend store.

  The credentials are expected to be in the format of
  `{credentials, metadata}`.

  This following three key-value will be inserted:

    - `{session_id, {[user_struct, :user, user_id], metadata}}`
    - `{[user_struct, :user, user_id], user}`
    - `{[user_struct, :user, user_id, :session, session_id], inserted_at}`

  If metadata has `:fingerprint` any active sessions for the user with the same
  `:fingerprint` in metadata will be deleted.
  """
  @spec put(Base.config(), binary(), {map(), list()}) :: :ok
  def put(config, session_id, {user, metadata}) do
    {struct, id} = user_to_struct_id!(user, [])
    user_key     = [struct, :user, id]
    session_key  = [struct, :user, id, :session, session_id]
    records      = [
      {session_id, {user_key, metadata}},
      {user_key, user},
      {session_key, :os.system_time(:millisecond)}
    ]

    delete_user_sessions_with_fingerprint(config, user, metadata)

    backend_config =
      config
      |> backend_config()
      |> warn_maximum_timeout()

    Base.put(config, backend_config, records)
  end

  defp warn_maximum_timeout(config) do
    if Config.get(config, :ttl, 0) > @recommended_max_idle_timeout do
      IO.warn(
        """
        warning: `:ttl` value for sessions should be no longer than #{round(@recommended_max_idle_timeout / 1_000 / 60)} minutes to prevent session hijack, please consider lowering the value
        """)
    end

    config
  end

  @doc """
  Delete the user credentials data from the backend store.

  This following two key-value will be deleted:

  - `{session_id, {[user_struct, :user, user_id], metadata}}`
  - `{[user_struct, :user, user_id, :session, session_id], inserted_at}`

  The `{[user_struct, :user, user_id], user}` key-value is expected to expire
  when reaching its TTL.
  """
  @impl true
  def delete(config, session_id) do
    backend_config = backend_config(config)

    case Base.get(config, backend_config, session_id) do
      {[struct, :user, key_id], _metadata} ->
        session_key = [struct, :user, key_id, :session, session_id]

        Base.delete(config, backend_config, session_id)
        Base.delete(config, backend_config, session_key)

      # TODO: Remove by 1.1.0
      {user, _metadata} when is_map(user) ->
        Base.delete(config, backend_config, session_id)

      :not_found ->
        :ok
    end
  end

  @doc """
  Fetch user credentials from the backend store from session id.
  """
  @impl true
  @spec get(Base.config(), binary()) :: {map(), list()} | nil | :not_found
  def get(config, session_id) do
    backend_config = backend_config(config)

    with {user_key, metadata} when is_list(user_key) <- Base.get(config, backend_config, session_id),
         user when is_map(user)                      <- Base.get(config, backend_config, user_key),
         user when not is_nil(user)                  <- maybe_reload(user, config) do
      {user, metadata}
    else
      # TODO: Remove by 1.1.0
      {user, metadata} when is_map(user) -> {user, metadata}
      :not_found -> :not_found
      nil -> nil
    end
  end

  defp maybe_reload(user, config) do
    # TODO: By 1.1.0 set this to `true` and update docs
    case Keyword.get(config, :reload, false) do
      true -> Operations.reload(user, fetch_pow_config!(config))
      _any -> user
    end
  end

  defp fetch_pow_config!(config), do: Keyword.get(config, :pow_config) || raise "No `:pow_config` value found in the store config."

  defp user_to_struct_id!(%mod{} = user, config) do
    key_values =
      user
      |> fetch_primary_key_values!(config)
      |> Enum.sort(&elem(&1, 0) < elem(&2, 0))
      |> case do
        [id: id] -> id
        clauses  -> clauses
      end

    {mod, key_values}
  end
  defp user_to_struct_id!(_user, _config), do: raise "Only structs can be stored as credentials"

  defp fetch_primary_key_values!(user, config) do
    pow_config = Keyword.get(config, :pow_config)

    user
    |> Operations.fetch_primary_key_values(pow_config)
    |> case do
      {:error, error} -> raise error
      {:ok, clauses}  -> clauses
    end
  end

  defp delete_user_sessions_with_fingerprint(config, user, metadata) do
    case Keyword.get(metadata, :fingerprint) do
      nil         -> :ok
      fingerprint -> do_delete_user_sessions_with_fingerprint(config, user, fingerprint)
    end
  end

  defp do_delete_user_sessions_with_fingerprint(config, user, fingerprint) do
    backend_config = backend_config(config)

    config
    |> sessions(user)
    |> Enum.each(fn session_id ->
      with {_user_key, metadata} when is_list(metadata) <- Base.get(config, backend_config, session_id),
           ^fingerprint <- Keyword.get(metadata, :fingerprint) do
        delete(config, session_id)
      end
    end)
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Use `users/2` or `sessions/2` instead"
  def user_session_keys(config, backend_config, struct) do
    config
    |> Base.all(backend_config, [struct, :user, :_, :session, :_])
    |> Enum.map(fn {key, _value} ->
      key
    end)
  end

  # TODO: Remove by 1.1.0
  @doc false
  @deprecated "Use `sessions/2` instead"
  def sessions(config, backend_config, user), do: fetch_sessions(config, backend_config, user)
end