improvement: Allow the strategy name to be passed for password validations and changes. (#102)

After #89 was merged folks were no longer able to use `AshAuthentication.Strategy.Password.HashPasswordChange` and `AshAuthentication.Strategy.Password.PasswordConfirmationValidation` in their own actions.  This change fixes this issue by allowing the name of the strategy to be passed in in the changeset context.
This commit is contained in:
James Harton 2023-01-09 09:27:50 +13:00 committed by GitHub
parent 25610d01d2
commit 5ba5e163f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 190 additions and 5 deletions

View file

@ -4,6 +4,27 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
Uses the configured `AshAuthentication.HashProvider` to generate a hash of the
user's password input and store it in the changeset.
You can use this change in your actions where you want to change the user's
password. If you're not using one of the actions generated by the password
strategy then you'll need to manually pass the strategy name in the changeset
context. Eg:
```elixir
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: :password})
|> Changeset.for_update(:update, params)
|> Accounts.update()
```
or by adding it statically to your action definition:
```elixir
update :change_password do
change set_context(%{strategy_name: :password})
change AshAuthentication.Strategy.Password.HashPasswordChange
end
```
"""
use Ash.Resource.Change
@ -13,10 +34,10 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _) do
def change(changeset, _opts, context) do
changeset
|> Changeset.before_action(fn changeset ->
with {:ok, strategy} <- Info.strategy_for_action(changeset.resource, changeset.action.name),
with {:ok, strategy} <- find_strategy(changeset, context),
value when is_binary(value) <-
Changeset.get_argument(changeset, strategy.password_field),
{:ok, hash} <- strategy.hash_provider.hash(value) do
@ -30,4 +51,18 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
end
end)
end
defp find_strategy(changeset, context) 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
else
{:ok, strategy_name} when is_atom(strategy_name) ->
Info.strategy(changeset.resource, strategy_name)
{:ok, strategy} ->
{:ok, strategy}
end
end
end

View file

@ -2,7 +2,29 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
@moduledoc """
Validate that the password and password confirmation match.
This check is only performed when the `confirmation_required?` DSL option is set to `true`.
This check is only performed when the `confirmation_required?` DSL option is
set to `true`.
You can use this validation in your own actions where you want to validate
that the password and the password confirmation arguments match. If you're
not using one of the actions generated by the password strategy then you'll
need to manually pass the strategy name in the changeset context. Eg:
```elixir
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: :password})
|> Changeset.for_update(:change_password, params)
|> Accounts.update()
```
or by adding it statically in your action definition:
```elixir
update :change_password do
change set_context(%{strategy_name: :password})
change AshAuthentication.Strategy.Password.HashPasswordChange
end
```
"""
use Ash.Resource.Validation
@ -15,8 +37,8 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
"""
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
def validate(changeset, _) do
case Info.strategy_for_action(changeset.resource, changeset.action.name) do
def validate(changeset, _options) do
case find_strategy(changeset) do
{:ok, %{confirmation_required?: true} = strategy} ->
validate_password_confirmation(changeset, strategy)
@ -45,4 +67,17 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
)}
end
end
defp find_strategy(changeset) do
with :error <- Info.strategy_for_action(changeset.resource, changeset.action.name),
:error <- Map.fetch(changeset.context, :strategy_name) do
:error
else
{:ok, strategy_name} when is_atom(strategy_name) ->
Info.strategy(changeset.resource, strategy_name)
{:ok, strategy} ->
{:ok, strategy}
end
end
end

View file

@ -0,0 +1,72 @@
defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do
use DataCase, async: true
alias Ash.Changeset
alias AshAuthentication.{Info, Strategy.Password.HashPasswordChange}
describe "change/3" do
test "when the action is associated with a strategy, it can hash the password" do
strategy = Info.strategy!(Example.User, :password)
username = username()
password = password()
attrs = %{
to_string(strategy.identity_field) => username,
to_string(strategy.password_field) => password,
to_string(strategy.password_confirmation_field) => password
}
{:ok, _user, _changeset, _} =
Changeset.new(strategy.resource, %{})
|> Changeset.for_create(strategy.register_action_name, attrs)
|> HashPasswordChange.change([], %{})
|> Changeset.with_hooks(fn changeset ->
assert strategy.hash_provider.valid?(password, changeset.attributes.hashed_password)
{:ok, struct(strategy.resource)}
end)
end
test "when the action is not associated with a strategy, but is provided a strategy name in the changeset context, 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.set_context(%{strategy_name: strategy.name})
|> Changeset.for_update(:update, attrs)
|> HashPasswordChange.change([], %{})
|> Changeset.with_hooks(fn changeset ->
assert strategy.hash_provider.valid?(password, changeset.attributes.hashed_password)
{:ok, struct(strategy.resource)}
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
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: strategy.name})
|> 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,41 @@
defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidationTest do
use DataCase, async: true
alias Ash.{Changeset, Error.Changes.InvalidArgument}
alias AshAuthentication.{Info, Strategy.Password.PasswordConfirmationValidation}
describe "validate/2" do
test "when the action is associated with a strategy, it can validate the password confirmation" do
strategy = Info.strategy!(Example.User, :password)
username = username()
password = password()
attrs = %{
to_string(strategy.identity_field) => username,
to_string(strategy.password_field) => password,
to_string(strategy.password_confirmation_field) => password <> "123"
}
assert {:error, %InvalidArgument{field: :password_confirmation}} =
Changeset.new(strategy.resource, %{})
|> Changeset.for_create(strategy.register_action_name, attrs)
|> PasswordConfirmationValidation.validate([])
end
end
test "when the action is not associated with a strategy, but is provided a strategy name in the changeset context" 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 <> "123"
}
assert {:error, %InvalidArgument{field: :password_confirmation}} =
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: strategy.name})
|> Changeset.for_update(:update, attrs)
|> PasswordConfirmationValidation.validate([])
end
end

View file

@ -43,6 +43,8 @@ defmodule Example.User do
end
update :update do
argument :password, :string, allow_nil?: true, sensitive?: true
argument :password_confirmation, :string, allow_nil?: true, sensitive?: true
primary? true
end