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(" +
+ 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.
+