feat(PasswordValidation): Add a validation which can check a password. (#144)

This commit is contained in:
James Harton 2023-01-18 14:46:22 +13:00 committed by GitHub
parent 2a10e2da6a
commit d4f3bec947
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 167 additions and 5 deletions

View file

@ -25,6 +25,14 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
change AshAuthentication.Strategy.Password.HashPasswordChange
end
```
or by adding it as an option to the change definition:
```elixir
update :change_password do
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
end
```
"""
use Ash.Resource.Change
@ -34,10 +42,10 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, context) do
def change(changeset, options, context) do
changeset
|> Changeset.before_action(fn changeset ->
with {:ok, strategy} <- find_strategy(changeset, context),
with {:ok, strategy} <- find_strategy(changeset, context, options),
value when is_binary(value) <-
Changeset.get_argument(changeset, strategy.password_field),
{:ok, hash} <- strategy.hash_provider.hash(value) do
@ -52,10 +60,11 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
end)
end
defp find_strategy(changeset, context) do
defp find_strategy(changeset, context, options) do
with :error <- Info.strategy_for_action(changeset.resource, changeset.action.name),
:error <- Map.fetch(changeset.context, :strategy_name),
:error <- Map.fetch(context, :strategy_name) do
:error <- Map.fetch(context, :strategy_name),
:error <- Keyword.fetch(options, :strategy_name) do
:error
else
{:ok, strategy_name} when is_atom(strategy_name) ->

View file

@ -0,0 +1,96 @@
defmodule AshAuthentication.Strategy.Password.PasswordValidation do
@moduledoc """
A convenience validation that checks that the password argument against the
hashed password stored in the record.
You can use this validation in your changes where you want the user to enter
their current password before being allowed to make a change (eg in a password
change flow).
## Options:
You can provide these options either in the DSL options, or in the changeset
context.
- `strategy_name` - the name of the authentication strategy to use. Required.
- `password_argument` - the name of the argument to check for the current
password. If missing this will default to the `password_field` value
configured on the strategy.
## Examples
```elixir
defmodule MyApp.Accounts.User do
# ...
actions do
update :change_password do
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false
argument :password, :string, sensitive?: true, allow_nil?: false
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
validate confirm(:password, :password_confirmation)
validate {AshAuthentication.Strategy.Password.PasswordValidation, strategy_name: :password, password_argument: :current_password}
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
end
end
# ...
end
```
"""
use Ash.Resource.Validation
alias Ash.Changeset
alias AshAuthentication.{Errors.AuthenticationFailed, Info}
require Logger
@doc false
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()}
def validate(changeset, options) do
{:ok, strategy} = get_strategy(changeset, options)
with {:ok, password_arg} <- get_password_arg(changeset, options, strategy),
{:ok, password} <- Changeset.fetch_argument(changeset, password_arg) do
hashed_password = Changeset.get_data(changeset, strategy.hashed_password_field)
if strategy.hash_provider.valid?(password, hashed_password) do
:ok
else
{:error, AuthenticationFailed.exception(changeset: changeset)}
end
else
:error ->
strategy.hash_provider.simulate()
{:error, AuthenticationFailed.exception(changeset: changeset)}
end
end
defp get_strategy(changeset, options) do
with :error <- Keyword.fetch(options, :strategy_name),
:error <- Map.fetch(changeset.context, :strategy_name),
:error <- Info.strategy_for_action(changeset.resource, changeset.action) do
Logger.warn(
"[PasswordValidation] Unable to identify the strategy_name for `#{inspect(changeset.action)}` on `#{inspect(changeset.resource)}`."
)
:error
else
{:ok, strategy_name} when is_atom(strategy_name) ->
Info.strategy(changeset.resource, strategy_name)
{:ok, strategy} ->
{:ok, strategy}
end
end
defp get_password_arg(changeset, options, strategy) do
with :error <- Keyword.fetch(options, :password_argument),
:error <- Map.fetch(changeset.context, :password_argument) do
Map.fetch(strategy, :password_field)
end
end
end

View file

@ -48,7 +48,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do
end)
end
test "when the action is not associated with a strategy, but is provided a strategy name in the action cotnext, it can hash the password" do
test "when the action is not associated with a strategy, but is provided a strategy name in the action context, it can hash the password" do
strategy = Info.strategy!(Example.User, :password)
user = build_user()
password = password()
@ -68,5 +68,26 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do
{:ok, struct(strategy.resource)}
end)
end
test "when the action is not associated with a strategy, but is provided a strategy name in the change options, it can hash the password" do
strategy = Info.strategy!(Example.User, :password)
user = build_user()
password = password()
attrs = %{
to_string(strategy.password_field) => password,
to_string(strategy.password_confirmation_field) => password
}
{:ok, _user, _changeset, _} =
Changeset.new(user, %{})
|> Changeset.for_update(:update, attrs)
|> HashPasswordChange.change([strategy_name: :password], %{})
|> Changeset.with_hooks(fn changeset ->
assert strategy.hash_provider.valid?(password, changeset.attributes.hashed_password)
{:ok, struct(strategy.resource)}
end)
end
end
end

View file

@ -0,0 +1,36 @@
defmodule AshAuthentication.Strategy.Password.PasswordValidationTest do
@moduledoc false
use DataCase, async: true
alias Ash.Changeset
alias AshAuthentication.{Errors.AuthenticationFailed, Strategy.Password.PasswordValidation}
describe "validate/2" do
test "when provided with a correct password it validates" do
user = build_user()
assert :ok =
user
|> Changeset.new(%{})
|> Changeset.set_argument(:current_password, user.__metadata__.password)
|> PasswordValidation.validate(
strategy_name: :password,
password_argument: :current_password
)
end
test "when provided with an incorrect password, it fails vailidation" do
user = build_user()
assert {:error, error} =
user
|> Changeset.new(%{})
|> Changeset.set_argument(:current_password, password())
|> PasswordValidation.validate(
strategy_name: :password,
password_argument: :current_password
)
assert is_struct(error, AuthenticationFailed)
end
end
end