Password breach lookup and other password validation rules
From the collection Elixir

articles
security
password

QR Code
Password breach lookup and other password validation rules

You can show this QR Code to a friend or ask them to scan directly on your screen!

Thanks for sharing! 🫶

The url for this was also copied to your clipboard!
by Maarten van Vliet, Dan Schultzer on 14 September 2019

ORIGINAL ARTICLE
HERE

By default Pow has a lax requirement of minimum 8 characters based on NIST SP800-63b, but there are many more types of validations you can use to ensure users don’t rely on weak passwords.

An important aspect to password requirements is that it should be user friendly. Requirements to mix alphanumeric with symbols and upper- and lowercase characters haven’t proven effective. In the following we’ll go through some effective methods to ensure users uses strong passwords.

They are based on the NIST SP800-63b recommendations:

  • Passwords obtained from previous breaches
  • Context-specific words, such as the name of the service, the username, and derivatives thereof
  • Repetitive or sequential characters
  • Dictionary words

Passwords obtained from previous breaches

We’ll use haveibeenpwned.com to check for breached passwords.

For the sake of brevity, we’ll use ExPwned in the following example, but you can use any client or your own custom module to communicate with the API.

First, we’ll add ExPwned to the mix.exs file:

def deps do
  [
    ...
    {:ex_pwned, "~> 0.1.0"}
  ]
end

Run mix deps.get to install it.

Now let’s add the password validation rule to our user schema module:

defmodule MyApp.Users.User do
  use Ecto.Schema
  use Pow.Ecto.Schema

# ...

def changeset(user_or_changeset, attrs) do user_or_changeset |> pow_changeset(attrs) |> validate_password_breach() end

defp validate_password_breach(changeset) do Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> case password_breached?(password) do true -> [password: "has appeared in a previous breach"] false -> [] end end) end

defp password_breached?(password) do case Mix.env() do :test -> false _any -> ExPwned.password_breached?(password) end end end

We’ll only do a lookup if the password has been changed, and we don’t do any lookups in test environment.


Context-specific words, such as the name of the service, the username, and derivatives thereof

We want to prevent context specific words to be used as passwords.

The context might be public user details. If the users email is john.doe@example.com then the password can’t be john.doe@example.com or johndoeexamplecom. The same rule applies for any user id we may use, such as username. If the username is john.doe then john.doe00 or johndoe001 can’t be used.

Our app may also be part of a website/service/platform and have an identity. As an example, if the service is called My Demo App then we don’t want to permit passwords like my demo app, my_demo_app or mydemoapp.

We’ll add the password validation rule to our user schema module:

defmodule MyApp.Users.User do
use Ecto.Schema
use Pow.Ecto.Schema

# ...

def changeset(user_or_changeset, attrs) do user_or_changeset |> pow_changeset(attrs) |> validate_password_no_context() end

@app_name "My Demo App"

defp validate_password_no_context(changeset) do Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> [ @app_name, String.downcase(@app_name), Ecto.Changeset.get_field(changeset, :email), Ecto.Changeset.get_field(changeset, :username) ] |> Enum.reject(&is_nil/1) |> similar_to_context?(password) |> case do true -> [password: "is too similar to username, email or #{@app_name}"] false -> [] end end) end

def similar_to_context?(contexts, password) do Enum.any?(contexts, &String.jaro_distance(&1, password) > 0.85) end end

We’re using the String.jaro_distance/2 to make sure that the password has a Jaro–Winkler similarity to the context of at most 0.85.


Repetitive or sequential characters

We want to prevent repetitive or sequential characters in passwords such as aaa, 1234 or abcd.

The rule we’ll use is that there may be no more than two repeating or three sequential characters in the password. We’ll add the validation rule to our user schema module:

defmodule MyApp.Users.User do
use Ecto.Schema
use Pow.Ecto.Schema

# ...

def changeset(user_or_changeset, attrs) do user_or_changeset |> pow_changeset(attrs) |> validate_password() end

defp validate_password(changeset) do changeset |> validate_no_repetitive_characters() |> validate_no_sequential_characters() end

defp validate_no_repetitive_characters(changeset) do Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> case repetitive_characters?(password) do true -> [password: "has repetitive characters"] false -> [] end end) end

defp repetitive_characters?(password) when is_binary(password) do password |> String.to_charlist() |> repetitive_characters?() end defp repetitive_characters?([c, c, c | _rest]), do: true defp repetitive_characters?([_c | rest]), do: repetitive_characters?(rest) defp repetitive_characters?([]), do: false

defp validate_no_sequential_characters(changeset) do Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> case sequential_characters?(password) do true -> [password: "has sequential characters"] false -> [] end end) end

@sequences ["01234567890", "abcdefghijklmnopqrstuvwxyz"] @max_sequential_chars 3

defp sequential_characters?(password) do Enum.any?(@sequences, &sequential_characters?(password, &1)) end

defp sequential_characters?(password, sequence) do max = String.length(sequence) - 1 - @max_sequential_chars

Enum<strong>.</strong>any?(0<strong>..</strong>max, <strong>fn</strong> x <strong>-&gt;</strong>
  pattern <strong>=</strong> String<strong>.</strong>slice(sequence, x, @max_sequential_chars <strong>+</strong> 1)

  String<strong>.</strong>contains?(password, pattern)
<strong>end</strong>)

end end

As you can see, you’ll be able to modify @sequences and add what is appropriate for your app. It may be that you want to support another alphabet or keyboard layout sequences like qwerty.


Dictionary words

A dictionary lookup is very easy to create. This is just a very simple example that you can add to your user schema module:

defmodule MyApp.Users.User do
use Ecto.Schema
use Pow.Ecto.Schema

# ...

def changeset(user_or_changeset, attrs) do user_or_changeset |> pow_changeset(attrs) |> validate_password_dictionary() end

defp validate_password_dictionary(changeset) do Ecto.Changeset.validate_change(changeset, :password, fn :password, password -> password |> String.downcase() |> password_in_dictionary?() |> case do true -> [password: "is too common"] false -> [] end end) end

:my_app |> :code.priv_dir() |> Path.join("dictionary.txt") |> File.stream!() |> Stream.map(&String.trim/1) |> Stream.each(fn password -> defp password_in_dictionary?(unquote(password)), do: true end) |> Stream.run()

defp password_in_dictionary?(_password), do: false end

In the above priv/dictionary.txt will be processed on compile time. The plain text file contains words separated by newline.


Require users to change weak password upon sign in

You may want to ensure that users update their password if they have been breached or are too weak. You can do this be requiring users to reset their password upon sign in.

This can be dealt with in a plug, or custom controller. A plug method could look like this:

def check_password(conn, _opts) do
changeset = MyApp.Users.User.changeset(%MyApp.Users.User{}, conn.params["user"])

case changeset.errors[:password] do nil -> conn

error <strong>-&gt;</strong>
  msg <strong>=</strong> MyAppWeb<strong>.</strong>ErrorHelpers<strong>.</strong>translate_error(error)

  conn
  <strong>|&gt;</strong> put_flash(:error, "You have to reset your password because it #{msg}")
  <strong>|&gt;</strong> redirect(to: Routes<strong>.</strong>pow_reset_password_reset_password_path(conn, :new))
  <strong>|&gt;</strong> Plug<strong>.</strong>Conn<strong>.</strong>halt()

end end

The user will be redirected to the reset password page, and the connection halted so authentication won’t happen. A caveat to this is that the user may not have entered valid credentials, since this runs before any authentication.


Conclusion

As you can see it is easy to customize and extend the password validation rules of Pow.

The landscape of web security is constantly changing, so it’s important that password requirements are neither so restricting that it affects user experience or too lax that it affects security. The above will work for most cases in the current landscape, but you should also consider supporting 2FA authentication, or alternative authentication schemes such as WebAuthn or OAuth.

It depends on your requirements and risk tolerance. It’s recommended to take your time to assess what is appropriate for your app.


Unit tests

Here is a unit test module that contains tests for two of of the above rulesets:

defmodule MyApp.Users.UserTest do
use MyApp.DataCase

alias MyApp.Users.User

test "changeset/2 validates context-specific words" do for invalid <- ["my demo app", "mydemo_app", "mydemoapp1"] do changeset = User.changeset(%User{}, %{"username" => "john.doe", "password" => invalid}) assert changeset.errors[:password] == {"is too similar to username, email or My Demo App", []} end

<em># The below is for email user id</em>
changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"email" <strong>=&gt;</strong> "john.doe@example.com", "password" <strong>=&gt;</strong> "password12"})
refute changeset<strong>.</strong>errors[:password]

for invalid <strong>&lt;-</strong> ["john.doe@example.com", "johndoeexamplecom"] <strong>do</strong>
  changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"email" <strong>=&gt;</strong> "john.doe@example.com", "password" <strong>=&gt;</strong> invalid})
  assert changeset<strong>.</strong>errors[:password] <strong>==</strong> {"is too similar to username, email or My Demo App", []}
<strong>end</strong>

<em># The below is for username user id</em>
changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"username" <strong>=&gt;</strong> "john.doe", "password" <strong>=&gt;</strong> "password12"})
refute changeset<strong>.</strong>errors[:password]

for invalid <strong>&lt;-</strong> ["john.doe00", "johndoe", "johndoe1"] <strong>do</strong>
  changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"username" <strong>=&gt;</strong> "john.doe", "password" <strong>=&gt;</strong> invalid})
  assert changeset<strong>.</strong>errors[:password] <strong>==</strong> {"is too similar to username, email or My Demo App", []}
<strong>end</strong>

end

test "changeset/2 validates repetitive and sequential password" do changeset = User.changeset(%User{}, %{"password" => "secret1222"}) assert changeset.errors[:password] == {"has repetitive characters", []}

changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=&gt;</strong> "secret1223"})
refute changeset<strong>.</strong>errors[:password]

changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=&gt;</strong> "secret1234"})
assert changeset<strong>.</strong>errors[:password] <strong>==</strong> {"has sequential characters", []}

changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=&gt;</strong> "secret1235"})
refute changeset<strong>.</strong>errors[:password]

changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=&gt;</strong> "secretefgh"})
assert changeset<strong>.</strong>errors[:password] <strong>==</strong> {"has sequential characters", []}

changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=&gt;</strong> "secretafgh"})
refute changeset<strong>.</strong>errors[:password]

end end