aidalgol

aidalgol

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.

First Post! Switch mode

zachdaniel

zachdaniel

Creator of Ash

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:

— All posts loaded —

Where Next?

Trending in Discussions Top

AstonJ
As the title says, please share what you’ve been up to with Elixir. Whether that’s been learning it, looking into it, making stuff with i...
2977 91561 914
New
byu
@chrismccord : I just saw the Extract AGENTS.md from Phoenix.new into phx.new generator commit to the phoenix project. My initial shotgu...
New
arcanemachine
I was working on an Ecto migration and I needed a timestamp. So, for the nth time, I looked up the different data types for timestamps, a...
New
AstonJ
Just a general thread to post chat/news/info relating to AI/ML stuff that may be relevant for Nx now or in the future. Got anything to sh...
New
type1fool
I just stumbled on a newly redesigned elixir-lang.org. :tada: It looks like @Software_Mansion did the work, and I think it is generally a...
New
juhalehtonen
There has been a thread to discuss the Stack Overflow Developer Survey on this forum every year since 2018, so here’s yet another one for...
New
alexslade
Fly’s CEO posted this recently - Turn And Face The Strange · The Fly Blog It says that Fly is going all-in on sprites, which is a worry ...
New

Other Trending Topics Top

JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
ausimian
Emily is an Elixir library that runs Nx computations on Apple’s MLX. Install it as the default Nx backend and Nx, defn, Axon, Nx.Serving,...
New
akoutmos
@hugobarauna and I (Alex Koutmos) have been hard at work on writing a book on Nerves that takes you from simply blinking LEDs to building...
New
bjorng
We want to introduce a new native datatype to Erlang: native records. Although replacing all tuple records with native records is not our...
New

We're in Beta

About us Mission Statement