I just added a password check to my Ash+Phoenix application, and I thought others would find it useful.
This is starting from an application that is already set up with AshAuthenticationPhoenix to allow users to register with a password.
My User
resource already had validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
on its register_with_password
create
action, so I looked at the source for that validation to see how AshAuthentication itself does validations on passwords, and came up with this.
defmodule MyApp.Validations.PasswordAllowed do
@moduledoc """
[Ash validation](https://hexdocs.pm/ash/validations.html) that enforces our
application's password requirements.
# Criteria
- The password is not in the [Have I Been Pwned passwords database](https://haveibeenpwned.com/Passwords).
"""
use Ash.Resource.Validation
alias Ash.{
Changeset,
Error.Changes.InvalidArgument,
Error.Framework.AssumptionFailed
}
alias AshAuthentication.Info
@impl true
def validate(changeset, opts, _context) do
case Info.find_strategy(changeset, opts) do
{:ok, %{password_field: _} = strategy} ->
validate_password(changeset, strategy)
# Allow non-password strategies.
{:ok, _} ->
:ok
:error ->
{:error,
AssumptionFailed.exception(
message: "Action does not correlate with an authentication strategy"
)}
end
end
@impl true
def atomic(changeset, opts, context) do
validate(changeset, opts, context)
end
defp validate_password(changeset, strategy) do
password = Changeset.get_argument(changeset, strategy.password_field)
# Skip check if password is nil
if not is_nil(password) and MyApp.HaveIBeenPwnd.is_password_in_database?(password) do
{:error,
InvalidArgument.exception(
field: strategy.password_field,
message: "present in HaveIBeenPwnd database"
)}
else
:ok
end
end
end
In this case, the only thing I want to enforce is that passwords from known data breaches are not allowed. If we wanted more password policies, say enforcing a minimum zxcvbn score, validate_password/2
would look something like this.
defp validate_password(changeset, strategy) do
password = Changeset.get_argument(changeset, strategy.password_field)
# Skip check if password is nil
cond do
is_nil(password) ->
:ok
MyApp.HaveIBeenPwnd.is_password_in_database?(password) ->
{:error,
InvalidArgument.exception(
field: strategy.password_field,
message: "present in HaveIBeenPwnd database"
)}
MyApp.Zxcvbn.estimate(password).score < 2 ->
{:error,
InvalidArgument.exception(
field: strategy.password_field,
message: "zxcvbn score too low"
)}
_ ->
:ok
end
end
Now for the HaveIBeenPwnd module. This API is well explained on the HIBP site, so I will not go into the this code piece by piece. My implementation uses Req Elixir HTTP client
defmodule MyApp.HaveIBeenPwnd do
@moduledoc """
Interface to the [Have I Been Pwned passwords API](https://haveibeenpwned.com/API/v3#PwnedPasswords).
"""
@endpoint "https://api.pwnedpasswords.com/range/"
@hash_algo :sha
@doc """
Checks whether `password` is in the HIBP breached passwords database.
Raises an error on any failure so that any use of this fails closed.
"""
def is_password_in_database?(password) do
hash = :crypto.hash(@hash_algo, password) |> Base.encode16()
hash_prefix = hash |> String.slice(0..4)
# Raise an error because we want to fail closed if we cannot perform the
# password check.
response = Req.get!(@endpoint <> hash_prefix, http_errors: :raise)
hash_suffixes = response.body |> String.split()
hash_suffixes
|> Stream.map(&String.split(&1, ":"))
|> Enum.any?(fn [suffix, prevalence] -> hash_prefix <> suffix == hash and prevalence > 0 end)
end
end
Now back to the User
resource,
# Validates that the password matches the confirmation
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
if Application.compile_env(:myapp, :enforce_password_policy) do
validate MyApp.Validations.PasswordAllowed
end
(You will also want to set this on your password-reset action.) This adds the validation, but only if our application configuration option :enforce_password_policy
was true
at compile time, to allow us to enforce this only in a production deployment. If you want to enforce your password policies in all environments, you should be aware that the calls to the HaveIBeenPwnd API may make your tests fragile in a CI/CD environment.
And that’s it! When you try to register a new user account in your application with a password that’s in the HIBP database, AshAuthentication will reject the action with a clear error.
If this is for a Phoenix application, you may want to present a more friendly error message to your user. As a suggestion,
The password you have entered has appeared in a known data breach and is insecure.
That’s a bit wordy to put in an Ash validation error, so you may want to use the :transform_errors
option on AshPhoenix.Form.for_action/3
, which would also require implementing your own AshAuthenticationPhoenix sign-up component, but that’s something for another post.