Password breach lookup and other password validation rules
by Maarten van Vliet, Dan Schultzer on 14 September 2019
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.any?(0..max, fn x ->
pattern = String.slice(sequence, x, @max_sequential_chars + 1)
String.contains?(password, pattern)
end)
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 ->
msg = MyAppWeb.ErrorHelpers.translate_error(error)
conn
|> put_flash(:error, "You have to reset your password because it #{msg}")
|> redirect(to: Routes.pow_reset_password_reset_password_path(conn, :new))
|> Plug.Conn.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
# The below is for email user id
changeset = User.changeset(%User{}, %{"email" => "john.doe@example.com", "password" => "password12"})
refute changeset.errors[:password]
for invalid <- ["john.doe@example.com", "johndoeexamplecom"] do
changeset = User.changeset(%User{}, %{"email" => "john.doe@example.com", "password" => invalid})
assert changeset.errors[:password] == {"is too similar to username, email or My Demo App", []}
end
# The below is for username user id
changeset = User.changeset(%User{}, %{"username" => "john.doe", "password" => "password12"})
refute changeset.errors[:password]
for invalid <- ["john.doe00", "johndoe", "johndoe1"] 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
end
test "changeset/2 validates repetitive and sequential password" do
changeset = User.changeset(%User{}, %{"password" => "secret1222"})
assert changeset.errors[:password] == {"has repetitive characters", []}
changeset = User.changeset(%User{}, %{"password" => "secret1223"})
refute changeset.errors[:password]
changeset = User.changeset(%User{}, %{"password" => "secret1234"})
assert changeset.errors[:password] == {"has sequential characters", []}
changeset = User.changeset(%User{}, %{"password" => "secret1235"})
refute changeset.errors[:password]
changeset = User.changeset(%User{}, %{"password" => "secretefgh"})
assert changeset.errors[:password] == {"has sequential characters", []}
changeset = User.changeset(%User{}, %{"password" => "secretafgh"})
refute changeset.errors[:password]
end
end