defmodule Pow.Ecto.Schema.Changeset do
@moduledoc """
Handles changesets methods for Pow schema.
These methods should never be called directly, but instead the methods
build in macros in `Pow.Ecto.Schema` should be used. This is to ensure
that only compile time configuration is used.
`Pow.Ecto.Schema.Password` is by default used to hash and verify passwords.
## Configuration options
* `:password_min_length` - minimum password length, defaults to 8
* `:password_max_length` - maximum password length, defaults to 4096
* `:password_hash_methods` - the password hash and verify methods to use,
defaults to:
{&Pow.Ecto.Schema.Password.pbkdf2_hash/1,
&Pow.Ecto.Schema.Password.pbkdf2_verify/2}
* `:email_validator` - the email validation method, defaults to:
&Pow.Ecto.Schema.Changeset.validate_email/1
The method should either return `:ok`, `:error`, or `{:error, reason}`.
"""
alias Ecto.Changeset
alias Pow.{Config, Ecto.Schema, Ecto.Schema.Password}
@password_min_length 8
@password_max_length 4096
@doc """
Validates the user id field.
The user id field is always required. It will be treated as case insensitive,
and it's required to be unique. If the user id field is `:email`, the value
will be validated as an e-mail address too.
"""
@spec user_id_field_changeset(Ecto.Schema.t() | Changeset.t(), map(), Config.t()) :: Changeset.t()
def user_id_field_changeset(user_or_changeset, params, config) do
user_id_field =
case user_or_changeset do
%Changeset{data: %struct{}} -> struct.pow_user_id_field()
%struct{} -> struct.pow_user_id_field()
end
user_or_changeset
|> Changeset.cast(params, [user_id_field])
|> Changeset.update_change(user_id_field, &maybe_normalize_user_id_field_value/1)
|> maybe_validate_email_format(user_id_field, config)
|> Changeset.validate_required([user_id_field])
|> Changeset.unique_constraint(user_id_field)
end
defp maybe_normalize_user_id_field_value(value) when is_binary(value), do: Schema.normalize_user_id_field_value(value)
defp maybe_normalize_user_id_field_value(any), do: any
@doc """
Validates the password field.
Calls `confirm_password_changeset/3` and `new_password_changeset/3`.
"""
@spec password_changeset(Ecto.Schema.t() | Changeset.t(), map(), Config.t()) :: Changeset.t()
def password_changeset(user_or_changeset, params, config) do
user_or_changeset
|> confirm_password_changeset(params, config)
|> new_password_changeset(params, config)
end
@doc """
Validates the password field.
A password hash is generated by using `:password_hash_methods` in the
configuration. The password is always required if the password hash is `nil`,
and it's required to be between `:password_min_length` to
`:password_max_length` characters long.
The password hash is only generated if the changeset is valid, but always
required.
"""
@spec new_password_changeset(Ecto.Schema.t() | Changeset.t(), map(), Config.t()) :: Changeset.t()
def new_password_changeset(user_or_changeset, params, config) do
user_or_changeset
|> Changeset.cast(params, [:password])
|> maybe_require_password()
|> maybe_validate_password(config)
|> maybe_put_password_hash(config)
|> maybe_validate_password_hash()
|> Changeset.prepare_changes(&Changeset.delete_change(&1, :password))
end
# TODO: Remove `confirm_password` support by 1.1.0
@doc """
Validates the confirm password field.
Requires `password` and `confirm_password` params to be equal. Validation is
only performed if a change for `:password` exists and the change is not
`nil`.
"""
@spec confirm_password_changeset(Ecto.Schema.t() | Changeset.t(), map(), Config.t()) :: Changeset.t()
def confirm_password_changeset(user_or_changeset, %{confirm_password: password_confirmation} = params, _config) do
params =
params
|> Map.delete(:confirm_password)
|> Map.put(:password_confirmation, password_confirmation)
do_confirm_password_changeset(user_or_changeset, params)
end
def confirm_password_changeset(user_or_changeset, %{"confirm_password" => password_confirmation} = params, _config) do
params =
params
|> Map.delete("confirm_password")
|> Map.put("password_confirmation", password_confirmation)
convert_confirm_password_param(user_or_changeset, params)
end
def confirm_password_changeset(user_or_changeset, params, _config),
do: do_confirm_password_changeset(user_or_changeset, params)
# TODO: Remove by 1.1.0
defp convert_confirm_password_param(user_or_changeset, params) do
IO.warn("warning: passing `confirm_password` value to `#{inspect unquote(__MODULE__)}.confirm_password_changeset/3` has been deprecated, please use `password_confirmation` instead")
changeset = do_confirm_password_changeset(user_or_changeset, params)
errors = Enum.map(changeset.errors, fn
{:password_confirmation, error} -> {:confirm_password, error}
error -> error
end)
%{changeset | errors: errors}
end
defp do_confirm_password_changeset(user_or_changeset, params) do
changeset = Changeset.cast(user_or_changeset, params, [:password])
changeset
|> Changeset.get_change(:password)
|> case do
nil -> changeset
_password -> Changeset.validate_confirmation(changeset, :password, required: true)
end
end
@doc """
Validates the current password field.
It's only required to provide a current password if the `password_hash`
value exists in the data struct.
"""
@spec current_password_changeset(Ecto.Schema.t() | Changeset.t(), map(), Config.t()) :: Changeset.t()
def current_password_changeset(user_or_changeset, params, config) do
user_or_changeset
|> reset_current_password_field()
|> Changeset.cast(params, [:current_password])
|> maybe_validate_current_password(config)
|> Changeset.prepare_changes(&Changeset.delete_change(&1, :current_password))
end
defp reset_current_password_field(%{data: user} = changeset) do
%{changeset | data: reset_current_password_field(user)}
end
defp reset_current_password_field(user) do
%{user | current_password: nil}
end
defp maybe_validate_email_format(changeset, :email, config) do
validate_method = email_validator(config)
Changeset.validate_change(changeset, :email, {:email_format, validate_method}, fn :email, email ->
case validate_method.(email) do
:ok -> []
:error -> [email: {"has invalid format", validation: :email_format}]
{:error, reason} -> [email: {"has invalid format", validation: :email_format, reason: reason}]
end
end)
end
defp maybe_validate_email_format(changeset, _type, _config), do: changeset
defp maybe_validate_current_password(%{data: %{password_hash: nil}} = changeset, _config),
do: changeset
defp maybe_validate_current_password(changeset, config) do
changeset = Changeset.validate_required(changeset, [:current_password])
case changeset.valid? do
true -> validate_current_password(changeset, config)
false -> changeset
end
end
defp validate_current_password(%{data: user, changes: %{current_password: password}} = changeset, config) do
user
|> verify_password(password, config)
|> case do
true ->
changeset
_ ->
changeset = %{changeset | validations: [{:current_password, {:verify_password, []}} | changeset.validations]}
Changeset.add_error(changeset, :current_password, "is invalid", validation: :verify_password)
end
end
@doc """
Verifies a password in a struct.
The password will be verified by using the `:password_hash_methods` in the
configuration.
To prevent timing attacks, a blank password will be passed to the hash method
in the `:password_hash_methods` configuration option if the `:password_hash`
is `nil`.
"""
@spec verify_password(Ecto.Schema.t(), binary(), Config.t()) :: boolean()
def verify_password(%{password_hash: nil}, _password, config) do
config
|> password_hash_method()
|> apply([""])
false
end
def verify_password(%{password_hash: password_hash}, password, config) do
config
|> password_verify_method()
|> apply([password, password_hash])
end
defp maybe_require_password(%{data: %{password_hash: nil}} = changeset) do
Changeset.validate_required(changeset, [:password])
end
defp maybe_require_password(changeset), do: changeset
defp maybe_validate_password(changeset, config) do
changeset
|> Changeset.get_change(:password)
|> case do
nil -> changeset
_ -> validate_password(changeset, config)
end
end
defp validate_password(changeset, config) do
password_min_length = Config.get(config, :password_min_length, @password_min_length)
password_max_length = Config.get(config, :password_max_length, @password_max_length)
Changeset.validate_length(changeset, :password, min: password_min_length, max: password_max_length)
end
defp maybe_put_password_hash(%Changeset{valid?: true, changes: %{password: password}} = changeset, config) do
Changeset.put_change(changeset, :password_hash, hash_password(password, config))
end
defp maybe_put_password_hash(changeset, _config), do: changeset
defp maybe_validate_password_hash(%Changeset{valid?: true} = changeset) do
Changeset.validate_required(changeset, [:password_hash])
end
defp maybe_validate_password_hash(changeset), do: changeset
defp hash_password(password, config) do
config
|> password_hash_method()
|> apply([password])
end
defp password_hash_method(config) do
{password_hash_method, _} = password_hash_methods(config)
password_hash_method
end
defp password_verify_method(config) do
{_, password_verify_method} = password_hash_methods(config)
password_verify_method
end
defp password_hash_methods(config) do
Config.get(config, :password_hash_methods, {&Password.pbkdf2_hash/1, &Password.pbkdf2_verify/2})
end
defp email_validator(config) do
Config.get(config, :email_validator, &__MODULE__.validate_email/1)
end
@doc """
Validates an e-mail.
This implementation has the following rules:
- Split into local-part and domain at last `@` occurance
- Local-part should;
- be at most 64 octets
- separate quoted and unquoted content with a single dot
- only have letters, digits, and the following characters outside quoted
content:
```text
!#$%&'*+-/=?^_`{|}~.
```
- not have any consecutive dots outside quoted content
- Domain should;
- be at most 255 octets
- only have letters, digits, hyphen, and dots
Unicode characters are permitted in both local-part and domain.
The implementation is based on
[RFC 3696](https://tools.ietf.org/html/rfc3696#section-3).
IP addresses are not allowed as per the RFC 3696 specification: "The domain
name can also be replaced by an IP address in square brackets, but that form
is strongly discouraged except for testing and troubleshooting purposes.".
"""
@spec validate_email(binary()) :: :ok | {:error, any()}
def validate_email(email) do
[domain | local_parts] =
email
|> String.split("@")
|> Enum.reverse()
local_part =
local_parts
|> Enum.reverse()
|> Enum.join("@")
cond do
String.length(local_part) > 64 -> {:error, "local-part too long"}
String.length(domain) > 255 -> {:error, "domain too long"}
local_part == "" -> {:error, "invalid format"}
local_part_only_quoted?(local_part) -> validate_domain(domain)
true -> validate_email(local_part, domain)
end
end
defp validate_email(local_part, domain) do
sanitized_local_part =
local_part
|> remove_comments()
|> remove_quotes_from_local_part()
cond do
local_part_consective_dots?(sanitized_local_part) ->
{:error, "consective dots in local-part"}
local_part_valid_characters?(sanitized_local_part) ->
validate_domain(domain)
true ->
{:error, "invalid characters in local-part"}
end
end
defp local_part_only_quoted?(local_part),
do: local_part =~ ~r/^"[^\"]+"$/
defp remove_quotes_from_local_part(local_part),
do: Regex.replace(~r/(^\".*\"$)|(^\".*\"\.)|(\.\".*\"$)?/, local_part, "")
defp remove_comments(any),
do: Regex.replace(~r/(^\(.*\))|(\(.*\)$)?/, any, "")
defp local_part_consective_dots?(local_part),
do: local_part =~ ~r/\.\./
defp local_part_valid_characters?(sanitized_local_part),
do: sanitized_local_part =~ ~r<^[\p{L}\p{M}0-9!#$%&'*+-/=?^_`{|}~\.]+$>u
defp validate_domain(domain) do
labels =
domain
|> remove_comments()
|> String.split(".")
labels
|> validate_tld()
|> validate_dns_labels()
end
defp validate_tld(labels) do
labels
|> List.last()
|> Kernel.=~(~r/^[0-9]+$/)
|> case do
true -> {:error, "tld cannot be all-numeric"}
false -> {:ok, labels}
end
end
defp validate_dns_labels({:ok, labels}) do
Enum.reduce_while(labels, :ok, fn
label, :ok -> {:cont, validate_dns_label(label)}
_label, error -> {:halt, error}
end)
end
defp validate_dns_labels({:error, error}), do: {:error, error}
defp validate_dns_label(label) do
cond do
label == "" -> {:error, "dns label is too short"}
String.length(label) > 63 -> {:error, "dns label too long"}
String.first(label) == "-" -> {:error, "dns label begins with hyphen"}
String.last(label) == "-" -> {:error, "dns label ends with hyphen"}
dns_label_valid_characters?(label) -> :ok
true -> {:error, "invalid characters in dns label"}
end
end
defp dns_label_valid_characters?(label),
do: label =~ ~r/^[\p{L}\p{M}0-9-]+$/u
end