From d4f3bec947b3cef160fa8e6768396884a3ee6149 Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Wed, 18 Jan 2023 14:46:22 +1300 Subject: [PATCH] feat(PasswordValidation): Add a validation which can check a password. (#144) --- .../password/hash_password_change.ex | 17 +++- .../password/password_validation.ex | 96 +++++++++++++++++++ .../password/hash_password_change_test.exs | 23 ++++- .../password/password_validation_test.exs | 36 +++++++ 4 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 lib/ash_authentication/strategies/password/password_validation.ex create mode 100644 test/ash_authentication/strategies/password/password_validation_test.exs diff --git a/lib/ash_authentication/strategies/password/hash_password_change.ex b/lib/ash_authentication/strategies/password/hash_password_change.ex index 4f2ce42..9693f8a 100644 --- a/lib/ash_authentication/strategies/password/hash_password_change.ex +++ b/lib/ash_authentication/strategies/password/hash_password_change.ex @@ -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) -> diff --git a/lib/ash_authentication/strategies/password/password_validation.ex b/lib/ash_authentication/strategies/password/password_validation.ex new file mode 100644 index 0000000..ece1ac7 --- /dev/null +++ b/lib/ash_authentication/strategies/password/password_validation.ex @@ -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 diff --git a/test/ash_authentication/strategies/password/hash_password_change_test.exs b/test/ash_authentication/strategies/password/hash_password_change_test.exs index f9bbbdd..40e89af 100644 --- a/test/ash_authentication/strategies/password/hash_password_change_test.exs +++ b/test/ash_authentication/strategies/password/hash_password_change_test.exs @@ -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 diff --git a/test/ash_authentication/strategies/password/password_validation_test.exs b/test/ash_authentication/strategies/password/password_validation_test.exs new file mode 100644 index 0000000..facaff2 --- /dev/null +++ b/test/ash_authentication/strategies/password/password_validation_test.exs @@ -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