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