Password breach lookup and other password validation rules
From the collection
Elixir
- 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
def deps do [ ... {:ex_pwned, "~> 0.1.0"} ] end
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
Context-specific words, such as the name of the service, the username, and derivatives thereof
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
Repetitive or sequential characters
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>-></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
Dictionary words
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
Require users to change weak password upon sign in
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>-></strong> msg <strong>=</strong> MyAppWeb<strong>.</strong>ErrorHelpers<strong>.</strong>translate_error(error) conn <strong>|></strong> put_flash(:error, "You have to reset your password because it #{msg}") <strong>|></strong> redirect(to: Routes<strong>.</strong>pow_reset_password_reset_password_path(conn, :new)) <strong>|></strong> Plug<strong>.</strong>Conn<strong>.</strong>halt()
end end
Conclusion
Unit tests
defmodule MyApp.Users.UserTest do use MyApp.DataCasealias 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>=></strong> "john.doe@example.com", "password" <strong>=></strong> "password12"}) refute changeset<strong>.</strong>errors[:password] for invalid <strong><-</strong> ["john.doe@example.com", "johndoeexamplecom"] <strong>do</strong> changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"email" <strong>=></strong> "john.doe@example.com", "password" <strong>=></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>=></strong> "john.doe", "password" <strong>=></strong> "password12"}) refute changeset<strong>.</strong>errors[:password] for invalid <strong><-</strong> ["john.doe00", "johndoe", "johndoe1"] <strong>do</strong> changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"username" <strong>=></strong> "john.doe", "password" <strong>=></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>=></strong> "secret1223"}) refute changeset<strong>.</strong>errors[:password] changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=></strong> "secret1234"}) assert changeset<strong>.</strong>errors[:password] <strong>==</strong> {"has sequential characters", []} changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=></strong> "secret1235"}) refute changeset<strong>.</strong>errors[:password] changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=></strong> "secretefgh"}) assert changeset<strong>.</strong>errors[:password] <strong>==</strong> {"has sequential characters", []} changeset <strong>=</strong> User<strong>.</strong>changeset(%User{}, %{"password" <strong>=></strong> "secretafgh"}) refute changeset<strong>.</strong>errors[:password]
end end