From 7e639e4a21d0fc1819de3b954d12a3a2ad6c1d5a Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Mon, 30 Jan 2023 13:16:37 +1300 Subject: [PATCH] feat: Add support and documentation for custom strategies. (#154) --- config/dev.exs | 2 + config/test.exs | 2 + dev/dev_server/test_page.ex | 23 +- documentation/topics/custom-strategy.md | 373 ++++++++++ lib/ash_authentication.ex | 10 +- .../add_ons/confirmation.ex | 8 +- .../add_ons/confirmation/actions.ex | 3 +- .../add_ons/confirmation/dsl.ex | 119 ++++ .../add_ons/confirmation/strategy.ex | 4 + .../add_ons/confirmation/transformer.ex | 38 +- .../add_ons/confirmation/verifier.ex | 40 +- lib/ash_authentication/dsl.ex | 667 ++---------------- lib/ash_authentication/info.ex | 2 +- lib/ash_authentication/plug/dispatcher.ex | 2 +- lib/ash_authentication/strategies/auth0.ex | 76 ++ lib/ash_authentication/strategies/custom.ex | 75 ++ .../strategies/custom/helpers.ex | 57 ++ .../strategies/custom/transformer.ex | 122 ++++ .../strategies/custom/verifier.ex | 39 + lib/ash_authentication/strategies/github.ex | 75 ++ lib/ash_authentication/strategies/oauth2.ex | 10 +- .../strategies/oauth2/dsl.ex | 292 ++++++++ .../strategies/oauth2/identity_change.ex | 4 +- .../strategies/oauth2/strategy.ex | 4 + .../strategies/oauth2/transformer.ex | 47 +- .../strategies/oauth2/verifier.ex | 42 +- lib/ash_authentication/strategies/password.ex | 9 +- .../strategies/password/dsl.ex | 170 +++++ .../strategies/password/resettable.ex | 61 +- .../strategies/password/strategy.ex | 4 + .../strategies/password/transformer.ex | 57 +- .../strategies/password/verifier.ex | 40 +- lib/ash_authentication/strategy.ex | 9 + .../get_confirmation_changes_preparation.ex | 3 +- lib/ash_authentication/verifier.ex | 27 +- .../strategies/custom_strategy_test.exs | 47 ++ .../password/hash_password_change_test.exs | 6 +- .../password_confirmation_validation_test.exs | 4 +- .../strategies/password_test.exs | 2 +- test/support/example/custom_strategy.ex | 112 +++ test/support/example/generic_oauth_change.ex | 4 +- .../example/only_marties_at_the_party.ex | 107 +++ test/support/example/user.ex | 5 + 43 files changed, 1845 insertions(+), 958 deletions(-) create mode 100644 documentation/topics/custom-strategy.md create mode 100644 lib/ash_authentication/add_ons/confirmation/dsl.ex create mode 100644 lib/ash_authentication/strategies/auth0.ex create mode 100644 lib/ash_authentication/strategies/custom.ex create mode 100644 lib/ash_authentication/strategies/custom/helpers.ex create mode 100644 lib/ash_authentication/strategies/custom/transformer.ex create mode 100644 lib/ash_authentication/strategies/custom/verifier.ex create mode 100644 lib/ash_authentication/strategies/github.ex create mode 100644 lib/ash_authentication/strategies/oauth2/dsl.ex create mode 100644 lib/ash_authentication/strategies/password/dsl.ex create mode 100644 test/ash_authentication/strategies/custom_strategy_test.exs create mode 100644 test/support/example/custom_strategy.ex create mode 100644 test/support/example/only_marties_at_the_party.ex diff --git a/config/dev.exs b/config/dev.exs index 139c742..5ae7f48 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -56,3 +56,5 @@ config :ash_authentication, ] # config :ash_authentication, debug_authentication_failures?: true + +config :ash_authentication, extra_strategies: [Example.OnlyMartiesAtTheParty] diff --git a/config/test.exs b/config/test.exs index 0780c66..22ea509 100644 --- a/config/test.exs +++ b/config/test.exs @@ -36,3 +36,5 @@ config :ash_authentication, signing_secret: "Marty McFly in the past with the Delorean" ] ] + +config :ash_authentication, extra_strategies: [Example.OnlyMartiesAtTheParty] diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex index da1ae54..edfe9be 100644 --- a/dev/dev_server/test_page.ex +++ b/dev/dev_server/test_page.ex @@ -178,8 +178,29 @@ defmodule DevServer.TestPage do defp render_strategy(strategy, :callback, _) when strategy.provider == :oauth2, do: "" + defp render_strategy(strategy, :sign_in, _options) + when is_struct(strategy, Example.OnlyMartiesAtTheParty) do + EEx.eval_string( + ~s""" +
+
+ Sign in a Marty + +
+ +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, :sign_in), + method: Strategy.method_for_phase(strategy, :sign_in) + ] + ) + end + defp render_strategy(strategy, phase, _options) do - inspect({strategy.provider, phase}) + inspect({strategy, phase}) end defp route_for_phase(strategy, phase) do diff --git a/documentation/topics/custom-strategy.md b/documentation/topics/custom-strategy.md new file mode 100644 index 0000000..240c238 --- /dev/null +++ b/documentation/topics/custom-strategy.md @@ -0,0 +1,373 @@ +# Defining Custom Authentication Strategies + +AshAuthentication allows you to bring your own authentication strategy without +having to change the Ash Authenticaiton codebase. + +> There is functionally no difference between "add ons" and "strategies" other +> than where they appear in the DSL. We invented "add ons" because it felt +> weird calling "confirmation" an authentication strategy. + +There are several moving parts which must all work together so hold on to your hat! + + 1. A `Spark.Dsl.Entity` struct. This is used to define the strategy DSL + inside the `strategies` (or `add_ons`) section of the `authentication` DSL. + 2. A strategy struct, which stores information about the strategy as + configured on a resource which must comply with a few rules. + 3. An optional transformer, which can be used to manipulate the DSL state of + the entity and the resource. + 4. An optional verifier, which can be used to verify the DSL state of the + entity and the resource after compilation. + 4. The `AshAuthentication.Strategy` protocol, which provides the glue needed + for everything to wire up and wrappers around the actions needed to run on + the resource. + 5. Runtime configuration of `AshAuthentication` to help it find the extra + strategies. + +We're going to define an extremely dumb strategy which lets anyone with a name +that starts with "Marty" sign in with just their name. Of course you would +never do this in real life, but this isn't real life - it's documentation! + +## DSL setup + +Let's start by defining a module for our strategy to live in. Let's call it +`OnlyMartiesAtTheParty`: + +```elixir +defmodule OnlyMartiesAtTheParty do + use AshAuthentication.Strategy.Custom +end +``` + +Sadly, this isn't enough to make the magic happen. We need to define our DSL +entity by implementing the `dsl/0` callback: + +```elixir +defmodule OnlyMartiesAtTheParty do + use AshAuthentication.Strategy.Custom + + def dsl do + %Spark.Dsl.Entity{ + name: :only_marty, + describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.", + examples: [ + """ + only_marty do + case_sensitive? true + name_field :name + end + """ + ], + target: __MODULE__, + args: [{:optional, :name, :marty}], + schema: [ + name: [ + type: :atom, + doc: """ + The strategy name. + """, + required: true + ], + case_sensitive?: [ + type: :boolean, + doc: """ + Ignore letter case when comparing? + """, + required: false, + default: false + ], + name_field: [ + type: :atom, + doc: """ + The field to check for the users' name. + """, + required: true + ] + ] + } + end +end +``` + +If you haven't you should take a look at the docs for `Spark.Dsl.Entity`, but +here's a brief overview of what each field we've set does: + + - `name` is the name for which the helper function will be generated in + the DSL (ie `only_marty do #... end`). + - `describe` and `examples` are used when generating documentation. Probably + worth doing this (and using `Spark.Dsl.Extension.doc_entity/2` to generate + your moduledocs if you plan on sharing this strategy with others). + - `target` is the name of the module which defines our entity struct. We've + set it to `__MODULE__` which means that we'll have to define the struct on + this module. + - `schema` is a keyword list that defines a `NimbleOptions` schema. Spark + provides a number of additional types over the default ones though, so check + out `Spark.OptionsHelpers` for more information. + +Next up, we need to define our struct. The struct should have *at least* the +fields named in the entity schema. Additionally, Ash Authentication requires +that it have a `resource` field which will be set to the module of the resource +it's attached to during compilation. + +```elixir +defmodule OnlyMartiesAtTheParty do + defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil + + use AshAuthentication.Strategy.Custom + + # other code elided ... +end +``` + +Now it would be theoretically possible to add this custom strategies to your app +by adding it to the runtime configuration and the user resource: + +```elixir +# config.exs +config :ash_authentication, extra_strategies: [OnlyMartiesAtTheParty] + +# user resource +defmodule MyApp.Accounts.User do + use Ash.Resource, extensions: [AshAuthentication] + + authentication do + api MyApp.Accounts + + strategies do + only_marty do + name_field :name + end + end + end + + attributes do + uuid_primary_key + attribute :name, :string, allow_nil?: false + end +end +``` + +## Implementing the `AshAuthentication.Strategy` protocol + +The Strategy protocol is used to introspect the strategy so that it can +seamlessly fit in with the rest of Ash Authentication. Here are the key +concepts: + + - "phases" - in terms of HTTP, each strategy is likely to have many phases (eg + OAuth 2.0's "request" and "callback" phases). Essentially you need one + phase for each HTTP endpoint you wish to support with your strategy. In our + case we just want one sign in endpoint. + - "actions" - actions are exactly as they sound - Resource actions which can + be executed by the strategy, whether generated by the strategy (as in the + password strategy) or typed in by the user (as in the OAuth 2.0 strategy). + The reason that we wrap the strategy's actions this way is that all the + built-in strategies (and we hope yours too) allow the user to customise the + name of the actions that it uses. At the very least it should probably + append the strategy name to the action. Using `Strategy.action/4` allows us + to refer these by a more generic name rather than via the user-specified one + (eg `:register` vs `:register_with_password`). + - "routes" - `AshAuthentication.Plug` (or `AshAuthentication.Phoenix.Router`) + will generate routes using `Plug.Router` (or `Phoenix.Router`) - the + `routes/1` callback is used to retrieve this information from the strategy. + +Given this information, let's implment the strategy. It's quite long, so I'm going to break it up into smaller chunks. + +```elixir +defimpl AshAuthentication.Strategy, for: OnlyMartiesAtTheParty do +``` + +The `name/1` function is used to uniquely identify the strategy. It *must* be an +atom and *should* be the same as the path fragment used in the generated routes. + +```elixir + def name(strategy), do: strategy.name +``` + +Since our strategy only supports sign-in we only need a single `:sign_in` phase +and action. + +```elixir + def phases(_), do: [:sign_in] + def actions(_), do: [:sign_in] +``` + +Next we generate the routes for the strategy. Routes *should* contain the +subject name of the resource being authenticated in case the implementor is +authenticating multiple different resources - eg `User` and `Admin`. + +```elixir + def routes(strategy) do + subject_name = Info.authentication_subject_name!(strategy.resource) + + [ + {"/#{subject_name}/#{strategy.name}", :sign_in} + ] + end +``` + +When generating routes or forms for this phase, what HTTP method should we use? + +```elixir + def method_for_phase(_, :sign_in), do: :post +``` + +Next up, we write our plug. We take the "name field" from the input params in +the conn and pass them to our sign in action. As long as the action returns +`{:ok, Ash.Resource.record}` or `{:error, any}` then we can just pass it +straight into `store_authentication_result/2` from +`AshAuthentication.Plug.Helpers`. + +```elixir + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + + def plug(strategy, :sign_in, conn) do + params = Map.take(conn.params, [to_string(strategy.name_field)]) + result = action(strategy, :sign_in, params, []) + store_authentication_result(conn, result) + end +``` + +Finally, we implement our sign in action. We use `Ash.Query` to find all +records whose name field matches the input, then constrain it to only records +whose name field starts with "Marty". Depending on whether the name field has a +unique identity on it we have to deal with it returning zero or more users, or +an error. When it returns a single user we return that user in an ok tuple, +otherwise we return an authentication failure. + +In this example we're assuming that there is a default `read` action present on +the resource. + +> #### Warning {: .warning} +> +> When it comes to authentication, you never want to reveal to the user what the +> failure was - this helps prevent [enumeration +> attacks](https://www.hacksplaining.com/prevention/user-enumeration). +> +> You can use `AshAuthentication.Errors.AuthenticationFailed` for this purpose +> as it will cause `ash_authentication`, `ash_authentication_phoenix`, +> `ash_graphql` and `ash_json_api` to return the correct HTTP 401 error. + +```elixir + alias AshAuthentication.Errors.AuthenticationFailed + require Ash.Query + + def action(strategy, :sign_in, params, options) when strategy.case_sensitive? do + name_field = strategy.name_field + name = Map.get(params, to_string(name_field)) + api = AshAuthentication.Info.authentication_api!(strategy.resource) + + strategy.resource + |> Ash.Query.filter(ref(^name_field) == ^name) + |> then(fn query -> + if strategy.case_sensitive? do + Ash.Query.filter(query, like(ref(^name_field), "Marty%")) + else + Ash.Query.filter(query, ilike(ref(^name_field), "Marty%")) + end + end) + |> api.read(options) + |> case do + {:ok, [user]} -> + {:ok, user} + + {:ok, []} -> + {:error, AuthenticationFailed.exception(caused_by: %{reason: :no_user})} + + {:ok, _users} -> + {:error, AuthenticationFailed.exception(caused_by: %{reason: :too_many_users})} + + {:error, reason} -> + {:error, AuthenticationFailed.exception(caused_by: %{reason: reason})} + end + end +end +``` + +## Bonus round - transformers and verifiers + +In some cases it may be required for your strategy to modify it's own +configuration or that of the whole resource at compile time. For that you can +define the `transform/2` callback on your strategy module. + +At the very least it is good practice to call +`AshAuthentication.Strategy.Custom.Helpers.register_strategy_actions/3` so that +Ash Authentication can keep track of which actions are related to which +strategies and `AshAuthentication.Strategy.Custom.Helpers` is automatically +imported by `use AshAuthentication.Strategy.Custom` for this purpose. + +### Transformers + +For simple cases where you're just transforming the strategy you can just return +the modified strategy and the DSL will be updated accordingly. For example if +you wanted to generate the name of an action if the user hasn't specified it: + +```elixir +def transform(strategy, _dsl_state) do + {:ok, Map.put_new(strategy, :sign_in_action_name, :"sign_in_with_#{strategy.name}")} +end +``` + +In some cases you may want to modify the strategy and the resources DSL. In +this case you can return the newly muted DSL state in an ok tuple or an error +tuple, preferably containing a `Spark.Error.DslError`. For example if we +wanted to build a sign in action for `OnlyMartiesAtTheParty` to use: + +```elixir +def transform(strategy, dsl_state) do + strategy = Map.put_new(strategy, :sign_in_action_name, :"sign_in_with_#{strategy.name}") + + sign_in_action = + Spark.Dsl.Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read, + name: strategy.sign_in_action_name, + accept: [strategy.name_field], + get?: true + ) + + dsl_state = + dsl_state + |> Spark.Dsl.Transformer.add_entity([:actions], sign_in_action) + |> put_strategy(strategy) + |> then(fn dsl_state -> + register_strategy_actions([strategy.sign_in_action_name], dsl_state, strategy) + end) + + {:ok, dsl_state} +end +``` + +Transformers can also be used to validate user input or even directly add code +to the resource. See the docs for `Spark.Dsl.Transformer` for more information. + +### Verifiers + +We also support a variant of transformers which run in the new `@after_verify` +compile hook provided by Elixir 1.14. This is a great place to put checks +to make sure that the user's configuration make sense without adding any +compile-time dependencies between modules which may cause compiler deadlocks. + +For example, verifying that the "name" attribute contains "marty" (why you would +do this I don't know but I'm running out of sensible examples): + +```elixir +def verify(strategy, _dsl_state) do + if String.contains?(to_string(strategy.name_field), "marty") do + :ok + else + {:error, + Spark.Error.DslError.exception( + path: [:authentication, :strategies, :only_marties], + message: "Option `name_field` must contain the \"marty\"" + )} + end +end +``` + +## Summary + +You should now have all the tools you need to build custom strategies - and in +fact the strategies provided by Ash Authentication are built using this system. + +If there is functionality or documentation missing please [raise an +issue](https://github.com/team-alembic/ash_authentication/issues/new) and we'll +take a look at it. + +Go forth and strategise! diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index c7e932c..e2c8e90 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -101,13 +101,11 @@ defmodule AshAuthentication do sections: dsl(), transformers: [ AshAuthentication.Transformer, + AshAuthentication.Strategy.Custom.Transformer + ], + verifiers: [ AshAuthentication.Verifier, - AshAuthentication.Strategy.Password.Transformer, - AshAuthentication.Strategy.Password.Verifier, - AshAuthentication.Strategy.OAuth2.Transformer, - AshAuthentication.Strategy.OAuth2.Verifier, - AshAuthentication.AddOn.Confirmation.Transformer, - AshAuthentication.AddOn.Confirmation.Verifier + AshAuthentication.Strategy.Custom.Verifier ] require Ash.Query diff --git a/lib/ash_authentication/add_ons/confirmation.ex b/lib/ash_authentication/add_ons/confirmation.ex index 5467379..8100497 100644 --- a/lib/ash_authentication/add_ons/confirmation.ex +++ b/lib/ash_authentication/add_ons/confirmation.ex @@ -1,5 +1,5 @@ defmodule AshAuthentication.AddOn.Confirmation do - import AshAuthentication.Dsl + alias __MODULE__.{Dsl, Transformer, Verifier} @moduledoc """ Confirmation support. @@ -82,7 +82,7 @@ defmodule AshAuthentication.AddOn.Confirmation do ## DSL Documentation - #{Spark.Dsl.Extension.doc_entity(strategy(:confirmation))} + #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} """ defstruct token_lifetime: nil, @@ -114,6 +114,10 @@ defmodule AshAuthentication.AddOn.Confirmation do name: :confirm } + defdelegate dsl(), to: Dsl + defdelegate transform(strategy, dsl_state), to: Transformer + defdelegate verify(strategy, dsl_state), to: Verifier + @doc """ Generate a confirmation token for a changeset. diff --git a/lib/ash_authentication/add_ons/confirmation/actions.ex b/lib/ash_authentication/add_ons/confirmation/actions.ex index 3175ba4..b644454 100644 --- a/lib/ash_authentication/add_ons/confirmation/actions.ex +++ b/lib/ash_authentication/add_ons/confirmation/actions.ex @@ -12,6 +12,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do Errors.InvalidToken, Info, Jwt, + Strategy, TokenResource } @@ -65,7 +66,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do |> Changeset.for_create(store_changes_action, %{ token: token, extra_data: changes, - purpose: to_string(strategy.name) + purpose: to_string(Strategy.name(strategy)) }) |> api.create(Keyword.merge(opts, upsert?: true)) do :ok diff --git a/lib/ash_authentication/add_ons/confirmation/dsl.ex b/lib/ash_authentication/add_ons/confirmation/dsl.ex new file mode 100644 index 0000000..b7410fb --- /dev/null +++ b/lib/ash_authentication/add_ons/confirmation/dsl.ex @@ -0,0 +1,119 @@ +defmodule AshAuthentication.AddOn.Confirmation.Dsl do + @moduledoc """ + Defines the Spark DSL entity for this add on. + """ + + alias AshAuthentication.AddOn.Confirmation + alias Spark.Dsl.Entity + + @default_confirmation_lifetime_days 3 + + @doc false + @spec dsl :: map + def dsl do + %Entity{ + name: :confirmation, + describe: "User confirmation flow", + args: [{:optional, :name, :confirm}], + target: Confirmation, + modules: [:sender], + schema: [ + name: [ + type: :atom, + doc: """ + Uniquely identifies the add-on. + """, + required: true + ], + token_lifetime: [ + type: :pos_integer, + doc: """ + How long should the confirmation token be valid, in hours. + Defaults to #{@default_confirmation_lifetime_days} days. + """, + default: @default_confirmation_lifetime_days * 24 + ], + monitor_fields: [ + type: {:list, :atom}, + doc: """ + A list of fields to monitor for changes (eg `[:email, :phone_number]`). + The confirmation will only be sent when one of these fields are changed. + """, + required: true + ], + confirmed_at_field: [ + type: :atom, + doc: """ + The name of a field to store the time that the last confirmation took + place. + This attribute will be dynamically added to the resource if not already + present. + """, + default: :confirmed_at + ], + confirm_on_create?: [ + type: :boolean, + doc: """ + Generate and send a confirmation token when a new resource is created? + Will only trigger when a create action is executed _and_ one of the + monitored fields is being set. + """, + default: true + ], + confirm_on_update?: [ + type: :boolean, + doc: """ + Generate and send a confirmation token when a resource is changed? + Will only trigger when an update action is executed _and_ one of the + monitored fields is being set. + """, + default: true + ], + inhibit_updates?: [ + type: :boolean, + doc: """ + Wait until confirmation is received before actually changing a monitored + field? + If a change to a monitored field is detected, then the change is stored + in the token resource and the changeset updated to not make the + requested change. When the token is confirmed, the change will be + applied. + This could be potentially weird for your users, but useful in the case + of a user changing their email address or phone number where you want + to verify that the new contact details are reachable. + """, + default: true + ], + sender: [ + type: + {:spark_function_behaviour, AshAuthentication.Sender, + {AshAuthentication.SenderFunction, 3}}, + doc: """ + How to send the confirmation instructions to the user. + Allows you to glue sending of confirmation 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. + The options will be a keyword list containing the original + changeset, before any changes were inhibited. This allows you + to send an email to the user's new email address if it is being + changed for example. + See `AshAuthentication.Sender` for more information. + """, + required: true + ], + confirm_action_name: [ + type: :atom, + doc: """ + The name of the action to use when performing confirmation. + If this action is not already present on the resource, it will be + created for you. + """, + default: :confirm + ] + ] + } + end +end diff --git a/lib/ash_authentication/add_ons/confirmation/strategy.ex b/lib/ash_authentication/add_ons/confirmation/strategy.ex index fc79167..10c9f90 100644 --- a/lib/ash_authentication/add_ons/confirmation/strategy.ex +++ b/lib/ash_authentication/add_ons/confirmation/strategy.ex @@ -14,6 +14,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.AddOn.Confirmation do @typedoc "The actions supported by this strategy" @type action :: :confirm + @doc false + @spec name(Confirmation.t()) :: atom + def name(strategy), do: strategy.name + @doc false @spec phases(Confirmation.t()) :: [phase] def phases(_), do: [:confirm] diff --git a/lib/ash_authentication/add_ons/confirmation/transformer.ex b/lib/ash_authentication/add_ons/confirmation/transformer.ex index da23251..a2485cd 100644 --- a/lib/ash_authentication/add_ons/confirmation/transformer.ex +++ b/lib/ash_authentication/add_ons/confirmation/transformer.ex @@ -6,9 +6,8 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do configured. """ - use Spark.Dsl.Transformer alias Ash.{Resource, Type} - alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange, Info} + alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange} alias Spark.{Dsl.Transformer, Error.DslError} import AshAuthentication.Utils import AshAuthentication.Validations @@ -16,38 +15,9 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do import AshAuthentication.Validations.Attribute @doc false - @impl true - @spec after?(module) :: boolean - def after?(AshAuthentication.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 - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - dsl_state - |> Info.authentication_add_ons() - |> Enum.filter(&is_struct(&1, Confirmation)) - |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> - case transform_strategy(strategy, dsl_state) do - {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp transform_strategy(strategy, dsl_state) do + @spec transform(Confirmation.t(), map) :: + {:ok, Confirmation.t() | map} | {:error, Exception.t()} + def transform(strategy, dsl_state) do with :ok <- validate_token_generation_enabled(dsl_state), :ok <- validate_monitor_fields(dsl_state, strategy), {:ok, dsl_state} <- diff --git a/lib/ash_authentication/add_ons/confirmation/verifier.ex b/lib/ash_authentication/add_ons/confirmation/verifier.ex index 97afa53..2bcdef0 100644 --- a/lib/ash_authentication/add_ons/confirmation/verifier.ex +++ b/lib/ash_authentication/add_ons/confirmation/verifier.ex @@ -3,47 +3,13 @@ defmodule AshAuthentication.AddOn.Confirmation.Verifier do DSL verifier for confirmation add-on. """ - use Spark.Dsl.Transformer - alias AshAuthentication.{AddOn.Confirmation, Info, Sender} + alias AshAuthentication.{AddOn.Confirmation, Sender} alias Spark.Error.DslError import AshAuthentication.Validations @doc false - @impl true - @spec after?(module) :: boolean - def after?(_), do: true - - @doc false - @impl true - @spec before?(module) :: boolean - def before?(_), do: false - - @doc false - @impl true - @spec after_compile? :: boolean - def after_compile?, do: true - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - dsl_state - |> Info.authentication_add_ons() - |> Stream.filter(&is_struct(&1, Confirmation)) - |> Enum.reduce_while(:ok, fn strategy, :ok -> - case transform_strategy(strategy) do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp transform_strategy(strategy) do + @spec verify(Confirmation.t(), map) :: :ok | {:error, Exception.t()} + def verify(strategy, _dsl_state) do case Map.fetch(strategy, :sender) do {:ok, {sender, _opts}} -> validate_behaviour(sender, Sender) diff --git a/lib/ash_authentication/dsl.ex b/lib/ash_authentication/dsl.ex index c0e4a25..c7293bc 100644 --- a/lib/ash_authentication/dsl.ex +++ b/lib/ash_authentication/dsl.ex @@ -12,59 +12,44 @@ defmodule AshAuthentication.Dsl do alias AshAuthentication.{ AddOn.Confirmation, + Strategy.Auth0, + Strategy.Github, Strategy.OAuth2, Strategy.Password } - alias Spark.{ - Dsl.Entity, - Dsl.Section, - OptionsHelpers - } - - @type strategy :: :confirmation | :oauth2 | :password | :auth0 | :github - - @shared_strategy_options [ - name: [ - type: :atom, - doc: """ - Uniquely identifies the strategy. - """, - required: true - ] - ] - - @shared_addon_options [ - name: [ - type: :atom, - doc: """ - Uniquely identifies the add-on. - """, - required: true - ] - ] - @default_token_lifetime_days 14 - @default_confirmation_lifetime_days 3 - @secret_type {:or, - [ - {:spark_function_behaviour, AshAuthentication.Secret, - {AshAuthentication.SecretFunction, 2}}, - :string - ]} + alias Spark.Dsl.Section - @secret_doc """ - Takes either a module which implements the `AshAuthentication.Secret` - behaviour, a 2 arity anonymous function or a string. + @doc false + @spec secret_type :: any + def secret_type, + do: + {:or, + [ + {:spark_function_behaviour, AshAuthentication.Secret, + {AshAuthentication.SecretFunction, 2}}, + :string + ]} - See the module documentation for `AshAuthentication.Secret` for more - information. - """ + @doc false + @spec secret_doc :: String.t() + def secret_doc, + do: """ + Takes either a module which implements the `AshAuthentication.Secret` + behaviour, a 2 arity anonymous function or a string. + + See the module documentation for `AshAuthentication.Secret` for more + information. + """ @doc false @spec dsl :: [Section.t()] def dsl do + secret_type = secret_type() + secret_doc = secret_doc() + [ %Section{ name: :authentication, @@ -178,11 +163,11 @@ defmodule AshAuthentication.Dsl do required: true ], signing_secret: [ - type: @secret_type, + type: secret_type, doc: """ The secret used to sign tokens. - #{@secret_doc} + #{secret_doc} """ ] ] @@ -190,595 +175,37 @@ defmodule AshAuthentication.Dsl do %Section{ name: :strategies, describe: "Configure authentication strategies on this resource", - entities: [ - strategy(:password), - strategy(:oauth2), - strategy(:auth0), - strategy(:github) - ] + entities: Enum.map(available_strategies(), & &1.dsl()) }, %Section{ name: :add_ons, describe: "Additional add-ons related to, but not providing authentication", - entities: [ - strategy(:confirmation) - ] + entities: Enum.map(available_add_ons(), & &1.dsl()) } ] } ] end - # The result spec should be changed to `Entity.t` when Spark 0.2.18 goes out. - @doc false - @spec strategy(strategy) :: map - def strategy(:password) do - %Entity{ - name: :password, - describe: "Strategy for authenticating using local resources as the source of truth.", - examples: [ - """ - password :password do - identity_field :email - hashed_password_field :hashed_password - hash_provider AshAuthentication.BcryptProvider - confirmation_required? true - end - """ - ], - args: [{:optional, :name, :password}], - hide: [:name], - target: Password, - modules: [:hash_provider], - schema: - OptionsHelpers.merge_schemas( - [ - identity_field: [ - type: :atom, - doc: """ - The name of the attribute which uniquely identifies the user. + @doc """ + Return the available strategy modules. - 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. - - If not present it will be generated by prepending the strategy name - with `register_with_`. - """, - required: false - ], - sign_in_action_name: [ - type: :atom, - doc: """ - The name to use for the sign in action. - - If not present it will be generated by prependign the strategy name - with `sign_in_with_`. - """, - required: false - ] - ], - @shared_strategy_options, - "Shared options" - ), - entities: [resettable: [Password.Resettable.entity()]] - } + This is used for DSL generation and transformation. + """ + @spec available_strategies :: [module] + def available_strategies do + [Auth0, Github, OAuth2, Password] + |> Enum.concat(Application.get_env(:ash_authentication, :extra_strategies, [])) end - def strategy(:oauth2) do - %Entity{ - name: :oauth2, - describe: "OAuth2 authentication", - args: [{:optional, :name, :oauth2}], - target: OAuth2, - modules: [ - :authorize_url, - :client_id, - :client_secret, - :identity_resource, - :private_key, - :redirect_uri, - :site, - :token_url, - :user_url - ], - schema: - OptionsHelpers.merge_schemas( - [ - client_id: [ - type: @secret_type, - doc: """ - The OAuth2 client ID. - - #{@secret_doc} - - Example: - - ```elixir - client_id fn _, resource -> - :my_app - |> Application.get_env(resource, []) - |> Keyword.fetch(:oauth_client_id) - end - ``` - """, - required: true - ], - site: [ - type: @secret_type, - doc: """ - The base URL of the OAuth2 server - including the leading protocol - (ie `https://`). - - #{@secret_doc} - - Example: - - ```elixir - site fn _, resource -> - :my_app - |> Application.get_env(resource, []) - |> Keyword.fetch(:oauth_site) - end - ``` - """, - required: true - ], - auth_method: [ - type: - {:in, - [ - nil, - :client_secret_basic, - :client_secret_post, - :client_secret_jwt, - :private_key_jwt - ]}, - doc: """ - The authentication strategy used, optional. If not set, no - authentication will be used during the access token request. The - value may be one of the following: - - * `:client_secret_basic` - * `:client_secret_post` - * `:client_secret_jwt` - * `:private_key_jwt` - """, - default: :client_secret_post - ], - client_secret: [ - type: @secret_type, - doc: """ - The OAuth2 client secret. - - Required if :auth_method is `:client_secret_basic`, - `:client_secret_post` or `:client_secret_jwt`. - - #{@secret_doc} - - Example: - - ```elixir - site fn _, resource -> - :my_app - |> Application.get_env(resource, []) - |> Keyword.fetch(:oauth_site) - end - ``` - """, - required: false - ], - authorize_url: [ - type: @secret_type, - doc: """ - The API url to the OAuth2 authorize endpoint. - - Relative to the value of `site`. - - #{@secret_doc} - - Example: - - ```elixir - authorize_url fn _, _ -> {:ok, "https://exampe.com/authorize"} end - ``` - """, - required: true - ], - token_url: [ - type: @secret_type, - doc: """ - The API url to access the token endpoint. - - Relative to the value of `site`. - - #{@secret_doc} - - Example: - - ```elixir - token_url fn _, _ -> {:ok, "https://example.com/oauth_token"} end - ``` - """, - required: true - ], - user_url: [ - type: @secret_type, - doc: """ - The API url to access the user endpoint. - - Relative to the value of `site`. - - #{@secret_doc} - - Example: - - ```elixir - user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end - ``` - """, - required: true - ], - private_key: [ - type: @secret_type, - doc: """ - The private key to use if `:auth_method` is `:private_key_jwt` - - #{@secret_doc} - """, - required: false - ], - redirect_uri: [ - type: @secret_type, - doc: """ - The callback URI base. - - Not the whole URI back to the callback endpoint, but the URI to your - `AuthPlug`. We can generate the rest. - - Whilst not particularly secret, it seemed prudent to allow this to be - configured dynamically so that you can use different URIs for - different environments. - - #{@secret_doc} - """, - required: true - ], - authorization_params: [ - type: :keyword_list, - doc: """ - Any additional parameters to encode in the request phase. - - eg: `authorization_params scope: "openid profile email"` - """, - default: [] - ], - registration_enabled?: [ - type: :boolean, - doc: """ - Is registration enabled for this provider? - - If this option is enabled, then new users will be able to register for - your site when authenticating and not already present. - - If not, then only existing users will be able to authenticate. - """, - default: true - ], - register_action_name: [ - type: :atom, - doc: ~S""" - The name of the action to use to register a user. - - Only needed if `registration_enabled?` is `true`. - - Because we we don't know the response format of the server, you must - implement your own registration action of the same name. - - See the "Registration and Sign-in" section of the module - documentation for more information. - - The default is computed from the strategy name eg: - `register_with_#{name}`. - """, - required: false - ], - sign_in_action_name: [ - type: :atom, - doc: ~S""" - The name of the action to use to sign in an existing user. - - Only needed if `registration_enabled?` is `false`. - - Because we don't know the response format of the server, you must - implement your own sign-in action of the same name. - - See the "Registration and Sign-in" section of the module - documentation for more information. - - The default is computed from the strategy name, eg: - `sign_in_with_#{name}`. - """, - required: false - ], - identity_resource: [ - type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]}, - doc: """ - The resource used to store user identities. - - Given that a user can be signed into multiple different - authentication providers at once we use the - `AshAuthentication.UserIdentity` resource to build a mapping - between users, providers and that provider's uid. - - See the Identities section of the module documentation for more - information. - - Set to `false` to disable. - """, - default: false - ], - identity_relationship_name: [ - type: :atom, - doc: "Name of the relationship to the provider identities resource", - default: :identities - ], - identity_relationship_user_id_attribute: [ - type: :atom, - doc: """ - The name of the destination (user_id) attribute on your provider - identity resource. - - The only reason to change this would be if you changed the - `user_id_attribute_name` option of the provider identity. - """, - default: :user_id - ] - ], - @shared_strategy_options, - "Shared options" - ), - auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2] - } - end - - def strategy(:confirmation) do - %Entity{ - name: :confirmation, - describe: "User confirmation flow", - args: [{:optional, :name, :confirm}], - target: Confirmation, - modules: [:sender], - schema: - OptionsHelpers.merge_schemas( - [ - token_lifetime: [ - type: :pos_integer, - doc: """ - How long should the confirmation token be valid, in hours. - - Defaults to #{@default_confirmation_lifetime_days} days. - """, - default: @default_confirmation_lifetime_days * 24 - ], - monitor_fields: [ - type: {:list, :atom}, - doc: """ - A list of fields to monitor for changes (eg `[:email, :phone_number]`). - - The confirmation will only be sent when one of these fields are changed. - """, - required: true - ], - confirmed_at_field: [ - type: :atom, - doc: """ - The name of a field to store the time that the last confirmation took - place. - - This attribute will be dynamically added to the resource if not already - present. - """, - default: :confirmed_at - ], - confirm_on_create?: [ - type: :boolean, - doc: """ - Generate and send a confirmation token when a new resource is created? - - Will only trigger when a create action is executed _and_ one of the - monitored fields is being set. - """, - default: true - ], - confirm_on_update?: [ - type: :boolean, - doc: """ - Generate and send a confirmation token when a resource is changed? - - Will only trigger when an update action is executed _and_ one of the - monitored fields is being set. - """, - default: true - ], - inhibit_updates?: [ - type: :boolean, - doc: """ - Wait until confirmation is received before actually changing a monitored - field? - - If a change to a monitored field is detected, then the change is stored - in the token resource and the changeset updated to not make the - requested change. When the token is confirmed, the change will be - applied. - - This could be potentially weird for your users, but useful in the case - of a user changing their email address or phone number where you want - to verify that the new contact details are reachable. - """, - default: true - ], - sender: [ - type: - {:spark_function_behaviour, AshAuthentication.Sender, - {AshAuthentication.SenderFunction, 3}}, - doc: """ - How to send the confirmation instructions to the user. - - Allows you to glue sending of confirmation 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. - - The options will be a keyword list containing the original - changeset, before any changes were inhibited. This allows you - to send an email to the user's new email address if it is being - changed for example. - - See `AshAuthentication.Sender` for more information. - """, - required: true - ], - confirm_action_name: [ - type: :atom, - doc: """ - The name of the action to use when performing confirmation. - - If this action is not already present on the resource, it will be - created for you. - """, - default: :confirm - ] - ], - @shared_addon_options, - "Shared options" - ) - } - end - - def strategy(:auth0) do - :oauth2 - |> strategy() - |> Map.merge(%{ - name: :auth0, - args: [{:optional, :name, :auth0}], - describe: """ - Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/). - - This strategy is built using `:oauth2` strategy, and thus provides all the same - configuration options should you need them. - - For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md) - in our documentation. - - #### Strategy defaults: - - #{strategy_override_docs(Assent.Strategy.Auth0)} - - #### Schema: - """, - auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0) - }) - end - - def strategy(:github) do - :oauth2 - |> strategy() - |> Map.merge(%{ - name: :github, - args: [{:optional, :name, :github}], - describe: """ - Provides a pre-configured authentication strategy for [GitHub](https://github.com/). - - This strategy is built using `:oauth2` strategy, and thus provides all the same - configuration options should you need them. - - For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md) - in our documentation. - - #### Strategy defaults: - - #{strategy_override_docs(Assent.Strategy.Github)} - - #### Schema: - """, - auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github) - }) - end - - defp strategy_fields(strategy, params) do - [] - |> strategy.default_config() - |> Keyword.put(:assent_strategy, strategy) - |> Keyword.merge(params) - end - - defp strategy_override_docs(strategy) do - defaults = - [] - |> strategy.default_config() - |> Enum.map_join( - ".\n", - fn {key, value} -> - " * `#{inspect(key)}` is set to `#{inspect(value)}`" - end - ) - - """ - The following defaults are applied: - - #{defaults}. - """ + @doc """ + Return the available add-on modules. + + This is used for DSL generation and transformation. + """ + @spec available_add_ons :: [module] + def available_add_ons do + [Confirmation] + |> Enum.concat(Application.get_env(:ash_authentication, :extra_add_ons, [])) end end diff --git a/lib/ash_authentication/info.ex b/lib/ash_authentication/info.ex index 6c90379..50582ba 100644 --- a/lib/ash_authentication/info.ex +++ b/lib/ash_authentication/info.ex @@ -22,7 +22,7 @@ defmodule AshAuthentication.Info do |> authentication_strategies() |> Stream.concat(authentication_add_ons(dsl_or_resource)) |> Enum.find_value(:error, fn strategy -> - if strategy.name == name, do: {:ok, strategy} + if Strategy.name(strategy) == name, do: {:ok, strategy} end) end diff --git a/lib/ash_authentication/plug/dispatcher.ex b/lib/ash_authentication/plug/dispatcher.ex index 4d6a3da..4afc0a1 100644 --- a/lib/ash_authentication/plug/dispatcher.ex +++ b/lib/ash_authentication/plug/dispatcher.ex @@ -23,7 +23,7 @@ defmodule AshAuthentication.Plug.Dispatcher do @impl true @spec call(Conn.t(), config | any) :: Conn.t() def call(conn, {phase, strategy, return_to}) do - activity = {strategy.name, phase} + activity = {Strategy.name(strategy), phase} strategy |> Strategy.plug(phase, conn) diff --git a/lib/ash_authentication/strategies/auth0.ex b/lib/ash_authentication/strategies/auth0.ex new file mode 100644 index 0000000..7f74064 --- /dev/null +++ b/lib/ash_authentication/strategies/auth0.ex @@ -0,0 +1,76 @@ +defmodule AshAuthentication.Strategy.Auth0 do + @moduledoc """ + Strategy for authenticating using [Auth0](https://auth0.com). + + This strategy builds on-top of `AshAuthentication.Strategy.OAuth2` and + [`assent`](https://hex.pm/packages/assent). + + In order to use Auth0 you need to provide the following minimum configuration: + + - `client_id` + - `redirect_uri` + - `client_secret` + - `site` + + See the [Auth0 quickstart guide](/documentation/tutorials/auth0-quickstart.html) + for more information. + """ + + alias AshAuthentication.Strategy.{Custom, OAuth2} + use Custom + + @doc false + # credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct + @spec dsl :: Custom.entity() + def dsl do + OAuth2.dsl() + |> Map.merge(%{ + name: :auth0, + args: [{:optional, :name, :auth0}], + describe: """ + Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/). + + This strategy is built using `:oauth2` strategy, and thus provides all the same + configuration options should you need them. + + For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md) + in our documentation. + + #### Strategy defaults: + + #{strategy_override_docs(Assent.Strategy.Auth0)} + + #### Schema: + """, + auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0) + }) + end + + defdelegate transform(strategy, dsl_state), to: OAuth2 + defdelegate verify(strategy, dsl_state), to: OAuth2 + + defp strategy_fields(strategy, params) do + [] + |> strategy.default_config() + |> Keyword.put(:assent_strategy, strategy) + |> Keyword.merge(params) + end + + defp strategy_override_docs(strategy) do + defaults = + [] + |> strategy.default_config() + |> Enum.map_join( + ".\n", + fn {key, value} -> + " * `#{inspect(key)}` is set to `#{inspect(value)}`" + end + ) + + """ + The following defaults are applied: + + #{defaults}. + """ + end +end diff --git a/lib/ash_authentication/strategies/custom.ex b/lib/ash_authentication/strategies/custom.ex new file mode 100644 index 0000000..1f87459 --- /dev/null +++ b/lib/ash_authentication/strategies/custom.ex @@ -0,0 +1,75 @@ +defmodule AshAuthentication.Strategy.Custom do + @moduledoc """ + Define your own custom authentication strategy. + + See [the Custom Strategies guide](/documentation/topics/custom-strategy.html) + for more information. + """ + + alias Spark.Dsl + + @typedoc """ + A Strategy DSL Entity. + + See `Spark.Dsl.Entity` for more information. + """ + # credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct + @type entity :: %Dsl.Entity{} + + @type strategy :: struct + + @doc """ + A callback which allows the strategy to provide it's own DSL-based + configuration. + """ + @callback dsl :: entity + + @doc """ + If your strategy needs to modify either the entity or the parent resource then + you can implement this callback. + + This callback can return one of three results: + + - `{:ok, Entity.t}` - an updated DSL entity - useful if you're just changing + the entity itself and not changing the wider DSL state of the resource. + If this is the response then the transformer will take care of updating + the entity in the DSL state. + - `{:ok, Dsl.t}` - an updated DSL state for the entire resource. + - `{:error, Exception.t}` - a compilation-stopping problem was found. Any + exception can be returned, but we strongly advise you to return a + `Spark.Error.DslError`. + + """ + @callback transform(strategy, Dsl.t()) :: + {:ok, strategy} | {:ok, Dsl.t()} | {:error, Exception.t()} + + @doc """ + If your strategy needs to verify either the entity or the parent resource then + you can implement this callback. + + This is called post-compilation in the `@after_verify` hook - see `Module` for + more information. + + This callback can return one of the following results: + + - `:ok` - everything is A-Okay. + - `{:error, Exception.t}` - a compilation-stopping problem was found. Any + exception can be returned, but we strongly advise you to return a + `Spark.Error.DslError`. + """ + @callback verify(strategy, Dsl.t()) :: :ok | {:error, Exception.t()} + + @doc false + @spec __using__(keyword) :: Macro.t() + defmacro __using__(_opts) do + quote generated: true do + @behaviour unquote(__MODULE__) + import unquote(__MODULE__).Helpers + + def transform(entity, _dsl_state), do: {:ok, entity} + def verify(_entity, _dsl_state), do: :ok + + defoverridable transform: 2, verify: 2 + end + end +end diff --git a/lib/ash_authentication/strategies/custom/helpers.ex b/lib/ash_authentication/strategies/custom/helpers.ex new file mode 100644 index 0000000..f9e5d38 --- /dev/null +++ b/lib/ash_authentication/strategies/custom/helpers.ex @@ -0,0 +1,57 @@ +defmodule AshAuthentication.Strategy.Custom.Helpers do + @moduledoc """ + Helpers for use within custom strategies. + """ + + alias AshAuthentication.{Strategy, Strategy.Custom} + alias Spark.Dsl.Transformer + + @doc """ + If there's any chance that an implementor may try and use actions genrated by + your strategy programatically then you should register your actions with Ash + Authentication so that it can find the appropriate strategy when needed. + + The strategy can be retrieved again by calling + `AshAuthentication.Info.strategy_for_action/2`. + + This helper should only be used within transformers. + """ + @spec register_strategy_actions(action_or_actions, dsl_state, Custom.strategy()) :: dsl_state + when dsl_state: map, action_or_actions: atom | [atom] + def register_strategy_actions(action, dsl_state, strategy) when is_atom(action), + do: register_strategy_actions([action], dsl_state, strategy) + + def register_strategy_actions(actions, dsl_state, strategy), + do: + Enum.reduce( + actions, + dsl_state, + &Transformer.persist(&2, {:authentication_action, &1}, strategy) + ) + + @doc """ + Update the strategy in the DSL state by name. + + This helper should only be used within transformers. + """ + @spec put_strategy(dsl_state, Custom.strategy()) :: dsl_state when dsl_state: map + def put_strategy(dsl_state, strategy), + do: put_entity(dsl_state, strategy, ~w[authentication strategies]a) + + @doc """ + Update the add-on in the DSL state by name. + + This helper should only be used within transformers. + """ + @spec put_add_on(dsl_state, Custom.strategy()) :: dsl_state when dsl_state: map + def put_add_on(dsl_state, strategy), + do: put_entity(dsl_state, strategy, ~w[authentication strategies]a) + + defp put_entity(dsl_state, strategy, path) do + name = Strategy.name(strategy) + + dsl_state + |> Transformer.remove_entity(path, &(Strategy.name(&1) == name)) + |> Transformer.add_entity(path, strategy) + end +end diff --git a/lib/ash_authentication/strategies/custom/transformer.ex b/lib/ash_authentication/strategies/custom/transformer.ex new file mode 100644 index 0000000..9b933d6 --- /dev/null +++ b/lib/ash_authentication/strategies/custom/transformer.ex @@ -0,0 +1,122 @@ +defmodule AshAuthentication.Strategy.Custom.Transformer do + @moduledoc """ + Transformer used by custom strategies. + + It delegates transformation passes to the individual strategies. + """ + + use Spark.Dsl.Transformer + + alias AshAuthentication.{Dsl, Info, Strategy} + alias Spark.{Dsl.Transformer, Error.DslError} + import AshAuthentication.Strategy.Custom.Helpers + + @doc false + @impl true + @spec after?(module) :: boolean + def after?(AshAuthentication.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 + + @doc false + @impl true + @spec transform(map) :: + :ok + | {:ok, map()} + | {:error, term()} + | {:warn, map(), String.t() | [String.t()]} + | :halt + def transform(dsl_state) do + strategy_modules = + Dsl.available_add_ons() + |> Stream.concat(Dsl.available_strategies()) + |> Enum.map(&{&1.dsl().target, &1}) + |> Map.new() + + with {:ok, dsl_state} <- do_strategy_transforms(dsl_state, strategy_modules) do + do_add_on_transforms(dsl_state, strategy_modules) + end + end + + defp do_strategy_transforms(dsl_state, strategy_modules) do + dsl_state + |> Info.authentication_strategies() + |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> + strategy_module = Map.fetch!(strategy_modules, strategy.__struct__) + + case do_transform(strategy_module, strategy, dsl_state, :strategy) do + {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp do_add_on_transforms(dsl_state, strategy_modules) do + dsl_state + |> Info.authentication_add_ons() + |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> + strategy_module = Map.fetch!(strategy_modules, strategy.__struct__) + + case do_transform(strategy_module, strategy, dsl_state, :add_on) do + {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp do_transform(strategy_module, strategy, dsl_state, :strategy) + when is_map_key(strategy, :resource) do + strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)} + dsl_state = put_strategy(dsl_state, strategy) + entity_module = strategy.__struct__ + + strategy + |> strategy_module.transform(dsl_state) + |> case do + {:ok, strategy} when is_struct(strategy, entity_module) -> + {:ok, put_strategy(dsl_state, strategy)} + + {:ok, dsl_state} when is_map(dsl_state) -> + {:ok, dsl_state} + + {:error, reason} -> + {:error, reason} + end + end + + defp do_transform(strategy_module, strategy, dsl_state, :add_on) + when is_map_key(strategy, :resource) do + strategy = %{strategy | resource: Transformer.get_persisted(dsl_state, :module)} + dsl_state = put_add_on(dsl_state, strategy) + entity_module = strategy.__struct__ + + strategy + |> strategy_module.transform(dsl_state) + |> case do + {:ok, strategy} when is_struct(strategy, entity_module) -> + {:ok, put_add_on(dsl_state, strategy)} + + {:ok, dsl_state} when is_map(dsl_state) -> + {:ok, dsl_state} + + {:error, reason} -> + {:error, reason} + end + end + + defp do_transform(_strategy_module, strategy, _, _) do + name = Strategy.name(strategy) + + {:error, + DslError.exception( + path: [:authentication, name], + message: + "The struct defined by `#{inspect(strategy.__struct__)}` must contain a `resource` field." + )} + end +end diff --git a/lib/ash_authentication/strategies/custom/verifier.ex b/lib/ash_authentication/strategies/custom/verifier.ex new file mode 100644 index 0000000..ddf2002 --- /dev/null +++ b/lib/ash_authentication/strategies/custom/verifier.ex @@ -0,0 +1,39 @@ +defmodule AshAuthentication.Strategy.Custom.Verifier do + @moduledoc """ + Verifier used by custom strategies. + + It delegates verification passes to the individual strategies. + """ + + use Spark.Dsl.Verifier + + alias AshAuthentication.{Dsl, Info} + + @doc false + @impl true + @spec verify(map) :: + :ok + | {:error, term} + | {:warn, String.t() | list(String.t())} + def verify(dsl_state) do + strategy_modules = + Dsl.available_add_ons() + |> Stream.concat(Dsl.available_strategies()) + |> Enum.map(&{&1.dsl().target, &1}) + |> Map.new() + + dsl_state + |> Info.authentication_strategies() + |> Stream.concat(Info.authentication_add_ons(dsl_state)) + |> Enum.reduce_while(:ok, fn strategy, :ok -> + strategy_module = Map.fetch!(strategy_modules, strategy.__struct__) + + strategy + |> strategy_module.verify(dsl_state) + |> case do + :ok -> {:cont, :ok} + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end +end diff --git a/lib/ash_authentication/strategies/github.ex b/lib/ash_authentication/strategies/github.ex new file mode 100644 index 0000000..a6838a3 --- /dev/null +++ b/lib/ash_authentication/strategies/github.ex @@ -0,0 +1,75 @@ +defmodule AshAuthentication.Strategy.Github do + @moduledoc """ + Strategy for authenticating using [GitHub](https://github.com) + + This strategy builds on-top of `AshAuthentication.Strategy.OAuth2` and + [`assent`](https://hex.pm/packages/assent). + + In order to use GitHub you need to provide the following minimum configuration: + + - `client_id` + - `redirect_uri` + - `client_secret` + + See the [GitHub quickstart guide](/documentation/tutorials/github-quickstart.html) + for more information. + """ + + alias AshAuthentication.Strategy.{Custom, OAuth2} + use Custom + + @doc false + # credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct + @spec dsl :: Custom.entity() + def dsl do + OAuth2.dsl() + |> Map.merge(%{ + name: :github, + args: [{:optional, :name, :github}], + describe: """ + Provides a pre-configured authentication strategy for [GitHub](https://github.com/). + + This strategy is built using `:oauth2` strategy, and thus provides all the same + configuration options should you need them. + + For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md) + in our documentation. + + #### Strategy defaults: + + #{strategy_override_docs(Assent.Strategy.Github)} + + #### Schema: + """, + auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github) + }) + end + + defdelegate transform(strategy, dsl_state), to: OAuth2 + defdelegate verify(strategy, dsl_state), to: OAuth2 + + defp strategy_fields(strategy, params) do + [] + |> strategy.default_config() + |> Keyword.put(:assent_strategy, strategy) + |> Keyword.merge(params) + end + + defp strategy_override_docs(strategy) do + defaults = + [] + |> strategy.default_config() + |> Enum.map_join( + ".\n", + fn {key, value} -> + " * `#{inspect(key)}` is set to `#{inspect(value)}`" + end + ) + + """ + The following defaults are applied: + + #{defaults}. + """ + end +end diff --git a/lib/ash_authentication/strategies/oauth2.ex b/lib/ash_authentication/strategies/oauth2.ex index b372f53..5a306b8 100644 --- a/lib/ash_authentication/strategies/oauth2.ex +++ b/lib/ash_authentication/strategies/oauth2.ex @@ -1,5 +1,5 @@ defmodule AshAuthentication.Strategy.OAuth2 do - import AshAuthentication.Dsl + alias __MODULE__.{Dsl, Transformer, Verifier} @moduledoc """ Strategy for authenticating using an OAuth 2.0 server as the source of truth. @@ -216,7 +216,7 @@ defmodule AshAuthentication.Strategy.OAuth2 do ## DSL Documentation - #{Spark.Dsl.Extension.doc_entity(strategy(:oauth2))} + #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} """ defstruct client_id: nil, @@ -241,6 +241,8 @@ defmodule AshAuthentication.Strategy.OAuth2 do icon: nil, assent_strategy: Assent.Strategy.OAuth2 + use AshAuthentication.Strategy.Custom + alias AshAuthentication.Strategy.OAuth2 @type secret :: nil | String.t() | {module, keyword} @@ -273,4 +275,8 @@ defmodule AshAuthentication.Strategy.OAuth2 do icon: nil | atom, assent_strategy: module } + + defdelegate dsl, to: Dsl + defdelegate transform(strategy, dsl_state), to: Transformer + defdelegate verify(strategy, dsl_state), to: Verifier end diff --git a/lib/ash_authentication/strategies/oauth2/dsl.ex b/lib/ash_authentication/strategies/oauth2/dsl.ex new file mode 100644 index 0000000..86f583e --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/dsl.ex @@ -0,0 +1,292 @@ +defmodule AshAuthentication.Strategy.OAuth2.Dsl do + @moduledoc """ + Defines the Spark DSL entity for this strategy. + """ + + alias AshAuthentication.Strategy.{Custom, OAuth2} + alias Spark.Dsl.Entity + + @doc false + @spec dsl :: Custom.entity() + def dsl do + secret_type = AshAuthentication.Dsl.secret_type() + secret_doc = AshAuthentication.Dsl.secret_doc() + + %Entity{ + name: :oauth2, + describe: "OAuth2 authentication", + args: [{:optional, :name, :oauth2}], + target: OAuth2, + modules: [ + :authorize_url, + :client_id, + :client_secret, + :identity_resource, + :private_key, + :redirect_uri, + :site, + :token_url, + :user_url + ], + schema: [ + name: [ + type: :atom, + doc: """ + Uniquely identifies the strategy. + """, + required: true + ], + client_id: [ + type: secret_type, + doc: """ + The OAuth2 client ID. + + #{secret_doc} + + Example: + + ```elixir + client_id fn _, resource -> + :my_app + |> Application.get_env(resource, []) + |> Keyword.fetch(:oauth_client_id) + end + ``` + """, + required: true + ], + site: [ + type: secret_type, + doc: """ + The base URL of the OAuth2 server - including the leading protocol + (ie `https://`). + + #{secret_doc} + + Example: + + ```elixir + site fn _, resource -> + :my_app + |> Application.get_env(resource, []) + |> Keyword.fetch(:oauth_site) + end + ``` + """, + required: true + ], + auth_method: [ + type: + {:in, + [ + nil, + :client_secret_basic, + :client_secret_post, + :client_secret_jwt, + :private_key_jwt + ]}, + doc: """ + The authentication strategy used, optional. If not set, no + authentication will be used during the access token request. The + value may be one of the following: + + * `:client_secret_basic` + * `:client_secret_post` + * `:client_secret_jwt` + * `:private_key_jwt` + """, + default: :client_secret_post + ], + client_secret: [ + type: secret_type, + doc: """ + The OAuth2 client secret. + + Required if :auth_method is `:client_secret_basic`, + `:client_secret_post` or `:client_secret_jwt`. + + #{secret_doc} + + Example: + + ```elixir + site fn _, resource -> + :my_app + |> Application.get_env(resource, []) + |> Keyword.fetch(:oauth_site) + end + ``` + """, + required: false + ], + authorize_url: [ + type: secret_type, + doc: """ + The API url to the OAuth2 authorize endpoint. + + Relative to the value of `site`. + + #{secret_doc} + + Example: + + ```elixir + authorize_url fn _, _ -> {:ok, "https://exampe.com/authorize"} end + ``` + """, + required: true + ], + token_url: [ + type: secret_type, + doc: """ + The API url to access the token endpoint. + + Relative to the value of `site`. + + #{secret_doc} + + Example: + + ```elixir + token_url fn _, _ -> {:ok, "https://example.com/oauth_token"} end + ``` + """, + required: true + ], + user_url: [ + type: secret_type, + doc: """ + The API url to access the user endpoint. + + Relative to the value of `site`. + + #{secret_doc} + + Example: + + ```elixir + user_url fn _, _ -> {:ok, "https://example.com/userinfo"} end + ``` + """, + required: true + ], + private_key: [ + type: secret_type, + doc: """ + The private key to use if `:auth_method` is `:private_key_jwt` + + #{secret_doc} + """, + required: false + ], + redirect_uri: [ + type: secret_type, + doc: """ + The callback URI base. + + Not the whole URI back to the callback endpoint, but the URI to your + `AuthPlug`. We can generate the rest. + + Whilst not particularly secret, it seemed prudent to allow this to be + configured dynamically so that you can use different URIs for + different environments. + + #{secret_doc} + """, + required: true + ], + authorization_params: [ + type: :keyword_list, + doc: """ + Any additional parameters to encode in the request phase. + + eg: `authorization_params scope: "openid profile email"` + """, + default: [] + ], + registration_enabled?: [ + type: :boolean, + doc: """ + Is registration enabled for this provider? + + If this option is enabled, then new users will be able to register for + your site when authenticating and not already present. + + If not, then only existing users will be able to authenticate. + """, + default: true + ], + register_action_name: [ + type: :atom, + doc: ~S""" + The name of the action to use to register a user. + + Only needed if `registration_enabled?` is `true`. + + Because we we don't know the response format of the server, you must + implement your own registration action of the same name. + + See the "Registration and Sign-in" section of the module + documentation for more information. + + The default is computed from the strategy name eg: + `register_with_#{name}`. + """, + required: false + ], + sign_in_action_name: [ + type: :atom, + doc: ~S""" + The name of the action to use to sign in an existing user. + + Only needed if `registration_enabled?` is `false`. + + Because we don't know the response format of the server, you must + implement your own sign-in action of the same name. + + See the "Registration and Sign-in" section of the module + documentation for more information. + + The default is computed from the strategy name, eg: + `sign_in_with_#{name}`. + """, + required: false + ], + identity_resource: [ + type: {:or, [{:behaviour, Ash.Resource}, {:in, [false]}]}, + doc: """ + The resource used to store user identities. + + Given that a user can be signed into multiple different + authentication providers at once we use the + `AshAuthentication.UserIdentity` resource to build a mapping + between users, providers and that provider's uid. + + See the Identities section of the module documentation for more + information. + + Set to `false` to disable. + """, + default: false + ], + identity_relationship_name: [ + type: :atom, + doc: "Name of the relationship to the provider identities resource", + default: :identities + ], + identity_relationship_user_id_attribute: [ + type: :atom, + doc: """ + The name of the destination (user_id) attribute on your provider + identity resource. + + The only reason to change this would be if you changed the + `user_id_attribute_name` option of the provider identity. + """, + default: :user_id + ] + ], + auto_set_fields: [assent_strategy: Assent.Strategy.OAuth2] + } + end +end diff --git a/lib/ash_authentication/strategies/oauth2/identity_change.ex b/lib/ash_authentication/strategies/oauth2/identity_change.ex index a48d2c3..6360fe0 100644 --- a/lib/ash_authentication/strategies/oauth2/identity_change.ex +++ b/lib/ash_authentication/strategies/oauth2/identity_change.ex @@ -4,7 +4,7 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do """ use Ash.Resource.Change - alias AshAuthentication.{Info, UserIdentity} + alias AshAuthentication.{Info, Strategy, UserIdentity} alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change} import AshAuthentication.Utils, only: [is_falsy: 1] @@ -33,7 +33,7 @@ defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do |> UserIdentity.Actions.upsert(%{ user_info: Changeset.get_argument(changeset, :user_info), oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens), - strategy: strategy.name, + strategy: Strategy.name(strategy), user_id: user.id }) |> case do diff --git a/lib/ash_authentication/strategies/oauth2/strategy.ex b/lib/ash_authentication/strategies/oauth2/strategy.ex index 25ad8c0..ebf0762 100644 --- a/lib/ash_authentication/strategies/oauth2/strategy.ex +++ b/lib/ash_authentication/strategies/oauth2/strategy.ex @@ -14,6 +14,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.OAuth2 do @typedoc "The actions supported by this strategy" @type action :: :register | :sign_in + @doc false + @spec name(OAuth2.t()) :: atom + def name(strategy), do: strategy.name + @doc false @spec phases(OAuth2.t()) :: [phase] def phases(_), do: [:request, :callback] diff --git a/lib/ash_authentication/strategies/oauth2/transformer.ex b/lib/ash_authentication/strategies/oauth2/transformer.ex index 79fb0df..0d0839e 100644 --- a/lib/ash_authentication/strategies/oauth2/transformer.ex +++ b/lib/ash_authentication/strategies/oauth2/transformer.ex @@ -6,47 +6,17 @@ defmodule AshAuthentication.Strategy.OAuth2.Transformer do actions and settings are in place. """ - use Spark.Dsl.Transformer alias Ash.{Resource, Type} - alias AshAuthentication.{GenerateTokenChange, Info, Strategy.OAuth2} + alias AshAuthentication.{GenerateTokenChange, Info, Strategy, Strategy.OAuth2} alias Spark.{Dsl.Transformer, Error.DslError} + import AshAuthentication.Strategy.Custom.Helpers import AshAuthentication.Utils import AshAuthentication.Validations import AshAuthentication.Validations.Action @doc false - @impl true - @spec after?(module) :: boolean - def after?(AshAuthentication.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 - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - dsl_state - |> Info.authentication_strategies() - |> Stream.filter(&is_struct(&1, OAuth2)) - |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> - case transform_strategy(strategy, dsl_state) do - {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp transform_strategy(strategy, dsl_state) do + @spec transform(OAuth2.t(), map) :: {:ok, OAuth2.t() | map} | {:error, Exception.t()} + def transform(strategy, dsl_state) do with strategy <- set_defaults(strategy), {:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, strategy), :ok <- maybe_validate_register_action(dsl_state, strategy), @@ -59,15 +29,12 @@ defmodule AshAuthentication.Strategy.OAuth2.Transformer do |> Transformer.replace_entity( ~w[authentication strategies]a, strategy, - &(&1.name == strategy.name) + &(Strategy.name(&1) == strategy.name) ) |> then(fn dsl_state -> ~w[register_action_name sign_in_action_name]a - |> Stream.map(&Map.get(strategy, &1)) - |> Enum.reduce( - dsl_state, - &Transformer.persist(&2, {:authentication_action, &1}, strategy) - ) + |> Enum.map(&Map.get(strategy, &1)) + |> register_strategy_actions(dsl_state, strategy) end) {:ok, dsl_state} diff --git a/lib/ash_authentication/strategies/oauth2/verifier.ex b/lib/ash_authentication/strategies/oauth2/verifier.ex index f8f8099..237a4d4 100644 --- a/lib/ash_authentication/strategies/oauth2/verifier.ex +++ b/lib/ash_authentication/strategies/oauth2/verifier.ex @@ -3,47 +3,13 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do DSL verifier for oauth2 strategies. """ - use Spark.Dsl.Transformer - alias AshAuthentication.{Info, Strategy.OAuth2} + alias AshAuthentication.{Secret, Strategy.OAuth2} alias Spark.Error.DslError import AshAuthentication.Validations @doc false - @impl true - @spec after?(module) :: boolean - def after?(_), do: true - - @doc false - @impl true - @spec before?(module) :: boolean - def before?(_), do: false - - @doc false - @impl true - @spec after_compile? :: boolean - def after_compile?, do: true - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - dsl_state - |> Info.authentication_strategies() - |> Stream.filter(&is_struct(&1, OAuth2)) - |> Enum.reduce_while(:ok, fn strategy, :ok -> - case transform_strategy(strategy) do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp transform_strategy(strategy) do + @spec verify(OAuth2.t(), map) :: :ok | {:error, Exception.t()} + def verify(strategy, _dsl_state) do with :ok <- validate_secret(strategy, :authorize_url), :ok <- validate_secret(strategy, :client_id), :ok <- validate_secret(strategy, :client_secret), @@ -64,7 +30,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Verifier do :ok {:ok, {module, _}} when is_atom(module) -> - validate_behaviour(module, AshAuthentication.Secret) + validate_behaviour(module, Secret) _ -> {:error, diff --git a/lib/ash_authentication/strategies/password.ex b/lib/ash_authentication/strategies/password.ex index eafac3c..434e7f2 100644 --- a/lib/ash_authentication/strategies/password.ex +++ b/lib/ash_authentication/strategies/password.ex @@ -1,5 +1,6 @@ defmodule AshAuthentication.Strategy.Password do - import AshAuthentication.Dsl + alias __MODULE__.{Dsl, Transformer, Verifier} + use AshAuthentication.Strategy.Custom @moduledoc """ Strategy for authenticating using local resources as the source of truth. @@ -92,7 +93,7 @@ defmodule AshAuthentication.Strategy.Password do ## DSL Documentation - #{Spark.Dsl.Extension.doc_entity(strategy(:password))} + #{Spark.Dsl.Extension.doc_entity(Dsl.dsl())} """ defstruct identity_field: :username, @@ -126,6 +127,10 @@ defmodule AshAuthentication.Strategy.Password do resource: module } + defdelegate dsl(), to: Dsl + defdelegate transform(strategy, dsl_state), to: Transformer + defdelegate verify(strategy, dsl_state), to: Verifier + @doc """ Generate a reset token for a user. diff --git a/lib/ash_authentication/strategies/password/dsl.ex b/lib/ash_authentication/strategies/password/dsl.ex new file mode 100644 index 0000000..48c7ecf --- /dev/null +++ b/lib/ash_authentication/strategies/password/dsl.ex @@ -0,0 +1,170 @@ +defmodule AshAuthentication.Strategy.Password.Dsl do + @moduledoc """ + Defines the Spark DSL entity for this strategy. + """ + + alias AshAuthentication.Strategy.Password + alias Spark.Dsl.Entity + + @default_token_lifetime_days 3 + + @doc false + @spec dsl :: map + def dsl do + %Entity{ + name: :password, + describe: "Strategy for authenticating using local resources as the source of truth.", + examples: [ + """ + password :password do + identity_field :email + hashed_password_field :hashed_password + hash_provider AshAuthentication.BcryptProvider + confirmation_required? true + end + """ + ], + args: [{:optional, :name, :password}], + hide: [:name], + target: Password, + modules: [:hash_provider], + schema: [ + name: [ + type: :atom, + doc: """ + Uniquely identifies the strategy. + """, + required: true + ], + 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. + + 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. + + If not present it will be generated by prepending the strategy name + with `register_with_`. + """, + required: false + ], + sign_in_action_name: [ + type: :atom, + doc: """ + The name to use for the sign in action. + + If not present it will be generated by prependign the strategy name + with `sign_in_with_`. + """, + required: false + ] + ], + entities: [ + resettable: [ + %Entity{ + name: :resettable, + describe: "Configure password reset options for the resource", + target: Password.Resettable, + schema: [ + token_lifetime: [ + type: :pos_integer, + doc: """ + How long should the reset token be valid, in hours. + + Defaults to #{@default_token_lifetime_days} days. + """, + default: @default_token_lifetime_days * 24 + ], + request_password_reset_action_name: [ + type: :atom, + doc: """ + The name to use for the action which generates a password reset token. + + If not present it will be generated by prepending the strategy name + with `request_password_reset_with_`. + """, + required: false + ], + password_reset_action_name: [ + type: :atom, + doc: """ + The name to use for the action which actually resets the user's + password. + + If not present it will be generated by prepending the strategy name + with `password_reset_with_`. + """, + required: false + ], + sender: [ + type: + {:spark_function_behaviour, AshAuthentication.Sender, + {AshAuthentication.SenderFunction, 3}}, + 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.Sender` for more information. + """, + required: true + ] + ] + } + ] + ] + } + end +end diff --git a/lib/ash_authentication/strategies/password/resettable.ex b/lib/ash_authentication/strategies/password/resettable.ex index 73938a6..cea4cb0 100644 --- a/lib/ash_authentication/strategies/password/resettable.ex +++ b/lib/ash_authentication/strategies/password/resettable.ex @@ -3,9 +3,7 @@ defmodule AshAuthentication.Strategy.Password.Resettable do The entity used to store password reset information. """ - @default_lifetime_days 3 - - defstruct token_lifetime: @default_lifetime_days * 24, + defstruct token_lifetime: nil, request_password_reset_action_name: nil, password_reset_action_name: nil, sender: nil @@ -16,61 +14,4 @@ defmodule AshAuthentication.Strategy.Password.Resettable do password_reset_action_name: atom, sender: {module, keyword} } - - @doc false - @spec entity :: struct() - def entity do - %Spark.Dsl.Entity{ - name: :resettable, - describe: "Configure password reset options for the resource", - target: __MODULE__, - 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. - - If not present it will be generated by prepending the strategy name - with `request_password_reset_with_`. - """, - required: false - ], - password_reset_action_name: [ - type: :atom, - doc: """ - The name to use for the action which actually resets the user's - password. - - If not present it will be generated by prepending the strategy name - with `password_reset_with_`. - """, - required: false - ], - sender: [ - type: - {:spark_function_behaviour, AshAuthentication.Sender, - {AshAuthentication.SenderFunction, 3}}, - 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.Sender` for more information. - """, - required: true - ] - ] - } - end end diff --git a/lib/ash_authentication/strategies/password/strategy.ex b/lib/ash_authentication/strategies/password/strategy.ex index 5e079b1..19888c8 100644 --- a/lib/ash_authentication/strategies/password/strategy.ex +++ b/lib/ash_authentication/strategies/password/strategy.ex @@ -18,6 +18,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do """ @type phase :: :register | :sign_in | :reset_request | :reset + @doc false + @spec name(Password.t()) :: atom + def name(strategy), do: strategy.name + @doc false @spec phases(Password.t()) :: [phase] def phases(%{resettable: []}), do: [:register, :sign_in] diff --git a/lib/ash_authentication/strategies/password/transformer.ex b/lib/ash_authentication/strategies/password/transformer.ex index 56193cd..ff2b711 100644 --- a/lib/ash_authentication/strategies/password/transformer.ex +++ b/lib/ash_authentication/strategies/password/transformer.ex @@ -6,49 +6,18 @@ defmodule AshAuthentication.Strategy.Password.Transformer do the correct actions and settings are in place. """ - use Spark.Dsl.Transformer - alias Ash.{Resource, Type} - alias AshAuthentication.{GenerateTokenChange, Info, Strategy.Password} + alias AshAuthentication.{GenerateTokenChange, Strategy, Strategy.Password} alias Spark.{Dsl.Transformer, Error.DslError} + import AshAuthentication.Strategy.Custom.Helpers import AshAuthentication.Utils import AshAuthentication.Validations import AshAuthentication.Validations.Action import AshAuthentication.Validations.Attribute @doc false - @impl true - @spec after?(module) :: boolean - def after?(AshAuthentication.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 - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - dsl_state - |> Info.authentication_strategies() - |> Stream.filter(&is_struct(&1, Password)) - |> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} -> - case transform_strategy(strategy, dsl_state) do - {:ok, dsl_state} -> {:cont, {:ok, dsl_state}} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - defp transform_strategy(strategy, dsl_state) do + @spec transform(Password.t(), map) :: {:ok, Password.t() | map} | {:error, Exception.t()} + def transform(strategy, dsl_state) do with :ok <- validate_identity_field(strategy.identity_field, dsl_state), :ok <- validate_hashed_password_field(strategy.hashed_password_field, dsl_state), strategy <- @@ -78,27 +47,21 @@ defmodule AshAuthentication.Strategy.Password.Transformer do |> Transformer.replace_entity( ~w[authentication strategies]a, strategy, - &(&1.name == strategy.name) + &(Strategy.name(&1) == strategy.name) ) |> then(fn dsl_state -> ~w[sign_in_action_name register_action_name]a - |> Stream.map(&Map.get(strategy, &1)) - |> Enum.reduce( - dsl_state, - &Transformer.persist(&2, {:authentication_action, &1}, strategy) - ) + |> Enum.map(&Map.get(strategy, &1)) + |> register_strategy_actions(dsl_state, strategy) end) |> then(fn dsl_state -> strategy |> Map.get(:resettable, []) - |> Stream.flat_map(fn resettable -> + |> Enum.flat_map(fn resettable -> ~w[request_password_reset_action_name password_reset_action_name]a - |> Stream.map(&Map.get(resettable, &1)) + |> Enum.map(&Map.get(resettable, &1)) end) - |> Enum.reduce( - dsl_state, - &Transformer.persist(&2, {:authentication_action, &1}, strategy) - ) + |> register_strategy_actions(dsl_state, strategy) end) {:ok, dsl_state} diff --git a/lib/ash_authentication/strategies/password/verifier.ex b/lib/ash_authentication/strategies/password/verifier.ex index 44d3850..148d740 100644 --- a/lib/ash_authentication/strategies/password/verifier.ex +++ b/lib/ash_authentication/strategies/password/verifier.ex @@ -3,47 +3,13 @@ defmodule AshAuthentication.Strategy.Password.Verifier do DSL verifier for the password strategy. """ - use Spark.Dsl.Transformer - alias AshAuthentication.{HashProvider, Info, Sender, Strategy.Password} + alias AshAuthentication.{HashProvider, Sender, Strategy.Password} alias Spark.Error.DslError import AshAuthentication.Validations @doc false - @impl true - @spec after?(module) :: boolean - def after?(_), do: true - - @doc false - @impl true - @spec before?(module) :: boolean - def before?(_), do: false - - @doc false - @impl true - @spec after_compile? :: boolean - def after_compile?, do: true - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - dsl_state - |> Info.authentication_strategies() - |> Stream.filter(&is_struct(&1, Password)) - |> Enum.reduce_while(:ok, fn strategy, :ok -> - case transform_strategy(strategy) do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end) - end - - def transform_strategy(strategy) do + @spec verify(Password.t(), map) :: :ok | {:error, Exception.t()} + def verify(strategy, _dsl_state) do with :ok <- validate_behaviour(strategy.hash_provider, HashProvider) do maybe_validate_resettable_sender(strategy) end diff --git a/lib/ash_authentication/strategy.ex b/lib/ash_authentication/strategy.ex index 8138712..74eba12 100644 --- a/lib/ash_authentication/strategy.ex +++ b/lib/ash_authentication/strategy.ex @@ -36,6 +36,15 @@ defprotocol AshAuthentication.Strategy do @type http_method :: :get | :head | :post | :put | :delete | :connect | :options | :trace | :patch + @doc """ + The "short name" of the strategy, used for genererating routes, etc. + + This is most likely the same value that you use for the entity's `name` + argument. + """ + @spec name(t) :: atom + def name(strategy) + @doc """ Return a list of phases supported by the strategy. diff --git a/lib/ash_authentication/token_resource/get_confirmation_changes_preparation.ex b/lib/ash_authentication/token_resource/get_confirmation_changes_preparation.ex index 7af2df0..d801451 100644 --- a/lib/ash_authentication/token_resource/get_confirmation_changes_preparation.ex +++ b/lib/ash_authentication/token_resource/get_confirmation_changes_preparation.ex @@ -6,6 +6,7 @@ defmodule AshAuthentication.TokenResource.GetConfirmationChangesPreparation do use Ash.Resource.Preparation alias Ash.{Query, Resource.Preparation} + alias AshAuthentication.Strategy require Ash.Query @doc false @@ -16,7 +17,7 @@ defmodule AshAuthentication.TokenResource.GetConfirmationChangesPreparation do strategy = query.context.strategy query - |> Query.filter(purpose: to_string(strategy.name), jti: jti) + |> Query.filter(purpose: to_string(Strategy.name(strategy)), jti: jti) |> Query.filter(expires_at >= now()) end end diff --git a/lib/ash_authentication/verifier.ex b/lib/ash_authentication/verifier.ex index db055d0..1832472 100644 --- a/lib/ash_authentication/verifier.ex +++ b/lib/ash_authentication/verifier.ex @@ -1,33 +1,22 @@ defmodule AshAuthentication.Verifier do @moduledoc """ The Authentication verifier. + + Checks configuration constraints after compile. """ - use Spark.Dsl.Transformer + use Spark.Dsl.Verifier alias AshAuthentication.Info alias Spark.{Dsl.Transformer, Error.DslError} import AshAuthentication.Utils @doc false @impl true - @spec after?(any) :: boolean - def after?(_), do: true - - @doc false - @impl true - @spec before?(any) :: boolean - def before?(_), do: false - - @doc false - @impl true - @spec after_compile? :: boolean - def after_compile?, do: true - - @doc false - @impl true - @spec transform(map) :: - :ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt - def transform(dsl_state) do + @spec verify(map) :: + :ok + | {:error, term} + | {:warn, String.t() | list(String.t())} + def verify(dsl_state) do with {:ok, _api} <- validate_api_presence(dsl_state) do validate_token_resource(dsl_state) end diff --git a/test/ash_authentication/strategies/custom_strategy_test.exs b/test/ash_authentication/strategies/custom_strategy_test.exs new file mode 100644 index 0000000..10d89d2 --- /dev/null +++ b/test/ash_authentication/strategies/custom_strategy_test.exs @@ -0,0 +1,47 @@ +defmodule AshAuthentication.Strategy.CustomStrategyTest do + @moduledoc false + use DataCase + alias AshAuthentication.{Errors.AuthenticationFailed, Info, Plug.Helpers, Strategy} + import Plug.Test + + test "when an existing user whose username doesn't start with \"marty\", they can't sign in" do + strategy = Info.strategy!(Example.User, :marty) + build_user(username: "doc_brown") + + conn = conn(:post, "/user/marty", %{"username" => "doc_brown"}) + + {_conn, {:error, error}} = + strategy + |> Strategy.plug(:sign_in, conn) + |> Helpers.get_authentication_result() + + assert %AuthenticationFailed{caused_by: %{reason: :no_user}} = error + end + + test "when not an existing user, they can't sign in" do + strategy = Info.strategy!(Example.User, :marty) + + conn = conn(:post, "/user/marty", %{"username" => username()}) + + {_conn, {:error, error}} = + strategy + |> Strategy.plug(:sign_in, conn) + |> Helpers.get_authentication_result() + + assert %AuthenticationFailed{caused_by: %{reason: :no_user}} = error + end + + test "when an existing user whose username starts with \"marty\", they can sign in" do + strategy = Info.strategy!(Example.User, :marty) + user0 = build_user(username: "marty_mcfly") + + conn = conn(:post, "/user/marty", %{"username" => "marty_mcfly"}) + + {_conn, {:ok, user1}} = + strategy + |> Strategy.plug(:sign_in, conn) + |> Helpers.get_authentication_result() + + assert user0.id == user1.id + end +end diff --git a/test/ash_authentication/strategies/password/hash_password_change_test.exs b/test/ash_authentication/strategies/password/hash_password_change_test.exs index 40e89af..2d32384 100644 --- a/test/ash_authentication/strategies/password/hash_password_change_test.exs +++ b/test/ash_authentication/strategies/password/hash_password_change_test.exs @@ -1,7 +1,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do use DataCase, async: true alias Ash.Changeset - alias AshAuthentication.{Info, Strategy.Password.HashPasswordChange} + alias AshAuthentication.{Info, Strategy, Strategy.Password.HashPasswordChange} describe "change/3" do test "when the action is associated with a strategy, it can hash the password" do @@ -38,7 +38,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do {:ok, _user, _changeset, _} = Changeset.new(user, %{}) - |> Changeset.set_context(%{strategy_name: strategy.name}) + |> Changeset.set_context(%{strategy_name: Strategy.name(strategy)}) |> Changeset.for_update(:update, attrs) |> HashPasswordChange.change([], %{}) |> Changeset.with_hooks(fn changeset -> @@ -61,7 +61,7 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChangeTest do {:ok, _user, _changeset, _} = Changeset.new(user, %{}) |> Changeset.for_update(:update, attrs) - |> HashPasswordChange.change([], %{strategy_name: strategy.name}) + |> HashPasswordChange.change([], %{strategy_name: Strategy.name(strategy)}) |> Changeset.with_hooks(fn changeset -> assert strategy.hash_provider.valid?(password, changeset.attributes.hashed_password) diff --git a/test/ash_authentication/strategies/password/password_confirmation_validation_test.exs b/test/ash_authentication/strategies/password/password_confirmation_validation_test.exs index 6e439e2..2b42e1b 100644 --- a/test/ash_authentication/strategies/password/password_confirmation_validation_test.exs +++ b/test/ash_authentication/strategies/password/password_confirmation_validation_test.exs @@ -1,7 +1,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidationTest do use DataCase, async: true alias Ash.{Changeset, Error.Changes.InvalidArgument} - alias AshAuthentication.{Info, Strategy.Password.PasswordConfirmationValidation} + alias AshAuthentication.{Info, Strategy, Strategy.Password.PasswordConfirmationValidation} describe "validate/2" do test "when the action is associated with a strategy, it can validate the password confirmation" do @@ -34,7 +34,7 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidationTest assert {:error, %InvalidArgument{field: :password_confirmation}} = Changeset.new(user, %{}) - |> Changeset.set_context(%{strategy_name: strategy.name}) + |> Changeset.set_context(%{strategy_name: Strategy.name(strategy)}) |> Changeset.for_update(:update, attrs) |> PasswordConfirmationValidation.validate([]) end diff --git a/test/ash_authentication/strategies/password_test.exs b/test/ash_authentication/strategies/password_test.exs index a3cc952..ff0366c 100644 --- a/test/ash_authentication/strategies/password_test.exs +++ b/test/ash_authentication/strategies/password_test.exs @@ -18,7 +18,7 @@ defmodule AshAuthentication.Strategy.PasswordTest do describe "reset_token_for/1" do test "it generates a token when resets are enabled" do user = build_user() - resettable = %Resettable{password_reset_action_name: :reset} + resettable = %Resettable{password_reset_action_name: :reset, token_lifetime: 72} strategy = %Password{resettable: [resettable], resource: user.__struct__} assert {:ok, token} = Password.reset_token_for(strategy, user) diff --git a/test/support/example/custom_strategy.ex b/test/support/example/custom_strategy.ex new file mode 100644 index 0000000..af6378b --- /dev/null +++ b/test/support/example/custom_strategy.ex @@ -0,0 +1,112 @@ +defmodule Example.CustomStrategy do + @moduledoc """ + An extremely dumb custom strategy that let's anyone with a name that starts + with "Marty" sign in. + """ + + defstruct case_sensitive?: false, name_field: nil, resource: nil + + use AshAuthentication.Strategy.Custom + + def dsl do + %Spark.Dsl.Entity{ + name: :only_marty, + describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.", + examples: [ + """ + only_marty do + case_sensitive? true + name_field :name + end + """ + ], + target: __MODULE__, + schema: [ + case_sensitive?: [ + type: :boolean, + doc: """ + Ignore letter case when comparing? + """, + required: false, + default: false + ], + name_field: [ + type: :atom, + doc: """ + The field to check for the users' name. + """, + required: true + ] + ] + } + end + + defimpl AshAuthentication.Strategy do + alias AshAuthentication.{Errors.AuthenticationFailed, Info} + require Ash.Query + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + + def name(_), do: :marty + + def phases(_), do: [:sign_in] + def actions(_), do: [:sign_in] + + def routes(strategy) do + subject_name = Info.authentication_subject_name!(strategy.resource) + + [ + {"/#{subject_name}/marty", :sign_in} + ] + end + + def method_for_phase(_, :sign_in), do: :post + + def plug(strategy, :sign_in, conn) do + params = Map.take(conn.params, [to_string(strategy.name_field)]) + result = AshAuthentication.Strategy.action(strategy, :sign_in, params, []) + store_authentication_result(conn, result) + end + + def action(strategy, :sign_in, params, _options) do + name_field = strategy.name_field + name = Map.get(params, to_string(name_field)) + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Ash.Query.filter(ref(^name_field) == ^name) + |> Ash.Query.after_action(fn + query, [user] -> + name = + user + |> Map.get(name_field) + |> to_string() + + {name, prefix} = + if strategy.case_sensitive? do + {name, "Marty"} + else + {String.downcase(name), "marty"} + end + + if String.starts_with?(name, prefix) do + {:ok, [user]} + else + {:error, + AuthenticationFailed.exception(query: query, caused_by: %{reason: :not_a_marty})} + end + + query, [] -> + {:error, AuthenticationFailed.exception(query: query, caused_by: %{reason: :no_user})} + + query, _ -> + {:error, + AuthenticationFailed.exception(query: query, caused_by: %{reason: :too_many_users})} + end) + |> api.read() + |> case do + {:ok, [user]} -> {:ok, user} + {:error, reason} -> {:error, reason} + end + end + end +end diff --git a/test/support/example/generic_oauth_change.ex b/test/support/example/generic_oauth_change.ex index 93d89d0..283d085 100644 --- a/test/support/example/generic_oauth_change.ex +++ b/test/support/example/generic_oauth_change.ex @@ -9,7 +9,9 @@ defmodule Example.GenericOAuth2Change do def change(changeset, _opts, _context) do user_info = Changeset.get_argument(changeset, :user_info) + username = user_info["nickname"] || user_info["login"] || user_info["preferred_username"] + changeset - |> Changeset.change_attribute(:username, user_info["nickname"] || user_info["login"]) + |> Changeset.change_attribute(:username, username) end end diff --git a/test/support/example/only_marties_at_the_party.ex b/test/support/example/only_marties_at_the_party.ex new file mode 100644 index 0000000..b461edb --- /dev/null +++ b/test/support/example/only_marties_at_the_party.ex @@ -0,0 +1,107 @@ +defmodule Example.OnlyMartiesAtTheParty do + @moduledoc """ + A really dumb custom strategy that lets anyone named Marty sign in. + """ + + defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil + + use AshAuthentication.Strategy.Custom + + def dsl do + %Spark.Dsl.Entity{ + name: :only_marty, + describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.", + examples: [ + """ + only_marty do + case_sensitive? true + name_field :name + end + """ + ], + target: __MODULE__, + args: [{:optional, :name, :marty}], + schema: [ + name: [ + type: :atom, + doc: """ + The strategy name. + """, + required: true + ], + case_sensitive?: [ + type: :boolean, + doc: """ + Ignore letter case when comparing? + """, + required: false, + default: false + ], + name_field: [ + type: :atom, + doc: """ + The field to check for the users' name. + """, + required: true + ] + ] + } + end + + defimpl AshAuthentication.Strategy do + alias AshAuthentication.Errors.AuthenticationFailed + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + require Ash.Query + + def name(strategy), do: strategy.name + + def phases(_), do: [:sign_in] + def actions(_), do: [:sign_in] + + def routes(strategy) do + subject_name = AshAuthentication.Info.authentication_subject_name!(strategy.resource) + + [ + {"/#{subject_name}/#{strategy.name}", :sign_in} + ] + end + + def method_for_phase(_, :sign_in), do: :post + + def plug(strategy, :sign_in, conn) do + params = Map.take(conn.params, [to_string(strategy.name_field)]) + result = action(strategy, :sign_in, params, []) + store_authentication_result(conn, result) + end + + def action(strategy, :sign_in, params, options) do + name_field = strategy.name_field + name = Map.get(params, to_string(name_field)) + api = AshAuthentication.Info.authentication_api!(strategy.resource) + + strategy.resource + |> Ash.Query.filter(ref(^name_field) == ^name) + |> then(fn query -> + if strategy.case_sensitive? do + Ash.Query.filter(query, like(ref(^name_field), "Marty%")) + else + Ash.Query.filter(query, ilike(ref(^name_field), "Marty%")) + end + end) + |> api.read(options) + |> case do + {:ok, [user]} -> + {:ok, user} + + {:ok, []} -> + {:error, AuthenticationFailed.exception(caused_by: %{reason: :no_user})} + + {:ok, _users} -> + {:error, AuthenticationFailed.exception(caused_by: %{reason: :too_many_users})} + + {:error, reason} -> + {:error, AuthenticationFailed.exception(caused_by: %{reason: reason})} + end + end + end +end diff --git a/test/support/example/user.ex b/test/support/example/user.ex index 4b6be53..ee25606 100644 --- a/test/support/example/user.ex +++ b/test/support/example/user.ex @@ -191,6 +191,11 @@ defmodule Example.User do redirect_uri &get_config/2 client_secret &get_config/2 end + + only_marty do + case_sensitive?(false) + name_field(:username) + end end end