lib/sql_membership_provider/membership.ex

defmodule SqlMembershipProvider.Membership do
  @moduledoc """
  Struct for representing a user's membership to an application and their authentication information.
  """

  use TypedEctoSchema
  import Ecto.Query, only: [from: 2]
  import Ecto.Changeset

  @primary_key {:user_id, :binary_id, null: false}
  @field_source_mapper fn name -> name |> Atom.to_string() |> Macro.camelize() end

  typed_schema "aspnet_Membership" do
    field(:password, :string, null: false)
    field(:password_format, :integer, null: false)
    field(:password_salt, :string, null: false)
    field(:plaintext_password, :string, virtual: true)
    field(:new_password, :string, virtual: true)
    field(:mobile_pin, :string)
    field(:email, :string)
    field(:lowered_email, :string)
    field(:password_question, :string)
    field(:password_answer, :string)
    field(:is_approved, :boolean, null: false)
    field(:is_locked_out, :boolean, null: false)
    field(:create_date, :utc_datetime, null: false)
    field(:last_login_date, :utc_datetime, null: false)
    field(:last_password_changed_date, :utc_datetime, null: false)
    field(:last_lockout_date, :utc_datetime, null: false)
    field(:failed_password_attempt_count, :integer, null: false)
    field(:failed_password_attempt_window_start, :utc_datetime, null: false)
    field(:failed_password_answer_attempt_count, :integer, null: false)
    field(:failed_password_answer_attempt_window_start, :utc_datetime, null: false)
    field(:comment, :string)

    belongs_to(:application, SqlMembershipProvider.Application,
      references: :application_id,
      type: :binary_id
    )

    belongs_to(:user, SqlMembershipProvider.User,
      define_field: false,
      primary_key: true,
      references: :user_id
    )
  end

  @doc """
  Changeset for user registration
  """
  @spec create_changeset(SqlMembershipProvider.Membership.t(), %{atom() => any()}) ::
          Ecto.Changeset.t()
  def create_changeset(model, params) do
    utc_now = DateTime.truncate(DateTime.utc_now(), :second)
    {:ok, the_past, _offset} = DateTime.from_iso8601("1974-01-01T00:00:00Z")

    model
    |> cast(params, [
      :application_id,
      :email,
      :plaintext_password,
      :password_question,
      :password_answer,
      :is_approved,
      :is_locked_out,
      :user_id
    ])
    |> put_lowered_email(params)
    |> put_change(:password_format, 1)
    |> put_change(:create_date, utc_now)
    |> put_change(:last_login_date, utc_now)
    |> put_change(:last_password_changed_date, the_past)
    |> put_change(:last_lockout_date, the_past)
    |> put_change(:failed_password_attempt_window_start, the_past)
    |> put_change(:failed_password_answer_attempt_window_start, the_past)
    |> put_change(:failed_password_attempt_count, 0)
    |> put_change(:failed_password_answer_attempt_count, 0)
    |> generate_password_salt()
    |> validate_required([
      :plaintext_password,
      :password_format,
      :password_salt,
      :is_approved,
      :is_locked_out,
      :create_date,
      :last_login_date,
      :last_password_changed_date,
      :last_lockout_date,
      :failed_password_attempt_count,
      :failed_password_attempt_window_start,
      :failed_password_answer_attempt_count,
      :failed_password_answer_attempt_window_start
    ])
    |> update_password()
  end

  @doc """
  Changeset for changing user password authenticated by the current password
  """
  @spec password_update_changeset(SqlMembershipProvider.Membership.t(), %{atom() => any()}) ::
          Ecto.Changeset.t()
  def password_update_changeset(model, params \\ %{}) do
    model
    |> cast(params, [:plaintext_password, :new_password])
    |> validate_required([:plaintext_password, :new_password])
    |> validate_current_password(model)
    |> update_encrypted_password
  end

  @doc """
  Changeset for resetting user password. This method has no authentication checks.
  """
  @spec password_reset_changeset(SqlMembershipProvider.Membership.t(), %{atom() => any()}) ::
          Ecto.Changeset.t()
  def password_reset_changeset(model, params \\ %{}) do
    model
    |> cast(params, [:new_password])
    |> validate_required([:new_password])
    |> update_encrypted_password
  end

  defp validate_current_password(changeset, membership) do
    validate_change(changeset, :plaintext_password, fn :plaintext_password, password ->
      case is_password_valid?(membership, password) do
        true -> []
        false -> [password: "is incorrect"]
      end
    end)
  end

  defp update_encrypted_password(changeset) do
    case fetch_change(changeset, :new_password) do
      {:ok, new_password} ->
        {:data, salt} = fetch_field(changeset, :password_salt)

        Ecto.Changeset.put_change(
          changeset,
          :password,
          hash_password(new_password, 1, salt)
        )

      :error ->
        changeset
    end
  end

  defp put_lowered_email(changeset, %{"email" => email}) do
    changeset
    |> put_change(:lowered_email, String.downcase(email))
  end

  defp update_password(changeset) do
    {:ok, password} = Ecto.Changeset.fetch_change(changeset, :plaintext_password)
    {:ok, salt} = Ecto.Changeset.fetch_change(changeset, :password_salt)

    Ecto.Changeset.put_change(changeset, :password, hash_password(password, 1, salt))
  end

  defp generate_password_salt(changeset) do
    put_change(changeset, :password_salt, Base.encode64(:crypto.strong_rand_bytes(16)))
  end

  @doc """
  Fetch a membership by user id.
  """
  @spec find_by_user_id(String.t()) :: Ecto.Query.t()
  def find_by_user_id(user_id) when is_binary(user_id) do
    from(
      m in SqlMembershipProvider.Membership,
      where: m.user_id == ^user_id
    )
  end

  @doc """
  Fetch a membership by case insensitive email address and case insensitive application name
  """
  @spec find_by_email(String.t(), String.t()) :: Ecto.Query.t()
  def find_by_email(email_address, application_name)
      when is_binary(email_address) and is_binary(application_name) do
    lowered_email_address = String.downcase(email_address)
    lowered_application_name = String.downcase(application_name)

    from(m in SqlMembershipProvider.Membership,
      join: a in assoc(m, :application),
      where: m.lowered_email == ^lowered_email_address,
      where: a.lowered_application_name == ^lowered_application_name,
      preload: [:application, user: [:profile, :roles]]
    )
  end

  @doc """
  Check if a plaintext password matches a user's hashed password.
  """
  @spec is_password_valid?(
          SqlMembershipProvider.Membership.t(),
          String.t()
        ) :: boolean()
  def is_password_valid?(
        %SqlMembershipProvider.Membership{
          password: hashed_password,
          password_format: format,
          password_salt: salt
        },
        password
      )
      when is_binary(password) do
    hash_password(password, format, salt) == hashed_password
  end

  defp hash_password(password, 1, salt) do
    salt = Base.decode64!(salt)

    password =
      String.to_charlist(password)
      |> :unicode.characters_to_binary(:utf8, {:utf16, :little})

    :crypto.hash(:sha, salt <> password)
    |> Base.encode64()
  end

  defp hash_password(_password, format, _salt),
    do: raise(SqlMembershipProvider.UnsupportedPasswordFormatError, format)
end