├── test ├── test_helper.exs ├── meta_test.exs ├── password_validator │ └── utils_test.exs ├── validators │ ├── length_validator_test.exs │ └── character_set_validator_test.exs └── password_validator_test.exs ├── .tool-versions ├── .formatter.exs ├── DEVELOPMENT.md ├── .gitignore ├── lib ├── password_validator │ ├── validator.ex │ ├── utils.ex │ └── validators │ │ ├── character_set_validator │ │ └── config.ex │ │ ├── length_validator.ex │ │ └── character_set_validator.ex └── password_validator.ex ├── config └── config.exs ├── .github └── workflows │ └── ci.yml ├── mix.exs ├── CHANGELOG.md ├── mix.lock ├── README.md ├── .credo.exs └── LICENSE.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.3-otp-27 2 | erlang 27.2.4 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Cutting a Release 2 | 3 | Bump the version in mix.exs and CHANGELOG.md 4 | 5 | ``` 6 | mix hex.publish 7 | git tag v0.5.2 # use specific version number 8 | git push --tags 9 | ``` 10 | 11 | View the hexdocs to ensure that they were published correctly 12 | 13 | References: 14 | * https://hex.pm/docs/publish 15 | -------------------------------------------------------------------------------- /test/meta_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidatorMetaTest do 2 | use ExUnit.Case, async: true 3 | 4 | for file <- ["README.md"] do 5 | doctest_file(file) 6 | end 7 | 8 | test "README.md version is up to date" do 9 | app = Mix.Project.get!().project()[:app] 10 | 11 | app_version = 12 | Application.spec(app, :vsn) 13 | |> to_string() 14 | |> Version.parse!() 15 | 16 | readme = File.read!("README.md") 17 | [_, readme_version] = Regex.run(~r/{:#{app}, "(.+)"}/, readme) 18 | assert String.contains?(readme_version, "#{app_version.major}.#{app_version.minor}") 19 | assert Version.match?(app_version, readme_version) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | password_validator-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /lib/password_validator/validator.ex: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Validator do 2 | @moduledoc """ 3 | Specifies the behaviour needed to implement a custom (or built-in) validator. 4 | """ 5 | 6 | @type error_info :: String.t() | {String.t(), Keyword.t()} 7 | 8 | @doc """ 9 | Validate the given string and return `:ok` or `{:error, errors}` where 10 | `errors` is a list. 11 | """ 12 | @callback validate(String.t(), Keyword.t()) :: 13 | :ok | {:error, nonempty_list(error_info)} 14 | 15 | @spec return_errors_or_ok(list()) :: :ok | {:error, nonempty_list()} 16 | def return_errors_or_ok(results) do 17 | PasswordValidator.Utils.ok_or_errors(results) 18 | end 19 | 20 | def return_errors_or_ok_old(results) do 21 | errors = for {:error, reason} <- results, do: reason 22 | 23 | _example_error_info = [ 24 | validator: PasswordValidator.Validators.CharacterSetValidator, 25 | error_type: :upper_case_too_long 26 | ] 27 | 28 | case errors do 29 | [] -> :ok 30 | _ -> {:error, errors} 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/password_validator/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.UtilsTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias PasswordValidator.Utils 5 | 6 | describe "ok_or_errors/1" do 7 | import Utils, only: [ok_or_errors: 1] 8 | 9 | test "with a single ok" do 10 | assert ok_or_errors([:ok]) == :ok 11 | end 12 | 13 | test "with multiple ok" do 14 | assert ok_or_errors([:ok, :ok]) == :ok 15 | end 16 | 17 | test "with a single error" do 18 | assert ok_or_errors([{:error, "err"}]) == {:error, ["err"]} 19 | end 20 | 21 | test "with all errors keeps the order" do 22 | assert ok_or_errors([{:error, "1"}, {:error, 2}]) == {:error, ["1", 2]} 23 | end 24 | 25 | test "with a mix of ok and errors" do 26 | assert ok_or_errors([:ok, :ok, {:error, 42}, :ok]) == {:error, [42]} 27 | end 28 | 29 | test "with some complex errors" do 30 | assert ok_or_errors([:ok, {:error, {1, 3}}, :ok, {:error, %{a: 42}}]) == 31 | {:error, [{1, 3}, %{a: 42}]} 32 | end 33 | 34 | test "with an empty list" do 35 | assert ok_or_errors([]) == :ok 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :password_validator, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:password_validator, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/password_validator/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Utils do 2 | @spec ok_or_errors(list(:ok | {:error, any()})) :: :ok | {:error, list()} 3 | def ok_or_errors(list) when is_list(list) do 4 | Enum.reduce(list, :ok, fn 5 | :ok, :ok -> 6 | :ok 7 | 8 | :ok, {:error, items} -> 9 | {:error, items} 10 | 11 | # First error 12 | {:error, item}, :ok -> 13 | {:error, [item]} 14 | 15 | {:error, item}, {:error, items} -> 16 | {:error, [item | items]} 17 | end) 18 | |> case do 19 | :ok -> :ok 20 | {:error, items} -> {:error, Enum.reverse(items)} 21 | end 22 | end 23 | 24 | @spec collect_errors(list({:ok, any()} | {:error, any()})) :: {:ok, list()} | {:error, list()} 25 | def collect_errors(list) when is_list(list) do 26 | Enum.reduce(list, {:ok, []}, fn 27 | {:ok, item}, {:ok, list} -> 28 | {:ok, [item | list]} 29 | 30 | {:ok, _}, {:error, messages} -> 31 | {:error, messages} 32 | 33 | {:error, message}, {:ok, _} -> 34 | {:error, [message]} 35 | 36 | {:error, message}, {:error, messages} -> 37 | {:error, [message | messages]} 38 | end) 39 | |> case do 40 | {atom, list} -> {atom, Enum.reverse(list)} 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/validators/length_validator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Validators.LengthValidatorTest do 2 | use ExUnit.Case, async: true 3 | import PasswordValidator.Validators.LengthValidator, only: [validate: 2] 4 | alias PasswordValidator.Validators.LengthValidator 5 | 6 | doctest LengthValidator 7 | 8 | test "invalid configuration" do 9 | assert_raise RuntimeError, fn -> 10 | validate("simple", length: [min: 3, max: 2]) 11 | end 12 | end 13 | 14 | test "a valid password" do 15 | assert validate("a standard pass", length: [min: 3, max: 20]) == :ok 16 | end 17 | 18 | test "a nil password is treated as an empty password" do 19 | assert validate(nil, length: [min: 1]) == 20 | {:error, 21 | [ 22 | {"String is too short. Only 0 characters instead of 1", 23 | validator: LengthValidator, error_type: :too_short} 24 | ]} 25 | end 26 | 27 | test "emoji characters are counted as one character" do 28 | assert validate("🤔12", length: [min: 3, max: 3]) == :ok 29 | end 30 | 31 | test "an invalid min value raises an error" do 32 | assert_raise RuntimeError, "min must be an integer", fn -> 33 | validate("", length: [min: "5"]) 34 | end 35 | end 36 | 37 | test "an invalid max value raises an error" do 38 | assert_raise RuntimeError, "max must be an integer", fn -> 39 | validate("", length: [max: "guild"]) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | env: 15 | MIX_ENV: test 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | include: 21 | - elixir: 1.18.3 22 | otp: 27.2.4 23 | - elixir: 1.17.2 24 | otp: 26.2.5 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: erlef/setup-beam@v1 29 | with: 30 | otp-version: ${{matrix.otp}} 31 | elixir-version: ${{matrix.elixir}} 32 | disable_problem_matchers: true 33 | version-type: "strict" 34 | - name: Cache deps 35 | uses: actions/cache@v4 36 | with: 37 | path: | 38 | ~/.hex 39 | ~/.mix 40 | deps 41 | key: ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-mix-${{ hashFiles('**/mix.lock') }} 42 | restore-keys: ${{ runner.os }}-${{ matrix.elixir }}-${{ matrix.otp }}-mix- 43 | - name: Cache build artifacts 44 | uses: actions/cache@v4 45 | with: 46 | path: | 47 | _build 48 | priv/plts 49 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 50 | - name: Install Dependencies 51 | run: | 52 | mix local.rebar --force 53 | mix local.hex --force 54 | mix deps.get 55 | # https://elixirforum.com/t/github-action-cache-elixir-always-recompiles-dependencies-elixir-1-13-3/45994/12 56 | - name: Compile Deps 57 | run: mix loadpaths 58 | - name: Run Tests 59 | run: mix test 60 | if: always() 61 | - name: Credo 62 | run: MIX_ENV=test mix credo --strict 63 | if: always() 64 | - name: Formatting 65 | run: MIX_ENV=test mix format --check-formatted 66 | if: always() 67 | # - name: Dialyzer 68 | # run: MIX_ENV=test mix dialyzer --halt-exit-status 69 | # if: always() 70 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/axelson/password-validator" 5 | @version "0.5.2" 6 | 7 | def project do 8 | [ 9 | app: :password_validator, 10 | name: "Password Validator", 11 | version: @version, 12 | elixir: "~> 1.7", 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | package: package(), 16 | deps: deps(), 17 | docs: docs(), 18 | source_url: @source_url, 19 | dialyzer: [flags: ["-Wunmatched_returns", :error_handling]] 20 | ] 21 | end 22 | 23 | def application do 24 | [extra_applications: [:logger]] 25 | end 26 | 27 | def package do 28 | [ 29 | description: 30 | "A library to validate passwords, with built-in validators for password " <> 31 | "length as well as the character sets used. Custom validators can also be created.", 32 | name: :password_validator, 33 | files: ["lib", "mix.exs", "README*", "LICENSE*", "CHANGELOG*.md"], 34 | maintainers: ["Jason Axelson"], 35 | licenses: ["Apache-2.0"], 36 | links: %{ 37 | "Changelog" => "https://hexdocs.pm/password_validator/changelog.html", 38 | "GitHub" => @source_url 39 | } 40 | ] 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:ecto, "~> 2.1 or ~> 3.0"}, 46 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 47 | {:credo, "~> 1.1", only: [:dev, :test], runtime: false}, 48 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 49 | ] 50 | end 51 | 52 | defp docs do 53 | [ 54 | extras: [ 55 | "CHANGELOG.md": [], 56 | "LICENSE.md": [title: "License"], 57 | "README.md": [title: "Overview"] 58 | ], 59 | main: "readme", 60 | canonical: "https://hexdocs.pm/password_validator", 61 | source_ref: "v#{@version}", 62 | homepage_url: @source_url, 63 | formatters: ["html"], 64 | nest_modules_by_prefix: [PasswordValidator.Validators] 65 | ] 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/password_validator/validators/character_set_validator/config.ex: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Validators.CharacterSetValidator.Config do 2 | defstruct [ 3 | :upper_case, 4 | :lower_case, 5 | :numbers, 6 | :special, 7 | :allowed_special_characters, 8 | :custom_messages 9 | ] 10 | 11 | @type keys :: :upper_case | :lower_case | :numbers | :special 12 | 13 | @type t() :: %__MODULE__{ 14 | upper_case: [:infinity | non_neg_integer()], 15 | lower_case: [:infinity | non_neg_integer()], 16 | numbers: [:infinity | non_neg_integer()], 17 | special: [:infinity | non_neg_integer()], 18 | allowed_special_characters: :all | String.t() | nil, 19 | custom_messages: any() 20 | } 21 | 22 | @spec from_options(list({atom(), any()})) :: t() 23 | def from_options(opts) do 24 | config = Keyword.get(opts, :character_set, []) 25 | 26 | %__MODULE__{ 27 | lower_case: character_set_config(config, :lower_case), 28 | upper_case: character_set_config(config, :upper_case), 29 | numbers: character_set_config(config, :numbers), 30 | special: character_set_config(config, :special), 31 | allowed_special_characters: allowed_special_characters_config(config), 32 | custom_messages: Keyword.get(config, :messages, []) 33 | } 34 | end 35 | 36 | @spec character_set_config(list(), keys()) :: list(integer() | :infinity) 37 | defp character_set_config(opts, key) do 38 | option = Keyword.get(opts, key, [0, :infinity]) 39 | 40 | case option do 41 | number when is_integer(number) -> [number, :infinity] 42 | [min, max] when is_integer(min) and is_integer(max) -> [min, max] 43 | [min, :infinity] when is_integer(min) -> [min, :infinity] 44 | _ -> raise "Invalid configuration" 45 | end 46 | end 47 | 48 | @spec allowed_special_characters_config(list()) :: String.t() | :all 49 | defp allowed_special_characters_config(opts) do 50 | case Keyword.get(opts, :allowed_special_characters, :all) do 51 | allowed_characters when is_binary(allowed_characters) -> 52 | allowed_characters 53 | 54 | :all -> 55 | :all 56 | 57 | invalid_config -> 58 | raise "Invalid allowed_special_characters config. Got: #{inspect(invalid_config)} when a binary (string) was expected" 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## 0.5.2 (2025-04-12) 9 | 10 | Housekeeping: 11 | * Update deps, docs and CI 12 | * Test on Elixir 1.18 and Erlang/OTP 27 13 | 14 | ## 0.5.1 (2024-07-14) 15 | 16 | Housekeeping: 17 | * Update deps, docs and CI 18 | * Fix dialyzer issues 19 | 20 | ## 0.5.0 (2023-05-14) 21 | 22 | **Potentially Breaking Changes** 23 | * Return additional information with the errors on changesets 24 | * This generally shouldn't break anything, but if the code is using 25 | `Ecto.Changeset.traverse_errors/2` and looking at the `additional_info` it could. 26 | 27 | Improvements: 28 | * Add the ability to customize error messages by passing `messages` to a validator 29 | 30 | Housekeeping: 31 | * Update deps and docs 32 | * Fix deprecation warning 33 | 34 | ## 0.4.1 (2020-11-11) 35 | 36 | * Bump dependencies used for development and testing 37 | * No user-visible changes 38 | 39 | ## 0.4.0 (2019-09-02) 40 | 41 | **Breaking Changes** 42 | * Extract `PasswordValidator.Validators.ZXCVBNValidator` to a separate (compatible) repository: https://github.com/axelson/password-validator-zxcvbn 43 | * Increased minimum Elixir version to 1.7 44 | * Please file an issue if this is too high 45 | 46 | ## 0.3.0 (2019-04-05) 47 | 48 | * Fix: Use a better exception message for invalid length validator configurations 49 | * Feature: Add support for zxcvbn via https://github.com/techgaun/zxcvbn-elixir 50 | * Adds zxcvbn as a dependency 51 | * Enabled by default (use `[zxcvbn: :disabled]` to disable** 52 | * Fix: Bump dev dependencies 53 | 54 | **Breaking Changes** 55 | * `PasswordValidator.Validators.ZXCVBNValidator` is enabled by default (with a 56 | minimum score of 2) which in many ways is more strict than the existing 57 | validators. 58 | * Pass `[zxcvbn: :disabled]` to disable ZXCVBNValidator. e.g. 59 | `PasswordValidator.validate_password("some password", zxcvbn: :disabled)` 60 | 61 | ## 0.2.1 (2019-01-23) 62 | 63 | * Fix compilation warning on Elixir 1.8 64 | ** https://github.com/axelson/password-validator/pull/3 65 | * Update dependencies 66 | 67 | ## 0.2.0 (2018-03-19) 68 | 69 | * Handle the case when a nil password is passed in 70 | ** https://github.com/axelson/password-validator/pull/1 71 | * Update internal dependencies 72 | 73 | Potentially breaking changes: 74 | * Update formatting of returned errors 75 | ** https://github.com/axelson/password-validator/pull/1 76 | 77 | ## 0.1.2 (2017-07-31) 78 | 79 | * Add a missing typespec and this changelog 80 | 81 | ## 0.1.1 (2017-07-31) 82 | 83 | * Doc fixes 84 | * Upgrade elixir and dependencies 85 | 86 | ## 0.1.0 (2017-07-31) 87 | 88 | * Initial public release 🎉 89 | -------------------------------------------------------------------------------- /lib/password_validator/validators/length_validator.ex: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Validators.LengthValidator do 2 | @moduledoc """ 3 | Validates a password by checking the length of the password. 4 | """ 5 | 6 | @behaviour PasswordValidator.Validator 7 | 8 | @doc """ 9 | Validate the password by checking the length 10 | 11 | Example config (min 5 characters, max 9 characters): 12 | ``` 13 | [ 14 | length: [ 15 | min: 5, 16 | max: 9, 17 | ] 18 | ] 19 | ``` 20 | 21 | ## Examples 22 | 23 | iex> LengthValidator.validate("simple2", [length: [min: 3]]) 24 | :ok 25 | 26 | iex> LengthValidator.validate("too_short", [length: [min: 10]]) 27 | {:error, [{"String is too short. Only 9 characters instead of 10", 28 | validator: PasswordValidator.Validators.LengthValidator, error_type: :too_short}]} 29 | 30 | iex> LengthValidator.validate("too_long", [length: [min: 3, max: 6]]) 31 | {:error, [{"String is too long. 8 but maximum is 6", 32 | validator: PasswordValidator.Validators.LengthValidator, error_type: :too_long}]} 33 | """ 34 | def validate(string, opts) do 35 | config = Keyword.get(opts, :length, []) 36 | min_length = Keyword.get(config, :min, :infinity) 37 | max_length = Keyword.get(config, :max, :infinity) 38 | custom_messages = Keyword.get(config, :messages, []) 39 | 40 | validate_password(string, min_length, max_length, custom_messages) 41 | end 42 | 43 | @spec validate_password(String.t(), integer(), integer() | :infinity, map()) :: 44 | :ok | {:error, nonempty_list()} 45 | defp validate_password(_, min_length, max_length, _) 46 | when is_integer(min_length) and is_integer(max_length) and min_length > max_length, 47 | do: raise("Min length cannot be greater than the max") 48 | 49 | defp validate_password(nil, min_length, max_length, custom_messages) do 50 | validate_password("", min_length, max_length, custom_messages) 51 | end 52 | 53 | defp validate_password(string, min_length, max_length, custom_messages) do 54 | length = String.length(string) 55 | 56 | [ 57 | valid_min_length?(length, min_length, custom_messages), 58 | valid_max_length?(length, max_length, custom_messages) 59 | ] 60 | |> PasswordValidator.Validator.return_errors_or_ok() 61 | end 62 | 63 | defp valid_min_length?(_, :infinity, _custom_messages), 64 | do: :ok 65 | 66 | defp valid_min_length?(_, min, _custom_messages) when not is_integer(min), 67 | do: raise("min must be an integer") 68 | 69 | defp valid_min_length?(length, min, custom_messages) when length < min, 70 | do: 71 | error( 72 | "String is too short. Only #{length} characters instead of #{min}", 73 | :too_short, 74 | custom_messages 75 | ) 76 | 77 | defp valid_min_length?(_, _, _), 78 | do: :ok 79 | 80 | defp valid_max_length?(_, :infinity, _custom_messages), 81 | do: :ok 82 | 83 | defp valid_max_length?(_, max, _custom_messages) when not is_integer(max), 84 | do: raise("max must be an integer") 85 | 86 | defp valid_max_length?(length, max, custom_messages) when length > max, 87 | do: error("String is too long. #{length} but maximum is #{max}", :too_long, custom_messages) 88 | 89 | defp valid_max_length?(_, _, _), 90 | do: :ok 91 | 92 | defp error(message, error_type, custom_messages) do 93 | message = Keyword.get(custom_messages, error_type, message) 94 | additional_info = [validator: __MODULE__, error_type: error_type] 95 | {:error, {message, additional_info}} 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/password_validator.ex: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator do 2 | @moduledoc """ 3 | Primary interface to PasswordValidator. The two main methods are `validate/3` 4 | and `validate_password/2`. 5 | 6 | ## Examples 7 | 8 | iex> opts = [ 9 | ...> length: [max: 6], 10 | ...> ] 11 | iex> PasswordValidator.validate_password("too_long", opts) 12 | {:error, ["String is too long. 8 but maximum is 6"]} 13 | 14 | iex> opts = [ 15 | ...> length: [min: 5, max: 30], 16 | ...> character_set: [ 17 | ...> lower_case: 1, # at least one lower case letter 18 | ...> upper_case: [3, :infinity], # at least three upper case letters 19 | ...> numbers: [0, 4], # at most 4 numbers 20 | ...> special: [0, 0], # no special characters allowed 21 | ...> ] 22 | ...> ] 23 | iex> changeset = Ecto.Changeset.change({%{password: "Simple_pass12345"}, %{}}, %{}) 24 | iex> changeset = PasswordValidator.validate(changeset, :password, opts) 25 | iex> changeset.errors 26 | [password: {"Too many special (1 but maximum is 0)", [ 27 | {:validator, PasswordValidator.Validators.CharacterSetValidator}, 28 | {:error_type, :too_many_special} 29 | ]}, 30 | password: {"Too many numbers (5 but maximum is 4)", [ 31 | {:validator, PasswordValidator.Validators.CharacterSetValidator}, 32 | {:error_type, :too_many_numbers} 33 | ]}, 34 | password: {"Not enough upper_case characters (only 1 instead of at least 3)", [ 35 | {:validator, PasswordValidator.Validators.CharacterSetValidator}, 36 | {:error_type, :too_few_upper_case} 37 | ]}] 38 | """ 39 | 40 | alias PasswordValidator.Validators 41 | 42 | @validators [ 43 | Validators.LengthValidator, 44 | Validators.CharacterSetValidator 45 | ] 46 | 47 | @spec validate(Ecto.Changeset.t(), atom(), list()) :: Ecto.Changeset.t() 48 | def validate(changeset, field, opts \\ []) do 49 | password = Ecto.Changeset.get_field(changeset, field) 50 | 51 | case do_validate(password, opts) do 52 | :ok -> 53 | changeset 54 | 55 | {:error, errors} -> 56 | Enum.reduce(errors, changeset, fn 57 | error, cset when is_binary(error) -> 58 | Ecto.Changeset.add_error(cset, field, error) 59 | 60 | {error_message, additional_info}, cset when is_binary(error_message) -> 61 | Ecto.Changeset.add_error(cset, field, error_message, additional_info) 62 | end) 63 | end 64 | end 65 | 66 | defp do_validate(password, opts) do 67 | results = 68 | validators(opts) 69 | |> Enum.map(&run_validator(&1, password, opts)) 70 | 71 | errors = 72 | for({:error, reason} <- results, do: reason) 73 | |> List.flatten() 74 | 75 | if length(errors) > 0 do 76 | {:error, errors} 77 | else 78 | :ok 79 | end 80 | end 81 | 82 | @spec validate_password(String.t(), list()) :: :ok | {:error, nonempty_list()} 83 | def validate_password(password, opts \\ []) do 84 | results = 85 | validators(opts) 86 | |> Enum.map(&run_validator(&1, password, opts)) 87 | 88 | errors = 89 | for {:error, errors} <- results do 90 | Enum.map(errors, fn 91 | {error_message, _} when is_binary(error_message) -> error_message 92 | error_message when is_binary(error_message) -> error_message 93 | end) 94 | end 95 | |> List.flatten() 96 | 97 | if length(errors) > 0 do 98 | {:error, errors} 99 | else 100 | :ok 101 | end 102 | end 103 | 104 | defp run_validator(validator, password, opts) do 105 | validator.validate(password, opts) 106 | end 107 | 108 | defp validators(opts) do 109 | additional_validators(opts) 110 | |> Enum.concat(@validators) 111 | end 112 | 113 | defp additional_validators(opts) do 114 | case Keyword.get(opts, :additional_validators, []) do 115 | validators when is_list(validators) -> validators 116 | non_list -> raise "Expected a list of validators, instead received #{inspect(non_list)}" 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /test/validators/character_set_validator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Validators.CharacterSetValidatorTest do 2 | use ExUnit.Case, async: true 3 | import PasswordValidator.Validators.CharacterSetValidator, only: [validate: 2] 4 | alias PasswordValidator.Validators.CharacterSetValidator 5 | 6 | doctest CharacterSetValidator 7 | 8 | test "a nil password is treated as an empty password" do 9 | opts = [character_set: [upper_case: 1]] 10 | result = validate(nil, opts) 11 | 12 | assert result == 13 | {:error, 14 | [ 15 | {"Not enough upper_case characters (only 0 instead of at least 1)", 16 | validator: CharacterSetValidator, error_type: :too_few_upper_case} 17 | ]} 18 | end 19 | 20 | test "upper_case 2" do 21 | opts = [character_set: [upper_case: 2]] 22 | result = validate("String", opts) 23 | 24 | assert result == 25 | {:error, 26 | [ 27 | {"Not enough upper_case characters (only 1 instead of at least 2)", 28 | validator: CharacterSetValidator, error_type: :too_few_upper_case} 29 | ]} 30 | end 31 | 32 | test "upper_case [0, 2] ok" do 33 | opts = [character_set: [upper_case: [0, 2]]] 34 | result = validate("String", opts) 35 | assert result == :ok 36 | end 37 | 38 | test "upper_case [0, 2] too many" do 39 | opts = [character_set: [upper_case: [0, 2]]] 40 | result = validate("STRING", opts) 41 | 42 | assert result == 43 | {:error, 44 | [ 45 | {"Too many upper_case (6 but maximum is 2)", 46 | validator: CharacterSetValidator, error_type: :too_many_upper_case} 47 | ]} 48 | end 49 | 50 | test "lower_case" do 51 | opts = [character_set: [lower_case: [1, :infinity]]] 52 | result = validate("String", opts) 53 | assert result == :ok 54 | end 55 | 56 | test "lower_case with a custom error message" do 57 | opts = [character_set: [lower_case: 10, messages: [too_few_lower_case: "way too few"]]] 58 | result = validate("String", opts) 59 | 60 | assert result == 61 | {:error, 62 | [ 63 | {"way too few", 64 | [validator: CharacterSetValidator, error_type: :too_few_lower_case]} 65 | ]} 66 | end 67 | 68 | test "allowed_special_characters when the string contains only allowed characters" do 69 | opts = [character_set: [allowed_special_characters: "!-_"]] 70 | assert validate("Spec-ial!", opts) == :ok 71 | end 72 | 73 | test "allowed_special_characters when the string contains non-allowed characters" do 74 | opts = [character_set: [allowed_special_characters: "!-_"]] 75 | result = validate("String_speci@l%", opts) 76 | 77 | assert result == 78 | {:error, 79 | [ 80 | {"Invalid character(s) found. (@%)", 81 | validator: CharacterSetValidator, error_type: :invalid_special_characters} 82 | ]} 83 | end 84 | 85 | test "multiple errors" do 86 | opts = [ 87 | character_set: [ 88 | allowed_special_characters: "!-_", 89 | special: 3 90 | ] 91 | ] 92 | 93 | result = validate("String_speci@l%", opts) 94 | 95 | assert result == 96 | {:error, 97 | [ 98 | {"Not enough special characters (only 1 instead of at least 3)", 99 | validator: CharacterSetValidator, error_type: :too_few_special}, 100 | {"Invalid character(s) found. (@%)", 101 | validator: CharacterSetValidator, error_type: :invalid_special_characters} 102 | ]} 103 | end 104 | 105 | test "with an invalid allowed_special_characters_config" do 106 | opts = [ 107 | character_set: [ 108 | allowed_special_characters: %{a: true} 109 | ] 110 | ] 111 | 112 | error_message = 113 | "Invalid allowed_special_characters config. Got: %{a: true} when a binary (string) was expected" 114 | 115 | assert_raise RuntimeError, error_message, fn -> 116 | validate("str@", opts) 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 4 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 8 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 9 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 10 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 11 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 12 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 16 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 17 | } 18 | -------------------------------------------------------------------------------- /test/password_validator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidatorTest do 2 | use ExUnit.Case, async: true 3 | doctest PasswordValidator 4 | 5 | @strong_password "shine coin desert" 6 | 7 | defmodule CustomValidator do 8 | @behaviour PasswordValidator.Validator 9 | 10 | def validate(_string, _opts) do 11 | {:error, ["Invalid password"]} 12 | end 13 | end 14 | 15 | describe "validate/3" do 16 | test "validate with a valid string returns a valid changeset" do 17 | changeset = validate(@strong_password) 18 | 19 | assert changeset.valid? 20 | end 21 | 22 | test "validate with one error returns an invalid changeset" do 23 | opts = [length: [max: 6]] 24 | 25 | changeset = validate("Passw0rd", opts) 26 | 27 | refute changeset.valid? 28 | assert errors_on(changeset) == %{password: ["String is too long. 8 but maximum is 6"]} 29 | end 30 | 31 | test "validate too few with custom error message" do 32 | opts = [ 33 | length: [ 34 | min: 9, 35 | max: 20, 36 | messages: [too_short: "Too few chars", too_long: "Too many chars"] 37 | ] 38 | ] 39 | 40 | changeset = validate("Password", opts) 41 | 42 | assert errors_on(changeset) == %{ 43 | password: ["Too few chars"] 44 | } 45 | end 46 | 47 | test "validate too many with custom error message" do 48 | opts = [ 49 | length: [ 50 | min: 6, 51 | max: 12, 52 | messages: [too_short: "Too few chars", too_long: "Too many chars"] 53 | ] 54 | ] 55 | 56 | changeset = validate("PasswordIsLong!", opts) 57 | 58 | assert errors_on(changeset) == %{ 59 | password: ["Too many chars"] 60 | } 61 | end 62 | 63 | test "validate with two errors returns an invalid changeset" do 64 | opts = [ 65 | length: [min: 9], 66 | character_set: [numbers: 3] 67 | ] 68 | 69 | changeset = validate("S3cr3t", opts) 70 | 71 | refute changeset.valid? 72 | 73 | assert hd(changeset.errors) == 74 | {:password, 75 | {"Not enough numbers characters (only 2 instead of at least 3)", 76 | validator: PasswordValidator.Validators.CharacterSetValidator, 77 | error_type: :too_few_numbers}} 78 | 79 | assert errors_on(changeset) == %{ 80 | password: [ 81 | "Not enough numbers characters (only 2 instead of at least 3)", 82 | "String is too short. Only 6 characters instead of 9" 83 | ] 84 | } 85 | end 86 | 87 | test "validate with an invalid setting for additional validators raises an error" do 88 | assert_raise RuntimeError, "Expected a list of validators, instead received :invalid", fn -> 89 | validate("password", additional_validators: :invalid) 90 | end 91 | end 92 | end 93 | 94 | test "validate_password length too short" do 95 | opts = [length: [min: 8]] 96 | assert {:error, reasons} = PasswordValidator.validate_password("short", opts) 97 | assert "String is too short. Only 5 characters instead of 8" in reasons 98 | end 99 | 100 | test "validate_password length too long" do 101 | opts = [length: [max: 6]] 102 | assert {:error, reasons} = PasswordValidator.validate_password("way too long", opts) 103 | assert "String is too long. 12 but maximum is 6" in reasons 104 | end 105 | 106 | test "validate_password with invalid options" do 107 | opts = [length: [min: 20, max: 10]] 108 | 109 | assert_raise RuntimeError, "Min length cannot be greater than the max", fn -> 110 | PasswordValidator.validate_password("some password", opts) 111 | end 112 | end 113 | 114 | test "validate_password with errors on multiple validators" do 115 | opts = [ 116 | length: [min: 7], 117 | character_set: [upper_case: 1] 118 | ] 119 | 120 | result = PasswordValidator.validate_password("short", opts) 121 | 122 | assert result == { 123 | :error, 124 | [ 125 | "String is too short. Only 5 characters instead of 7", 126 | "Not enough upper_case characters (only 0 instead of at least 1)" 127 | ] 128 | } 129 | end 130 | 131 | test "validate_password works with a custom validator" do 132 | result = 133 | PasswordValidator.validate_password(@strong_password, 134 | additional_validators: [CustomValidator] 135 | ) 136 | 137 | assert result == {:error, ["Invalid password"]} 138 | end 139 | 140 | test "validate/3 works with a custom validator" do 141 | changeset = validate(@strong_password, additional_validators: [CustomValidator]) 142 | 143 | assert changeset.errors == [password: {"Invalid password", []}] 144 | refute changeset.valid? 145 | end 146 | 147 | test "README.md version is up to date" do 148 | app = :password_validator 149 | app_version = Application.spec(app, :vsn) |> to_string() 150 | readme = File.read!("README.md") 151 | [_, readme_version] = Regex.run(~r/{:#{app}, "(.+)"}/, readme) 152 | assert Version.match?(app_version, readme_version) 153 | end 154 | 155 | # test "README.md doctests" do 156 | # Mix.Task.run("docception", ["README.md"]) 157 | # end 158 | 159 | defp validate(password, opts \\ []) do 160 | {%{password: password}, password: :string} 161 | |> Ecto.Changeset.change() 162 | |> PasswordValidator.validate(:password, opts) 163 | end 164 | 165 | defp errors_on(changeset) do 166 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 167 | Enum.reduce(opts, message, fn {key, value}, acc -> 168 | String.replace(acc, "%{#{key}}", to_string(value)) 169 | end) 170 | end) 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/password_validator/validators/character_set_validator.ex: -------------------------------------------------------------------------------- 1 | defmodule PasswordValidator.Validators.CharacterSetValidator do 2 | @moduledoc """ 3 | Validates a password by checking the different types of characters contained 4 | within. 5 | """ 6 | 7 | alias PasswordValidator.Validators.CharacterSetValidator.Config 8 | 9 | @behaviour PasswordValidator.Validator 10 | 11 | @initial_counts %{ 12 | upper_case: 0, 13 | lower_case: 0, 14 | numbers: 0, 15 | special: 0, 16 | other: [] 17 | } 18 | 19 | @character_sets [:lower_case, :upper_case, :numbers, :special] 20 | 21 | @doc """ 22 | Example config 23 | [ 24 | character_set: [ 25 | # Require at least 1 upper case letter 26 | upper_case: [1, :infinity], 27 | # Require at least 1 lower case letter 28 | lower_case: 1, 29 | # Require at least 1 number 30 | numbers: 1, 31 | # Require exactly 0 special characters 32 | special: [0, 0], 33 | # Specify which special characters are allowed (default is :all) 34 | allowed_special_characters: "!@#$%^&*()", 35 | ] 36 | ] 37 | """ 38 | def validate(_, []), do: :ok 39 | 40 | def validate(string, opts) when is_list(opts) do 41 | config = Config.from_options(opts) 42 | validate_password(string, config) 43 | end 44 | 45 | defp validate_password(nil, %Config{} = config) do 46 | validate_password("", config) 47 | end 48 | 49 | @spec validate_password(String.t(), Config.t()) :: :ok | {:error, nonempty_list()} 50 | defp validate_password(string, %Config{} = config) do 51 | counts = count_character_sets(string, config.allowed_special_characters) 52 | 53 | @character_sets 54 | |> Enum.map(&validate_character_set(&1, counts, config)) 55 | |> Enum.concat([validate_other(counts)]) 56 | |> Enum.map(&interpret_additional_info(&1, config.custom_messages)) 57 | |> PasswordValidator.Validator.return_errors_or_ok() 58 | end 59 | 60 | def interpret_additional_info({_, :ok}, _custom_messages), do: :ok 61 | 62 | def interpret_additional_info({type, {:error, sub_type, reason}}, custom_messages) do 63 | error_type = error_type(type, sub_type) 64 | reason = Keyword.get(custom_messages, error_type, reason) 65 | additional_info = [validator: __MODULE__, error_type: error_type] 66 | {:error, {reason, additional_info}} 67 | end 68 | 69 | defp error_type(:other, :invalid), do: :invalid_special_characters 70 | 71 | defp error_type(type, sub_type) do 72 | String.to_atom("#{sub_type}_#{type}") 73 | end 74 | 75 | @spec validate_character_set(atom(), map(), Config.t()) :: 76 | {atom(), :ok} | {atom(), {:error, String.t()}} 77 | for character_set <- @character_sets do 78 | def validate_character_set( 79 | unquote(character_set), 80 | %{unquote(character_set) => count}, 81 | %Config{unquote(character_set) => character_set_config} 82 | ) do 83 | result = do_validate_character_set(unquote(character_set), count, character_set_config) 84 | 85 | {unquote(character_set), result} 86 | end 87 | end 88 | 89 | @spec do_validate_character_set(atom(), integer(), list()) :: :ok | {:error, String.t()} 90 | def do_validate_character_set(character_set, count, config) 91 | 92 | def do_validate_character_set(_, _, [0, :infinity]) do 93 | :ok 94 | end 95 | 96 | def do_validate_character_set(_, count, [min, :infinity]) when count > min do 97 | :ok 98 | end 99 | 100 | def do_validate_character_set(character_set, count, [min, _]) when count < min do 101 | {:error, :too_few, 102 | "Not enough #{character_set} characters (only #{count} instead of at least #{min})"} 103 | end 104 | 105 | def do_validate_character_set(character_set, count, [_, max]) when count > max do 106 | {:error, :too_many, "Too many #{character_set} (#{count} but maximum is #{max})"} 107 | end 108 | 109 | def do_validate_character_set(_, count, [min, max]) when min <= count and count <= max do 110 | :ok 111 | end 112 | 113 | def do_validate_character_set(_, _, config) do 114 | raise "Invalid character set config. (#{inspect(config)})" 115 | end 116 | 117 | defp validate_other(%{other: []}), 118 | do: {:other, :ok} 119 | 120 | defp validate_other(%{other: other_characters}) when length(other_characters) > 0, 121 | do: {:other, {:error, :invalid, "Invalid character(s) found. (#{other_characters})"}} 122 | 123 | @spec count_character_sets(String.t(), String.t() | nil, map()) :: map() 124 | defp count_character_sets(string, special_characters, counts \\ @initial_counts) 125 | defp count_character_sets("", _, counts), do: counts 126 | 127 | defp count_character_sets(string, special_characters, counts) do 128 | {grapheme, rest} = String.next_grapheme(string) 129 | 130 | counts = 131 | cond do 132 | String.match?(grapheme, ~r/[a-z]/) -> 133 | update_count(counts, :lower_case) 134 | 135 | String.match?(grapheme, ~r/[A-Z]/) -> 136 | update_count(counts, :upper_case) 137 | 138 | String.match?(grapheme, ~r/[0-9]/) -> 139 | update_count(counts, :numbers) 140 | 141 | special_character?(grapheme, special_characters) -> 142 | update_count(counts, :special) 143 | 144 | true -> 145 | Map.update!(counts, :other, &Enum.concat(&1, [grapheme])) 146 | end 147 | 148 | count_character_sets(rest, special_characters, counts) 149 | end 150 | 151 | @spec update_count(map(), atom()) :: map() 152 | defp update_count(counts, key) do 153 | Map.update!(counts, key, &(&1 + 1)) 154 | end 155 | 156 | @spec special_character?(String.t(), :all | String.t()) :: boolean() 157 | defp special_character?(_string, :all), do: true 158 | 159 | defp special_character?(string, special_characters) when is_binary(special_characters) do 160 | String.contains?(special_characters, string) 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PasswordValidator 2 | 3 | [![Module Version](https://img.shields.io/hexpm/v/password_validator.svg)](https://hex.pm/packages/password_validator) 4 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/password_validator/) 5 | [![Total Download](https://img.shields.io/hexpm/dt/password_validator.svg)](https://hex.pm/packages/password_validator) 6 | [![License](https://img.shields.io/hexpm/l/password-validator.svg)](https://github.com/axelson/password-validator/blob/master/LICENSE.md) 7 | [![Last Updated](https://img.shields.io/github/last-commit/axelson/password-validator.svg)](https://github.com/axelson/password-validator/commits/master) 8 | 9 | PasswordValidator is a library to validate passwords, makes sense doesn't it? By 10 | default two validators are built in, but it is also possible to create your own 11 | custom validator for more advanced usage. 12 | 13 | Validators: 14 | * LengthValidator - validates the length of the password 15 | * CharacterSetValidator - validates the characters contained within the 16 | password, number of lower case, number of upper case, number of special 17 | characters, etc. 18 | * [ZXCVBNValidator](https://github.com/axelson/password-validator-zxcvbn) - Uses 19 | Dropbox's [zxcvbn](https://github.com/dropbox/zxcvbn) algorithm to rate 20 | passwords 21 | 22 | The primary use case is validating an `%Ecto.Changeset{}` 23 | 24 | ## Installation 25 | 26 | `PasswordValidator` is [available in Hex](https://hex.pm/packages/password_validator), the package can be installed 27 | by adding `password_validator` to your list of dependencies in `mix.exs`: 28 | 29 | ```elixir 30 | def deps do 31 | [ 32 | {:password_validator, "~> 0.5"}, 33 | ] 34 | end 35 | ``` 36 | 37 | The docs can be found at [https://hexdocs.pm/password_validator](https://hexdocs.pm/password_validator). 38 | 39 | ## Usage 40 | 41 | PasswordValidator will typically be used within the changeset function of an Ecto schema: 42 | 43 | ``` elixir 44 | @password_opts [ 45 | length: [min: 7, max: 30, messages: [too_short: "Password is too short!"]], 46 | character_set: [ 47 | lower_case: 5, # at least five lower case letters 48 | upper_case: [3, :infinity], # at least three upper case letters 49 | numbers: [1, 4], # from 1 to 4 number characters 50 | special: [0, 0], # no special characters allowed 51 | ] 52 | ] 53 | 54 | def changeset(user, attrs) do 55 | user 56 | |> cast(attrs, [:name, :age, :password]) 57 | |> validate_required([:name, :age, :password]) 58 | # Add this to your changeset 59 | |> PasswordValidator.validate(:password, @password_opts) 60 | end 61 | ``` 62 | 63 | Example interactive usage: 64 | 65 | ``` elixir 66 | iex> opts = [ 67 | ...> length: [min: 12, max: 30], 68 | ...> ] 69 | iex> changeset = Ecto.Changeset.change({%{password: "simple_pass"}, %{}}, %{}) 70 | #Ecto.Changeset 71 | iex> PasswordValidator.validate(changeset, :password, opts) 72 | #Ecto.Changeset 73 | ``` 74 | 75 | Full example: 76 | ``` elixir 77 | iex> opts = [ 78 | ...> length: [min: 5, max: 30], 79 | ...> character_set: [ 80 | ...> lower_case: 1, # at least one lower case letter 81 | ...> upper_case: [3, :infinity], # at least three upper case letters 82 | ...> numbers: [0, 4], # at most 4 numbers 83 | ...> special: [0, 0], # no special characters allowed 84 | ...> ] 85 | ...> ] 86 | iex> changeset = Ecto.Changeset.change({%{password: "Simple_pass12345"}, %{}}, %{}) 87 | iex> changeset = PasswordValidator.validate(changeset, :password, opts) 88 | iex> changeset.errors 89 | [password: {"Too many special (1 but maximum is 0)", [ 90 | {:validator, PasswordValidator.Validators.CharacterSetValidator}, 91 | {:error_type, :too_many_special} 92 | ]}, 93 | password: {"Too many numbers (5 but maximum is 4)", [ 94 | {:validator, PasswordValidator.Validators.CharacterSetValidator}, 95 | {:error_type, :too_many_numbers} 96 | ]}, 97 | password: {"Not enough upper_case characters (only 1 instead of at least 3)", [ 98 | {:validator, PasswordValidator.Validators.CharacterSetValidator}, 99 | {:error_type, :too_few_upper_case} 100 | ]}] 101 | ``` 102 | 103 | If you want to check that a PasswordValidator error was added then in the changeset's errors field you can check that there is an error with the key `:validator` 104 | 105 | PasswordValidator can also be run directly on a String: 106 | 107 | ``` 108 | iex> opts = [ 109 | ...> length: [max: 6], 110 | ...> ] 111 | iex> PasswordValidator.validate_password("too_long", opts) 112 | {:error, ["String is too long. 8 but maximum is 6"]} 113 | ``` 114 | 115 | Note: The `CharacterSetValidator` set of allowed special characters defaults to 116 | any character that is not lower case, upper case, or a number. If the 117 | `CharacterSetValidator` is passed `allowed_special_characters` (as a string) 118 | then just those characters will be considered as special characters and any 119 | other characters will be considered "other" and will fail the password check. 120 | For full details see the `CharacterSetValidator` docs. 121 | 122 | Note: On an invalid configuration the library will raise an error. 123 | 124 | ## Custom validators 125 | 126 | Custom Validators need to implement the `PasswordValidator.Validator` behaviour. 127 | Currently the only callback is `validate`. They can then be supplied as options (to either `PasswordValidator.validate/3` or `PasswordValidator.validate_password/2`) 128 | 129 | ## Constraints 130 | 131 | * Doesn't deal well with non-latin characters 132 | * Currently always pulls in Ecto as a dependency 133 | 134 | ## Contributing 135 | 136 | To run the default test suite, run `mix test` 137 | 138 | PR's and discussions welcome! 139 | 140 | ## Copyright and License 141 | 142 | Copyright (c) 2017 Jason Axelson 143 | 144 | Licensed under the Apache License, Version 2.0 (the "License"); 145 | you may not use this file except in compliance with the License. 146 | You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) 147 | 148 | Unless required by applicable law or agreed to in writing, software 149 | distributed under the License is distributed on an "AS IS" BASIS, 150 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 151 | See the License for the specific language governing permissions and 152 | limitations under the License. 153 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: [ 68 | # 69 | ## Consistency Checks 70 | # 71 | {Credo.Check.Consistency.ExceptionNames, []}, 72 | {Credo.Check.Consistency.LineEndings, []}, 73 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 74 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 75 | {Credo.Check.Consistency.SpaceInParentheses, []}, 76 | {Credo.Check.Consistency.TabsOrSpaces, []}, 77 | 78 | # 79 | ## Design Checks 80 | # 81 | # You can customize the priority of any check 82 | # Priority values are: `low, normal, high, higher` 83 | # 84 | {Credo.Check.Design.AliasUsage, 85 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 86 | # You can also customize the exit_status of each check. 87 | # If you don't want TODO comments to cause `mix credo` to fail, just 88 | # set this value to 0 (zero). 89 | # 90 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | 93 | # 94 | ## Readability Checks 95 | # 96 | {Credo.Check.Readability.AliasOrder, []}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.LargeNumbers, []}, 99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 100 | {Credo.Check.Readability.ModuleAttributeNames, []}, 101 | {Credo.Check.Readability.ModuleDoc, false}, 102 | {Credo.Check.Readability.ModuleNames, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PredicateFunctionNames, []}, 106 | {Credo.Check.Readability.PreferImplicitTry, []}, 107 | {Credo.Check.Readability.RedundantBlankLines, []}, 108 | {Credo.Check.Readability.Semicolons, []}, 109 | {Credo.Check.Readability.SpaceAfterCommas, []}, 110 | {Credo.Check.Readability.StringSigils, []}, 111 | {Credo.Check.Readability.TrailingBlankLine, []}, 112 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 113 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 114 | {Credo.Check.Readability.VariableNames, []}, 115 | 116 | # 117 | ## Refactoring Opportunities 118 | # 119 | {Credo.Check.Refactor.CondStatements, []}, 120 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 121 | {Credo.Check.Refactor.FunctionArity, []}, 122 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 123 | # {Credo.Check.Refactor.MapInto, []}, 124 | {Credo.Check.Refactor.MatchInCondition, []}, 125 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 126 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 127 | {Credo.Check.Refactor.Nesting, []}, 128 | {Credo.Check.Refactor.UnlessWithElse, []}, 129 | {Credo.Check.Refactor.WithClauses, []}, 130 | 131 | # 132 | ## Warnings 133 | # 134 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 135 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 136 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 137 | {Credo.Check.Warning.IExPry, []}, 138 | {Credo.Check.Warning.IoInspect, []}, 139 | # {Credo.Check.Warning.LazyLogging, []}, 140 | {Credo.Check.Warning.MixEnv, false}, 141 | {Credo.Check.Warning.OperationOnSameValues, []}, 142 | {Credo.Check.Warning.OperationWithConstantResult, []}, 143 | {Credo.Check.Warning.RaiseInsideRescue, []}, 144 | {Credo.Check.Warning.UnusedEnumOperation, []}, 145 | {Credo.Check.Warning.UnusedFileOperation, []}, 146 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 147 | {Credo.Check.Warning.UnusedListOperation, []}, 148 | {Credo.Check.Warning.UnusedPathOperation, []}, 149 | {Credo.Check.Warning.UnusedRegexOperation, []}, 150 | {Credo.Check.Warning.UnusedStringOperation, []}, 151 | {Credo.Check.Warning.UnusedTupleOperation, []}, 152 | {Credo.Check.Warning.UnsafeExec, []}, 153 | 154 | # 155 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 156 | 157 | # 158 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 159 | # 160 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 161 | {Credo.Check.Consistency.UnusedVariableNames, false}, 162 | {Credo.Check.Design.DuplicatedCode, false}, 163 | {Credo.Check.Readability.AliasAs, false}, 164 | {Credo.Check.Readability.BlockPipe, false}, 165 | {Credo.Check.Readability.ImplTrue, false}, 166 | {Credo.Check.Readability.MultiAlias, false}, 167 | {Credo.Check.Readability.SeparateAliasRequire, false}, 168 | {Credo.Check.Readability.SinglePipe, false}, 169 | {Credo.Check.Readability.Specs, false}, 170 | {Credo.Check.Readability.StrictModuleLayout, false}, 171 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 172 | {Credo.Check.Refactor.ABCSize, false}, 173 | {Credo.Check.Refactor.AppendSingleItem, false}, 174 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 175 | {Credo.Check.Refactor.ModuleDependencies, false}, 176 | {Credo.Check.Refactor.NegatedIsNil, false}, 177 | {Credo.Check.Refactor.PipeChainStart, false}, 178 | {Credo.Check.Refactor.VariableRebinding, false}, 179 | {Credo.Check.Warning.LeakyEnvironment, false}, 180 | {Credo.Check.Warning.MapGetUnsafePass, false}, 181 | {Credo.Check.Warning.UnsafeToAtom, false} 182 | 183 | # 184 | # Custom checks can be created using `mix credo.gen.check`. 185 | # 186 | ] 187 | } 188 | ] 189 | } 190 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Apache License 2 | Version 2.0, January 2004 3 | 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | ## 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, and 11 | distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 14 | owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all other entities 17 | that control, are controlled by, or are under common control with that entity. 18 | For the purposes of this definition, "control" means (i) the power, direct or 19 | indirect, to cause the direction or management of such entity, whether by 20 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity exercising 24 | permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, including 27 | but not limited to software source code, documentation source, and configuration 28 | files. 29 | 30 | "Object" form shall mean any form resulting from mechanical transformation or 31 | translation of a Source form, including but not limited to compiled object code, 32 | generated documentation, and conversions to other media types. 33 | 34 | "Work" shall mean the work of authorship, whether in Source or Object form, made 35 | available under the License, as indicated by a copyright notice that is included 36 | in or attached to the work (an example is provided in the Appendix below). 37 | 38 | "Derivative Works" shall mean any work, whether in Source or Object form, that 39 | is based on (or derived from) the Work and for which the editorial revisions, 40 | annotations, elaborations, or other modifications represent, as a whole, an 41 | original work of authorship. For the purposes of this License, Derivative Works 42 | shall not include works that remain separable from, or merely link (or bind by 43 | name) to the interfaces of, the Work and Derivative Works thereof. 44 | 45 | "Contribution" shall mean any work of authorship, including the original version 46 | of the Work and any modifications or additions to that Work or Derivative Works 47 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 48 | by the copyright owner or by an individual or Legal Entity authorized to submit 49 | on behalf of the copyright owner. For the purposes of this definition, 50 | "submitted" means any form of electronic, verbal, or written communication sent 51 | to the Licensor or its representatives, including but not limited to 52 | communication on electronic mailing lists, source code control systems, and 53 | issue tracking systems that are managed by, or on behalf of, the Licensor for 54 | the purpose of discussing and improving the Work, but excluding communication 55 | that is conspicuously marked or otherwise designated in writing by the copyright 56 | owner as "Not a Contribution." 57 | 58 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 59 | of whom a Contribution has been received by Licensor and subsequently 60 | incorporated within the Work. 61 | 62 | ## 2. Grant of Copyright License. 63 | 64 | Subject to the terms and conditions of this License, each Contributor hereby 65 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 66 | irrevocable copyright license to reproduce, prepare Derivative Works of, 67 | publicly display, publicly perform, sublicense, and distribute the Work and such 68 | Derivative Works in Source or Object form. 69 | 70 | ## 3. Grant of Patent License. 71 | 72 | Subject to the terms and conditions of this License, each Contributor hereby 73 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 74 | irrevocable (except as stated in this section) patent license to make, have 75 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 76 | such license applies only to those patent claims licensable by such Contributor 77 | that are necessarily infringed by their Contribution(s) alone or by combination 78 | of their Contribution(s) with the Work to which such Contribution(s) was 79 | submitted. If You institute patent litigation against any entity (including a 80 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 81 | Contribution incorporated within the Work constitutes direct or contributory 82 | patent infringement, then any patent licenses granted to You under this License 83 | for that Work shall terminate as of the date such litigation is filed. 84 | 85 | ## 4. Redistribution. 86 | 87 | You may reproduce and distribute copies of the Work or Derivative Works thereof 88 | in any medium, with or without modifications, and in Source or Object form, 89 | provided that You meet the following conditions: 90 | 91 | 1. You must give any other recipients of the Work or Derivative Works a copy of 92 | this License; and 93 | 94 | 2. You must cause any modified files to carry prominent notices stating that 95 | You changed the files; and 96 | 97 | 3. You must retain, in the Source form of any Derivative Works that You 98 | distribute, all copyright, patent, trademark, and attribution notices from 99 | the Source form of the Work, excluding those notices that do not pertain to 100 | any part of the Derivative Works; and 101 | 102 | 4. If the Work includes a "NOTICE" text file as part of its distribution, then 103 | any Derivative Works that You distribute must include a readable copy of the 104 | attribution notices contained within such NOTICE file, excluding those 105 | notices that do not pertain to any part of the Derivative Works, in at least 106 | one of the following places: within a NOTICE text file distributed as part 107 | of the Derivative Works; within the Source form or documentation, if 108 | provided along with the Derivative Works; or, within a display generated by 109 | the Derivative Works, if and wherever such third-party notices normally 110 | appear. The contents of the NOTICE file are for informational purposes only 111 | and do not modify the License. You may add Your own attribution notices 112 | within Derivative Works that You distribute, alongside or as an addendum to 113 | the NOTICE text from the Work, provided that such additional attribution 114 | notices cannot be construed as modifying the License. 115 | 116 | You may add Your own copyright statement to Your modifications and may provide 117 | additional or different license terms and conditions for use, reproduction, or 118 | distribution of Your modifications, or for any such Derivative Works as a whole, 119 | provided Your use, reproduction, and distribution of the Work otherwise complies 120 | with the conditions stated in this License. 121 | 122 | ## 5. Submission of Contributions. 123 | 124 | Unless You explicitly state otherwise, any Contribution intentionally submitted 125 | for inclusion in the Work by You to the Licensor shall be under the terms and 126 | conditions of this License, without any additional terms or conditions. 127 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 128 | any separate license agreement you may have executed with Licensor regarding 129 | such Contributions. 130 | 131 | ## 6. Trademarks. 132 | 133 | This License does not grant permission to use the trade names, trademarks, 134 | service marks, or product names of the Licensor, except as required for 135 | reasonable and customary use in describing the origin of the Work and 136 | reproducing the content of the NOTICE file. 137 | 138 | ## 7. Disclaimer of Warranty. 139 | 140 | Unless required by applicable law or agreed to in writing, Licensor provides the 141 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 142 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 143 | including, without limitation, any warranties or conditions of TITLE, NON- 144 | INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 145 | solely responsible for determining the appropriateness of using or 146 | redistributing the Work and assume any risks associated with Your exercise of 147 | permissions under this License. 148 | 149 | ## 8. Limitation of Liability. 150 | 151 | In no event and under no legal theory, whether in tort (including negligence), 152 | contract, or otherwise, unless required by applicable law (such as deliberate 153 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 154 | liable to You for damages, including any direct, indirect, special, incidental, 155 | or consequential damages of any character arising as a result of this License or 156 | out of the use or inability to use the Work (including but not limited to 157 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 158 | any and all other commercial damages or losses), even if such Contributor has 159 | been advised of the possibility of such damages. 160 | 161 | ## 9. Accepting Warranty or Additional Liability. 162 | 163 | While redistributing the Work or Derivative Works thereof, You may choose to 164 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 165 | other liability obligations and/or rights consistent with this License. However, 166 | in accepting such obligations, You may act only on Your own behalf and on Your 167 | sole responsibility, not on behalf of any other Contributor, and only if You 168 | agree to indemnify, defend, and hold each Contributor harmless for any liability 169 | incurred by, or claims asserted against, such Contributor by reason of your 170 | accepting any such warranty or additional liability. 171 | 172 | END OF TERMS AND CONDITIONS 173 | --------------------------------------------------------------------------------