Password policies with with AshAuthentication

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.

2 Likes

If you make a custom exception for this you’ll likely have a better time.

defmodule MyApp.Errors.PwnedPassword do
  use Splode.Error, fields: [:field], class: :forbidden

  # shown internally if this error is raised
  def message(error) do
    "pwned password chosen"
  end
  # rendered in forms with this text
  defimpl AshPhoenix.FormData.Error do
    def to_form_error(error) do
       {error.field, "The password you have entered has appeared in a known data breach and is insecure.", []}
    end
  end
end

Then you can return {:error, MyApp.Errors.PwnedPassword.exception(field: :password)} from your validation.

One other note: you should add before_action?: true to the validation if you plan on putting it in forms otherwise it will check on every keystroke :smiley:

3 Likes