mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
feat(PasswordValidation): Add a validation which can check a password. (#144)
This commit is contained in:
parent
2a10e2da6a
commit
d4f3bec947
4 changed files with 167 additions and 5 deletions
|
@ -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) ->
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue