From 8ac7386db1266686bb3e31339e5c0bb17e76d4e1 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Mon, 7 Oct 2024 14:20:37 -0400 Subject: [PATCH] improvement: add `ash_authentication.add_strategy` task improvement: add atomic implementations for various changes/validations --- documentation/tutorials/get-started.md | 2 +- .../generate_token_change.ex | 5 + lib/ash_authentication/igniter.ex | 93 +++++ .../strategies/password/actions.ex | 59 +++- .../password/hash_password_change.ex | 27 ++ .../password_confirmation_validation.ex | 5 + .../password/request_password_reset.ex | 72 ++++ .../password/reset_token_validation.ex | 5 + .../strategies/password/transformer.ex | 6 +- .../tasks/ash_authentication.add_strategy.ex | 334 ++++++++++++++++++ mix.lock | 2 +- .../ash_authentication.add_strategy_test.exs | 266 ++++++++++++++ 12 files changed, 858 insertions(+), 18 deletions(-) create mode 100644 lib/ash_authentication/strategies/password/request_password_reset.ex create mode 100644 lib/mix/tasks/ash_authentication.add_strategy.ex create mode 100644 test/mix/tasks/ash_authentication.add_strategy_test.exs diff --git a/documentation/tutorials/get-started.md b/documentation/tutorials/get-started.md index 9b07f83..9cd81cf 100644 --- a/documentation/tutorials/get-started.md +++ b/documentation/tutorials/get-started.md @@ -130,7 +130,7 @@ AshAuthentication includes a supervisor which you should add to your application's supervisor tree. This is used to run any periodic jobs related to your authenticated resources (removing expired tokens, for example). -### Example +##### Example ```elixir defmodule MyApp.Application do diff --git a/lib/ash_authentication/generate_token_change.ex b/lib/ash_authentication/generate_token_change.ex index 37db6a3..ff20c62 100644 --- a/lib/ash_authentication/generate_token_change.ex +++ b/lib/ash_authentication/generate_token_change.ex @@ -23,6 +23,11 @@ defmodule AshAuthentication.GenerateTokenChange do end) end + @impl true + def atomic(changeset, options, context) do + {:ok, change(changeset, options, context)} + end + defp generate_token(purpose, record, strategy) when is_integer(strategy.sign_in_token_lifetime) and purpose == :sign_in do {:ok, token, _claims} = diff --git a/lib/ash_authentication/igniter.ex b/lib/ash_authentication/igniter.ex index ed100ec..1133fd6 100644 --- a/lib/ash_authentication/igniter.ex +++ b/lib/ash_authentication/igniter.ex @@ -25,4 +25,97 @@ defmodule AshAuthentication.Igniter do {:ok, Igniter.Code.Common.add_code(zipper, func)} end) end + + @doc "Adds a new strategy to the authentication.strategies section of a resource" + @spec add_new_strategy( + Igniter.t(), + Ash.Resource.t(), + type :: atom, + name :: atom, + contents :: String.t() + ) :: Igniter.t() + def add_new_strategy(igniter, resource, type, name, contents) do + {igniter, defines?} = defines_strategy(igniter, resource, type, name) + + if defines? do + igniter + else + add_strategy(igniter, resource, contents) + end + end + + @doc "Adds a strategy to the authentication.strategies section of a resource" + @spec add_strategy( + Igniter.t(), + Ash.Resource.t(), + contents :: String.t() + ) :: Igniter.t() + def add_strategy(igniter, resource, contents) do + Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper -> + with {:authentication, {:ok, zipper}} <- + {:authentication, enter_section(zipper, :authentication)}, + {:strategies, _authentication_zipper, {:ok, zipper}} <- + {:strategies, zipper, enter_section(zipper, :strategies)} do + {:ok, Igniter.Code.Common.add_code(zipper, contents)} + else + {:authentication, :error} -> + {:ok, + Igniter.Code.Common.add_code(zipper, """ + authentication do + strategies do + #{contents} + end + end + """)} + + {:strategies, zipper, :error} -> + {:ok, + Igniter.Code.Common.add_code(zipper, """ + strategies do + #{contents} + end + """)} + end + end) + end + + @doc "Returns true if the given resource defines an attribute with the provided name" + @spec defines_strategy(Igniter.t(), Ash.Resource.t(), constructor :: atom(), name :: atom()) :: + {Igniter.t(), true | false} + def defines_strategy(igniter, resource, constructor, name) do + Spark.Igniter.find(igniter, resource, fn _, zipper -> + with {:ok, zipper} <- enter_section(zipper, :authentication), + {:ok, zipper} <- enter_section(zipper, :strategies), + {:ok, _zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + constructor, + [1, 2], + &Igniter.Code.Function.argument_equals?(&1, 0, name) + ) do + {:ok, true} + else + _ -> + :error + end + end) + |> case do + {:ok, igniter, _module, _value} -> + {igniter, true} + + {:error, igniter} -> + {igniter, false} + end + end + + defp enter_section(zipper, name) do + with {:ok, zipper} <- + Igniter.Code.Function.move_to_function_call_in_current_scope( + zipper, + name, + 1 + ) do + Igniter.Code.Common.move_to_do_block(zipper) + end + end end diff --git a/lib/ash_authentication/strategies/password/actions.ex b/lib/ash_authentication/strategies/password/actions.ex index c3a6daa..82da6c8 100644 --- a/lib/ash_authentication/strategies/password/actions.ex +++ b/lib/ash_authentication/strategies/password/actions.ex @@ -188,22 +188,51 @@ defmodule AshAuthentication.Strategy.Password.Actions do params, options ) do - options = - options - |> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end) + case Ash.Resource.Info.action( + strategy.resource, + resettable.request_password_reset_action_name + ) do + nil -> + {:error, + NoSuchAction.exception(resource: strategy.resource, action: :reset_request, type: :read)} - strategy.resource - |> Query.new() - |> Query.set_context(%{ - private: %{ - ash_authentication?: true - } - }) - |> Query.for_read(resettable.request_password_reset_action_name, params) - |> Ash.read(options) - |> case do - {:ok, _} -> :ok - {:error, reason} -> {:error, reason} + %{type: :read, name: action_name} -> + options = + options + |> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end) + + strategy.resource + |> Query.new() + |> Query.set_context(%{ + private: %{ + ash_authentication?: true + } + }) + |> Query.for_read(action_name, params, options) + |> Ash.read() + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + + %{type: :action, name: action_name} -> + options = + options + |> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end) + + strategy.resource + |> Ash.ActionInput.new() + |> Ash.ActionInput.set_context(%{ + private: %{ + ash_authentication?: true + } + }) + |> Ash.ActionInput.for_action(action_name, params, options) + |> Ash.run_action() + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end end end diff --git a/lib/ash_authentication/strategies/password/hash_password_change.ex b/lib/ash_authentication/strategies/password/hash_password_change.ex index 1699f7e..60d9756 100644 --- a/lib/ash_authentication/strategies/password/hash_password_change.ex +++ b/lib/ash_authentication/strategies/password/hash_password_change.ex @@ -59,4 +59,31 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do end end) end + + @impl true + def atomic(changeset, options, context) do + with {:ok, strategy} <- Info.find_strategy(changeset, context, options), + value when is_binary(value) <- + Changeset.get_argument(changeset, strategy.password_field) do + {:atomic, changeset, + %{ + strategy.hashed_password_field => + expr(lazy({__MODULE__, :hash_or_raise, [strategy.hash_provider, value]})) + }} + else + _ -> + changeset + end + end + + @doc false + def hash_or_raise(hash_provider, value) do + case hash_provider.hash(value) do + {:ok, hash} -> + hash + + :error -> + raise AssumptionFailed, message: "Error hashing password." + end + end end diff --git a/lib/ash_authentication/strategies/password/password_confirmation_validation.ex b/lib/ash_authentication/strategies/password/password_confirmation_validation.ex index 62f8f14..9d26260 100644 --- a/lib/ash_authentication/strategies/password/password_confirmation_validation.ex +++ b/lib/ash_authentication/strategies/password/password_confirmation_validation.ex @@ -61,6 +61,11 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do end end + @impl true + def atomic(changeset, opts, context) do + validate(changeset, opts, context) + end + defp validate_password_confirmation(changeset, strategy) do password = Changeset.get_argument(changeset, strategy.password_field) confirmation = Changeset.get_argument(changeset, strategy.password_confirmation_field) diff --git a/lib/ash_authentication/strategies/password/request_password_reset.ex b/lib/ash_authentication/strategies/password/request_password_reset.ex new file mode 100644 index 0000000..81e4a15 --- /dev/null +++ b/lib/ash_authentication/strategies/password/request_password_reset.ex @@ -0,0 +1,72 @@ +defmodule AshAuthentication.Strategy.Password.RequestPasswordReset do + @moduledoc """ + Requests a password reset. + + This implementation performs three jobs: + 1. looks up the user with the given action and field + 2. if a matching user is found: + a. a reset token is generated + b. and the password reset sender is invoked + """ + use Ash.Resource.Actions.Implementation + alias AshAuthentication.{Info, Strategy.Password} + require Ash.Query + require Logger + + @doc false + @impl true + def run(action_input, opts, context) do + read_action = opts[:action] + + strategy = Info.strategy_for_action!(action_input.resource, action_input.action.name) + + if strategy.resettable && strategy.resettable.sender do + identity_field = strategy.identity_field + identity = Ash.ActionInput.get_argument(action_input, identity_field) + select_for_senders = Info.authentication_select_for_senders!(action_input.resource) + {sender, send_opts} = strategy.resettable.sender + + context = + if context[:private][:ash_authentication?] do + %{private: %{ash_authentication?: true}} + else + %{} + end + + query_result = + action_input.resource + |> Ash.Query.new() + |> Ash.Query.set_context(context) + |> Ash.Query.for_read(read_action, %{ + identity_field => identity + }) + |> Ash.Query.ensure_selected(select_for_senders) + |> Ash.read_one() + + with {:ok, user} when not is_nil(user) <- query_result, + {:ok, token} <- Password.reset_token_for(strategy, user) do + sender.send(user, token, send_opts) + else + {:ok, nil} -> + :ok + + :error -> + Logger.warning(""" + Something went wrong generating a token during password reset + for: #{inspect(action_input.resource)} `#{identity}` + """) + + {:error, error} -> + Logger.warning(""" + Something went wrong resetting password for #{inspect(action_input.resource)} `#{identity}` + + #{Exception.format(:error, error)} + """) + + :ok + end + else + :ok + end + end +end diff --git a/lib/ash_authentication/strategies/password/reset_token_validation.ex b/lib/ash_authentication/strategies/password/reset_token_validation.ex index 7f014bd..f7815ff 100644 --- a/lib/ash_authentication/strategies/password/reset_token_validation.ex +++ b/lib/ash_authentication/strategies/password/reset_token_validation.ex @@ -22,4 +22,9 @@ defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do {:error, InvalidArgument.exception(field: :reset_token, message: "is not valid")} end end + + @impl true + def atomic(changeset, opts, context) do + validate(changeset, opts, context) + end end diff --git a/lib/ash_authentication/strategies/password/transformer.ex b/lib/ash_authentication/strategies/password/transformer.ex index e2e574b..6ab0f3c 100644 --- a/lib/ash_authentication/strategies/password/transformer.ex +++ b/lib/ash_authentication/strategies/password/transformer.ex @@ -426,7 +426,11 @@ defmodule AshAuthentication.Strategy.Password.Transformer do with {:ok, action} <- validate_action_exists(dsl_state, resettable.request_password_reset_action_name), :ok <- validate_identity_argument(dsl_state, action, strategy.identity_field) do - validate_action_has_preparation(action, Password.RequestPasswordResetPreparation) + if action.type == :read do + validate_action_has_preparation(action, Password.RequestPasswordResetPreparation) + else + :ok + end end end diff --git a/lib/mix/tasks/ash_authentication.add_strategy.ex b/lib/mix/tasks/ash_authentication.add_strategy.ex new file mode 100644 index 0000000..404c9db --- /dev/null +++ b/lib/mix/tasks/ash_authentication.add_strategy.ex @@ -0,0 +1,334 @@ +# credo:disable-for-this-file Credo.Check.Design.AliasUsage +defmodule Mix.Tasks.AshAuthentication.AddStrategy do + use Igniter.Mix.Task + + @example "mix ash_authentication.patch.add_strategy password" + + @shortdoc "Adds the provided strategy or strategies to your user resource" + + @strategies [password: "Register and sign in with a username/email and a password."] + + @strategy_explanation Enum.map_join(@strategies, "\n", fn {name, description} -> + " * `#{name}` - #{description}" + end) + + @strategy_names @strategies |> Keyword.keys() |> Enum.map(&to_string/1) + + @moduledoc """ + #{@shortdoc} + + This task will add the provided strategy or strategies to your user resource. + + The following strategies are available. For all others, see the relevant documentation for setup + + #{@strategy_explanation} + + ## Example + + ```bash + #{@example} + ``` + + ## Options + + * `--user`, `-u` - The user resource. Defaults to `YourApp.Accounts.User` + * `--identity-field`, `-i` - The field on the user resource that will be used to identify + the user. Defaults to `:email` + """ + + def info(_argv, _composing_task) do + %Igniter.Mix.Task.Info{ + example: @example, + extra_args?: false, + # A list of environments that this should be installed in, only relevant if this is an installer. + only: nil, + # a ist of positional arguments, i.e `[:file]` + positional: [ + strategies: [rest: true] + ], + schema: [ + user: :string + ], + aliases: [ + u: :user + ] + } + end + + def igniter(igniter, argv) do + {%{strategies: strategies}, argv} = positional_args!(argv) + default_user = Igniter.Project.Module.module_name(igniter, "Accounts.User") + + options = + argv + |> options!() + |> Keyword.update(:identity_field, :email, &String.to_atom/1) + |> Keyword.update(:user, default_user, &Igniter.Code.Module.parse/1) + + if invalid_strategy = Enum.find(strategies, &(&1 not in @strategy_names)) do + Mix.shell().error(""" + Invalid strategy provided: `#{invalid_strategy}` + + Available Strategies: + + #{@strategy_explanation} + """) + + exit({:shutdown, 1}) + end + + case Igniter.Project.Module.module_exists(igniter, options[:user]) do + {true, igniter} -> + Enum.reduce(strategies, igniter, fn "password", igniter -> + password(igniter, options) + end) + + {false, igniter} -> + Igniter.add_issue(igniter, """ + User module #{inspect(options[:user])} was not found. + + Perhaps you have not yet installed ash_authentication? + """) + end + end + + defp password(igniter, options) do + sender = Module.concat(options[:user], Senders.SendPasswordResetEmail) + + igniter + |> Igniter.Project.Deps.add_dep({:bcrypt_elixir, "~> 3.0"}) + |> Ash.Resource.Igniter.add_new_attribute(options[:user], options[:identity_field], """ + attribute :#{options[:identity_field]}, :ci_string do + allow_nil? false + public? true + end + """) + |> Ash.Resource.Igniter.add_new_attribute(options[:user], :hashed_password, """ + attribute :hashed_password, :string do + allow_nil? false + sensitive? true + end + """) + |> Ash.Resource.Igniter.add_new_identity( + options[:user], + :"unique_#{options[:identity_field]}", + """ + identity :unique_#{options[:identity_field]}, [:#{options[:identity_field]}] + """ + ) + |> AshAuthentication.Igniter.add_new_strategy(options[:user], :password, :password, """ + password :password do + identity_field :#{options[:identity_field]} + + resettable do + sender #{inspect(sender)} + end + end + """) + |> generate_sign_in_and_registration(options) + |> generate_reset(sender, options) + |> Ash.Igniter.codegen("add_password_authentication") + end + + defp generate_reset(igniter, sender, options) do + igniter + |> create_reset_sender(sender, options) + |> Ash.Resource.Igniter.add_new_action(options[:user], :request_password_reset, """ + action :request_password_reset do + description "Send password reset instructions to a user if they exist." + + argument :#{options[:identity_field]}, :ci_string do + allow_nil? false + end + + # creates a reset token and invokes the relevant senders + run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_#{options[:identity_field]}} + end + """) + |> Ash.Resource.Igniter.add_new_action( + options[:user], + :"get_by_#{options[:identity_field]}", + """ + read :get_by_#{options[:identity_field]} do + description "Looks up a user by their #{options[:identity_field]}" + get? true + + argument :#{options[:identity_field]}, :ci_string do + allow_nil? false + end + + filter expr(#{options[:identity_field]} == ^arg(:#{options[:identity_field]})) + end + """ + ) + |> Ash.Resource.Igniter.add_new_action(options[:user], :reset_password, """ + update :reset_password do + argument :reset_token, :string do + allow_nil? false + sensitive? true + end + + argument :password, :string do + description "The proposed password for the user, in plain text." + allow_nil? false + constraints [min_length: 8] + sensitive? true + end + + argument :password_confirmation, :string do + description "The proposed password for the user (again), in plain text." + allow_nil? false + sensitive? true + end + + # validates the provided reset token + validate AshAuthentication.Strategy.Password.ResetTokenValidation + + # validates that the password matches the confirmation + validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation + + # Hashes the provided password + change AshAuthentication.Strategy.Password.HashPasswordChange + + # Generates an authentication token for the user + change AshAuthentication.GenerateTokenChange + end + """) + end + + defp create_reset_sender(igniter, sender, options) do + web_module = Igniter.Libs.Phoenix.web_module(igniter) + {web_module_exists?, igniter} = Igniter.Project.Module.module_exists(igniter, web_module) + + use_web_module = + if web_module_exists? do + "use #{inspect(web_module)}, :verified_routes" + end + + example_domain = options[:user] |> Module.split() |> :lists.droplast() |> Module.concat() + + real_example = + if web_module_exists? do + """ + # Example of how you might send this email + # #{inspect(example_domain)}.Emails.send_password_reset_email( + # user, + # token + # ) + """ + end + + Igniter.Project.Module.create_module( + igniter, + sender, + ~s''' + @moduledoc """ + Sends a password reset email + """ + + use AshAuthentication.Sender + #{use_web_module} + + @impl true + def send(_user, token, _) do + #{real_example} + IO.puts(""" + Click this link to reset your password: + + \#{url(~p"/password-reset/\#{token}")} + """) + end + ''' + ) + end + + defp generate_sign_in_and_registration(igniter, options) do + igniter + |> Ash.Resource.Igniter.add_new_action(options[:user], :sign_in_with_password, """ + read :sign_in_with_password do + description "Attempt to sign in using a #{options[:identity_field]} and password." + get? true + + argument :#{options[:identity_field]}, :ci_string do + description "The #{options[:identity_field]} to use for retrieving the user." + allow_nil? false + end + + argument :password, :string do + description "The password to check for the matching user." + allow_nil? false + sensitive? true + end + + # validates the provided #{options[:identity_field]} and password and generates a token + prepare AshAuthentication.Strategy.Password.SignInPreparation + + metadata :token, :string do + description "A JWT that can be used to authenticate the user." + allow_nil? false + end + end + """) + |> Ash.Resource.Igniter.add_new_action(options[:user], :sign_in_with_token, """ + read :sign_in_with_token do + # In the generated sign in components, we generate a validate the + # #{options[:identity_field]} and password directly in the LiveView + # and generate a short-lived token that can be used to sign in over + # a standard controller action, exchanging it for a standard token. + # This action performs that exchange. If you do not use the generated + # liveviews, you may remove this action, and set + # `sign_in_tokens_enabled? false` in the password strategy. + + description "Attempt to sign in using a short-lived sign in token." + get? true + + argument :token, :string do + description "The short-lived sign in token." + allow_nil? false + sensitive? true + end + + # validates the provided sign in token and generates a token + prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation + + metadata :token, :string do + description "A JWT that can be used to authenticate the user." + allow_nil? false + end + end + """) + |> Ash.Resource.Igniter.add_new_action(options[:user], :register_with_password, """ + create :register_with_password do + description "Register a new user with a #{options[:identity_field]} and password." + accept [:#{options[:identity_field]}] + + argument :password, :string do + description "The proposed password for the user, in plain text." + allow_nil? false + constraints [min_length: 8] + sensitive? true + end + + argument :password_confirmation, :string do + description "The proposed password for the user (again), in plain text." + allow_nil? false + sensitive? true + end + + # Hashes the provided password + change AshAuthentication.Strategy.Password.HashPasswordChange + + # Generates an authentication token for the user + change AshAuthentication.GenerateTokenChange + + # validates that the password matches the confirmation + validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation + + metadata :token, :string do + description "A JWT that can be used to authenticate the user." + allow_nil? false + end + end + """) + end +end diff --git a/mix.lock b/mix.lock index f4e4ead..dcb5e1c 100644 --- a/mix.lock +++ b/mix.lock @@ -36,7 +36,7 @@ "glob_ex": {:hex, :glob_ex, "0.1.9", "b97a25392f5339e49f587e5b24c468c6a4f38299febd5ec85c5f8bb2e42b5c1e", [:mix], [], "hexpm", "be72e584ad1d8776a4d134d4b6da1bac8b80b515cdadf0120e0920b9978d7f01"}, "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, - "igniter": {:hex, :igniter, "0.3.48", "ae1dcdd0b840684e556a8c9d21c9510449d971675766e0649d60d44df681c3a2", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "339d616bc9f4e0b0f19ae07cc52c71d70383666139c8a376a01c561c59d3e294"}, + "igniter": {:hex, :igniter, "0.3.50", "8601b18a14298dbf2347c935cb1fda2bdb81c2ceb0dc0295fa632394b98b2131", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "a633f11ff684f482c82a3a6d3400f74595c662c396c7a86f1cf24f808f24a4b8"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"}, "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, diff --git a/test/mix/tasks/ash_authentication.add_strategy_test.exs b/test/mix/tasks/ash_authentication.add_strategy_test.exs new file mode 100644 index 0000000..ac4d071 --- /dev/null +++ b/test/mix/tasks/ash_authentication.add_strategy_test.exs @@ -0,0 +1,266 @@ +# credo:disable-for-this-file Credo.Check.Design.AliasUsage +defmodule Mix.Tasks.AshAuthentication.AddStrategyTest do + use ExUnit.Case + + import Igniter.Test + + setup do + igniter = + test_project() + |> Igniter.Project.Deps.add_dep({:simple_sat, ">= 0.0.0"}) + |> Igniter.Project.Formatter.add_formatter_plugin(Spark.Formatter) + |> Igniter.compose_task("ash_authentication.install", ["--yes"]) + |> apply_igniter!() + + [igniter: igniter] + end + + describe "password" do + test "adds the password strategy to the user", %{igniter: igniter} do + igniter + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_has_patch("lib/test/accounts/user.ex", """ + 26 + | strategies do + 27 + | password :password do + 28 + | identity_field(:email) + 29 + | + 30 + | resettable do + 31 + | sender(Test.Accounts.User.Senders.SendPasswordResetEmail) + 32 + | end + 33 + | end + 34 + | end + """) + end + + test "adds the identity to the user resource", %{igniter: igniter} do + igniter + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_has_patch("lib/test/accounts/user.ex", """ + 202 + | identities do + 203 + | identity(:unique_email, [:email]) + 204 + | end + """) + end + + test "adds the attributes to the user resource", %{igniter: igniter} do + igniter + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_has_patch("lib/test/accounts/user.ex", """ + 45 + | attribute :email, :ci_string do + 46 + | allow_nil?(false) + 47 + | public?(true) + 48 + | end + 49 + | + 50 + | attribute :hashed_password, :string do + 51 + | allow_nil?(false) + 52 + | sensitive?(true) + 53 + | end + """) + end + + test "adds the password actions to the user resource", %{igniter: igniter} do + igniter + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_has_patch("lib/test/accounts/user.ex", """ + 64 + | read :sign_in_with_password do + 65 + | description("Attempt to sign in using a email and password.") + 66 + | get?(true) + 67 + | + 68 + | argument :email, :ci_string do + 69 + | description("The email to use for retrieving the user.") + 70 + | allow_nil?(false) + 71 + | end + 72 + | + 73 + | argument :password, :string do + 74 + | description("The password to check for the matching user.") + 75 + | allow_nil?(false) + 76 + | sensitive?(true) + 77 + | end + 78 + | + 79 + | # validates the provided email and password and generates a token + 80 + | prepare(AshAuthentication.Strategy.Password.SignInPreparation) + 81 + | + 82 + | metadata :token, :string do + 83 + | description("A JWT that can be used to authenticate the user.") + 84 + | allow_nil?(false) + 85 + | end + 86 + | end + 87 + | + 88 + | read :sign_in_with_token do + 89 + | # In the generated sign in components, we generate a validate the + 90 + | # email and password directly in the LiveView + 91 + | # and generate a short-lived token that can be used to sign in over + 92 + | # a standard controller action, exchanging it for a standard token. + 93 + | # This action performs that exchange. If you do not use the generated + 94 + | # liveviews, you may remove this action, and set + 95 + | # `sign_in_tokens_enabled? false` in the password strategy. + 96 + | + 97 + | description("Attempt to sign in using a short-lived sign in token.") + 98 + | get?(true) + 99 + | + 100 + | argument :token, :string do + 101 + | description("The short-lived sign in token.") + 102 + | allow_nil?(false) + 103 + | sensitive?(true) + 104 + | end + 105 + | + 106 + | # validates the provided sign in token and generates a token + 107 + | prepare(AshAuthentication.Strategy.Password.SignInWithTokenPreparation) + 108 + | + 109 + | metadata :token, :string do + 110 + | description("A JWT that can be used to authenticate the user.") + 111 + | allow_nil?(false) + 112 + | end + 113 + | end + 114 + | + 115 + | create :register_with_password do + 116 + | description("Register a new user with a email and password.") + 117 + | accept([:email]) + 118 + | + 119 + | argument :password, :string do + 120 + | description("The proposed password for the user, in plain text.") + 121 + | allow_nil?(false) + 122 + | constraints(min_length: 8) + 123 + | sensitive?(true) + 124 + | end + 125 + | + 126 + | argument :password_confirmation, :string do + 127 + | description("The proposed password for the user (again), in plain text.") + 128 + | allow_nil?(false) + 129 + | sensitive?(true) + 130 + | end + 131 + | + 132 + | # Hashes the provided password + 133 + | change(AshAuthentication.Strategy.Password.HashPasswordChange) + 134 + | + 135 + | # Generates an authentication token for the user + 136 + | change(AshAuthentication.GenerateTokenChange) + 137 + | + 138 + | # validates that the password matches the confirmation + 139 + | validate(AshAuthentication.Strategy.Password.PasswordConfirmationValidation) + 140 + | + 141 + | metadata :token, :string do + 142 + | description("A JWT that can be used to authenticate the user.") + 143 + | allow_nil?(false) + 144 + | end + 145 + | end + 146 + | + 147 + | action :request_password_reset do + 148 + | description("Send password reset instructions to a user if they exist.") + 149 + | + 150 + | argument :email, :ci_string do + 151 + | allow_nil?(false) + 152 + | end + 153 + | + 154 + | # creates a reset token and invokes the relevant senders + 155 + | run({AshAuthentication.Strategy.Password.RequestPasswordReset, action: :get_by_email}) + 156 + | end + 157 + | + 158 + | read :get_by_email do + 159 + | description("Looks up a user by their email") + 160 + | get?(true) + 161 + | + 162 + | argument :email, :ci_string do + 163 + | allow_nil?(false) + 164 + | end + 165 + | + 166 + | filter(expr(email == ^arg(:email))) + 167 + | end + 168 + | + 169 + | update :reset_password do + 170 + | argument :reset_token, :string do + 171 + | allow_nil?(false) + 172 + | sensitive?(true) + 173 + | end + 174 + | + 175 + | argument :password, :string do + 176 + | description("The proposed password for the user, in plain text.") + 177 + | allow_nil?(false) + 178 + | constraints(min_length: 8) + 179 + | sensitive?(true) + 180 + | end + 181 + | + 182 + | argument :password_confirmation, :string do + 183 + | description("The proposed password for the user (again), in plain text.") + 184 + | allow_nil?(false) + 185 + | sensitive?(true) + 186 + | end + 187 + | + 188 + | # validates the provided reset token + 189 + | validate(AshAuthentication.Strategy.Password.ResetTokenValidation) + 190 + | + 191 + | # validates that the password matches the confirmation + 192 + | validate(AshAuthentication.Strategy.Password.PasswordConfirmationValidation) + 193 + | + 194 + | # Hashes the provided password + 195 + | change(AshAuthentication.Strategy.Password.HashPasswordChange) + 196 + | + 197 + | # Generates an authentication token for the user + 198 + | change(AshAuthentication.GenerateTokenChange) + 199 + | end + """) + end + + test "adds the bycrypt dependency", %{igniter: igniter} do + igniter + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_has_patch("mix.exs", """ + 25 + | bcrypt_elixir: "~> 3.0", + """) + end + + test "creates a phoenix-idiomatic password reset sender", %{igniter: igniter} do + igniter + |> Igniter.Project.Module.create_module(TestWeb, "") + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_creates("lib/test/accounts/user/senders/send_password_reset_email.ex", """ + defmodule Test.Accounts.User.Senders.SendPasswordResetEmail do + @moduledoc \"\"\" + Sends a password reset email + \"\"\" + + use AshAuthentication.Sender + use TestWeb, :verified_routes + + @impl true + def send(_user, token, _) do + # Example of how you might send this email + # Test.Accounts.Emails.send_password_reset_email( + # user, + # token + # ) + + IO.puts(\"\"\" + Click this link to reset your password: + + \#{url(~p"/password-reset/\#{token}")} + \"\"\") + end + end + """) + end + + test "creates a plain password reset sender if you are not using phoenix", %{igniter: igniter} do + igniter + |> Igniter.compose_task("ash_authentication.add_strategy", ["password"]) + |> assert_creates("lib/test/accounts/user/senders/send_password_reset_email.ex", """ + defmodule Test.Accounts.User.Senders.SendPasswordResetEmail do + @moduledoc \"\"\" + Sends a password reset email + \"\"\" + + use AshAuthentication.Sender + + @impl true + def send(_user, token, _) do + IO.puts(\"\"\" + Click this link to reset your password: + + \#{url(~p"/password-reset/\#{token}")} + \"\"\") + end + end + """) + end + end +end