diff --git a/.doctor.exs b/.doctor.exs index fed208c..3b82948 100644 --- a/.doctor.exs +++ b/.doctor.exs @@ -2,6 +2,7 @@ ignore_modules: [ ~r/^Inspect\./, ~r/.Plug$/, + ~r/^Example/, AshAuthentication.InfoGenerator, AshAuthentication.Plug.Macros ], diff --git a/.tool-versions b/.tool-versions index dda9112..4c03c71 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -elixir 1.14.0 -erlang 25.1 +elixir 1.14.1 +erlang 25.1.2 diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index 3534f0a..4d950e8 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -1,78 +1,80 @@ defmodule AshAuthentication do import AshAuthentication.Utils, only: [to_sentence: 2] - @authentication %Spark.Dsl.Section{ - name: :authentication, - describe: "Configure authentication for this resource", - schema: [ - subject_name: [ - type: :atom, - doc: """ - The subject name is used in generating token claims and in generating authentication routes. + @dsl [ + %Spark.Dsl.Section{ + name: :authentication, + describe: "Configure authentication for this resource", + schema: [ + subject_name: [ + type: :atom, + doc: """ + The subject name is used in generating token claims and in generating authentication routes. - This needs to be unique system-wide and if not set will be inferred - from the resource name (ie `MyApp.Accounts.User` will have a subject - name of `user`). - """ - ], - api: [ - type: {:behaviour, Ash.Api}, - doc: """ - The name of the Ash API to use to access this resource when registering/authenticating. - """, - required: true - ], - get_by_subject_action_name: [ - type: :atom, - doc: """ - The name of the read action used to retrieve records. + This needs to be unique system-wide and if not set will be inferred + from the resource name (ie `MyApp.Accounts.User` will have a subject + name of `user`). + """ + ], + api: [ + type: {:behaviour, Ash.Api}, + doc: """ + The name of the Ash API to use to access this resource when registering/authenticating. + """, + required: true + ], + get_by_subject_action_name: [ + type: :atom, + doc: """ + The name of the read action used to retrieve records. - Used internally by `AshAuthentication.subject_to_resource/2`. If the - action doesn't exist, one will be generated for you. - """, - default: :get_by_subject + Used internally by `AshAuthentication.subject_to_resource/2`. If the + action doesn't exist, one will be generated for you. + """, + default: :get_by_subject + ] ] - ] - } - @tokens %Spark.Dsl.Section{ - name: :tokens, - describe: "Configure JWT settings for this resource", - schema: [ - enabled?: [ - type: :boolean, - doc: """ - Should JWTs be generated by this resource? - """, - default: false - ], - signing_algorithm: [ - type: :string, - doc: """ - The algorithm to use for token signing. + }, + %Spark.Dsl.Section{ + name: :tokens, + describe: "Configure JWT settings for this resource", + schema: [ + enabled?: [ + type: :boolean, + doc: """ + Should JWTs be generated by this resource? + """, + default: false + ], + signing_algorithm: [ + type: :string, + doc: """ + The algorithm to use for token signing. - Available signing algorithms are; - #{to_sentence(Joken.Signer.algorithms(), final: "and")}. - """ - ], - token_lifetime: [ - type: :pos_integer, - doc: """ - How long a token should be valid, in hours. - """ - ], - revocation_resource: [ - type: {:behaviour, Ash.Resource}, - doc: """ - The resource used to store token revocation information. + Available signing algorithms are; + #{to_sentence(Joken.Signer.algorithms(), final: "and")}. + """ + ], + token_lifetime: [ + type: :pos_integer, + doc: """ + How long a token should be valid, in hours. + """ + ], + revocation_resource: [ + type: {:behaviour, Ash.Resource}, + doc: """ + The resource used to store token revocation information. - If token generation is enabled for this resource, we need a place to - store revocation information. This option is the name of an Ash - Resource which has the `AshAuthentication.TokenRevocation` extension - present. - """ + If token generation is enabled for this resource, we need a place to + store revocation information. This option is the name of an Ash + Resource which has the `AshAuthentication.TokenRevocation` extension + present. + """ + ] ] - ] - } + } + ] @moduledoc """ AshAuthentication provides a turn-key authentication solution for folks using @@ -134,25 +136,15 @@ defmodule AshAuthentication do * OpenID Connect - ## Authentication DSL + ## DSL Documentation ### Index - #{Spark.Dsl.Extension.doc_index([@authentication])} + #{Spark.Dsl.Extension.doc_index(@dsl)} ### Docs - #{Spark.Dsl.Extension.doc([@authentication])} - - ## Token DSL - - ### Index - - #{Spark.Dsl.Extension.doc_index([@tokens])} - - ### Docs - - #{Spark.Dsl.Extension.doc([@tokens])} + #{Spark.Dsl.Extension.doc(@dsl)} """ alias Ash.{Api, Query, Resource} @@ -160,7 +152,7 @@ defmodule AshAuthentication do alias Spark.Dsl.Extension use Spark.Dsl.Extension, - sections: [@authentication, @tokens], + sections: @dsl, transformers: [AshAuthentication.Transformer] require Ash.Query @@ -188,14 +180,23 @@ defmodule AshAuthentication do otp_app |> Application.get_env(:ash_apis, []) |> Stream.flat_map(&Api.Info.resources(&1)) - |> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)}) - |> Stream.reject(&(elem(&1, 1) == nil)) - |> Stream.map(fn {resource, config} -> - Map.put(config, :resource, resource) - end) + |> Stream.map(&resource_config/1) + |> Stream.reject(&(&1 == :error)) |> Enum.to_list() end + def resource_config(resource) do + resource + |> Extension.get_persisted(:authentication) + |> case do + nil -> + :error + + config -> + Map.put(config, :resource, resource) + end + end + @doc """ Return a subject string for an AshAuthentication resource. """ diff --git a/lib/ash_authentication/info_generator.ex b/lib/ash_authentication/info_generator.ex index a41591e..aa38fc8 100644 --- a/lib/ash_authentication/info_generator.ex +++ b/lib/ash_authentication/info_generator.ex @@ -17,115 +17,157 @@ defmodule AshAuthentication.InfoGenerator do @doc false @spec __using__(options) :: Macro.t() defmacro __using__(opts) do - extension = Keyword.fetch!(opts, :extension) + extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__) sections = Keyword.get(opts, :sections, []) prefix? = Keyword.get(opts, :prefix?, false) quote do + require AshAuthentication.InfoGenerator require unquote(extension) - end - for section <- sections do - quote do - AshAuthentication.InfoGenerator.generate_options_function( - unquote(extension), - unquote(section), - unquote(prefix?) - ) + AshAuthentication.InfoGenerator.generate_config_functions( + unquote(extension), + unquote(sections), + unquote(prefix?) + ) - AshAuthentication.InfoGenerator.generate_config_functions( - unquote(extension), - unquote(section), - unquote(prefix?) - ) - end + AshAuthentication.InfoGenerator.generate_options_functions( + unquote(extension), + unquote(sections), + unquote(prefix?) + ) end end - @doc false - @spec generate_config_functions(module, atom, boolean) :: Macro.t() - defmacro generate_config_functions(extension, section, prefix?) do - options = - extension - |> Macro.expand_once(__CALLER__) - |> apply(:sections, []) - |> Enum.find(&(&1.name == section)) - |> Map.get(:schema, []) + @doc """ + Given an extension and a list of DSL sections, generate an options function + which returns a map of all configured options for a resource (including + defaults). + """ + @spec generate_options_functions(module, [atom], boolean) :: Macro.t() + defmacro generate_options_functions(_extension, sections, false) when length(sections) > 1, + do: raise("Cannot generate options functions for more than one section without prefixes.") - for {name, opts} <- options do - pred? = name |> to_string() |> String.ends_with?("?") - function_name = if prefix?, do: :"#{section}_#{name}", else: name + defmacro generate_options_functions(extension, sections, prefix?) do + for {section, options} <- extension_sections_to_list(extension, sections) do + function_name = if prefix?, do: :"#{section}_options", else: :options - if pred? do - generate_predicate_function(function_name, section, name, Keyword.get(opts, :doc, false)) - else - spec = AshAuthentication.Utils.spec_for_option(opts) + quote location: :keep do + @doc """ + #{unquote(section)} DSL options - quote do - unquote( - generate_config_function( - function_name, - section, - name, - Keyword.get(opts, :doc, false), - spec - ) - ) + Returns a map containing the and any configured or default values. + """ + @spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any} + def unquote(function_name)(dsl_or_resource) do + import Spark.Dsl.Extension, only: [get_opt: 4] - unquote( - generate_config_bang_function( - function_name, - section, - name, - Keyword.get(opts, :doc, false), - spec - ) - ) + unquote(Macro.escape(options)) + |> Stream.map(fn option -> + value = + dsl_or_resource + |> get_opt([option.section], option.name, Map.get(option, :default)) + + {option.name, value} + end) + |> Stream.reject(&is_nil(elem(&1, 1))) + |> Map.new() end end end end - defp generate_predicate_function(function_name, section, name, doc) do - quote do - @doc unquote(doc) - @spec unquote(function_name)(dsl_or_resource :: module | map) :: boolean - def unquote(function_name)(dsl_or_resource) do - import Spark.Dsl.Extension, only: [get_opt: 4] - get_opt(dsl_or_resource, [unquote(section)], unquote(name), false) + @doc """ + Given an extension and a list of DSL sections generate individual config + functions for each option. + """ + @spec generate_config_functions(module, [atom], boolean) :: Macro.t() + defmacro generate_config_functions(extension, sections, prefix?) do + for {_, options} <- extension_sections_to_list(extension, sections) do + for option <- options do + function_name = if prefix?, do: :"#{option.section}_#{option.name}", else: option.name + + option + |> Map.put(:function_name, function_name) + |> generate_config_function() end end end - defp generate_config_function(function_name, section, name, doc, spec) do - quote do - @doc unquote(doc) - @spec unquote(function_name)(dsl_or_resource :: module | map) :: - {:ok, unquote(spec)} | :error + defp extension_sections_to_list(extension, sections) do + extension.sections() + |> Stream.map(fn section -> + schema = + section.schema + |> Enum.map(fn {name, opts} -> + opts + |> Map.new() + |> Map.take(~w[type doc default]a) + |> Map.update!(:type, &spec_for_type/1) + |> Map.put(:pred?, name |> to_string() |> String.ends_with?("?")) + |> Map.put(:name, name) + |> Map.put(:section, section.name) + end) - def unquote(function_name)(dsl_or_resource) do + {section.name, schema} + end) + |> Map.new() + |> Map.take(sections) + end + + defp generate_config_function(%{pred?: true} = option) do + quote location: :keep do + @doc unquote(option.doc) + @spec unquote(option.function_name)(dsl_or_resource :: module | map) :: + unquote(option.type) + def unquote(option.function_name)(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] - case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do + get_opt( + dsl_or_resource, + [unquote(option.section)], + unquote(option.name), + unquote(option.default) + ) + end + end + end + + defp generate_config_function(option) do + quote location: :keep do + @doc unquote(Map.get(option, :doc, false)) + @spec unquote(option.function_name)(dsl_or_resource :: module | map) :: + {:ok, unquote(option.type)} | :error + + def unquote(option.function_name)(dsl_or_resource) do + import Spark.Dsl.Extension, only: [get_opt: 4] + + case get_opt( + dsl_or_resource, + [unquote(option.section)], + unquote(option.name), + unquote(Map.get(option, :default, :error)) + ) do :error -> :error value -> {:ok, value} end end - end - end - defp generate_config_bang_function(function_name, section, name, doc, spec) do - quote do - @doc unquote(doc) - @spec unquote(:"#{function_name}!")(dsl_or_resource :: module | map) :: - unquote(spec) | no_return + @doc unquote(Map.get(option, :doc, false)) + @spec unquote(:"#{option.function_name}!")(dsl_or_resource :: module | map) :: + unquote(option.type) | no_return - def unquote(:"#{function_name}!")(dsl_or_resource) do + def unquote(:"#{option.function_name}!")(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] - case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do + case get_opt( + dsl_or_resource, + [unquote(option.section)], + unquote(option.name), + unquote(Map.get(option, :default, :error)) + ) do :error -> - raise "No configuration for `#{unquote(name)}` present on `#{inspect(dsl_or_resource)}`." + raise "No configuration for `#{unquote(option.name)}` present on `#{inspect(dsl_or_resource)}`." value -> value @@ -134,37 +176,25 @@ defmodule AshAuthentication.InfoGenerator do end end - @doc false - @spec generate_options_function(module, atom, boolean) :: Macro.t() - defmacro generate_options_function(extension, section, prefix?) do - options = - extension - |> Macro.expand_once(__CALLER__) - |> apply(:sections, []) - |> Enum.find(&(&1.name == section)) - |> Map.get(:schema, []) + defp spec_for_type({:behaviour, _module}), do: {:module, [], Elixir} - function_name = if prefix?, do: :"#{section}_options", else: :options + defp spec_for_type({:spark_function_behaviour, behaviour, _}), + do: {spec_for_type({:behaviour, behaviour}), {:list, [], Elixir}} - quote do - @doc """ - The DSL options + defp spec_for_type({:fun, arity}) do + args = + 0..(arity - 1) + |> Enum.map(fn _ -> {:any, [], Elixir} end) - Returns a map containing the schema and any configured or default values. - """ - @spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any} - def unquote(function_name)(dsl_or_resource) do - import Spark.Dsl.Extension, only: [get_opt: 3] - - Enum.reduce(unquote(options), %{}, fn {name, opts}, result -> - with nil <- get_opt(dsl_or_resource, [unquote(section)], name), - nil <- Keyword.get(opts, :default) do - result - else - value -> Map.put(result, name, value) - end - end) - end - end + [{:->, [], [args, {:any, [], Elixir}]}] end + + defp spec_for_type({:or, choices}) do + {:|, [], Enum.map(choices, &spec_for_type/1)} + end + + defp spec_for_type(:string), + do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []} + + defp spec_for_type(terminal), do: {terminal, [], Elixir} end diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex index 8ddf4e4..f1327b8 100644 --- a/lib/ash_authentication/jwt.ex +++ b/lib/ash_authentication/jwt.ex @@ -68,14 +68,17 @@ defmodule AshAuthentication.Jwt do Given a record, generate a signed JWT for use while authenticating. """ @spec token_for_record(Resource.record()) :: {:ok, token, claims} | :error - def token_for_record(record) do + def token_for_record(record, extra_claims \\ %{}, opts \\ []) do resource = record.__struct__ - default_claims = Config.default_claims(resource) - signer = Config.token_signer(resource) + default_claims = Config.default_claims(resource, opts) + signer = Config.token_signer(resource, opts) subject = AshAuthentication.resource_to_subject(record) - extra_claims = %{"sub" => subject} + + extra_claims = + extra_claims + |> Map.put("sub", subject) extra_claims = case Map.fetch(record.__metadata__, :tenant) do @@ -89,9 +92,30 @@ defmodule AshAuthentication.Jwt do @doc """ Given a token, verify it's signature and validate it's claims. """ - @spec verify(token, module) :: + @spec verify(token, Ash.Resource.t() | module) :: {:ok, claims, AshAuthentication.resource_config()} | :error - def verify(token, otp_app) do + def verify(token, otp_app_or_resource) do + if function_exported?(otp_app_or_resource, :spark_is, 0) && + otp_app_or_resource.spark_is() == Ash.Resource do + verify_for_resource(token, otp_app_or_resource) + else + verify_for_otp_app(token, otp_app_or_resource) + end + end + + defp verify_for_resource(token, resource) do + with config <- AshAuthentication.resource_config(resource), + signer <- Config.token_signer(resource), + {:ok, claims} <- Joken.verify(token, signer), + defaults <- Config.default_claims(resource), + {:ok, claims} <- Joken.validate(defaults, claims, config) do + {:ok, claims, config} + else + _ -> :error + end + end + + defp verify_for_otp_app(token, otp_app) do with {:ok, config} <- token_to_resource(token, otp_app), signer <- Config.token_signer(config.resource), {:ok, claims} <- Joken.verify(token, signer), diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex index 6b88e65..75eaa72 100644 --- a/lib/ash_authentication/jwt/config.ex +++ b/lib/ash_authentication/jwt/config.ex @@ -14,11 +14,12 @@ defmodule AshAuthentication.Jwt.Config do @doc """ Generate the default claims for a specified resource. """ - @spec default_claims(Resource.t()) :: Joken.token_config() - def default_claims(resource) do + @spec default_claims(Resource.t(), keyword) :: Joken.token_config() + def default_claims(resource, opts \\ []) do config = resource |> config() + |> Keyword.merge(opts) {:ok, vsn} = :application.get_key(:ash_authentication, :vsn) @@ -108,9 +109,12 @@ defmodule AshAuthentication.Jwt.Config do @doc """ The signer used to sign the token on a per-resource basis. """ - @spec token_signer(Resource.t()) :: Signer.t() - def token_signer(resource) do - config = config(resource) + @spec token_signer(Resource.t(), keyword) :: Signer.t() + def token_signer(resource, opts \\ []) do + config = + resource + |> config() + |> Keyword.merge(opts) algorithm = Keyword.get_lazy(config, :signing_algorithm, &Jwt.default_algorithm/0) diff --git a/lib/ash_authentication/password_authentication.ex b/lib/ash_authentication/password_authentication.ex index 12cb64c..516db45 100644 --- a/lib/ash_authentication/password_authentication.ex +++ b/lib/ash_authentication/password_authentication.ex @@ -1,65 +1,71 @@ defmodule AshAuthentication.PasswordAuthentication do - @password_authentication %Spark.Dsl.Section{ - name: :password_authentication, - describe: """ - Configure password authentication authentication for this resource. - """, - schema: [ - identity_field: [ - type: :atom, - doc: """ - The name of the attribute which uniquely identifies the user. Usually something like `username` or `email_address`. - """, - default: :username - ], - hashed_password_field: [ - type: :atom, - doc: """ - The name of the attribute within which to store the user's password once it has been hashed. - """, - default: :hashed_password - ], - hash_provider: [ - type: {:behaviour, AshAuthentication.HashProvider}, - doc: """ - A module which implements the `AshAuthentication.HashProvider` behaviour - which is used to provide cryptographic hashing of passwords. - """, - default: AshAuthentication.BcryptProvider - ], - confirmation_required?: [ - type: :boolean, - required: false, - doc: """ - Whether a password confirmation field is required when registering or changing passwords. - """, - default: true - ], - password_field: [ - type: :atom, - doc: """ - The name of the argument used to collect the user's password in plaintext when registering, checking or changing passwords. - """, - default: :password - ], - password_confirmation_field: [ - type: :atom, - doc: """ - The name of the argument used to confirm the user's password in plaintext when registering or changing passwords. - """, - default: :password_confirmation - ], - register_action_name: [ - type: :atom, - doc: "The name to use for the register action", - default: :register - ], - sign_in_action_name: [ - type: :atom, - doc: "The name to use for the sign in action", - default: :sign_in + @dsl [ + %Spark.Dsl.Section{ + name: :password_authentication, + describe: """ + Configure password authentication authentication for this resource. + """, + schema: [ + identity_field: [ + type: :atom, + doc: """ + The name of the attribute which uniquely identifies the actor. + + Usually something like `username` or `email_address`. + """, + default: :username + ], + hashed_password_field: [ + type: :atom, + doc: """ + The name of the attribute within which to store the user's password once it has been hashed. + """, + default: :hashed_password + ], + hash_provider: [ + type: {:behaviour, AshAuthentication.HashProvider}, + doc: """ + A module which implements the `AshAuthentication.HashProvider` behaviour. + + Used to provide cryptographic hashing of passwords. + """, + default: AshAuthentication.BcryptProvider + ], + confirmation_required?: [ + type: :boolean, + required: false, + doc: """ + Whether a password confirmation field is required when registering or changing passwords. + """, + default: true + ], + password_field: [ + type: :atom, + doc: """ + The name of the argument used to collect the user's password in plaintext when registering, checking or changing passwords. + """, + default: :password + ], + password_confirmation_field: [ + type: :atom, + doc: """ + The name of the argument used to confirm the user's password in plaintext when registering or changing passwords. + """, + default: :password_confirmation + ], + register_action_name: [ + type: :atom, + doc: "The name to use for the register action", + default: :register + ], + sign_in_action_name: [ + type: :atom, + doc: "The name to use for the sign in action", + default: :sign_in + ] ] - ] - } + } + ] @moduledoc """ Authentication using your application as the source of truth. @@ -98,19 +104,20 @@ defmodule AshAuthentication.PasswordAuthentication do ### Index - #{Spark.Dsl.Extension.doc_index([@password_authentication])} + #{Spark.Dsl.Extension.doc_index(@dsl)} ### Docs - #{Spark.Dsl.Extension.doc([@password_authentication])} + #{Spark.Dsl.Extension.doc(@dsl)} """ @behaviour AshAuthentication.Provider use Spark.Dsl.Extension, - sections: [@password_authentication], + sections: @dsl, transformers: [AshAuthentication.PasswordAuthentication.Transformer] + alias Ash.Resource alias AshAuthentication.PasswordAuthentication alias Plug.Conn @@ -123,7 +130,7 @@ defmodule AshAuthentication.PasswordAuthentication do {:ok, #MyApp.User<>} """ @impl true - @spec sign_in_action(module, map) :: {:ok, struct} | {:error, term} + @spec sign_in_action(Resource.t(), map) :: {:ok, struct} | {:error, term} defdelegate sign_in_action(resource, attributes), to: PasswordAuthentication.Actions, as: :sign_in @@ -137,7 +144,7 @@ defmodule AshAuthentication.PasswordAuthentication do {:ok, #MyApp.User<>} """ @impl true - @spec register_action(module, map) :: {:ok, struct} | {:error, term} + @spec register_action(Resource.t(), map) :: {:ok, struct} | {:error, term} defdelegate register_action(resource, attributes), to: PasswordAuthentication.Actions, as: :register @@ -172,4 +179,10 @@ defmodule AshAuthentication.PasswordAuthentication do @impl true @spec has_register_step?(any) :: boolean def has_register_step?(_), do: true + + @doc """ + Returns whether password authentication is enabled for the resource + """ + @spec enabled?(Resource.t()) :: boolean + def enabled?(resource), do: __MODULE__ in Spark.extensions(resource) end diff --git a/lib/ash_authentication/password_authentication/actions.ex b/lib/ash_authentication/password_authentication/actions.ex index acd9abd..c8ee252 100644 --- a/lib/ash_authentication/password_authentication/actions.ex +++ b/lib/ash_authentication/password_authentication/actions.ex @@ -20,7 +20,9 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do """ @spec sign_in(module, map) :: {:ok, struct} | {:error, term} def sign_in(resource, attributes) do - {:ok, action} = PasswordAuthentication.Info.sign_in_action_name(resource) + {:ok, action} = + PasswordAuthentication.Info.password_authentication_sign_in_action_name(resource) + {:ok, api} = AshAuthentication.Info.authentication_api(resource) resource @@ -43,7 +45,9 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do """ @spec register(module, map) :: {:ok, struct} | {:error, term} def register(resource, attributes) do - {:ok, action} = PasswordAuthentication.Info.register_action_name(resource) + {:ok, action} = + PasswordAuthentication.Info.password_authentication_register_action_name(resource) + {:ok, api} = AshAuthentication.Info.authentication_api(resource) resource diff --git a/lib/ash_authentication/password_authentication/hash_password_change.ex b/lib/ash_authentication/password_authentication/hash_password_change.ex index f0654b3..20c41d7 100644 --- a/lib/ash_authentication/password_authentication/hash_password_change.ex +++ b/lib/ash_authentication/password_authentication/hash_password_change.ex @@ -16,9 +16,9 @@ defmodule AshAuthentication.PasswordAuthentication.HashPasswordChange do def change(changeset, _opts, _) do changeset |> Changeset.before_action(fn changeset -> - {:ok, password_field} = Info.password_field(changeset.resource) - {:ok, hash_field} = Info.hashed_password_field(changeset.resource) - {:ok, hasher} = Info.hash_provider(changeset.resource) + {:ok, password_field} = Info.password_authentication_password_field(changeset.resource) + {:ok, hash_field} = Info.password_authentication_hashed_password_field(changeset.resource) + {:ok, hasher} = Info.password_authentication_hash_provider(changeset.resource) with value when is_binary(value) <- Changeset.get_argument(changeset, password_field), {:ok, hash} <- hasher.hash(value) do diff --git a/lib/ash_authentication/password_authentication/html.ex b/lib/ash_authentication/password_authentication/html.ex index 584afe0..5e405c1 100644 --- a/lib/ash_authentication/password_authentication/html.ex +++ b/lib/ash_authentication/password_authentication/html.ex @@ -90,7 +90,7 @@ defmodule AshAuthentication.PasswordAuthentication.HTML do @defaults |> Keyword.merge(options) |> Map.new() - |> Map.merge(PasswordAuthentication.Info.options(resource)) + |> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource)) |> Map.merge(AshAuthentication.Info.authentication_options(resource)) end end diff --git a/lib/ash_authentication/password_authentication/info.ex b/lib/ash_authentication/password_authentication/info.ex index c307172..baac10a 100644 --- a/lib/ash_authentication/password_authentication/info.ex +++ b/lib/ash_authentication/password_authentication/info.ex @@ -5,5 +5,6 @@ defmodule AshAuthentication.PasswordAuthentication.Info do use AshAuthentication.InfoGenerator, extension: AshAuthentication.PasswordAuthentication, - sections: [:password_authentication] + sections: [:password_authentication], + prefix?: true end diff --git a/lib/ash_authentication/password_authentication/password_confirmation_validation.ex b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex index 3d5f199..ebfc6ef 100644 --- a/lib/ash_authentication/password_authentication/password_confirmation_validation.ex +++ b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex @@ -13,11 +13,13 @@ defmodule AshAuthentication.PasswordAuthentication.PasswordConfirmationValidatio Validates that the password and password confirmation fields contain equivalent values - if confirmation is required. """ + @impl true @spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()} def validate(changeset, _) do - with true <- Info.confirmation_required?(changeset.resource), - {:ok, password_field} <- Info.password_field(changeset.resource), - {:ok, confirm_field} <- Info.password_confirmation_field(changeset.resource), + with true <- Info.password_authentication_confirmation_required?(changeset.resource), + {:ok, password_field} <- Info.password_authentication_password_field(changeset.resource), + {:ok, confirm_field} <- + Info.password_authentication_password_confirmation_field(changeset.resource), password <- Changeset.get_argument(changeset, password_field), confirmation <- Changeset.get_argument(changeset, confirm_field), false <- password == confirmation do diff --git a/lib/ash_authentication/password_authentication/plug.ex b/lib/ash_authentication/password_authentication/plug.ex index e7e438e..ab90d67 100644 --- a/lib/ash_authentication/password_authentication/plug.ex +++ b/lib/ash_authentication/password_authentication/plug.ex @@ -14,7 +14,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do an "action" parameter along with the form data. """ import AshAuthentication.Plug.Helpers, only: [private_store: 2] - alias AshAuthentication.PasswordAuthentication + alias AshAuthentication.{PasswordAuthentication, PasswordReset} alias Plug.Conn @doc """ @@ -52,4 +52,9 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do defp do_action(%{"action" => "register"} = attrs, resource), do: PasswordAuthentication.register_action(resource, attrs) + + defp do_action(%{"action" => "reset_password"} = attrs, resource), + do: PasswordReset.reset_password(resource, attrs) + + defp do_action(_attrs, _resource), do: {:error, "No action provided"} end diff --git a/lib/ash_authentication/password_authentication/sign_in_preparation.ex b/lib/ash_authentication/password_authentication/sign_in_preparation.ex index a13fa9f..5264748 100644 --- a/lib/ash_authentication/password_authentication/sign_in_preparation.ex +++ b/lib/ash_authentication/password_authentication/sign_in_preparation.ex @@ -21,9 +21,9 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do @impl true @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() def prepare(query, _opts, _) do - {:ok, identity_field} = Info.identity_field(query.resource) - {:ok, password_field} = Info.password_field(query.resource) - {:ok, hasher} = Info.hash_provider(query.resource) + {:ok, identity_field} = Info.password_authentication_identity_field(query.resource) + {:ok, password_field} = Info.password_authentication_password_field(query.resource) + {:ok, hasher} = Info.password_authentication_hash_provider(query.resource) identity = Query.get_argument(query, identity_field) diff --git a/lib/ash_authentication/password_authentication/transformer.ex b/lib/ash_authentication/password_authentication/transformer.ex index 06ef14a..6a387d0 100644 --- a/lib/ash_authentication/password_authentication/transformer.ex +++ b/lib/ash_authentication/password_authentication/transformer.ex @@ -72,9 +72,23 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do with :ok <- validate_authentication_extension(dsl_state), {:ok, dsl_state} <- validate_identity_field(dsl_state), {:ok, dsl_state} <- validate_hashed_password_field(dsl_state), - {:ok, dsl_state} <- maybe_build_action(dsl_state, :register, &build_register_action/1), + {:ok, register_action_name} <- + Info.password_authentication_register_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + register_action_name, + &build_register_action(&1, register_action_name) + ), {:ok, dsl_state} <- validate_register_action(dsl_state), - {:ok, dsl_state} <- maybe_build_action(dsl_state, :sign_in, &build_sign_in_action/1), + {:ok, sign_in_action_name} <- + Info.password_authentication_sign_in_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + sign_in_action_name, + &build_sign_in_action(&1, sign_in_action_name) + ), {:ok, dsl_state} <- validate_sign_in_action(dsl_state), :ok <- validate_hash_provider(dsl_state) do authentication = @@ -105,11 +119,13 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do def before?(Resource.Transformers.DefaultAccept), do: true def before?(_), do: false - defp build_register_action(dsl_state) do - with {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state), - {:ok, password_field} <- Info.password_field(dsl_state), - {:ok, confirm_field} <- Info.password_confirmation_field(dsl_state), - confirmation_required? <- Info.confirmation_required?(dsl_state) do + defp build_register_action(dsl_state, action_name) do + with {:ok, hashed_password_field} <- + Info.password_authentication_hashed_password_field(dsl_state), + {:ok, password_field} <- Info.password_authentication_password_field(dsl_state), + {:ok, confirm_field} <- + Info.password_authentication_password_confirmation_field(dsl_state), + confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state) do password_opts = [ type: Type.String, allow_nil?: false, @@ -154,7 +170,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do ]) Transformer.build_entity(Resource.Dsl, [:actions], :create, - name: :register, + name: action_name, arguments: arguments, changes: changes, allow_nil_input: [hashed_password_field] @@ -162,9 +178,9 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do end end - defp build_sign_in_action(dsl_state) do - with {:ok, identity_field} <- Info.identity_field(dsl_state), - {:ok, password_field} <- Info.password_field(dsl_state) do + defp build_sign_in_action(dsl_state, action_name) do + with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state), + {:ok, password_field} <- Info.password_authentication_password_field(dsl_state) do identity_attribute = Resource.Info.attribute(dsl_state, identity_field) arguments = [ @@ -188,7 +204,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do ] Transformer.build_entity(Resource.Dsl, [:actions], :read, - name: :sign_in, + name: action_name, arguments: arguments, preparations: preparations, get?: true diff --git a/lib/ash_authentication/password_authentication/user_validations.ex b/lib/ash_authentication/password_authentication/user_validations.ex index aa55d61..77a69aa 100644 --- a/lib/ash_authentication/password_authentication/user_validations.ex +++ b/lib/ash_authentication/password_authentication/user_validations.ex @@ -46,7 +46,7 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do """ @spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()} def validate_hash_provider(dsl_state) do - case Info.hash_provider(dsl_state) do + case Info.password_authentication_hash_provider(dsl_state) do {:ok, hash_provider} -> validate_module_implements_behaviour(hash_provider, HashProvider) @@ -64,9 +64,10 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do """ @spec validate_sign_in_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} def validate_sign_in_action(dsl_state) do - with {:ok, identity_field} <- Info.identity_field(dsl_state), - {:ok, password_field} <- Info.password_field(dsl_state), - {:ok, action} <- validate_action_exists(dsl_state, :sign_in), + with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state), + {:ok, password_field} <- Info.password_authentication_password_field(dsl_state), + {:ok, action_name} <- Info.password_authentication_sign_in_action_name(dsl_state), + {:ok, action} <- validate_action_exists(dsl_state, action_name), :ok <- validate_identity_argument(dsl_state, action, identity_field), :ok <- validate_password_argument(action, password_field), :ok <- validate_action_has_preparation(action, SignInPreparation) do @@ -79,11 +80,14 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do """ @spec validate_register_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} def validate_register_action(dsl_state) do - with {:ok, password_field} <- Info.password_field(dsl_state), - {:ok, password_confirmation_field} <- Info.password_confirmation_field(dsl_state), - {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state), - confirmation_required? <- Info.confirmation_required?(dsl_state), - {:ok, action} <- validate_action_exists(dsl_state, :register), + with {:ok, password_field} <- Info.password_authentication_password_field(dsl_state), + {:ok, password_confirmation_field} <- + Info.password_authentication_password_confirmation_field(dsl_state), + {:ok, hashed_password_field} <- + Info.password_authentication_hashed_password_field(dsl_state), + confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state), + {:ok, action_name} <- Info.password_authentication_register_action_name(dsl_state), + {:ok, action} <- validate_action_exists(dsl_state, action_name), :ok <- validate_allow_nil_input(action, hashed_password_field), :ok <- validate_password_argument(action, password_field), :ok <- @@ -169,7 +173,7 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do @spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} def validate_identity_field(dsl_state) do with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, identity_field} <- Info.identity_field(dsl_state), + {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state), {:ok, attribute} <- find_attribute(dsl_state, identity_field), :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]), diff --git a/lib/ash_authentication/password_reset.ex b/lib/ash_authentication/password_reset.ex new file mode 100644 index 0000000..60d438a --- /dev/null +++ b/lib/ash_authentication/password_reset.ex @@ -0,0 +1,166 @@ +defmodule AshAuthentication.PasswordReset do + @default_lifetime_days 3 + + @dsl [ + %Spark.Dsl.Section{ + name: :password_reset, + describe: "Configure password reset behaviour", + schema: [ + token_lifetime: [ + type: :pos_integer, + doc: """ + How long should the reset token be valid, in hours. + + Defaults to #{@default_lifetime_days} days. + """, + default: @default_lifetime_days * 24 + ], + request_password_reset_action_name: [ + type: :atom, + doc: """ + The name to use for the action which generates a password reset token. + """, + default: :request_password_reset + ], + password_reset_action_name: [ + type: :atom, + doc: """ + The name to use for the action which actually resets the user's password. + """, + default: :reset_password + ], + sender: [ + type: + {:spark_function_behaviour, AshAuthentication.PasswordReset.Sender, + {AshAuthentication.PasswordReset.SenderFunction, 2}}, + doc: """ + How to send the password reset instructions to the user. + + Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. + + Accepts a module, module and opts, or a function that takes a record, reset token and options. + + See `AshAuthentication.PasswordReset.Sender` for more information. + """, + required: true + ] + ] + } + ] + + @moduledoc """ + Allow users to reset their passwords. + + This extension provides a mechanism to allow users to reset their password as + in your typical "forgotten password" flow. + + This requires the `AshAuthentication.PasswordAuthentication` extension to be + present, in order to be able to update the password. + + ## Senders + + You can set the DSL's `sender` key to be either a two-arity anonymous function + or a module which implements the `AshAuthentication.PasswordReset.Sender` + behaviour. This callback can be used to send password reset instructions to + the user via the system of your choice. + + ## Usage + + ```elixir + defmodule MyApp.Accounts.Users do + use Ash.Resource, + extensions: [ + AshAuthentication.PasswordAuthentication, + AshAuthentication.PasswordReset + ] + + attributes do + uuid_primary_key :id + attribute :email, :ci_string, allow_nil?: false + end + + password_reset do + token_lifetime 24 + sender MyApp.ResetRequestSender + end + end + ``` + + Because you often want to submit the password reset token via the web, you can + also use the password authentication callback endpoint with an action of + "reset_password" and the reset password action will be called with the + included params. + + ## DSL Documentation + + ### Index + + #{Spark.Dsl.Extension.doc_index(@dsl)} + + ### Docs + + #{Spark.Dsl.Extension.doc(@dsl)} + """ + + use Spark.Dsl.Extension, + sections: @dsl, + transformers: [AshAuthentication.PasswordReset.Transformer] + + alias Ash.{Changeset, Resource} + alias AshAuthentication.{Jwt, PasswordReset} + + @doc """ + Returns whether password reset is enabled for the resource + """ + @spec enabled?(Resource.t()) :: boolean + def enabled?(resource), do: __MODULE__ in Spark.extensions(resource) + + @doc """ + Request a password reset for a user. + + If the record supports password resets then the reset token will be generated and sent. + + ## Example + + iex> user = MyApp.Accounts.get(MyApp.Accounts.User, email: "marty@mcfly.me") + ...> request_password_reset(user) + :ok + """ + def request_password_reset(user) do + resource = user.__struct__ + + with true <- enabled?(resource), + {:ok, action} <- PasswordReset.Info.request_password_reset_action_name(resource), + {:ok, api} <- AshAuthentication.Info.authentication_api(resource) do + user + |> Changeset.for_update(action, %{}) + |> api.update() + else + {:error, reason} -> {:error, reason} + _ -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"} + end + end + + @doc """ + Reset a user's password. + + Given a reset token, password and _maybe_ password confirmation, validate and + change the user's password. + """ + @spec reset_password(Resource.t(), params) :: {:ok, Resource.record()} | {:error, Changeset.t()} + when params: %{required(String.t()) => String.t()} + def reset_password(resource, params) do + with {:ok, token} <- Map.fetch(params, "reset_token"), + {:ok, %{"sub" => subject}, config} <- Jwt.verify(token, resource), + {:ok, user} <- AshAuthentication.subject_to_resource(subject, config), + {:ok, action} <- PasswordReset.Info.password_reset_action_name(config.resource), + {:ok, api} <- AshAuthentication.Info.authentication_api(resource) do + user + |> Changeset.for_update(action, params) + |> api.update() + else + :error -> {:error, "Invalid reset token"} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/ash_authentication/password_reset/info.ex b/lib/ash_authentication/password_reset/info.ex new file mode 100644 index 0000000..a73964c --- /dev/null +++ b/lib/ash_authentication/password_reset/info.ex @@ -0,0 +1,9 @@ +defmodule AshAuthentication.PasswordReset.Info do + @moduledoc """ + Generated configuration functions based on a resource's DSL configuration. + """ + + use AshAuthentication.InfoGenerator, + extension: AshAuthentication.PasswordReset, + sections: [:password_reset] +end diff --git a/lib/ash_authentication/password_reset/notifier.ex b/lib/ash_authentication/password_reset/notifier.ex new file mode 100644 index 0000000..2206a60 --- /dev/null +++ b/lib/ash_authentication/password_reset/notifier.ex @@ -0,0 +1,21 @@ +defmodule AshAuthentication.PasswordReset.Notifier do + @moduledoc """ + This is a moduledoc + """ + use Ash.Notifier + alias AshAuthentication.{PasswordReset, PasswordReset.Info} + + @doc false + @impl true + def notify(notification) do + with true <- PasswordReset.enabled?(notification.resource), + {:ok, action} <- Info.request_password_reset_action_name(notification.resource), + true <- notification.action.name == action, + {:ok, {sender, send_opts}} <- Info.sender(notification.resource), + {:ok, reset_token} <- Map.fetch(notification.data.__metadata__, :reset_token) do + sender.send(notification.data, reset_token, send_opts) + end + + :ok + end +end diff --git a/lib/ash_authentication/password_reset/request_password_reset_action.ex b/lib/ash_authentication/password_reset/request_password_reset_action.ex new file mode 100644 index 0000000..70bb368 --- /dev/null +++ b/lib/ash_authentication/password_reset/request_password_reset_action.ex @@ -0,0 +1,35 @@ +defmodule AshAuthentication.PasswordReset.RequestPasswordResetAction do + @moduledoc """ + A manually implemented action which generates a reset token for a user. + """ + use Ash.Resource.ManualUpdate + alias Ash.{Changeset, Resource, Resource.ManualUpdate} + alias AshAuthentication.{Jwt, PasswordReset.Info} + + @doc false + @impl true + @spec update(Changeset.t(), keyword, ManualUpdate.context()) :: + {:ok, Resource.record()} | {:error, any} + def update(changeset, _opts, _context) do + lifetime = Info.token_lifetime!(changeset.resource) + + action = + changeset.action + |> Map.fetch!(:name) + |> to_string() + + {:ok, token, _claims} = + changeset.data + |> Jwt.token_for_record(%{"act" => action}, token_lifetime: lifetime) + + metadata = + changeset.data.__metadata__ + |> Map.put(:reset_token, token) + + data = + changeset.data + |> Map.put(:__metadata__, metadata) + + {:ok, data} + end +end diff --git a/lib/ash_authentication/password_reset/reset_token_validation.ex b/lib/ash_authentication/password_reset/reset_token_validation.ex new file mode 100644 index 0000000..307a3e4 --- /dev/null +++ b/lib/ash_authentication/password_reset/reset_token_validation.ex @@ -0,0 +1,24 @@ +defmodule AshAuthentication.PasswordReset.ResetTokenValidation do + @moduledoc """ + Validate that the token is a valid password reset request token. + """ + + use Ash.Resource.Validation + alias Ash.{Changeset, Error.Changes.InvalidArgument} + alias AshAuthentication.{Jwt, PasswordReset.Info} + + @doc false + @impl true + @spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()} + def validate(changeset, _) do + with token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token), + {:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource), + {:ok, resource_action} <- Info.request_password_reset_action_name(changeset.resource), + true <- to_string(resource_action) == token_action do + :ok + else + _ -> + {:error, InvalidArgument.exception(field: :reset_token, message: "is not valid")} + end + end +end diff --git a/lib/ash_authentication/password_reset/sender.ex b/lib/ash_authentication/password_reset/sender.ex new file mode 100644 index 0000000..4417046 --- /dev/null +++ b/lib/ash_authentication/password_reset/sender.ex @@ -0,0 +1,80 @@ +defmodule AshAuthentication.PasswordReset.Sender do + @moduledoc ~S""" + A module to implement sending of the password reset token to a user. + + Allows you to glue sending of reset instructions to + [swoosh](https://hex.pm/packages/swoosh), + [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system + is appropriate for your application. + + Note that the return value and any failures are ignored. If you need retry + logic, etc, then you should implement it in your sending system. + + ## Example + + Implementing as a module: + + ```elixir + defmodule MyApp.PasswordResetSender do + use AshAuthentication.PasswordReset.Sender + import Swoosh.Email + alias MyAppWeb.Router.Helpers, as: Routes + + def send(user, reset_token, _opts) do + new() + |> to({user.name, user.email}) + |> from({"Doc Brown", "emmet@brown.inc"}) + |> subject("Password reset instructions") + |> html_body(" +

Password reset instructions

+

+ Hi #{user.name},
+ + Someone (maybe you) has requested a password reset for your account. + If you did not initiate this request then please ignore this email. +

+ + Click here to reset + + ") + |> MyApp.Mailer.deliver() + end + end + + defmodule MyApp.Accounts.User do + use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest] + + password_reset do + sender MyApp.PasswordResetSender + end + end + ``` + + You can also implment it directly as a function: + + + ```elixir + defmodule MyApp.Accounts.User do + use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest] + + password_reset do + sender fn user, token, _opt -> + MyApp.Mailer.send_password_reset_email(user, token) + end + end + end + ``` + """ + + alias Ash.Resource + + @callback send(user :: Resource.record(), reset_token :: String.t(), opts :: list) :: :ok + + @doc false + @spec __using__(any) :: Macro.t() + defmacro __using__(_) do + quote do + @behaviour AshAuthentication.PasswordReset.Sender + end + end +end diff --git a/lib/ash_authentication/password_reset/sender_function.ex b/lib/ash_authentication/password_reset/sender_function.ex new file mode 100644 index 0000000..b838cac --- /dev/null +++ b/lib/ash_authentication/password_reset/sender_function.ex @@ -0,0 +1,28 @@ +defmodule AshAuthentication.PasswordReset.SenderFunction do + @moduledoc """ + Implements `AshAuthentication.PasswordReset.Sender` for functions that are + provided to the DSL instead of modules. + """ + + use AshAuthentication.PasswordReset.Sender + alias Ash.Resource + + @doc false + @impl true + @spec send(Resource.record(), String.t(), list()) :: :ok + def send(user, token, fun: {m, f, a}) do + apply(m, f, [user, token | a]) + :ok + end + + def send(user, token, fun: fun) when is_function(fun, 2) do + fun.(user, token) + :ok + end + + def send(user, token, [fun: fun] = opts) when is_function(fun, 3) do + opts = Keyword.delete(opts, :fun) + fun.(user, token, opts) + :ok + end +end diff --git a/lib/ash_authentication/password_reset/transformer.ex b/lib/ash_authentication/password_reset/transformer.ex new file mode 100644 index 0000000..ec9206c --- /dev/null +++ b/lib/ash_authentication/password_reset/transformer.ex @@ -0,0 +1,246 @@ +defmodule AshAuthentication.PasswordReset.Transformer do + @moduledoc """ + The PasswordReset transformer. + + Scans the resource and checks that all the fields and actions needed are + present. + """ + + use Spark.Dsl.Transformer + + alias AshAuthentication.PasswordReset.{ + Info, + Notifier, + RequestPasswordResetAction, + ResetTokenValidation, + Sender + } + + alias Ash.{Resource, Type} + alias AshAuthentication.PasswordAuthentication, as: PA + + alias Spark.{Dsl.Transformer, Error.DslError} + + import AshAuthentication.Utils + import AshAuthentication.Validations.Action + + @doc false + @impl true + @spec transform(map) :: + :ok + | {:ok, map()} + | {:error, term()} + | {:warn, map(), String.t() | [String.t()]} + | :halt + def transform(dsl_state) do + with :ok <- validate_authentication_extension(dsl_state), + :ok <- validate_password_authentication_extension(dsl_state), + :ok <- validate_token_generation_enabled(dsl_state), + :ok <- validate_sender(dsl_state), + {:ok, request_action_name} <- Info.request_password_reset_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + request_action_name, + &build_request_action(&1, request_action_name) + ), + :ok <- validate_request_action(dsl_state, request_action_name), + {:ok, change_action_name} <- Info.password_reset_action_name(dsl_state), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + change_action_name, + &build_change_action(&1, change_action_name) + ), + :ok <- validate_change_action(dsl_state, change_action_name), + {:ok, dsl_state} <- maybe_add_notifier(dsl_state, Notifier) do + {:ok, dsl_state} + else + :error -> {:error, "Configuration error"} + {:error, reason} -> {:error, reason} + end + end + + @doc false + @impl true + @spec after?(module) :: boolean + def after?(AshAuthentication.Transformer), do: true + def after?(PA.Transformer), do: true + def after?(_), do: false + + @doc false + @impl true + @spec before?(module) :: boolean + def before?(Resource.Transformers.DefaultAccept), do: true + def before?(_), do: false + + defp validate_authentication_extension(dsl_state) do + extensions = Transformer.get_persisted(dsl_state, :extensions, []) + + if AshAuthentication in extensions, + do: :ok, + else: + {:error, + DslError.exception( + path: [:extensions], + message: + "The `AshAuthentication` extension must also be present on this resource in order to generate reset tokens." + )} + end + + defp validate_password_authentication_extension(dsl_state) do + extensions = Transformer.get_persisted(dsl_state, :extensions, []) + + if PA in extensions, + do: :ok, + else: + {:error, + DslError.exception( + path: [:extensions], + message: + "The `AshAuthentication.PasswordAuthentication` extension must also be present on this resource in order to be able to change the user's password." + )} + end + + defp validate_token_generation_enabled(dsl_state) do + if AshAuthentication.Info.tokens_enabled?(dsl_state), + do: :ok, + else: + {:error, + DslError.exception( + path: [:tokens], + message: "Token generation must be enabled for password resets to work." + )} + end + + defp validate_sender(dsl_state) do + with {:ok, {sender, _opts}} <- Info.sender(dsl_state), + true <- Spark.implements_behaviour?(sender, Sender) do + :ok + else + _ -> + {:error, + DslError.exception( + path: [:password_reset], + message: + "`sender` must be a module that implements the `AshAuthentication.PasswordReset.Sender` behaviour." + )} + end + end + + defp build_request_action(_dsl_state, action_name) do + Transformer.build_entity(Resource.Dsl, [:actions], :update, + name: action_name, + manual: RequestPasswordResetAction, + accept: [] + ) + end + + defp build_change_action(dsl_state, action_name) do + with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state), + {:ok, confirm_field} <- + PA.Info.password_authentication_password_confirmation_field(dsl_state), + confirmation_required? <- + PA.Info.password_authentication_confirmation_required?(dsl_state) do + password_opts = [ + type: Type.String, + allow_nil?: false, + constraints: [min_length: 8], + sensitive?: true + ] + + arguments = + [ + Transformer.build_entity!( + Resource.Dsl, + [:actions, :update], + :argument, + name: :reset_token, + type: Type.String, + sensitive?: true + ), + Transformer.build_entity!( + Resource.Dsl, + [:actions, :update], + :argument, + Keyword.put(password_opts, :name, password_field) + ) + ] + |> maybe_append( + confirmation_required?, + Transformer.build_entity!( + Resource.Dsl, + [:actions, :update], + :argument, + Keyword.put(password_opts, :name, confirm_field) + ) + ) + + changes = + [ + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate, + validation: ResetTokenValidation + ) + ] + |> maybe_append( + confirmation_required?, + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate, + validation: PA.PasswordConfirmationValidation + ) + ) + |> Enum.concat([ + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, + change: PA.HashPasswordChange + ) + ]) + + Transformer.build_entity(Resource.Dsl, [:actions], :update, + name: action_name, + arguments: arguments, + changes: changes, + accept: [] + ) + end + end + + defp validate_request_action(dsl_state, action_name) do + with {:ok, action} <- validate_action_exists(dsl_state, action_name) do + validate_action_has_manual(action, RequestPasswordResetAction) + end + end + + defp validate_change_action(dsl_state, action_name) do + with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state), + {:ok, password_confirmation_field} <- + PA.Info.password_authentication_password_confirmation_field(dsl_state), + confirmation_required? <- + PA.Info.password_authentication_confirmation_required?(dsl_state), + {:ok, action} <- validate_action_exists(dsl_state, action_name), + :ok <- validate_action_has_validation(action, ResetTokenValidation), + :ok <- validate_action_has_change(action, PA.HashPasswordChange), + :ok <- PA.UserValidations.validate_password_argument(action, password_field), + :ok <- + PA.UserValidations.validate_password_confirmation_argument( + action, + password_confirmation_field, + confirmation_required? + ) do + PA.UserValidations.validate_action_has_validation( + action, + PA.PasswordConfirmationValidation, + confirmation_required? + ) + end + end + + defp maybe_add_notifier(dsl_state, notifier) do + notifiers = + dsl_state + |> Transformer.get_persisted(:notifiers, []) + |> MapSet.new() + |> MapSet.put(notifier) + |> Enum.to_list() + + {:ok, Transformer.persist(dsl_state, :notifiers, notifiers)} + end +end diff --git a/lib/ash_authentication/utils.ex b/lib/ash_authentication/utils.ex index b06fb6b..167ed7f 100644 --- a/lib/ash_authentication/utils.ex +++ b/lib/ash_authentication/utils.ex @@ -45,25 +45,6 @@ defmodule AshAuthentication.Utils do def maybe_append(collection, test, _element) when test in [nil, false], do: collection def maybe_append(collection, _test, element), do: Enum.concat(collection, [element]) - @doc """ - Generate the AST for an options function spec. - - Not something you should ever need. - """ - @spec spec_for_option(keyword) :: Macro.t() - def spec_for_option(options) do - case Keyword.get(options, :type, :term) do - {:behaviour, _module} -> - {:module, [], Elixir} - - :string -> - {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []} - - terminal -> - {terminal, [], Elixir} - end - end - @doc """ Used within transformers to optionally build actions as needed. """ diff --git a/lib/ash_authentication/validations/action.ex b/lib/ash_authentication/validations/action.ex index e905eb1..06b59ce 100644 --- a/lib/ash_authentication/validations/action.ex +++ b/lib/ash_authentication/validations/action.ex @@ -111,6 +111,30 @@ defmodule AshAuthentication.Validations.Action do )} end + @doc """ + Validate the presence of the named manual module on an action. + """ + @spec validate_action_has_manual(Actions.action(), module) :: + :ok | {:error, Exception.t()} + def validate_action_has_manual(action, manual_module) do + has_manual? = + action + |> Map.get(:manual) + |> then(fn {module, _args} -> + module == manual_module + end) + + if has_manual?, + do: :ok, + else: + {:error, + DslError.exception( + path: [:actions, :manual], + message: + "The action `#{inspect(action.name)}` should have the `#{inspect(manual_module)}` manual present." + )} + end + @doc """ Validate the presence of the named validation module on an action. """ diff --git a/mix.exs b/mix.exs index a14bfb0..6b6d052 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,7 @@ defmodule AshAuthentication.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, "~> 2.3"}, + {:ash, "~> 2.4"}, {:bcrypt_elixir, "~> 3.0", optional: true}, {:jason, "~> 1.4"}, {:joken, "~> 2.5"}, diff --git a/mix.lock b/mix.lock index 776862e..51b1fa0 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, - "ash": {:hex, :ash, "2.3.0", "3f47a8f1f273a8fce66ac48ef146f4f7a51a6e50d26f50c2f650fbb976e6f5a8", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.2", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1540d43533b2c9caa9602209035f33ec2e32240df53d289fc196766dc0e3b510"}, + "ash": {:hex, :ash, "2.4.1", "51c6968fec4980c44c3bc667935b4790a246a16eb1581365e3ede3c6f3bdb51b", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, ">= 0.2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b0ba024e26bd07bf48fdda20e5dc3f100dba14c8259e99e48131cd088acf234"}, "ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []}, "ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []}, "ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"}, @@ -47,13 +47,13 @@ "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"}, "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "18746f439afc31cf3ac4bbd26a87b236d7f4dd3ee735cebaee4116e2b0f1d08d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "1969ea00868737ceeecc7c93b2b0f3136829c38948c5109391b9c1d402062228"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"}, "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, - "spark": {:hex, :spark, "0.2.1", "4f76234fce4bf48a6236e2268fba4d33c441ed8e30944785852c483a7aed231c", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "29033cb2ebecfff5ceff5209cca06c8e1e7ce8c1da189676de19cdc07d146b43"}, + "spark": {:hex, :spark, "0.2.6", "84dbfe7153dc51f988a2b43f28031be87dee724d2ac535069d05807cfacde7c4", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f0fba891abc70d4e7431b3ed6283ee46ccd6e8045e0bfdce13bc06e8904bbd25"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, diff --git a/test/ash_authentication/password_authentication/action_test.exs b/test/ash_authentication/password_authentication/action_test.exs index 8b91a88..9896573 100644 --- a/test/ash_authentication/password_authentication/action_test.exs +++ b/test/ash_authentication/password_authentication/action_test.exs @@ -159,7 +159,7 @@ defmodule AshAuthentication.PasswordAuthentication.ActionTest do defp resource_config(%{resource: resource}) do config = resource - |> Info.options() + |> Info.password_authentication_options() {:ok, config: config} end diff --git a/test/ash_authentication/password_authentication/identity_test.exs b/test/ash_authentication/password_authentication/identity_test.exs index 2a78777..7ff5541 100644 --- a/test/ash_authentication/password_authentication/identity_test.exs +++ b/test/ash_authentication/password_authentication/identity_test.exs @@ -42,7 +42,7 @@ defmodule AshAuthentication.IdentityTest do defp resource_config(%{resource: resource}) do config = resource - |> Info.options() + |> Info.password_authentication_options() {:ok, config: config} end diff --git a/test/ash_authentication/password_reset_test.exs b/test/ash_authentication/password_reset_test.exs new file mode 100644 index 0000000..3741eaf --- /dev/null +++ b/test/ash_authentication/password_reset_test.exs @@ -0,0 +1,71 @@ +defmodule AshAuthentication.PasswordResetTest do + @moduledoc false + use AshAuthentication.DataCase, async: true + alias AshAuthentication.PasswordReset + import ExUnit.CaptureLog + + describe "enabled?/1" do + test "is false when the resource doesn't support password resets" do + refute PasswordReset.enabled?(Example.TokenRevocation) + end + + test "it is true when the resource does support password resets" do + assert PasswordReset.enabled?(Example.UserWithUsername) + end + end + + describe "reset_password_request/1" do + test "it generates a password reset token" do + {:ok, user} = + build_user() + |> PasswordReset.request_password_reset() + + assert user.__metadata__.reset_token =~ ~r/[\w.]/i + end + + test "it sends the reset instructions" do + assert capture_log(fn -> + {:ok, _} = + build_user() + |> PasswordReset.request_password_reset() + end) =~ ~r/Password reset request/i + end + end + + describe "reset_password/2" do + test "when the reset token is valid, it can change the password" do + {:ok, user} = + build_user() + |> PasswordReset.request_password_reset() + + password = password() + + attrs = %{ + "reset_token" => user.__metadata__.reset_token, + "password" => password, + "password_confirmation" => password + } + + {:ok, new_user} = PasswordReset.reset_password(Example.UserWithUsername, attrs) + + assert new_user.hashed_password != user.hashed_password + end + + test "when the reset token is invalid, it doesn't change the password" do + user = build_user() + + password = password() + + attrs = %{ + "reset_token" => Ecto.UUID.generate(), + "password" => password, + "password_confirmation" => password + } + + assert {:error, _} = PasswordReset.reset_password(Example.UserWithUsername, attrs) + + {:ok, reloaded_user} = Example.get(Example.UserWithUsername, id: user.id) + assert reloaded_user.hashed_password == user.hashed_password + end + end +end diff --git a/test/support/example/user_with_username.ex b/test/support/example/user_with_username.ex index 5fcdf8e..5db78c9 100644 --- a/test/support/example/user_with_username.ex +++ b/test/support/example/user_with_username.ex @@ -5,10 +5,13 @@ defmodule Example.UserWithUsername do extensions: [ AshAuthentication, AshAuthentication.PasswordAuthentication, + AshAuthentication.PasswordReset, AshGraphql.Resource, AshJsonApi.Resource ] + require Logger + @type t :: %__MODULE__{ id: Ecto.UUID.t(), username: String.t(), @@ -86,6 +89,12 @@ defmodule Example.UserWithUsername do hashed_password_field(:hashed_password) end + password_reset do + sender(fn user, token -> + Logger.debug("Password reset request for user #{user.username}, token #{inspect(token)}") + end) + end + identities do identity(:username, [:username]) end