defmodule ExPassword do
@moduledoc """
Documentation for ExPassword.
"""
@type algorithm :: module
@doc false
@spec find_algorithm(hash :: ExPassword.Algorithm.hash) :: algorithm | nil
def find_algorithm(hash) do
available_algorithms()
|> Enum.find(nil, &(&1.valid?(hash)))
end
# for compat with previous version
defdelegate available_algorithms(), to: ExPassword.Registry
@doc ~S"""
This a convenient function which handles ExPassword.verify?/2 and ExPassword.needs_rehash?/3 for you (and takes care of timing attacks)
Returns:
* `{:error, :user_is_nil}`: if *user* is `nil` (not found in your repo, typically after a Repo.get_by/3 operation)
* `{:error, :password_missmatch}`: if *password* does not match the hash in *user*.*field*
* `{:ok, []}`: if *password* is correct (matches the hash in *user*.*field*) and does not need to be updated according to *algorithm* nor *options*
* `{:ok, a non empty keyword-list}`: if *password* is correct but needs to be updated, meaning the algorithm and/or options extracted from the hash
(*user*.*field*) are different from *algorithm* and/or *options* to allow you to then update *user*'s hash
A typical usage would be:
```elixir
# in config/*
config :my_app,
password_algorithm: ExPassword.Bcrypt,
password_options: %{cost: 11}
algorithm = Application.fetch_env!(:my_app, :password_algorithm)
options = Application.fetch_env!(:my_app, :password_options)
user = MyApp.Context.Users.get_user_by(name: login)
case ExPassword.verify_and_rehash_if_needed(user, password, algorithm, options) do
{:ok, changes} ->
if changes != [] do
user
|> Ecto.Changeset.change(changes)
|> MyApp.Repo.update!()
end
# ... (successful authentication) ...
{:error, _reason} ->
# ... (authentication failed) ...
end
```
The `case` above can even be simplified if at each authentication you do additional updates like the following:
```elixir
case ExPassword.verify_and_rehash_if_needed(user, password, algorithm, options, [last_sign_in: DateTime.utc_now()]) do
{:ok, changes} ->
user
|> Ecto.Changeset.change(changes)
|> MyApp.Repo.update!()
# ... (successful authentication) ...
{:error, _reason} ->
# ... (authentication failed) ...
end
```
"""
@spec verify_and_rehash_if_needed(user :: struct | nil, password :: ExPassword.Algorithm.password, field :: atom, algorithm :: algorithm, options :: ExPassword.Algorithm.options) :: {:ok, Keyword.t} | {:error, :user_is_nil | :password_missmatch} | no_return
def verify_and_rehash_if_needed(user, password, field \\ :encrypted_password, algorithm, options, changes \\ [])
def verify_and_rehash_if_needed(nil, password, field, algorithm, options = %{}, changes)
when is_binary(password) and is_atom(field) and is_atom(algorithm) and is_list(changes)
do
ExPassword.hash(algorithm, password, options)
{:error, :user_is_nil}
end
def verify_and_rehash_if_needed(user = %_{}, password, field, algorithm, options = %{}, changes)
when is_binary(password) and is_atom(field) and is_atom(algorithm) and is_list(changes)
do
hash = Map.fetch!(user, field)
case ExPassword.verify?(password, hash) do
true ->
changes = if ExPassword.needs_rehash?(algorithm, hash, options) do
Keyword.put(changes, field, ExPassword.hash(algorithm, password, options))
else
changes
end
{:ok, changes}
false ->
{:error, :password_missmatch}
end
end
@doc ~S"""
Hashes *password* using the given *algorithm* and *options*.
*algorithm* has to be a module present in `available_algorithms/0`.
"""
@spec hash(algorithm :: algorithm, password :: ExPassword.Algorithm.password, options :: ExPassword.Algorithm.options) :: ExPassword.Algorithm.hash | no_return
def hash(algorithm, password, options = %{})
when is_binary(password)
do
algorithm.hash(password, options)
end
@doc ~S"""
Checks if *password* matches the given *hash*
Raises a `ExPassword.UnidentifiedAlgorithmError` error if any of `available_algorithms/0` recognizes *hash*
"""
@spec verify?(password :: ExPassword.Algorithm.password, hash :: ExPassword.Algorithm.hash) :: boolean | no_return
def verify?(password, hash)
when is_binary(password) and is_binary(hash)
do
case find_algorithm(hash) do
nil ->
raise ExPassword.UnidentifiedAlgorithmError, hash: hash
algorithm ->
algorithm.verify?(password, hash)
end
end
@doc ~S"""
Returns `true` if the *hash* has not been issued by *algorithm* or *options* are different from the one used to generate *hash*
"""
@spec needs_rehash?(algorithm :: algorithm, hash :: ExPassword.Algorithm.hash, options :: ExPassword.Algorithm.options) :: boolean | no_return
def needs_rehash?(algorithm, hash, options = %{})
when is_binary(hash)
do
case find_algorithm(hash) do
^algorithm ->
algorithm.needs_rehash?(hash, options)
_ ->
true
end
end
@doc ~S"""
Extracts the options and the algorithm used to generate *hash*.
Returns `{:error, :invalid}` if *hash* is invalid or not recognized by `available_algorithms/0`.
"""
@spec get_options(hash :: ExPassword.Algorithm.hash) :: {:ok, ExPassword.Algorithm.options} | {:error, :invalid}
def get_options(hash)
when is_binary(hash)
do
with(
module when not is_nil(module) <- find_algorithm(hash),
{:ok, options} <- module.get_options(hash)
) do
{:ok, Map.put(options, :provider, module)}
else
_ ->
{:error, :invalid}
end
end
end