lib/ident/factor/lib.ex

defmodule Rivet.Ident.Factor.Lib do
  @type str :: String.t()
  @type log_msg :: str
  @type usr_msg :: str
  @type auth_result :: {:ok | :error, Rivet.Auth.Domain.t()}
  alias Rivet.Ident
  use Rivet.Ecto.Collection.Context, model: Ident.Factor
  require Logger
  import Rivet.Utils.Time, only: [epoch_time: 1]

  # override the function brought in by the collection module
  # was one_with_user_tenant
  def get(factor_id) when is_binary(factor_id) do
    case Ident.Factor.Cache.get_user_factor(factor_id) do
      {:ok, %Ident.Factor{}} = pass ->
        pass

      :miss ->
        case Ident.Factor.one!(
               from(f in Ident.Factor, where: f.id == ^factor_id, preload: [:user])
             ) do
          %Ident.Factor{user: %Ident.User{} = user} = factor ->
            user = Ident.User.Lib.get_authz(user)
            user = %Ident.User{user | state: Map.put(user.state, :active_factor_id, factor.id)}

            %Ident.Factor{factor | user: user}
            |> Ident.Factor.Cache.persist()

          err ->
            err
        end
    end
  rescue
    err in Ecto.Query.CastError ->
      {:error, err.message}
  end

  @doc """
  Preload factors for a related model, with criteria, and only unexpired factors

      Ident.Factor.Lib.preloaded_with(model, type)

  """
  def preloaded_with(%Ident.User{} = user, type) when is_list(type) do
    now = epoch_time(:second)

    Ident.User.preload!(user,
      factors:
        from(a in Ident.Factor,
          where: a.type in ^type and a.expires_at > ^now,
          order_by: [desc: :updated_at]
        )
    )
  end

  def preloaded_with(%Ident.User{} = user, type) when is_atom(type) do
    now = epoch_time(:second)

    Ident.User.preload!(user,
      factors:
        from(a in Ident.Factor,
          where: a.type == ^type and a.expires_at > ^now,
          order_by: [desc: :updated_at]
        )
    )
  end

  @doc """
  iex> strong_password("<KO)(IJM,ko09ijm")
  :ok
  iex> strong_password("boo")
  {:error, "Password is not long enough (greater than 8)"}
  iex> strong_password("<ko)(ijm,ko09ijm")
  {:error, "Password needs both upper and lower case characters"}
  iex> strong_password("KOIJMko09ijm")
  {:error, "Password needs special characters (not alphanumeric)"}
  iex> strong_password("<KO)(IJM,koijm")
  {:error, "Password needs numbers"}
  iex> strong_password("<)(,)(*&^%$#@><")
  {:error, "Password needs letters"}
  """
  @password_minlen 8
  def strong_password(password) do
    cond do
      String.length(password) < @password_minlen ->
        {:error, "Password is not long enough (greater than #{@password_minlen})"}

      Regex.replace(~r/[a-z]/i, password, "") == password ->
        {:error, "Password needs letters"}

      String.downcase(password) == password ->
        {:error, "Password needs both upper and lower case characters"}

      Regex.replace(~r/[^a-z0-9]/i, password, "") == password ->
        {:error, "Password needs special characters (not alphanumeric)"}

      Regex.replace(~r/[0-9]/, password, "") == password ->
        {:error, "Password needs numbers"}

      true ->
        :ok
    end
  end

  @doc """
  set a password

  Future change:

  change Ident.Factors so there is an archive state, some types when being cleaned
  are archived instead of deleted (such as passwords).

  Then Auth.Signin.Local.load_password_factor should filter on !archived
  """

  @password_history 5
  def set_password(user, password, overrides \\ %{}) do
    with :ok <- strong_password(password) do
      Logger.info("setting password", user_id: user.id)

      params =
        %{
          type: :password,
          expires_at: get_expiration(nil, :password),
          password: password,
          user_id: user.id
        }
        |> Map.merge(overrides)

      case Ident.Factor.create(params) do
        {:error, _} = pass ->
          pass

        {:ok, factor} ->
          clean_password_history(user.id, factor.id)
          {:ok, factor}
      end
    end
  end

  def clean_password_history(user_id, excluding_id) do
    from(f in Ident.Factor,
      where: f.user_id == ^user_id and f.type == :password,
      order_by: [asc: f.expires_at]
    )
    |> Ident.Factor.all!()
    |> Enum.filter(fn f -> f.id != excluding_id end)
    |> clean_old_factors(@password_history)
  end

  defp clean_old_factors([x | rest], max) do
    now = epoch_time(:second)

    cond do
      length(rest) + 1 > @password_history ->
        Ident.Factor.delete(x)

      x.expires_at > now ->
        Ident.Factor.update(x, %{expires_at: now})

      true ->
        :ok
    end

    clean_old_factors(rest, max)
  end

  defp clean_old_factors(_, _), do: :ok

  defp get_expiration(provider_exp, type) do
    cfg = Rivet.Auth.Settings.getcfg(:auth_expire_limits, %{})
    def_exp = 86400 * 365

    # because of how releases bring in configs, this appears as a keyword
    # list in prod, vs a map in lower environs.  grr.
    expiration =
      if is_list(cfg) do
        Keyword.get(cfg, type, def_exp)
      else
        if is_map(cfg),
          do: Map.get(cfg, type, def_exp),
          else: def_exp
      end

    case {provider_exp, epoch_time(:second) + expiration} do
      {nil, our_exp} ->
        our_exp

      {provider_exp, our_exp} when provider_exp >= our_exp ->
        our_exp

      {provider_exp, _our_exp} ->
        provider_exp
    end
  end

  # TODO: rename to set_federated_factor
  @spec set_factor(user :: Ident.User.t(), fedid :: Ident.Factor.FedId.t()) ::
          {:ok, Ident.Factor.t()} | {:error, Ecto.Changeset.t()}
  def set_factor(user, fedid) do
    Ident.Factor.create(%{
      name: fedid.provider.kid,
      type: :federated,
      fedtype: fedid.provider.type,
      expires_at: get_expiration(fedid.provider.exp, :password),
      user_id: user.id,
      details: Map.from_struct(fedid.provider)
    })
  end

  def get_user(factor_id) do
    case get(factor_id) do
      {:ok, %Ident.Factor{user: %Ident.User{}} = factor} ->
        {:ok, factor}

      {:error, _} ->
        {:error, "Cannot find identity factor=#{factor_id}"}
    end
  end

  def drop_expired() do
    now = epoch_time(:second)

    # drop any non-password factors; password factors are cleaned when they
    # are changed (to keep a history)
    from(f in Ident.Factor,
      where: f.expires_at < ^now and f.type != :password
    )
    |> Ident.Factor.delete_all()
  end

  def all_not_expired!(%Ident.User{id: user_id}) do
    now = epoch_time(:second)

    from(f in Ident.Factor, where: f.user_id == ^user_id and f.expires_at > ^now)
    |> Ident.Factor.all!()
  end

  def all_not_expired!(%Ident.User{} = user, type) when is_binary(type),
    do: all_not_expired!(user, Transmogrify.As.as_atom!(type))

  def all_not_expired!(%Ident.User{id: user_id}, type) when is_atom(type) do
    now = epoch_time(:second)

    from(f in Ident.Factor,
      where: f.user_id == ^user_id and f.expires_at > ^now and f.type == ^type
    )
    |> Ident.Factor.all!()
  end
end