diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5b151aa --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +Copyright 2022 Alembic Pty Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 2d77e3c..3f4c193 100644 --- a/README.md +++ b/README.md @@ -19,17 +19,16 @@ end ## Usage -This package assumes that you have [Phoenix](https://phoenixframework.org/) and -[Ash](https://ash-hq.org/) installed and configured. See their individual -documentation for details. +This package assumes that you have [Ash](https://ash-hq.org/) installed and +configured. See the Ash documentation for details. -Once installed you can easily add support for authentication by configuring one -or more extensions onto your Ash resource: +Once installed you can easily add support for authentication by adding the +`AshAuthentication` extension to your resource: ```elixir defmodule MyApp.Accounts.User do use Ash.Resource, - extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication] + extensions: [AshAuthentication] attributes do uuid_primary_key :id @@ -39,11 +38,13 @@ defmodule MyApp.Accounts.User do authentication do api MyApp.Accounts - end - password_authentication do - identity_field :email - hashed_password_field :hashed_password + strategies do + password do + identity_field :email + hashed_password_field :hashed_password + end + end end identities do @@ -55,7 +56,7 @@ end If you plan on providing authentication via the web, then you will need to define a plug using [`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html) -which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which +which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) that routes incoming authentication requests to the correct provider and provides callbacks for you to manipulate the conn after success or failure. @@ -64,18 +65,16 @@ If you're using AshAuthentication with Phoenix, then check out which provides route helpers, a controller abstraction and LiveView components for easy set up. -## Authentication Providers +## Authentication Strategies -Currently the only supported authentication provider is -[`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html) -which provides actions for registering and signing in users using an identifier -and a password. +Currently supported strategies: -Planned future providers include: - - * OAuth 1.0 - * OAuth 2.0 - * OpenID Connect + 1. [`AshAuthentication.Strategy.Password`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.Password.html) + - authenticate users against your local database using a unique identity + (such as username or email address) and a password. + 2. [`AshAuthentication.Strategy.OAuth2`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.OAuth2.html) + - authenticate using local or remote [OAuth 2.0](https://oauth.net/2/) + compatible services. ## Documentation @@ -83,6 +82,10 @@ Documentation for the latest release will be [available on hexdocs](https://hexdocs.pm/ash_authentication) and for the [`main` branch](https://team-alembic.github.io/ash_authentication). +Additional support can be found on the [GitHub discussions +page](https://github.com/team-alembic/ash_authentication/discussions) and the +[Ash Discord](https://discord.gg/D7FNG2q). + ## Contributing * To contribute updates, fixes or new features please fork and open a @@ -95,4 +98,7 @@ branch](https://team-alembic.github.io/ash_authentication). ## Licence -MIT +`AshAuthentication` is licensed under the terms of the [MIT +license](https://opensource.org/licenses/MIT). See the [`LICENSE` file in this +repository](https://github.com/team-alembic/ash_authentication/blob/main/LICENSE) +for details. diff --git a/config/dev.exs b/config/dev.exs index ba29688..692af03 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -28,3 +28,18 @@ config :ash_authentication, Example, config :ash_authentication, AshAuthentication.Jwt, signing_secret: "Marty McFly in the past with the Delorean" + +config :ash_authentication, + authentication: [ + strategies: [ + oauth2: [ + client_id: System.get_env("OAUTH2_CLIENT_ID"), + redirect_uri: "http://localhost:4000/auth", + client_secret: System.get_env("OAUTH2_CLIENT_SECRET"), + site: System.get_env("OAUTH2_SITE"), + authorize_path: "/authorize", + token_path: "/oauth/token", + user_path: "/userinfo" + ] + ] + ] diff --git a/config/test.exs b/config/test.exs index 5242761..29f57f9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -21,3 +21,18 @@ config :ash, :disable_async?, true config :ash_authentication, AshAuthentication.Jwt, signing_secret: "Marty McFly in the past with the Delorean" + +config :ash_authentication, + authentication: [ + strategies: [ + oauth2: [ + client_id: "pretend client id", + redirect_uri: "http://localhost:4000/auth", + client_secret: "pretend client secret", + site: "https://example.com/", + authorize_path: "/authorize", + token_path: "/oauth/token", + user_path: "/userinfo" + ] + ] + ] diff --git a/dev/dev_server/api_router.ex b/dev/dev_server/api_router.ex index 98cd633..eb2da81 100644 --- a/dev/dev_server/api_router.ex +++ b/dev/dev_server/api_router.ex @@ -6,7 +6,7 @@ defmodule DevServer.ApiRouter do import Example.AuthPlug plug(:load_from_bearer) - plug(:set_actor, :user_with_username) + plug(:set_actor, :user) plug(:match) plug(:dispatch) diff --git a/dev/dev_server/gql_router.ex b/dev/dev_server/gql_router.ex index 72f28ca..1fe3c4a 100644 --- a/dev/dev_server/gql_router.ex +++ b/dev/dev_server/gql_router.ex @@ -6,7 +6,7 @@ defmodule DevServer.GqlRouter do import Example.AuthPlug plug(:load_from_bearer) - plug(:set_actor, :user_with_username) + plug(:set_actor, :user) plug(AshGraphql.Plug) plug(:match) plug(:dispatch) diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex index 15a4356..da1ae54 100644 --- a/dev/dev_server/test_page.ex +++ b/dev/dev_server/test_page.ex @@ -4,6 +4,7 @@ defmodule DevServer.TestPage do Überauth providers. """ @behaviour Plug + alias AshAuthentication.{Info, Strategy} alias Plug.Conn require EEx @@ -20,7 +21,10 @@ defmodule DevServer.TestPage do @spec call(Conn.t(), any) :: Conn.t() @impl true def call(conn, _opts) do - resources = AshAuthentication.authenticated_resources(:ash_authentication) + resources = + :ash_authentication + |> AshAuthentication.authenticated_resources() + |> Enum.map(&{&1, Info.authentication_options(&1), Info.authentication_strategies(&1)}) current_users = conn.assigns @@ -34,4 +38,157 @@ defmodule DevServer.TestPage do payload = render(resources: resources, current_users: current_users) Conn.send_resp(conn, 200, payload) end + + defp render_strategy(strategy, phase, options) + when strategy.provider == :password and phase == :register do + EEx.eval_string( + ~s""" +
+
+ Register with <%= @strategy.name %> + +
+ +
+ <%= if @strategy.confirmation_required? do %> + +
+ <% end %> + +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, phase, options) + when strategy.provider == :password and phase == :sign_in do + EEx.eval_string( + ~s""" +
+
+ Sign in with <%= @strategy.name %> + +
+ +
+ +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, phase, options) + when strategy.provider == :password and phase == :reset_request do + EEx.eval_string( + ~s""" +
+
+ <%= @strategy.name %> reset request + +
+ +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, phase, options) + when strategy.provider == :password and phase == :reset do + EEx.eval_string( + ~s""" +
+
+ <%= @strategy.name %> reset request + +
+ +
+ <%= if @strategy.confirmation_required? do %> + +
+ <% end %> + +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, phase, options) + when strategy.provider == :confirmation and phase == :confirm do + EEx.eval_string( + ~s""" +
+
+ <%= @strategy.name %> + +
+ +
+
+ """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase), + options: options, + method: Strategy.method_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, phase, _options) + when strategy.provider == :oauth2 and phase == :request do + EEx.eval_string( + ~s""" + Sign in with <%= @strategy.name %> + """, + assigns: [ + strategy: strategy, + route: route_for_phase(strategy, phase) + ] + ) + end + + defp render_strategy(strategy, :callback, _) when strategy.provider == :oauth2, do: "" + + defp render_strategy(strategy, phase, _options) do + inspect({strategy.provider, phase}) + end + + defp route_for_phase(strategy, phase) do + path = + strategy + |> Strategy.routes() + |> Enum.find(&(elem(&1, 1) == phase)) + |> elem(0) + + Path.join("/auth", path) + end end diff --git a/dev/dev_server/test_page.html.eex b/dev/dev_server/test_page.html.eex index fec46b7..fd3e2ed 100644 --- a/dev/dev_server/test_page.html.eex +++ b/dev/dev_server/test_page.html.eex @@ -9,12 +9,14 @@ <%= if Enum.any?(@resources) do %>

Resources:

- <%= for config <- @resources do %> -

<%= inspect(config.subject_name) %> - <%= Ash.Api.Info.short_name(config.api) %> / <%= Ash.Resource.Info.short_name(config.resource) %>

+ <%= for {resource, options, strategies} <- @resources do %> +

<%= inspect(options.subject_name) %> - <%= Ash.Api.Info.short_name(options.api) %> / <%= Ash.Resource.Info.short_name(resource) %>

- <%= for provider <- config.providers do %> - <%= Module.concat(provider, Html).request(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides(config.resource)}") %> - <%= Module.concat(provider, Html).callback(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides(config.resource)}/callback") %> + + <%= for strategy <- strategies do %> + <%= for phase <- Strategy.phases(strategy) do %> + <%= render_strategy(strategy, phase, options) %> + <% end %> <% end %> <% end %> diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index 3061c8a..76237db 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -1,83 +1,5 @@ defmodule AshAuthentication do - import AshAuthentication.Utils, only: [to_sentence: 2] - - @dsl [ - %Spark.Dsl.Section{ - name: :authentication, - describe: "Configure authentication for this resource", - schema: [ - subject_name: [ - type: :atom, - doc: """ - The subject name is used in generating token claims and in generating - authentication routes. - - This needs to be unique system-wide and if not set will be inferred - from the resource name (ie `MyApp.Accounts.User` will have a subject - name of `user`). - """ - ], - api: [ - type: {:behaviour, Ash.Api}, - doc: """ - The name of the Ash API to use to access this resource when - registering/authenticating. - """, - required: true - ], - get_by_subject_action_name: [ - type: :atom, - doc: """ - The name of the read action used to retrieve records. - - Used internally by `AshAuthentication.subject_to_resource/2`. If the - action doesn't exist, one will be generated for you. - """, - default: :get_by_subject - ] - ] - }, - %Spark.Dsl.Section{ - name: :tokens, - describe: "Configure JWT settings for this resource", - schema: [ - enabled?: [ - type: :boolean, - doc: """ - Should JWTs be generated by this resource? - """, - default: false - ], - signing_algorithm: [ - type: :string, - doc: """ - The algorithm to use for token signing. - - Available signing algorithms are; - #{to_sentence(Joken.Signer.algorithms(), final: "and")}. - """, - default: hd(Joken.Signer.algorithms()) - ], - token_lifetime: [ - type: :pos_integer, - doc: """ - How long a token should be valid, in hours. - """ - ], - revocation_resource: [ - type: {:behaviour, Ash.Resource}, - doc: """ - The resource used to store token revocation information. - - If token generation is enabled for this resource, we need a place to - store revocation information. This option is the name of an Ash - Resource which has the `AshAuthentication.TokenRevocation` extension - present. - """ - ] - ] - } - ] + import AshAuthentication.Dsl @moduledoc """ AshAuthentication provides a turn-key authentication solution for folks using @@ -85,17 +7,16 @@ defmodule AshAuthentication do ## Usage - This package assumes that you have [Phoenix](https://phoenixframework.org/) and - [Ash](https://ash-hq.org/) installed and configured. See their individual - documentation for details. + This package assumes that you have [Ash](https://ash-hq.org/) installed and + configured. See the Ash documentation for details. - Once installed you can easily add support for authentication by configuring one - or more extensions onto your Ash resource: + Once installed you can easily add support for authentication by configuring + the `AshAuthentication` extension on your resource: ```elixir defmodule MyApp.Accounts.User do use Ash.Resource, - extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication] + extensions: [AshAuthentication] attributes do uuid_primary_key :id @@ -105,11 +26,13 @@ defmodule AshAuthentication do authentication do api MyApp.Accounts - end - password_authentication do - identity_field :email - hashed_password_field :hashed_password + strategies do + password do + identity_field :email + hashed_password_field :hashed_password + end + end end identities do @@ -121,41 +44,49 @@ defmodule AshAuthentication do If you plan on providing authentication via the web, then you will need to define a plug using [`AshAuthentication.Plug`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Plug.html) - which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) which + which builds a [`Plug.Router`](https://hexdocs.pm/plug/Plug.Router.html) that routes incoming authentication requests to the correct provider and provides callbacks for you to manipulate the conn after success or failure. - ## Authentication Providers + If you're using AshAuthentication with Phoenix, then check out + [`ash_authentication_phoenix`](https://github.com/team-alembic/ash_authentication_phoenix) + which provides route helpers, a controller abstraction and LiveView components + for easy set up. - Currently the only supported authentication provider is - [`AshAuthentication.PasswordAuthentication`](https://team-alembic.github.io/ash_authentication/AshAuthentication.PasswordAuthentication.html) - which provides actions for registering and signing in users using an identifier - and a password. + ## Authentication Strategies - Planned future providers include: + Currently supported strategies: - * OAuth 1.0 - * OAuth 2.0 - * OpenID Connect + 1. [`AshAuthentication.Strategy.Password`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.Password.html) + - authenticate users against your local database using a unique identity + (such as username or email address) and a password. + 2. [`AshAuthentication.Strategy.OAuth2`](https://team-alembic.github.io/ash_authentication/AshAuthentication.Strategy.OAuth2.html) + - authenticate using local or remote [OAuth 2.0](https://oauth.net/2/) + compatible services. ## DSL Documentation ### Index - #{Spark.Dsl.Extension.doc_index(@dsl)} + #{Spark.Dsl.Extension.doc_index(dsl())} ### Docs - #{Spark.Dsl.Extension.doc(@dsl)} + #{Spark.Dsl.Extension.doc(dsl())} """ - alias Ash.{Api, Query, Resource} + alias Ash.{Api, Error.Query.NotFound, Query, Resource} alias AshAuthentication.Info alias Spark.Dsl.Extension use Spark.Dsl.Extension, - sections: @dsl, - transformers: [AshAuthentication.Transformer] + sections: dsl(), + transformers: [ + AshAuthentication.Transformer, + AshAuthentication.Strategy.Password.Transformer, + AshAuthentication.Strategy.OAuth2.Transformer, + AshAuthentication.Strategy.Confirmation.Transformer + ] require Ash.Query @@ -171,42 +102,40 @@ defmodule AshAuthentication do @doc """ Find all resources which support authentication for a given OTP application. - Returns a map where the key is the authentication provider, and the values are - lists of api/resource pairs. + Returns a list of resource modules. + + ## Example + + iex> authenticated_resources(:ash_authentication) + [Example.User] - This is primarily useful for introspection, but also allows us to simplify - token lookup. """ - @spec authenticated_resources(atom) :: [resource_config] + @spec authenticated_resources(atom) :: [Resource.t()] def authenticated_resources(otp_app) do otp_app |> Application.get_env(:ash_apis, []) |> Stream.flat_map(&Api.Info.resources(&1)) - |> Stream.map(&resource_config/1) - |> Stream.reject(&(&1 == :error)) + |> Stream.filter(&(AshAuthentication in Spark.extensions(&1))) |> Enum.to_list() end - def resource_config(resource) do - resource - |> Extension.get_persisted(:authentication) - |> case do - nil -> - :error - - config -> - Map.put(config, :resource, resource) - end - end - @doc """ - Return a subject string for an AshAuthentication resource. + Return a subject string for user. + + This is done by concatenating the resource's subject name with the resource's + primary key field(s) to generate a uri-like string. + + Example: + + iex> build_user(id: "ce7969f9-afa5-474c-bc52-ac23a103cef6") |> user_to_subject() + "user?id=ce7969f9-afa5-474c-bc52-ac23a103cef6" + """ - @spec resource_to_subject(Resource.record()) :: subject - def resource_to_subject(record) do + @spec user_to_subject(Resource.record()) :: subject + def user_to_subject(record) do subject_name = record.__struct__ - |> AshAuthentication.Info.authentication_subject_name!() + |> Info.authentication_subject_name!() record.__struct__ |> Resource.Info.primary_key() @@ -216,30 +145,34 @@ defmodule AshAuthentication do end) end - @doc """ - Given a subject string, attempt to retrieve a resource. - """ - @spec subject_to_resource(subject | URI.t(), resource_config) :: - {:ok, Resource.record()} | {:error, any} - def subject_to_resource(subject, config) when is_binary(subject), - do: subject |> URI.parse() |> subject_to_resource(config) + @doc ~S""" + Given a subject string, attempt to retrieve a user record. - def subject_to_resource(%URI{path: subject_name, query: primary_key} = _subject, config) - when is_map(config) do - with ^subject_name <- to_string(config.subject_name), - {:ok, action_name} <- Info.authentication_get_by_subject_action_name(config.resource) do + iex> %{id: user_id} = build_user() + ...> {:ok, %{id: ^user_id}} = subject_to_user("user?id=#{user_id}", Example.User) + """ + @spec subject_to_user(subject | URI.t(), Resource.t()) :: + {:ok, Resource.record()} | {:error, any} + def subject_to_user(subject, resource) when is_binary(subject), + do: subject |> URI.parse() |> subject_to_user(resource) + + def subject_to_user(%URI{path: subject_name, query: primary_key} = _subject, resource) do + with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource), + ^subject_name <- to_string(resource_subject_name), + {:ok, action_name} <- Info.authentication_get_by_subject_action_name(resource), + {:ok, api} <- Info.authentication_api(resource) do primary_key = primary_key |> URI.decode_query() |> Enum.to_list() - config.resource + resource |> Query.for_read(action_name, %{}) |> Query.filter(^primary_key) - |> config.api.read() + |> api.read() |> case do {:ok, [user]} -> {:ok, user} - _ -> {:error, "Invalid subject"} + _ -> {:error, NotFound.exception([])} end end end diff --git a/lib/ash_authentication/bcrypt_provider.ex b/lib/ash_authentication/bcrypt_provider.ex index 9acdcdb..beec39a 100644 --- a/lib/ash_authentication/bcrypt_provider.ex +++ b/lib/ash_authentication/bcrypt_provider.ex @@ -6,6 +6,12 @@ defmodule AshAuthentication.BcryptProvider do @doc """ Given some user input as a string, convert it into it's hashed form using `Bcrypt`. + + ## Example + + iex> {:ok, hashed} = hash("Marty McFly") + ...> String.starts_with?(hashed, "$2b$04$") + true """ @impl true @spec hash(String.t()) :: {:ok, String.t()} | :error @@ -14,6 +20,12 @@ defmodule AshAuthentication.BcryptProvider do @doc """ Check if the user input matches the hash. + + ## Example + + iex> valid?("Marty McFly", "$2b$04$qgacrnrAJz8aPwaVQiGJn.PvryldV.NfOSYYvF/CZAGgMvvzhIE7S") + true + """ @impl true @spec valid?(input :: String.t(), hash :: String.t()) :: boolean @@ -22,6 +34,11 @@ defmodule AshAuthentication.BcryptProvider do @doc """ Simulate a password check to help avoid timing attacks. + + ## Example + + iex> simulate() + false """ @impl true @spec simulate :: false diff --git a/lib/ash_authentication/confirmation.ex b/lib/ash_authentication/confirmation.ex deleted file mode 100644 index b8bcac8..0000000 --- a/lib/ash_authentication/confirmation.ex +++ /dev/null @@ -1,225 +0,0 @@ -defmodule AshAuthentication.Confirmation do - @default_lifetime_days 3 - - @dsl [ - %Spark.Dsl.Section{ - name: :confirmation, - describe: "User confirmation behaviour", - schema: [ - token_lifetime: [ - type: :pos_integer, - doc: """ - How long should the confirmation token be valid, in hours. - - Defaults to #{@default_lifetime_days} days. - """, - default: @default_lifetime_days * 24 - ], - monitor_fields: [ - type: {:list, :atom}, - doc: """ - A list of fields to monitor for changes (eg `[:email, :phone_number]`). - """, - 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? - """, - default: true - ], - confirm_on_update?: [ - type: :boolean, - doc: """ - Generate and send a confirmation token when a resource is changed? - """, - 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 confirmation token and the changeset updated to not make the requested change. When the token is confirmed, the change will be applied. - """, - default: false - ], - sender: [ - type: - {:spark_function_behaviour, AshAuthentication.Sender, - {AshAuthentication.SenderFunction, 2}}, - 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. - - See `AshAuthentication.Sender` for more information. - """, - required: true - ], - confirm_action_name: [ - type: :atom, - doc: """ - The name of the action to use when performing confirmation. - """, - default: :confirm - ] - ] - } - ] - - @moduledoc """ - Add a confirmation steps to creates and updates. - - This extension provides a mechanism to force users to confirm some of their - details upon create as in your typical "email confirmation" flow. - - ## Senders - - You can set the DSL's `sender` key to be either a three-arity anonymous - function or a module which implements the `AshAuthentication.Sender` - behaviour. This callback can be used to send confirmation instructions to the - user via the system of your choice. See `AshAuthentication.Sender` for more - information. - - ## Usage - - ```elixir - defmodule MyApp.Accounts.Users do - use Ash.Resource, extensions: [AshAuthentication.Confirmation] - - attributes do - uuid_primary_key :id - attribute :email, :ci_string, allow_nil?: false - end - - confirmation do - monitor_fields [:email] - end - end - ``` - - ## Endpoints - - A confirmation can be sent to either the `request` or `callback` endpoints. - The only required parameter is `"confirm"` which should contain the - confirmation token. - - ## DSL Documentation - - ### Index - - #{Spark.Dsl.Extension.doc_index(@dsl)} - - ### Docs - - #{Spark.Dsl.Extension.doc(@dsl)} - """ - - use Spark.Dsl.Extension, - sections: @dsl, - transformers: [AshAuthentication.Confirmation.Transformer] - - use AshAuthentication.Provider - - alias Ash.{Changeset, Resource} - alias AshAuthentication.{Confirmation, Jwt} - - @doc """ - Generate a confirmation token for the changes in the changeset. - - ## Example - - iex> changeset = Ash.Changeset.for_create(MyApp.Accounts.User, :register, %{"email" => "marty@myfly.me", # ... }) - ...> confirmation_token_for(changeset) - {:ok, "abc123"} - """ - @spec confirmation_token_for(Changeset.t(), Resource.record()) :: - {:ok, String.t()} | {:error, any} - def confirmation_token_for(changeset, user) when changeset.resource == user.__struct__ do - resource = changeset.resource - - with true <- enabled?(resource), - {:ok, monitored_fields} <- Confirmation.Info.monitor_fields(resource), - changes <- get_changes(changeset, monitored_fields), - {:ok, action} <- Confirmation.Info.confirm_action_name(resource), - {:ok, lifetime} <- Confirmation.Info.token_lifetime(resource), - {:ok, token, _claims} <- - Jwt.token_for_record(user, %{"act" => action, "chg" => changes}, - token_lifetime: lifetime - ) do - {:ok, token} - else - {:error, reason} -> {:error, reason} - _ -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"} - end - end - - defp get_changes(changeset, monitored_fields) do - monitored_fields - |> Enum.filter(&Changeset.changing_attribute?(changeset, &1)) - |> Enum.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))}) - |> Map.new() - end - - @doc """ - Confirm a creation or change. - - ## Example - - iex> confirm(MyApp.Accounts.User, %{"confirm" => "abc123"}) - {:ok, user} - """ - @spec confirm(Resource.t(), params) :: {:ok, Resource.record()} | {:error, any} - when params: %{required(String.t()) => String.t()} - def confirm(resource, params) do - with true <- enabled?(resource), - {:ok, token} <- Map.fetch(params, "confirm"), - {:ok, %{"sub" => subject}} <- Jwt.peek(token), - config <- AshAuthentication.resource_config(resource), - {:ok, user} <- AshAuthentication.subject_to_resource(subject, config), - {:ok, action} <- Confirmation.Info.confirm_action_name(resource), - {:ok, api} <- AshAuthentication.Info.authentication_api(resource) do - user - |> Changeset.for_update(action, %{"confirm" => token}) - |> api.update() - else - false -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"} - {:ok, _} -> {:error, "Invalid confirmation token"} - :error -> {:error, "Invalid confirmation token"} - {:error, reason} -> {:error, reason} - end - end - - @doc """ - Handle the callback phase. - - Handles confirmation via the same endpoint. - """ - @impl true - defdelegate callback_plug(conn, opts), to: Confirmation.Plug, as: :handle - - @doc """ - Handle the request phase. - - Handles confirmation via the same endpoint. - """ - @impl true - defdelegate request_plug(conn, opts), to: Confirmation.Plug, as: :handle - - @doc false - @impl true - def provides(_), do: "confirm" -end diff --git a/lib/ash_authentication/confirmation/confirm_change.ex b/lib/ash_authentication/confirmation/confirm_change.ex deleted file mode 100644 index 00ff87d..0000000 --- a/lib/ash_authentication/confirmation/confirm_change.ex +++ /dev/null @@ -1,35 +0,0 @@ -defmodule AshAuthentication.Confirmation.ConfirmChange do - @moduledoc """ - Performs a change based on the contents of a confirmation token. - """ - - use Ash.Resource.Change - alias AshAuthentication.{Confirmation.Info, Jwt} - alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change} - - @doc false - @impl true - @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() - def change(changeset, _opts, _) do - changeset - |> Changeset.before_action(fn changeset -> - with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm), - {:ok, %{"act" => token_action, "chg" => changes}, _} <- - Jwt.verify(token, changeset.resource), - {:ok, resource_action} <- Info.confirm_action_name(changeset.resource), - true <- to_string(resource_action) == token_action, - {:ok, allowed_fields} <- Info.monitor_fields(changeset.resource), - {:ok, confirmed_at} <- Info.confirmed_at_field(changeset.resource) do - allowed_changes = - changes - |> Map.take(Enum.map(allowed_fields, &to_string/1)) - - changeset - |> Changeset.change_attributes(allowed_changes) - |> Changeset.change_attribute(confirmed_at, DateTime.utc_now()) - else - _ -> {:error, InvalidArgument.exception(field: :confirm, message: "is not valid")} - end - end) - end -end diff --git a/lib/ash_authentication/confirmation/confirmation_hook_change.ex b/lib/ash_authentication/confirmation/confirmation_hook_change.ex deleted file mode 100644 index b9563df..0000000 --- a/lib/ash_authentication/confirmation/confirmation_hook_change.ex +++ /dev/null @@ -1,88 +0,0 @@ -defmodule AshAuthentication.Confirmation.ConfirmationHookChange do - @moduledoc """ - Triggers a confirmation flow when one of the monitored fields is changed. - - Optionally inhibits changes to monitored fields on update. - """ - - use Ash.Resource.Change - alias AshAuthentication.{Confirmation, Confirmation.Info} - alias Ash.{Changeset, Resource.Change} - - @doc false - @impl true - @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() - def change(changeset, _opts, _context) do - changeset - |> Changeset.before_action(fn changeset -> - options = Info.options(changeset.resource) - - changeset - |> not_confirm_action(options) - |> should_confirm_action_type(options) - |> monitored_field_changing(options) - |> changes_would_be_valid() - |> maybe_inhibit_updates(options) - |> maybe_perform_confirmation(options, changeset) - end) - end - - defp not_confirm_action(changeset, options) - when changeset.action != options.confirm_action_name, - do: changeset - - defp not_confirm_action(_changeset, _options), do: nil - - defp should_confirm_action_type(changeset, options) - when changeset.action_type == :create and options.confirm_on_create?, - do: changeset - - defp should_confirm_action_type(changeset, options) - when changeset.action_type == :update and options.confirm_on_update?, - do: changeset - - defp should_confirm_action_type(_changeset, _options), do: nil - - defp monitored_field_changing(nil, _options), do: nil - - defp monitored_field_changing(changeset, options) do - if Enum.any?(options.monitor_fields, &Changeset.changing_attribute?(changeset, &1)), - do: changeset, - else: nil - end - - defp changes_would_be_valid(changeset) when changeset.valid?, do: changeset - defp changes_would_be_valid(_), do: nil - - defp maybe_inhibit_updates(changeset, options) - when changeset.action_type == :update and options.inhibit_updates? do - options.monitor_fields - |> Enum.reduce(changeset, &Changeset.clear_change(&2, &1)) - end - - defp maybe_inhibit_updates(changeset, _options), do: changeset - - defp maybe_perform_confirmation(nil, _options, original_changeset), do: original_changeset - - defp maybe_perform_confirmation(changeset, options, original_changeset) do - changeset - |> Changeset.after_action(fn _changeset, user -> - original_changeset - |> Confirmation.confirmation_token_for(user) - |> case do - {:ok, token} -> - {sender, send_opts} = options.sender - sender.send(user, token, send_opts) - - metadata = - user.__metadata__ - |> Map.put(:confirmation_token, token) - - {:ok, %{user | __metadata__: metadata}} - - _ -> - {:ok, user} - end - end) - end -end diff --git a/lib/ash_authentication/confirmation/html.ex b/lib/ash_authentication/confirmation/html.ex deleted file mode 100644 index 628cdfb..0000000 --- a/lib/ash_authentication/confirmation/html.ex +++ /dev/null @@ -1,65 +0,0 @@ -defmodule AshAuthentication.Confirmation.Html do - @moduledoc """ - Renders a very basic form for handling a confirmation token. - - These are mainly used for testing, and you should instead write your own or - use the widgets in `ash_authentication_phoenix`. - """ - - require EEx - alias AshAuthentication.Confirmation - - EEx.function_from_string( - :defp, - :render, - ~s""" -
-
- <%= if @legend do %><%= @legend %><% end %> - -
- -
-
- """, - [:assigns] - ) - - @defaults [method: "POST", legend: "Confirm"] - - @type options :: [method_option | action_option] - - @typedoc """ - The HTTP method used to submit the form. - - Defaults to `#{inspect(Keyword.get(@defaults, :method))}`. - """ - @type method_option :: {:method, String.t()} - - @typedoc """ - The path/URL to which the form should be submitted. - """ - @type action_option :: {:action, String.t()} - - @doc false - @spec callback(module, options) :: String.t() - def callback(_module, _options), do: "" - - @doc """ - Render a basic HTML confirmation form. - """ - @spec request(module, options) :: String.t() - def request(resource, options) do - resource - |> build_assigns(options) - |> render() - end - - defp build_assigns(resource, options) do - @defaults - |> Keyword.merge(options) - |> Map.new() - |> Map.merge(Confirmation.Info.options(resource)) - |> Map.merge(AshAuthentication.Info.authentication_options(resource)) - end -end diff --git a/lib/ash_authentication/confirmation/info.ex b/lib/ash_authentication/confirmation/info.ex deleted file mode 100644 index f2cc4a0..0000000 --- a/lib/ash_authentication/confirmation/info.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule AshAuthentication.Confirmation.Info do - @moduledoc """ - Generated configuration functions based on a resource's DSL configuration. - """ - - use AshAuthentication.InfoGenerator, - extension: AshAuthentication.Confirmation, - sections: [:confirmation] -end diff --git a/lib/ash_authentication/confirmation/plug.ex b/lib/ash_authentication/confirmation/plug.ex deleted file mode 100644 index 2d50a6c..0000000 --- a/lib/ash_authentication/confirmation/plug.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule AshAuthentication.Confirmation.Plug do - @moduledoc """ - Handlers for incoming HTTP requests. - """ - - import AshAuthentication.Plug.Helpers, only: [private_store: 2] - alias AshAuthentication.Confirmation - alias Plug.Conn - - @doc """ - Handle an inbound confirmation request. - """ - @spec handle(Conn.t(), any) :: Conn.t() - def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do - case Confirmation.confirm(config.resource, params) do - {:ok, user} -> - private_store(conn, {:success, user}) - - {:error, reason} -> - private_store(conn, {:failure, reason}) - end - end -end diff --git a/lib/ash_authentication/confirmation/transformer.ex b/lib/ash_authentication/confirmation/transformer.ex deleted file mode 100644 index c26bc00..0000000 --- a/lib/ash_authentication/confirmation/transformer.ex +++ /dev/null @@ -1,222 +0,0 @@ -defmodule AshAuthentication.Confirmation.Transformer do - @moduledoc """ - The Confirmation transformer. - - Scans the resource and checks that all the fields and actions needed are present. - """ - use Spark.Dsl.Transformer - - alias AshAuthentication.Confirmation.{ - ConfirmationHookChange, - ConfirmChange, - Info - } - - alias Ash.{Resource, Type} - alias AshAuthentication.{GenerateTokenChange, Sender} - alias Spark.{Dsl.Transformer, Error.DslError} - - import AshAuthentication.Utils - import AshAuthentication.Validations - import AshAuthentication.Validations.Action - import AshAuthentication.Validations.Attribute - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - with :ok <- validate_extension(dsl_state, AshAuthentication), - :ok <- validate_token_generation_enabled(dsl_state), - {:ok, {sender, _opts}} <- Info.sender(dsl_state), - :ok <- validate_behaviour(sender, Sender), - :ok <- validate_monitor_fields(dsl_state), - {:ok, action_name} <- Info.confirm_action_name(dsl_state), - {:ok, dsl_state} <- - maybe_build_action(dsl_state, action_name, &build_confirm_action(&1, action_name)), - :ok <- validate_confirm_action(dsl_state, action_name), - {:ok, confirmed_at} <- Info.confirmed_at_field(dsl_state), - {:ok, dsl_state} <- - maybe_build_attribute( - dsl_state, - confirmed_at, - &build_confirmed_at_attribute(&1, confirmed_at) - ), - :ok <- validate_confirmed_at_attribute(dsl_state), - {:ok, dsl_state} <- maybe_build_change(dsl_state, ConfirmationHookChange) do - authentication = - Transformer.get_persisted(dsl_state, :authentication) - |> Map.update( - :providers, - [AshAuthentication.Confirmation], - &[AshAuthentication.Confirmation | &1] - ) - - dsl_state = - dsl_state - |> Transformer.persist(:authentication, authentication) - - {:ok, dsl_state} - else - :error -> {:error, "Configuration error"} - {:error, reason} -> {:error, reason} - end - end - - @doc false - @impl true - @spec after?(module) :: boolean - def after?(AshAuthentication.Transformer), do: true - def after?(_), do: false - - @doc false - @impl true - @spec before?(module) :: boolean - def before?(Resource.Transformers.DefaultAccept), do: true - def before?(_), do: false - - defp validate_confirmed_at_attribute(dsl_state) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, field_name} <- Info.confirmed_at_field(dsl_state), - {:ok, attribute} <- find_attribute(dsl_state, field_name), - :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), - :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]), - :ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do - :ok - else - :error -> - {:error, - DslError.exception( - path: [:confirmation], - message: "The `confirmed_at_field` option must be set." - )} - - {:error, reason} -> - {:error, reason} - end - end - - defp validate_monitor_fields(dsl_state) do - case Info.monitor_fields(dsl_state) do - {:ok, [_ | _] = fields} -> - Enum.reduce_while(fields, :ok, &validate_monitored_field_reducer(dsl_state, &1, &2)) - - _ -> - {:error, - DslError.exception( - path: [:confirmation], - message: - "The `AshAuthentication.Confirmation` extension requires at least one monitored field to be configured." - )} - end - end - - defp validate_monitored_field_reducer(dsl_state, field, _) do - case validate_monitored_field(dsl_state, field) do - :ok -> {:cont, :ok} - {:error, reason} -> {:halt, {:error, reason}} - end - end - - defp validate_monitored_field(dsl_state, field) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, attribute} <- find_attribute(dsl_state, field), - :ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do - maybe_validate_eager_checking(dsl_state, field, resource) - end - end - - defp maybe_validate_eager_checking(dsl_state, field, resource) do - if Info.inhibit_updates?(dsl_state) do - dsl_state - |> Resource.Info.identities() - |> Enum.find(&(&1.keys == [field])) - |> case do - %{eager_check_with: nil} -> - {:error, - DslError.exception( - path: [:identities, :identity], - message: - "The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated." - )} - - _ -> - :ok - end - else - :ok - end - end - - defp build_confirm_action(dsl_state, action_name) do - with {:ok, fields} <- Info.monitor_fields(dsl_state) do - arguments = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument, - name: :confirm, - type: Type.String, - allow_nil?: false - ) - ] - - changes = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, - change: ConfirmChange - ), - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, - change: GenerateTokenChange - ) - ] - - Transformer.build_entity(Resource.Dsl, [:actions], :update, - name: action_name, - accept: fields, - arguments: arguments, - changes: changes - ) - end - end - - defp maybe_build_attribute(dsl_state, attribute_name, builder) do - with {:error, _} <- find_attribute(dsl_state, attribute_name), - {:ok, attribute} <- builder.(dsl_state) do - {:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)} - else - {:ok, attribute} when is_struct(attribute, Resource.Attribute) -> {:ok, dsl_state} - {:error, reason} -> {:error, reason} - end - end - - defp build_confirmed_at_attribute(_dsl_state, attribute_name) do - Transformer.build_entity(Resource.Dsl, [:attributes], :attribute, - name: attribute_name, - type: Type.UtcDatetimeUsec, - allow_nil?: true, - writable?: true - ) - end - - defp maybe_build_change(dsl_state, change_module) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - changes <- Resource.Info.changes(resource), - false <- change_module in changes, - {:ok, change} <- - Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do - {:ok, Transformer.add_entity(dsl_state, [:changes], change)} - else - true -> {:ok, dsl_state} - {:error, reason} -> {:error, reason} - end - end - - defp validate_confirm_action(dsl_state, action_name) do - with {:ok, action} <- validate_action_exists(dsl_state, action_name), - :ok <- validate_action_has_change(action, ConfirmChange), - :ok <- validate_action_argument_option(action, :confirm, :type, [Type.String]) do - validate_action_argument_option(action, :confirm, :allow_nil?, [false]) - end - end -end diff --git a/lib/ash_authentication/dsl.ex b/lib/ash_authentication/dsl.ex new file mode 100644 index 0000000..95af160 --- /dev/null +++ b/lib/ash_authentication/dsl.ex @@ -0,0 +1,532 @@ +defmodule AshAuthentication.Dsl do + @moduledoc false + + ### + ### Only exists to move the DSL out of `AshAuthentication` to aid readability. + ### + + import AshAuthentication.Utils, only: [to_sentence: 2] + import Joken.Signer, only: [algorithms: 0] + + alias Ash.{Api, Resource} + + alias AshAuthentication.{ + Strategy.Confirmation, + Strategy.OAuth2, + Strategy.Password + } + + alias Spark.{ + Dsl.Entity, + Dsl.Section, + OptionsHelpers + } + + @shared_strategy_options [ + name: [ + type: :atom, + doc: """ + Uniquely identifies the strategy. + """, + required: true + ] + ] + + @default_lifetime_days 14 + + @secret_type {:or, + [ + {:spark_function_behaviour, AshAuthentication.Secret, + {AshAuthentication.SecretFunction, 2}}, + :string + ]} + + @secret_doc """ + 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 + [ + %Section{ + name: :authentication, + describe: "Configure authentication for this resource", + schema: [ + subject_name: [ + type: :atom, + doc: """ + The subject name is used anywhere that a short version of your + resource name is needed, eg: + + - generating token claims, + - generating routes, + - form parameter nesting. + + This needs to be unique system-wide and if not set will be inferred + from the resource name (ie `MyApp.Accounts.User` will have a subject + name of `user`). + """ + ], + api: [ + type: {:behaviour, Api}, + doc: """ + The name of the Ash API to use to access this resource when + doing anything authenticaiton related. + """, + required: true + ], + get_by_subject_action_name: [ + type: :atom, + doc: """ + The name of the read action used to retrieve records. + + Used internally by `AshAuthentication.subject_to_user/2`. If the + action doesn't exist, one will be generated for you. + """, + default: :get_by_subject + ] + ], + sections: [ + %Section{ + name: :tokens, + describe: "Configure JWT settings for this resource", + schema: [ + enabled?: [ + type: :boolean, + doc: """ + Should JWTs be generated by this resource? + """, + default: false + ], + signing_algorithm: [ + type: :string, + doc: """ + The algorithm to use for token signing. + + Available signing algorithms are; + #{to_sentence(algorithms(), final: "and")}. + """, + default: hd(algorithms()) + ], + token_lifetime: [ + type: :pos_integer, + doc: """ + How long a token should be valid, in hours. + + Since refresh tokens are not yet supported, you should + probably set this to a reasonably long time to ensure + a good user experience. + + Defaults to #{@default_lifetime_days} days. + """, + default: @default_lifetime_days * 24 + ], + revocation_resource: [ + type: {:behaviour, Resource}, + doc: """ + The resource used to store token revocation information. + + If token generation is enabled for this resource, we need a place to + store revocation information. This option is the name of an Ash + Resource which has the `AshAuthentication.TokenRevocation` extension + present. + """ + ] + ] + }, + %Section{ + name: :strategies, + describe: "Configure authentication strategies on this resource", + entities: [ + strategy(:password), + strategy(:oauth2), + strategy(:confirmation) + ] + } + ] + } + ] + end + + 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: [:name], + hide: [:name], + target: Password, + schema: + OptionsHelpers.merge_schemas( + [ + 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 + ] + ], + @shared_strategy_options, + "Shared options" + ), + entities: [resettable: [Password.Resettable.entity()]] + } + end + + def strategy(:oauth2) do + %Entity{ + name: :oauth2, + describe: "OAuth2 authentication", + args: [:name], + target: OAuth2, + 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_path: [ + type: @secret_type, + doc: """ + The API path to the OAuth2 authorize endpoint. + + Relative to the value of `site`. + If not set, it defaults to `#{inspect(OAuth2.Default.default(:authorize_path))}`. + + #{@secret_doc} + + Example: + + ```elixir + authorize_path fn _, _ -> {:ok, "/authorize"} end + ``` + """, + required: false + ], + token_path: [ + type: @secret_type, + doc: """ + The API path to access the token endpoint. + + Relative to the value of `site`. + If not set, it defaults to `#{inspect(OAuth2.Default.default(:token_path))}`. + + #{@secret_doc} + + Example: + + ```elixir + token_path fn _, _ -> {:ok, "/oauth_token"} end + ``` + """, + required: false + ], + user_path: [ + type: @secret_type, + doc: """ + The API path to access the user endpoint. + + Relative to the value of `site`. + If not set, it defaults to `#{inspect(OAuth2.Default.default(:user_path))}`. + + #{@secret_doc} + + Example: + + ```elixir + user_path fn _, _ -> {:ok, "/userinfo"} end + ``` + """, + required: false + ], + 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" + ) + } + end + + def strategy(:confirmation) do + %Entity{ + name: :confirmation, + describe: "User confirmation flow", + target: Confirmation, + schema: Confirmation.schema() + } + end +end diff --git a/lib/ash_authentication/errors/authentication_failed.ex b/lib/ash_authentication/errors/authentication_failed.ex index 685e33d..ecbb3a1 100644 --- a/lib/ash_authentication/errors/authentication_failed.ex +++ b/lib/ash_authentication/errors/authentication_failed.ex @@ -5,6 +5,8 @@ defmodule AshAuthentication.Errors.AuthenticationFailed do use Ash.Error.Exception def_ash_error([], class: :forbidden) + @type t :: Exception.t() + defimpl Ash.ErrorKind do @moduledoc false def id(_), do: Ecto.UUID.generate() diff --git a/lib/ash_authentication/errors/invalid_token.ex b/lib/ash_authentication/errors/invalid_token.ex new file mode 100644 index 0000000..d953463 --- /dev/null +++ b/lib/ash_authentication/errors/invalid_token.ex @@ -0,0 +1,15 @@ +defmodule AshAuthentication.Errors.InvalidToken do + @moduledoc """ + An invalid token was presented. + """ + use Ash.Error.Exception + def_ash_error([:type], class: :forbidden) + + defimpl Ash.ErrorKind do + @moduledoc false + def id(_), do: Ecto.UUID.generate() + def code(_), do: "invalid_token" + def message(%{type: nil}), do: "Invalid token" + def message(%{type: type}), do: "Invalid #{type} token" + end +end diff --git a/lib/ash_authentication/errors/missing_secret.ex b/lib/ash_authentication/errors/missing_secret.ex new file mode 100644 index 0000000..19bc30f --- /dev/null +++ b/lib/ash_authentication/errors/missing_secret.ex @@ -0,0 +1,17 @@ +defmodule AshAuthentication.Errors.MissingSecret do + @moduledoc """ + A secret is now missing. + """ + use Ash.Error.Exception + def_ash_error([:resource], class: :forbidden) + + defimpl Ash.ErrorKind do + @moduledoc false + def id(_), do: Ecto.UUID.generate() + def code(_), do: "missing_secret" + + def message(%{path: path, resource: resource}), + do: + "Secret for `#{Enum.join(path, ".")}` on the `#{inspect(resource)}` resource is not accessible." + end +end diff --git a/lib/ash_authentication/generate_token_change.ex b/lib/ash_authentication/generate_token_change.ex index d9a78a1..9543cef 100644 --- a/lib/ash_authentication/generate_token_change.ex +++ b/lib/ash_authentication/generate_token_change.ex @@ -13,8 +13,8 @@ defmodule AshAuthentication.GenerateTokenChange do def change(changeset, _opts, _) do changeset |> Changeset.after_action(fn _changeset, result -> - if Info.tokens_enabled?(result.__struct__) do - {:ok, token, _claims} = Jwt.token_for_record(result) + if Info.authentication_tokens_enabled?(result.__struct__) do + {:ok, token, _claims} = Jwt.token_for_user(result) {:ok, %{result | __metadata__: Map.put(result.__metadata__, :token, token)}} else {:ok, result} diff --git a/lib/ash_authentication/info.ex b/lib/ash_authentication/info.ex index 150c3c8..b488b49 100644 --- a/lib/ash_authentication/info.ex +++ b/lib/ash_authentication/info.ex @@ -5,6 +5,33 @@ defmodule AshAuthentication.Info do use AshAuthentication.InfoGenerator, extension: AshAuthentication, - sections: [:authentication, :tokens], - prefix?: true + sections: [:authentication] + + @doc """ + Retrieve a named strategy from a resource. + """ + @spec strategy(dsl_or_resource :: map | module, atom) :: {:ok, strategy} | :error + when strategy: struct + def strategy(dsl_or_resource, name) do + dsl_or_resource + |> authentication_strategies() + |> Enum.find_value(:error, fn strategy -> + if strategy.name == name, do: {:ok, strategy} + end) + end + + @doc """ + Retrieve a named strategy from a resource (raising version). + """ + @spec strategy!(dsl_or_resource :: map | module, atom) :: strategy | no_return + when strategy: struct + def strategy!(dsl_or_resource, name) do + case strategy(dsl_or_resource, name) do + {:ok, strategy} -> + strategy + + :error -> + raise "No strategy named `#{inspect(name)}` found on resource `#{inspect(dsl_or_resource)}`" + end + end end diff --git a/lib/ash_authentication/info_generator.ex b/lib/ash_authentication/info_generator.ex index ec466d9..c0a25b8 100644 --- a/lib/ash_authentication/info_generator.ex +++ b/lib/ash_authentication/info_generator.ex @@ -12,14 +12,13 @@ defmodule AshAuthentication.InfoGenerator do ``` """ - @type options :: [{:extension, module} | {:sections, [atom]} | {:prefix?, boolean}] + @type options :: [{:extension, module} | {:sections, [atom]}] @doc false @spec __using__(options) :: Macro.t() defmacro __using__(opts) do extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__) sections = Keyword.get(opts, :sections, []) - prefix? = Keyword.get(opts, :prefix?, false) quote do require AshAuthentication.InfoGenerator @@ -27,14 +26,17 @@ defmodule AshAuthentication.InfoGenerator do AshAuthentication.InfoGenerator.generate_config_functions( unquote(extension), - unquote(sections), - unquote(prefix?) + unquote(sections) ) AshAuthentication.InfoGenerator.generate_options_functions( unquote(extension), - unquote(sections), - unquote(prefix?) + unquote(sections) + ) + + AshAuthentication.InfoGenerator.generate_entity_functions( + unquote(extension), + unquote(sections) ) end end @@ -44,17 +46,14 @@ defmodule AshAuthentication.InfoGenerator do which returns a map of all configured options for a resource (including defaults). """ - @spec generate_options_functions(module, [atom], boolean) :: Macro.t() - defmacro generate_options_functions(_extension, sections, false) when length(sections) > 1, - do: raise("Cannot generate options functions for more than one section without prefixes.") - - defmacro generate_options_functions(extension, sections, prefix?) do - for {section, options} <- extension_sections_to_list(extension, sections) do - function_name = if prefix?, do: :"#{section}_options", else: :options + @spec generate_options_functions(module, [atom]) :: Macro.t() + defmacro generate_options_functions(extension, sections) do + for {path, options} <- extension_sections_to_option_list(extension, sections) do + function_name = :"#{Enum.join(path, "_")}_options" quote location: :keep do @doc """ - #{unquote(section)} DSL options + #{unquote(Enum.join(path, "."))} DSL options Returns a map containing the and any configured or default values. """ @@ -66,7 +65,7 @@ defmodule AshAuthentication.InfoGenerator do |> Stream.map(fn option -> value = dsl_or_resource - |> get_opt([option.section], option.name, Map.get(option, :default)) + |> get_opt(option.path, option.name, Map.get(option, :default)) {option.name, value} end) @@ -78,25 +77,66 @@ defmodule AshAuthentication.InfoGenerator do end @doc """ - Given an extension and a list of DSL sections generate individual config - functions for each option. + Given an extension and a list of DSL sections, generate an entities function + which returns a list of entities. """ - @spec generate_config_functions(module, [atom], boolean) :: Macro.t() - defmacro generate_config_functions(extension, sections, prefix?) do - for {_, options} <- extension_sections_to_list(extension, sections) do - for option <- options do - function_name = if prefix?, do: :"#{option.section}_#{option.name}", else: option.name + @spec generate_entity_functions(module, [atom]) :: Macro.t() + defmacro generate_entity_functions(extension, sections) do + entity_paths = + extension.sections() + |> Stream.filter(&(&1.name in sections)) + |> Stream.flat_map(&explode_section([], &1)) + |> Stream.filter(fn {_, section} -> Enum.any?(section.entities) end) + |> Stream.map(&elem(&1, 0)) - option - |> Map.put(:function_name, function_name) - |> generate_config_function() + for path <- entity_paths do + function_name = path |> Enum.join("_") |> String.to_atom() + + quote location: :keep do + @doc """ + #{unquote(Enum.join(path, "."))} DSL entities + """ + @spec unquote(function_name)(dsl_or_resource :: module | map) :: [struct] + def unquote(function_name)(dsl_or_resource) do + import Spark.Dsl.Extension, only: [get_entities: 2] + + get_entities(dsl_or_resource, unquote(path)) + end end end end - defp extension_sections_to_list(extension, sections) do + @doc """ + Given an extension and a list of DSL sections generate individual config + functions for each option. + """ + @spec generate_config_functions(module, [atom]) :: Macro.t() + defmacro generate_config_functions(extension, sections) do + for {_, options} <- extension_sections_to_option_list(extension, sections) do + for option <- options do + generate_config_function(option) + end + end + end + + defp explode_section(path, %{sections: [], name: name} = section), + do: [{path ++ [name], section}] + + defp explode_section(path, %{sections: sections, name: name} = section) do + path = path ++ [name] + + head = [{path, section}] + tail = Stream.flat_map(sections, &explode_section(path, &1)) + + Stream.concat(head, tail) + end + + defp extension_sections_to_option_list(extension, sections) do extension.sections() - |> Stream.map(fn section -> + |> Stream.filter(&(&1.name in sections)) + |> Stream.flat_map(&explode_section([], &1)) + |> Stream.reject(fn {_, section} -> Enum.empty?(section.schema) end) + |> Stream.map(fn {path, section} -> schema = section.schema |> Enum.map(fn {name, opts} -> @@ -106,26 +146,35 @@ defmodule AshAuthentication.InfoGenerator do |> Map.update!(:type, &spec_for_type/1) |> Map.put(:pred?, name |> to_string() |> String.ends_with?("?")) |> Map.put(:name, name) - |> Map.put(:section, section.name) + |> Map.put(:path, path) + |> Map.put( + :function_name, + path + |> Enum.concat([name]) + |> Enum.join("_") + |> String.trim_trailing("?") + |> String.to_atom() + ) end) - {section.name, schema} + {path, schema} end) |> Map.new() - |> Map.take(sections) end defp generate_config_function(%{pred?: true} = option) do + function_name = :"#{option.function_name}?" + quote location: :keep do @doc unquote(option.doc) - @spec unquote(option.function_name)(dsl_or_resource :: module | map) :: + @spec unquote(function_name)(dsl_or_resource :: module | map) :: unquote(option.type) - def unquote(option.function_name)(dsl_or_resource) do + def unquote(function_name)(dsl_or_resource) do import Spark.Dsl.Extension, only: [get_opt: 4] get_opt( dsl_or_resource, - [unquote(option.section)], + unquote(option.path), unquote(option.name), unquote(option.default) ) @@ -144,7 +193,7 @@ defmodule AshAuthentication.InfoGenerator do case get_opt( dsl_or_resource, - [unquote(option.section)], + unquote(option.path), unquote(option.name), unquote(Map.get(option, :default, :error)) ) do @@ -162,7 +211,7 @@ defmodule AshAuthentication.InfoGenerator do case get_opt( dsl_or_resource, - [unquote(option.section)], + unquote(option.path), unquote(option.name), unquote(Map.get(option, :default, :error)) ) do diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex index f13cd7e..f5ad0bb 100644 --- a/lib/ash_authentication/jwt.ex +++ b/lib/ash_authentication/jwt.ex @@ -1,6 +1,6 @@ defmodule AshAuthentication.Jwt do @default_algorithm "HS256" - @default_lifetime_hrs 7 * 24 + @default_lifetime_days 7 @supported_algorithms Joken.Signer.algorithms() import AshAuthentication.Utils, only: [to_sentence: 2] @@ -24,7 +24,7 @@ defmodule AshAuthentication.Jwt do config :ash_authentication, #{inspect(__MODULE__)}, signing_algorithm: #{inspect(@default_algorithm)} signing_secret: "I finally invent something that works!", - token_lifetime: #{@default_lifetime_hrs} # #{@default_lifetime_hrs / 24.0} days + token_lifetime: #{@default_lifetime_days * 24} # #{@default_lifetime_days} days ``` Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}. @@ -34,12 +34,12 @@ defmodule AshAuthentication.Jwt do [`runtime.exs`](https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html#configuration) and read it from the system environment or other secret store. - The default token lifetime is #{@default_lifetime_hrs} and should be specified + The default token lifetime is #{@default_lifetime_days * 24} and should be specified in integer positive hours. """ alias Ash.Resource - alias AshAuthentication.Jwt.Config + alias AshAuthentication.{Info, Jwt.Config} @typedoc """ A string likely to contain a valid JWT. @@ -62,32 +62,35 @@ defmodule AshAuthentication.Jwt do @doc "The default token lifetime" @spec default_lifetime_hrs :: pos_integer - def default_lifetime_hrs, do: @default_lifetime_hrs + def default_lifetime_hrs, do: @default_lifetime_days * 24 @doc """ - Given a record, generate a signed JWT for use while authenticating. + Given a user, generate a signed JWT for use while authenticating. """ - @spec token_for_record(Resource.record(), extra_claims :: %{}, options :: keyword) :: + @spec token_for_user(Resource.record(), extra_claims :: %{}, options :: keyword) :: {:ok, token, claims} | :error - def token_for_record(record, extra_claims \\ %{}, opts \\ []) do - resource = record.__struct__ + def token_for_user(user, extra_claims \\ %{}, opts \\ []) do + resource = user.__struct__ default_claims = Config.default_claims(resource, opts) signer = Config.token_signer(resource, opts) - subject = AshAuthentication.resource_to_subject(record) + subject = AshAuthentication.user_to_subject(user) extra_claims = extra_claims |> Map.put("sub", subject) extra_claims = - case Map.fetch(record.__metadata__, :tenant) do + case Map.fetch(user.__metadata__, :tenant) do {:ok, tenant} -> Map.put(extra_claims, "tenant", to_string(tenant)) :error -> extra_claims end - Joken.generate_and_sign(default_claims, extra_claims, signer) + case Joken.generate_and_sign(default_claims, extra_claims, signer) do + {:ok, token, claims} -> {:ok, token, claims} + {:error, _reason} -> :error + end end @doc """ @@ -99,11 +102,10 @@ defmodule AshAuthentication.Jwt do @doc """ Given a token, verify it's signature and validate it's claims. """ - @spec verify(token, Ash.Resource.t() | module) :: - {:ok, claims, AshAuthentication.resource_config()} | :error + @spec verify(token, Resource.t() | atom) :: {:ok, claims, Resource.t()} | :error def verify(token, otp_app_or_resource) do if function_exported?(otp_app_or_resource, :spark_is, 0) && - otp_app_or_resource.spark_is() == Ash.Resource do + otp_app_or_resource.spark_is() == Resource do verify_for_resource(token, otp_app_or_resource) else verify_for_otp_app(token, otp_app_or_resource) @@ -111,24 +113,23 @@ defmodule AshAuthentication.Jwt do end defp verify_for_resource(token, resource) do - with config <- AshAuthentication.resource_config(resource), - signer <- Config.token_signer(resource), + with signer <- Config.token_signer(resource), {:ok, claims} <- Joken.verify(token, signer), defaults <- Config.default_claims(resource), - {:ok, claims} <- Joken.validate(defaults, claims, config) do - {:ok, claims, config} + {:ok, claims} <- Joken.validate(defaults, claims, resource) do + {:ok, claims, resource} else _ -> :error end end defp verify_for_otp_app(token, otp_app) do - with {:ok, config} <- token_to_resource(token, otp_app), - signer <- Config.token_signer(config.resource), + with {:ok, resource} <- token_to_resource(token, otp_app), + signer <- Config.token_signer(resource), {:ok, claims} <- Joken.verify(token, signer), - defaults <- Config.default_claims(config.resource), - {:ok, claims} <- Joken.validate(defaults, claims, config) do - {:ok, claims, config} + defaults <- Config.default_claims(resource), + {:ok, claims} <- Joken.validate(defaults, claims, resource) do + {:ok, claims, resource} else _ -> :error end @@ -142,21 +143,23 @@ defmodule AshAuthentication.Jwt do This function *does not* validate the token, so don't rely on it for authentication or authorisation. """ - @spec token_to_resource(token, module) :: {:ok, AshAuthentication.resource_config()} | :error + @spec token_to_resource(token, module) :: {:ok, Resource.t()} | :error def token_to_resource(token, otp_app) do with {:ok, %{"sub" => subject}} <- peek(token), %URI{path: subject_name} <- URI.parse(subject) do - config_for_subject_name(subject_name, otp_app) + resource_for_subject_name(subject_name, otp_app) else _ -> :error end end - defp config_for_subject_name(subject_name, otp_app) do + defp resource_for_subject_name(subject_name, otp_app) do otp_app |> AshAuthentication.authenticated_resources() - |> Enum.find_value(:error, fn config -> - if to_string(config.subject_name) == subject_name, do: {:ok, config} + |> Enum.find_value(:error, fn resource -> + with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource), + true <- subject_name == to_string(resource_subject_name), + do: {:ok, resource} end) end end diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex index 75eaa72..ebe5890 100644 --- a/lib/ash_authentication/jwt/config.ex +++ b/lib/ash_authentication/jwt/config.ex @@ -93,9 +93,9 @@ defmodule AshAuthentication.Jwt.Config do resource. Requires that the subject's resource configuration be passed as the validation context. This is automatically done by calling `Jwt.verify/2`. """ - @spec validate_jti(String.t(), any, %{resource: module} | any) :: boolean - def validate_jti(jti, _claims, %{resource: resource}) do - case Info.tokens_revocation_resource(resource) do + @spec validate_jti(String.t(), any, Resource.t() | any) :: boolean + def validate_jti(jti, _claims, resource) when is_atom(resource) do + case Info.authentication_tokens_revocation_resource(resource) do {:ok, revocation_resource} -> TokenRevocation.valid?(revocation_resource, jti) @@ -138,7 +138,7 @@ defmodule AshAuthentication.Jwt.Config do defp config(resource) do config = resource - |> Info.tokens_options() + |> Info.authentication_tokens_options() |> Enum.reject(&is_nil(elem(&1, 1))) :ash_authentication diff --git a/lib/ash_authentication/oauth2_authentication.ex b/lib/ash_authentication/oauth2_authentication.ex deleted file mode 100644 index ec13c6b..0000000 --- a/lib/ash_authentication/oauth2_authentication.ex +++ /dev/null @@ -1,423 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication do - @dsl [ - %Spark.Dsl.Section{ - name: :oauth2_authentication, - describe: """ - Configure generic OAuth2 authentication for this resource. - """, - schema: [ - provider_name: [ - type: :atom, - doc: """ - A short name for the authentication provider. - - Used in routes, etc. - """, - default: :oauth2 - ], - client_id: [ - type: - {:spark_function_behaviour, AshAuthentication.Secret, - {AshAuthentication.SecretFunction, 3}}, - doc: """ - The OAuth2 client ID. - - Takes either a 2..3 arity anonymous function, or a module which - implements the `AshAuthentication.Secret` behaviour. - - See the module documentation for `AshAuthentication.Secret` for more - information. - """, - required: true - ], - site: [ - type: - {:spark_function_behaviour, AshAuthentication.Secret, - {AshAuthentication.SecretFunction, 3}}, - doc: """ - The base URL of the OAuth2 server. - - Takes either a 2..3 arity anonymous function, or a module which - implements the `AshAuthentication.Secret` behaviour. - - See the module documentation for `AshAuthentication.Secret` for more - information. - """, - 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: - {:spark_function_behaviour, AshAuthentication.Secret, - {AshAuthentication.SecretFunction, 3}}, - doc: """ - The OAuth2 client secret. - - Takes either a 2..3 arity anonymous function, or a module which - implements the `AshAuthentication.Secret` behaviour. - - See the module documentation for `AshAuthentication.Secret` for more - information. - - Required if :auth_method is `:client_secret_basic`, `:client_secret_post` or `:client_secret_jwt`. - """, - required: false - ], - authorize_path: [ - type: :string, - doc: "The API path to the OAuth2 authorize endpoint.", - default: "/authorize" - ], - token_path: [ - type: :string, - doc: "The API path to access the token endpoint.", - default: "/oauth/access_token" - ], - user_path: [ - type: :string, - doc: "The API path to access the user endpoint.", - default: "/user" - ], - private_key: [ - type: - {:spark_function_behaviour, AshAuthentication.Secret, - {AshAuthentication.SecretFunction, 3}}, - doc: """ - The private key to use if `:auth_method` is `:private_key_jwt` - - Takes either a 2..3 arity anonymous function, or a module which - implements the `AshAuthentication.Secret` behaviour. - - See the module documentation for `AshAuthentication.Secret` for more - information. - """, - required: false - ], - redirect_uri: [ - type: - {:spark_function_behaviour, AshAuthentication.Secret, - {AshAuthentication.SecretFunction, 3}}, - 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. - - Takes either a 2..3 arity anonymous function, or a module which - implements the `AshAuthentication.Secret` behaviour. - - See the module documentation for `AshAuthentication.Secret` for more information. - """, - 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: """ - """, - default: true - ], - sign_in_enabled?: [ - type: :boolean, - doc: """ - """, - default: false - ], - register_action_name: [ - type: :atom, - doc: ~S""" - The name of the action to use to register a user. - - Because we we don't know the response format of the server, you must - implement your own registration action of the same name. Set to - `false` to disable registration of new users. - - See the "Registration and Sign-in" section of the module - documentation for more information. - - The default is computed from the `provider_name` eg: - `register_with_#{provider_name}`. - """, - required: false - ], - sign_in_action_name: [ - type: :atom, - doc: ~S""" - The name of the action to use to sign in an existing user. - - Because we don't know the response format of the server, you must - implement your own sign-in action of the same name. Set to `false` - to disable signing in of existing users. - - See the "Registration and Sign-in" section of the module - documentation for more information. - - The default is computed from the `provider_name`, eg: - `sign_in_with_#{provider_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.ProviderIdentity` 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 - ] - ] - } - ] - - @moduledoc """ - Authentication using an external OAuth2 server as the source of truth. - - This extension provides support for authenticating to a generic OAuth2 server. - Use this if a service-specific strategy is not available for your - authentication provider. - - ## Usage - - ```elixir - defmodule MyApp.Accounts.User do - use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication] - - attributes do - uuid_primary_key :id - attribute :email, :ci_string, allow_nil? - end - - oauth2_authentication do - client_id fn _, _, _ -> - Application.fetch_env(:my_app, :oauth2_client_id) - end - - client_secret fn _, _, _ -> - Application.fetch_env(:my_app, :oauth2_client_secret) - end - - site fn _, _, _ -> - {:ok, "https://auth.example.com"} - end) - - redirect_uri fn _, _, _ -> - {:ok, "https://localhost:4000/auth"} - end - end - - actions do - create :oauth2_register do - argument :user_info, :map, allow_nil?: false - argument :oauth_tokens, :map, allow_nil?: false - - change AshAuthentication.GenerateTokenChange - change MyApp.RegisterUser - end - end - end - ``` - - ## Identities - - Given that it's possible for a user to be authenticated with more than one - OAuth2 provider, we provide the `AshAuthentication.ProviderIdentity` - extension. This extension dynamically generates a resource which can be used - to keep track of which providers a user has authenticated with, and stores any - tokens they may have in case you wish to make requests to the service on - behalf of the user. - - Additionally, for some providers, the provider identity resource can handle - refreshing of access tokens before they expire. - - ## Registration and Sign-in - - You can operate your OAuth2 authentication in either registration or sign-in - mode. You do this by setting one of either `registration_enabled?` or - `sign_in_enabled?` to `true`. - - ### Registration - - When registration is enabled you will need to define a create action (see the - `register_action_name` option for details). - - This action will be called when a user successfully authenticates with the - remote authentication provider and it will be passed two arguments: - - * `user_info` which contains the [response from the OAuth2 user info - endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse). - * `oauth_tokens` which the [OAuth2 token - response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse). - - Add a change to this action which can use this information to build a user - record, eg: - - ```elixir - create :register_with_oauth2 do - argument :user_info, :map, allow_nil?: false - argument :oauth_tokens, :map, allow_nil?: false - upsert? true - upsert_identity :unique_email - - change fn changeset, _ -> - user_info = Ash.Changeset.get_argument(changeset, :user_info) - - changeset - |> Ash.Changeset.change_attribute(:email, user_info["email"]) - end - end - ``` - - There are likely to be additional change modules required depending on your - configuration options. These will be validated at compile time. - - ### Sign-in - - When registration is disabled, you will need to define a sign-in action (see - the `sign_in_action_name` option for details). - - This action will be called with the same `user_info` and `oauth_tokens` - arguments as the register action. You use this action to query for an - existing user that matches your criteria, eg: - - ```elixir - read :sign_in_with_oauth2 do - argument :user_info, :map, allow_nil?: false - argument :oauth_tokens, :map, allow_nil?: false - prepare AshAuthentication.OAuth2Authentication.SignInPreparation - - filter expr(email == get_path(^arg(:user_info), [:email])) - end - ``` - - ## Endpoints - - This provider provides both `request` and `callback` endpoints to handle both - phases of the request cycle. - - ## DSL Documentation - - ### Index - - #{Spark.Dsl.Extension.doc_index(@dsl)} - - ### Docs - - #{Spark.Dsl.Extension.doc(@dsl)} - """ - - use Spark.Dsl.Extension, - sections: @dsl, - transformers: [AshAuthentication.OAuth2Authentication.Transformer] - - use AshAuthentication.Provider - - alias Ash.Resource - alias Plug.Conn - - @doc """ - The register action. - - See "Registration and Sign-in" above. - """ - @impl true - @spec register_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any} - defdelegate register_action(resource, attributes), to: __MODULE__.Actions, as: :register - - @doc """ - The sign-in action. - - See "Registration and Sign-in" above. - """ - @impl true - @spec sign_in_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any} - defdelegate sign_in_action(resource, attributes), to: __MODULE__.Actions, as: :sign_in - - @doc """ - The request plug. - - Called by the router when a request which can be handled by this provider is - received. - """ - @impl true - @spec request_plug(Conn.t(), any) :: Conn.t() - defdelegate request_plug(conn, config), to: __MODULE__.Plug, as: :request - - @doc """ - The callback plug. - - Called by the router when a user returns from the remote provider. - """ - @impl true - @spec callback_plug(Conn.t(), any) :: Conn.t() - defdelegate callback_plug(conn, config), to: __MODULE__.Plug, as: :callback - - @doc false - @impl true - @spec has_register_step?(Resource.t()) :: boolean - def has_register_step?(_), do: false - - @doc false - @impl true - def provides(resource) do - resource - |> __MODULE__.Info.provider_name!() - |> to_string() - end -end diff --git a/lib/ash_authentication/oauth2_authentication/actions.ex b/lib/ash_authentication/oauth2_authentication/actions.ex deleted file mode 100644 index 934fe22..0000000 --- a/lib/ash_authentication/oauth2_authentication/actions.ex +++ /dev/null @@ -1,76 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.Actions do - @moduledoc """ - Code interface for oauth2 authentication actions. - - Allows you to use the OAuth2 authentication provider without needing to mess - with around with changesets, apis, etc. These functions are delegated to from - within `AshAuthentication.OAuth2Authentication`. - """ - - alias Ash.{Changeset, Query, Resource} - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - - @doc """ - Attempt to register a user based on the `user_info` and `oauth_tokens` from a - completed OAuth2 request. - """ - @spec register(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term} - def register(resource, attributes), - do: register(resource, attributes, OAuth2.Info.registration_enabled?(resource)) - - defp register(resource, attributes, true) do - action_name = OAuth2.Info.register_action_name!(resource) - api = AshAuthentication.Info.authentication_api!(resource) - action = Resource.Info.action(resource, action_name, :create) - - resource - |> Changeset.for_create(action_name, attributes, - upsert?: true, - upsert_identity: action.upsert_identity - ) - |> api.create() - end - - defp register(resource, _attributes, false) do - provider_name = OAuth2.Info.provider_name!(resource) - - {:error, - """ - Registration of new #{provider_name} users is disabled for resource `#{inspect(resource)}`. - - Hint: call `AshAuthentication.OAuth2Authentication.sign_in_action/2` instead. - """} - end - - @doc """ - Attempt to sign in a user based on the `user_info` and `oauth_tokens` from a - completed OAuth2 request. - """ - @spec sign_in(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term} - def sign_in(resource, attributes), - do: sign_in(resource, attributes, OAuth2.Info.sign_in_enabled?(resource)) - - defp sign_in(resource, attributes, true) do - action = OAuth2.Info.sign_in_action_name!(resource) - api = AshAuthentication.Info.authentication_api!(resource) - - resource - |> Query.for_read(action, attributes) - |> api.read() - |> case do - {:ok, [user]} -> {:ok, user} - {:error, reason} -> {:error, reason} - end - end - - defp sign_in(resource, _attributes, false) do - provider_name = OAuth2.Info.provider_name!(resource) - - {:error, - """ - Signing in #{provider_name} users is disabled for resource `#{inspect(resource)}`. - - Hint: call `AshAuthentication.OAuth2Authentication.register_action/2` instead. - """} - end -end diff --git a/lib/ash_authentication/oauth2_authentication/html.ex b/lib/ash_authentication/oauth2_authentication/html.ex deleted file mode 100644 index 94cf77c..0000000 --- a/lib/ash_authentication/oauth2_authentication/html.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.Html do - @moduledoc """ - Renders a very basic sign-in button. - """ - - require EEx - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - - EEx.function_from_string( - :defp, - :render, - ~s""" - <%= @legend %> - """, - [:assigns] - ) - - @doc false - @spec callback(module, keyword) :: String.t() - def callback(_, _), do: "" - - @doc false - @spec request(module, keyword) :: String.t() - def request(resource, options) do - options - |> Map.new() - |> Map.merge(OAuth2.Info.options(resource)) - |> Map.merge(AshAuthentication.Info.authentication_options(resource)) - |> Map.put_new(:legend, "Sign in with #{OAuth2.provides(resource)}") - |> render() - end -end diff --git a/lib/ash_authentication/oauth2_authentication/identity_change.ex b/lib/ash_authentication/oauth2_authentication/identity_change.ex deleted file mode 100644 index 2e8ae10..0000000 --- a/lib/ash_authentication/oauth2_authentication/identity_change.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.IdentityChange do - @moduledoc """ - Updates the identity resource when a user is registered. - """ - - use Ash.Resource.Change - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - alias AshAuthentication.ProviderIdentity - alias Ash.{Changeset, Resource.Change} - import AshAuthentication.Utils, only: [is_falsy: 1] - - @doc false - @impl true - @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() - def change(changeset, _opts, _context) do - identity_resource = OAuth2.Info.identity_resource!(changeset.resource) - maybe_change(changeset, identity_resource) - end - - defp maybe_change(changeset, falsy) when is_falsy(falsy), do: changeset - - defp maybe_change(changeset, identity_resource) do - identity_relationship = OAuth2.Info.identity_relationship_name!(changeset.resource) - provider_name = OAuth2.Info.provider_name!(changeset.resource) - - changeset - |> Changeset.after_action(fn changeset, user -> - identity_resource - |> ProviderIdentity.Actions.upsert(%{ - user_info: Changeset.get_argument(changeset, :user_info), - oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens), - provider: provider_name, - user_id: user.id - }) - |> case do - {:ok, _identity} -> - user - |> changeset.api.load(identity_relationship) - - {:error, reason} -> - {:error, reason} - end - end) - end -end diff --git a/lib/ash_authentication/oauth2_authentication/info.ex b/lib/ash_authentication/oauth2_authentication/info.ex deleted file mode 100644 index 6893eda..0000000 --- a/lib/ash_authentication/oauth2_authentication/info.ex +++ /dev/null @@ -1,88 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.Info do - @moduledoc """ - Generated configuration functions based on a resource's DSL configuration. - """ - - use AshAuthentication.InfoGenerator, - extension: AshAuthentication.OAuth2Authentication, - sections: [:oauth2_authentication] - - alias Ash.Resource - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - - @doc """ - Returns the resource configuration in a format ready for use by `Assent`. - """ - @spec resource_config(Resource.t()) :: {:ok, keyword} | {:error, any} - def resource_config(resource) do - with {:ok, auth_method} <- auth_method(resource), - {:ok, client_id} <- fetch_secret(resource, :client_id), - {:ok, client_secret} <- get_secret(resource, :client_secret), - {:ok, private_key} <- get_secret(resource, :private_key), - {:ok, jwt_algorithm} <- - AshAuthentication.Info.tokens_signing_algorithm(resource), - {:ok, authorization_params} <- authorization_params(resource), - {:ok, redirect_uri} <- fetch_secret(resource, :redirect_uri), - {:ok, site} <- fetch_secret(resource, :site), - {:ok, authorize_path} <- authorize_path(resource), - {:ok, token_path} <- token_path(resource), - {:ok, user_path} <- user_path(resource) do - config = - [ - auth_method: auth_method, - client_id: client_id, - client_secret: client_secret, - private_key: private_key, - jwt_algoirthm: jwt_algorithm, - authorization_params: authorization_params, - redirect_uri: build_redirect_uri(redirect_uri, resource), - site: site, - authorize_url: append_uri_path(site, authorize_path), - token_url: append_uri_path(site, token_path), - user_url: append_uri_path(site, user_path), - http_adapter: Assent.HTTPAdapter.Mint - ] - |> Enum.reject(&is_nil(elem(&1, 1))) - - {:ok, config} - end - end - - defp fetch_secret(resource, secret_name) do - with {:ok, {secret_module, secret_opts}} <- apply(__MODULE__, secret_name, [resource]), - {:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <- - secret_module.secret_for([:oauth2_authentication, secret_name], resource, secret_opts) do - {:ok, secret} - else - _ -> {:error, {:missing_secret, secret_name}} - end - end - - defp get_secret(resource, secret_name) do - case fetch_secret(resource, secret_name) do - {:ok, secret} -> {:ok, secret} - _ -> {:ok, nil} - end - end - - defp build_redirect_uri(base, resource) do - uri = URI.new!(base) - config = AshAuthentication.resource_config(resource) - - path = - Path.join([ - uri.path || "/", - to_string(config.subject_name), - OAuth2.provides(resource), - "callback" - ]) - - %URI{uri | path: path} |> to_string() - end - - defp append_uri_path(base, path) do - uri = URI.new!(base) - path = Path.join(uri.path || "/", path) - %URI{uri | path: path} |> to_string() - end -end diff --git a/lib/ash_authentication/oauth2_authentication/plug.ex b/lib/ash_authentication/oauth2_authentication/plug.ex deleted file mode 100644 index 4cb29ea..0000000 --- a/lib/ash_authentication/oauth2_authentication/plug.ex +++ /dev/null @@ -1,94 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.Plug do - @moduledoc """ - Handlers for incoming OAuth2 HTTP requests. - """ - - import AshAuthentication.Plug.Helpers, only: [private_store: 2] - alias AshAuthentication.Errors.AuthenticationFailed - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - alias Assent.Strategy.OAuth2, as: Strategy - alias Plug.Conn - import Plug.Conn - require Logger - - @doc """ - Perform the request phase of OAuth2. - - Builds a redirection URL based on the provider configuration and redirects the - user to that endpoint. - """ - @spec request(Conn.t(), any) :: Conn.t() - def request(conn, _opts) when is_map(conn.private.authenticator) do - config = conn.private.authenticator - - with {:ok, provider_name} <- OAuth2.Info.provider_name(config.resource), - {:ok, resource_config} <- OAuth2.Info.resource_config(config.resource), - {:ok, %{session_params: session_params, url: url}} <- - Strategy.authorize_url(resource_config) do - conn - |> put_session(session_key(config), session_params) - |> put_resp_header("location", url) - |> send_resp(:found, "Redirecting to #{provider_name}") - else - :error -> - Logger.error( - "Configuration error with OAuth2 configuration for `#{inspect(config.resource)}`" - ) - - conn - - {:error, reason} -> - Logger.error( - "Configuration error with OAuth2 configuration for `#{inspect(config.resource)}`: #{inspect(reason)}`" - ) - - conn - end - end - - @doc """ - Perform the callback phase of OAuth2. - - Responds to a user being redirected back from the remote authentication - provider, and validates the passed options, ultimately registering or - signing-in a user if the authentication was successful. - """ - @spec callback(Conn.t(), any) :: Conn.t() - def callback(conn, _opts) when is_map(conn.private.authenticator) do - config = conn.private.authenticator - - with {:ok, resource_config} <- OAuth2.Info.resource_config(config.resource), - session_key <- session_key(config), - session_params when is_map(session_params) <- get_session(conn, session_key), - conn <- delete_session(conn, session_key), - resource_config <- Assent.Config.put(resource_config, :session_params, session_params), - {:ok, %{user: user, token: token}} <- Strategy.callback(resource_config, conn.params), - {:ok, user} <- register_or_sign_in_user(config, %{user_info: user, oauth_tokens: token}) do - private_store(conn, {:success, user}) - else - {:error, reason} -> private_store(conn, {:failure, reason}) - _ -> conn - end - end - - # We need to temporarily store some information about the request in the - # session so that we can verify that there hasn't been a CSRF-related attack. - defp session_key(config), - do: "#{config.subject_name}/#{config.provider.provides(config.resource)}" - - defp register_or_sign_in_user(config, params) do - registration_enabled? = OAuth2.Info.registration_enabled?(config.resource) - sign_in_enabled? = OAuth2.Info.sign_in_enabled?(config.resource) - - cond do - registration_enabled? -> - OAuth2.register_action(config.resource, params) - - sign_in_enabled? -> - OAuth2.sign_in_action(config.resource, params) - - true -> - {:error, AuthenticationFailed.exception([])} - end - end -end diff --git a/lib/ash_authentication/oauth2_authentication/sign_in_preparation.ex b/lib/ash_authentication/oauth2_authentication/sign_in_preparation.ex deleted file mode 100644 index 7e1359e..0000000 --- a/lib/ash_authentication/oauth2_authentication/sign_in_preparation.ex +++ /dev/null @@ -1,72 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.SignInPreparation do - @moduledoc """ - Prepare a query for sign in - - Performs three main tasks: - - 1. Ensures that there is only one matching user record returned, otherwise - returns an authentication failed error. - 2. Generates an access token if token generation is enabled. - 3. Updates the user identity resource, if one is enabled. - """ - use Ash.Resource.Preparation - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, ProviderIdentity} - alias Ash.{Query, Resource.Preparation} - require Ash.Query - import AshAuthentication.Utils, only: [is_falsy: 1] - - @doc false - @impl true - @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() - def prepare(query, _opts, _context) do - query - |> Query.after_action(fn - query, [user] -> - with {:ok, user} <- maybe_update_identity(user, query) do - {:ok, [maybe_generate_token(user)]} - end - - _, _ -> - {:error, AuthenticationFailed.exception(query: query)} - end) - end - - defp maybe_update_identity(user, query) do - case OAuth2.Info.identity_resource(query.resource) do - {:ok, falsy} when is_falsy(falsy) -> - user - - :error -> - user - - {:ok, resource} -> - identity_relationship = OAuth2.Info.identity_relationship_name!(query.resource) - - resource - |> ProviderIdentity.Actions.upsert(%{ - user_info: Query.get_argument(query, :user_info), - oauth_tokens: Query.get_argument(query, :oauth_tokens), - provider: OAuth2.Info.provider_name!(query.resource), - user_id: user.id - }) - |> case do - {:ok, _identity} -> - user - |> query.api.load(identity_relationship) - - {:error, reason} -> - {:error, reason} - end - end - end - - defp maybe_generate_token(user) do - if AshAuthentication.Info.tokens_enabled?(user.__struct__) do - {:ok, token, _claims} = Jwt.token_for_record(user) - %{user | __metadata__: Map.put(user.__metadata__, :token, token)} - else - user - end - end -end diff --git a/lib/ash_authentication/oauth2_authentication/transformer.ex b/lib/ash_authentication/oauth2_authentication/transformer.ex deleted file mode 100644 index 6a85a8a..0000000 --- a/lib/ash_authentication/oauth2_authentication/transformer.ex +++ /dev/null @@ -1,205 +0,0 @@ -defmodule AshAuthentication.OAuth2Authentication.Transformer do - @moduledoc """ - The OAuth2Authentication Authentication transformer. - - Scans the resource and checks that all the fields and actions needed are - present. - """ - - use Spark.Dsl.Transformer - - alias Ash.Resource - alias AshAuthentication.GenerateTokenChange - alias AshAuthentication.OAuth2Authentication, as: OAuth2 - alias Spark.{Dsl.Transformer, Error.DslError} - - import AshAuthentication.Validations - import AshAuthentication.Validations.Action - import AshAuthentication.Utils - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - with :ok <- validate_extension(dsl_state, AshAuthentication), - {:ok, identity_resource} <- OAuth2.Info.identity_resource(dsl_state), - {:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, identity_resource), - {:ok, dsl_state} <- - maybe_set_action_name(dsl_state, :register_action_name, "register_with_"), - {:ok, register_action_name} <- OAuth2.Info.register_action_name(dsl_state), - registration_enabled? <- OAuth2.Info.registration_enabled?(dsl_state), - :ok <- validate_register_action(dsl_state, register_action_name, registration_enabled?), - {:ok, dsl_state} <- - maybe_set_action_name(dsl_state, :sign_in_action_name, "sign_in_with_"), - {:ok, sign_in_action_name} <- OAuth2.Info.sign_in_action_name(dsl_state), - sign_in_enabled? <- OAuth2.Info.sign_in_enabled?(dsl_state), - :ok <- validate_sign_in_action(dsl_state, sign_in_action_name, sign_in_enabled?), - :ok <- validate_only_one_action_enabled(dsl_state) do - authentication = - Transformer.get_persisted(dsl_state, :authentication) - |> Map.update( - :providers, - [AshAuthentication.OAuth2Authentication], - &[AshAuthentication.OAuth2Authentication | &1] - ) - - dsl_state = - dsl_state - |> Transformer.persist(:authentication, authentication) - - {:ok, dsl_state} - else - {:error, reason} when is_binary(reason) -> - {:error, DslError.exception(path: [:oauth2_authentication], message: reason)} - - {:error, reason} -> - {:error, reason} - - :error -> - {:error, - DslError.exception( - path: [:oauth2_authentication], - message: "Configuration error while validating `oauth2_authentication`." - )} - end - end - - @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?(Resource.Transformers.HasDestinationField), do: true - def before?(Resource.Transformers.SetRelationshipSource), do: true - def before?(Resource.Transformers.ValidateRelationshipAttributes), do: true - def before?(_), do: false - - defp validate_only_one_action_enabled(dsl_state) do - registration_enabled? = OAuth2.Info.registration_enabled?(dsl_state) - sign_in_enabled? = OAuth2.Info.sign_in_enabled?(dsl_state) - - case {registration_enabled?, sign_in_enabled?} do - {true, true} -> - {:error, "Only one of `registration_enabled?` and `sign_in_enabled?` can be set."} - - {false, false} -> - {:error, "One of either `registration_enabled?` and `sign_in_enabled?` must be set."} - - _ -> - :ok - end - end - - defp maybe_set_action_name(dsl_state, option, prefix) do - cfg = OAuth2.Info.options(dsl_state) - - case Map.fetch(cfg, option) do - {:ok, _value} -> - {:ok, dsl_state} - - :error -> - action_name = String.to_atom("#{prefix}#{cfg.provider_name}") - {:ok, Transformer.set_option(dsl_state, [:oauth2_authentication], option, action_name)} - end - end - - defp maybe_build_identity_relationship(dsl_state, falsy) when is_falsy(falsy), - do: {:ok, dsl_state} - - defp maybe_build_identity_relationship(dsl_state, identity_resource) do - with {:ok, identity_relationship} <- OAuth2.Info.identity_relationship_name(dsl_state) do - maybe_build_relationship( - dsl_state, - identity_relationship, - &build_identity_relationship(&1, identity_relationship, identity_resource) - ) - end - end - - defp validate_register_action(_dsl_state, _action_name, false), do: :ok - - defp validate_register_action(dsl_state, action_name, true) do - with {:ok, action} <- validate_action_exists(dsl_state, action_name), - :ok <- validate_action_has_argument(action, :user_info), - :ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]), - :ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]), - :ok <- validate_action_has_argument(action, :oauth_tokens), - :ok <- - validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]), - :ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]), - :ok <- maybe_validate_action_has_token_change(dsl_state, action), - :ok <- validate_field_in_values(action, :upsert?, [true]), - :ok <- - validate_field_with( - action, - :upsert_identity, - &(is_atom(&1) and not is_falsy(&1)), - "Expected `upsert_identity` to be set" - ), - {:ok, identity_resource} <- OAuth2.Info.identity_resource(dsl_state), - :ok <- maybe_validate_action_has_identity_change(action, identity_resource) do - :ok - else - :error -> - {:error, "Unable to validate register action"} - - {:error, reason} when is_binary(reason) -> - {:error, "`#{inspect(action_name)}` action: #{reason}"} - - {:error, reason} -> - {:error, reason} - end - end - - defp validate_sign_in_action(_dsl_state, _action_name, false), do: :ok - - defp validate_sign_in_action(dsl_state, action_name, true) do - with {:ok, action} <- validate_action_exists(dsl_state, action_name), - :ok <- validate_action_has_argument(action, :user_info), - :ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]), - :ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]), - :ok <- validate_action_has_argument(action, :oauth_tokens), - :ok <- - validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]), - :ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]), - :ok <- validate_action_has_preparation(action, OAuth2.SignInPreparation) do - :ok - else - :error -> {:error, "Unable to validate sign in action"} - {:error, reason} -> {:error, reason} - end - end - - defp maybe_validate_action_has_token_change(dsl_state, action) do - if AshAuthentication.Info.tokens_enabled?(dsl_state) do - validate_action_has_change(action, GenerateTokenChange) - else - :ok - end - end - - defp build_identity_relationship(dsl_state, name, destination) do - with {:ok, dest_attr} <- OAuth2.Info.identity_relationship_user_id_attribute(dsl_state) do - Transformer.build_entity(Resource.Dsl, [:relationships], :has_many, - name: name, - destination: destination, - destination_attribute: dest_attr - ) - end - end - - defp maybe_validate_action_has_identity_change(_action, falsy) when is_falsy(falsy), do: :ok - - defp maybe_validate_action_has_identity_change(action, _identity_resource), - do: validate_action_has_change(action, OAuth2.IdentityChange) -end diff --git a/lib/ash_authentication/password_authentication.ex b/lib/ash_authentication/password_authentication.ex deleted file mode 100644 index c0a23f3..0000000 --- a/lib/ash_authentication/password_authentication.ex +++ /dev/null @@ -1,201 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication do - @dsl [ - %Spark.Dsl.Section{ - name: :password_authentication, - describe: """ - Configure password authentication authentication for this resource. - """, - schema: [ - identity_field: [ - type: :atom, - doc: """ - The name of the attribute which uniquely identifies the actor. - - Usually something like `username` or `email_address`. - """, - default: :username - ], - hashed_password_field: [ - type: :atom, - doc: """ - The name of the attribute within which to store the user's password once it has been hashed. - """, - default: :hashed_password - ], - hash_provider: [ - type: {:behaviour, AshAuthentication.HashProvider}, - doc: """ - A module which implements the `AshAuthentication.HashProvider` behaviour. - - Used to provide cryptographic hashing of passwords. - """, - default: AshAuthentication.BcryptProvider - ], - confirmation_required?: [ - type: :boolean, - required: false, - doc: """ - Whether a password confirmation field is required when registering or changing passwords. - """, - default: true - ], - password_field: [ - type: :atom, - doc: """ - The name of the argument used to collect the user's password in plaintext when registering, checking or changing passwords. - """, - default: :password - ], - password_confirmation_field: [ - type: :atom, - doc: """ - The name of the argument used to confirm the user's password in plaintext when registering or changing passwords. - """, - default: :password_confirmation - ], - register_action_name: [ - type: :atom, - doc: "The name to use for the register action", - default: :register - ], - sign_in_action_name: [ - type: :atom, - doc: "The name to use for the sign in action", - default: :sign_in - ] - ] - } - ] - - @moduledoc """ - Authentication using your application as the source of truth. - - This extension provides an authentication mechanism for authenticating with a - username (or other unique identifier) and password. - - ## Usage - - ```elixir - defmodule MyApp.Accounts.User do - use Ash.Resource, extensions: [AshAuthentication.PasswordAuthentication] - - attributes do - uuid_primary_key :id - attribute :username, :ci_string, allow_nil?: false - attribute :hashed_password, :string, allow_nil?: false - end - - password_authentication do - identity_field :username - password_field :password - password_confirmation_field :password_confirmation - hashed_password_field :hashed_password - hash_provider AshAuthentication.BcryptProvider - confirmation_required? true - end - - authentication do - api MyApp.Accounts - end - end - ``` - - ## Endpoints - - This provider routes requests to both the `request` and `callback` endpoints - to the same handler, so either can be used. Requests are differentiated by - the presence of an `action` parameter in the request body. - - ### Examples - - When attempting to register a new user - - ``` - %{"user" => %{ - "action" => "register", - "email" => "marty@mcfly.me", - "password" => "back to 1985", - "password_confirmation" => "back to 1985" - # any additional user fields you wish to accept on creation. - }} - ``` - - When attempting to sign-in a user - - ``` - %{"user" => %{ - "action" => "sign_in", - "email" => "marty@mcfly.me", - "password" => "back to 1985" - }} - ``` - - ## DSL Documentation - - ### Index - - #{Spark.Dsl.Extension.doc_index(@dsl)} - - ### Docs - - #{Spark.Dsl.Extension.doc(@dsl)} - """ - - use Spark.Dsl.Extension, - sections: @dsl, - transformers: [AshAuthentication.PasswordAuthentication.Transformer] - - use AshAuthentication.Provider - - alias Ash.Resource - alias AshAuthentication.PasswordAuthentication - - @doc """ - Attempt to sign in an user of the provided resource type. - - ## Example - - iex> sign_in_action(MyApp.User, %{username: "marty", password: "its_1985"}) - {:ok, #MyApp.User<>} - """ - @impl true - @spec sign_in_action(Resource.t(), map) :: {:ok, struct} | {:error, term} - defdelegate sign_in_action(resource, attributes), - to: PasswordAuthentication.Actions, - as: :sign_in - - @doc """ - Attempt to register an user of the provided resource type. - - ## Example - - iex> register(MyApp.User, %{username: "marty", password: "its_1985", password_confirmation: "its_1985"}) - {:ok, #MyApp.User<>} - """ - @impl true - @spec register_action(Resource.t(), map) :: {:ok, struct} | {:error, term} - defdelegate register_action(resource, attributes), - to: PasswordAuthentication.Actions, - as: :register - - @doc """ - Handle the callback phase. - - Handles both sign-in and registration actions via the same endpoint. - """ - @impl true - defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle - - @doc """ - Handle the request phase. - - Handles both sign-in and registration actions via the same endpoint. - """ - @impl true - defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle - - @doc false - @impl true - @spec has_register_step?(Resource.t()) :: boolean - def has_register_step?(_resource), do: true -end diff --git a/lib/ash_authentication/password_authentication/actions.ex b/lib/ash_authentication/password_authentication/actions.ex deleted file mode 100644 index c8ee252..0000000 --- a/lib/ash_authentication/password_authentication/actions.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.Actions do - @moduledoc """ - Code interface for password authentication. - - Allows you to use the password authentication provider without needing to mess - around with changesets, apis, etc. These functions are delegated to from - within `AshAuthentication.PasswordAuthentication`. - """ - - alias Ash.{Changeset, Query} - alias AshAuthentication.PasswordAuthentication - - @doc """ - Attempt to sign in an user of the provided resource type. - - ## Example - - iex> sign_in(MyApp.User, %{username: "marty", password: "its_1985"}) - {:ok, #MyApp.User<>} - """ - @spec sign_in(module, map) :: {:ok, struct} | {:error, term} - def sign_in(resource, attributes) do - {:ok, action} = - PasswordAuthentication.Info.password_authentication_sign_in_action_name(resource) - - {:ok, api} = AshAuthentication.Info.authentication_api(resource) - - resource - |> Query.for_read(action, attributes) - |> api.read() - |> case do - {:ok, [user]} -> {:ok, user} - {:ok, []} -> {:error, "Invalid username or password"} - {:error, reason} -> {:error, reason} - end - end - - @doc """ - Attempt to register an user of the provided resource type. - - ## Example - - iex> register(MyApp.User, %{username: "marty", password: "its_1985", password_confirmation: "its_1985"}) - {:ok, #MyApp.User<>} - """ - @spec register(module, map) :: {:ok, struct} | {:error, term} - def register(resource, attributes) do - {:ok, action} = - PasswordAuthentication.Info.password_authentication_register_action_name(resource) - - {:ok, api} = AshAuthentication.Info.authentication_api(resource) - - resource - |> Changeset.for_create(action, attributes) - |> api.create() - end -end diff --git a/lib/ash_authentication/password_authentication/hash_password_change.ex b/lib/ash_authentication/password_authentication/hash_password_change.ex deleted file mode 100644 index 20c41d7..0000000 --- a/lib/ash_authentication/password_authentication/hash_password_change.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.HashPasswordChange do - @moduledoc """ - Set the hash based on the password input. - - Uses the configured `AshAuthentication.HashProvider` to generate a hash of the - user's password input and store it in the changeset. - """ - - use Ash.Resource.Change - alias AshAuthentication.PasswordAuthentication.Info - alias Ash.{Changeset, Resource.Change} - - @doc false - @impl true - @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() - def change(changeset, _opts, _) do - changeset - |> Changeset.before_action(fn changeset -> - {:ok, password_field} = Info.password_authentication_password_field(changeset.resource) - {:ok, hash_field} = Info.password_authentication_hashed_password_field(changeset.resource) - {:ok, hasher} = Info.password_authentication_hash_provider(changeset.resource) - - with value when is_binary(value) <- Changeset.get_argument(changeset, password_field), - {:ok, hash} <- hasher.hash(value) do - Changeset.change_attribute(changeset, hash_field, hash) - else - nil -> changeset - :error -> {:error, "Error hashing password"} - end - end) - end -end diff --git a/lib/ash_authentication/password_authentication/html.ex b/lib/ash_authentication/password_authentication/html.ex deleted file mode 100644 index a671499..0000000 --- a/lib/ash_authentication/password_authentication/html.ex +++ /dev/null @@ -1,105 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.Html do - @moduledoc """ - Renders a very basic form for using password authentication. - - These are mainly used for testing, and you should instead write your own or - use the widgets in `ash_authentication_phoenix`. - """ - - require EEx - alias AshAuthentication.PasswordAuthentication - - EEx.function_from_string( - :defp, - :render_sign_in, - ~s""" -
- -
- <%= if @legend do %><%= @legend %><% end %> - -
- -
- -
-
- """, - [:assigns] - ) - - EEx.function_from_string( - :defp, - :render_register, - ~s""" -
- -
- <%= if @legend do %><%= @legend %><% end %> - -
- -
- <%= if @confirmation_required? do %> - -
- <% end %> - -
-
- """, - [:assigns] - ) - - @defaults [method: "POST", legend: nil] - - @type options :: [method_option | action_option] - - @typedoc """ - The HTTP method used to submit the form. - - Defaults to `#{inspect(Keyword.get(@defaults, :method))}`. - """ - @type method_option :: {:method, String.t()} - - @typedoc """ - The path/URL to which the form should be submitted. - """ - @type action_option :: {:action, String.t()} - - @doc """ - Render a basic HTML sign-in form. - """ - @spec callback(module, options) :: String.t() - def callback(resource, options) do - options = - options - |> Keyword.put_new(:legend, "Sign in") - - resource - |> build_assigns(options) - |> render_sign_in() - end - - @doc """ - Render a basic HTML registration form. - """ - @spec request(module, options) :: String.t() - def request(resource, options) do - options = - options - |> Keyword.put_new(:legend, "Register") - - resource - |> build_assigns(options) - |> render_register() - end - - defp build_assigns(resource, options) do - @defaults - |> Keyword.merge(options) - |> Map.new() - |> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource)) - |> Map.merge(AshAuthentication.Info.authentication_options(resource)) - end -end diff --git a/lib/ash_authentication/password_authentication/info.ex b/lib/ash_authentication/password_authentication/info.ex deleted file mode 100644 index baac10a..0000000 --- a/lib/ash_authentication/password_authentication/info.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.Info do - @moduledoc """ - Generated configuration functions based on a resource's DSL configuration. - """ - - use AshAuthentication.InfoGenerator, - extension: AshAuthentication.PasswordAuthentication, - sections: [:password_authentication], - prefix?: true -end diff --git a/lib/ash_authentication/password_authentication/password_confirmation_validation.ex b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex deleted file mode 100644 index ebfc6ef..0000000 --- a/lib/ash_authentication/password_authentication/password_confirmation_validation.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.PasswordConfirmationValidation do - @moduledoc """ - Validate that the password and password confirmation match. - - This check is only performed when the `confirmation_required?` DSL option is set to `true`. - """ - - use Ash.Resource.Validation - alias Ash.{Changeset, Error.Changes.InvalidArgument} - alias AshAuthentication.PasswordAuthentication.Info - - @doc """ - Validates that the password and password confirmation fields contain - equivalent values - if confirmation is required. - """ - @impl true - @spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()} - def validate(changeset, _) do - with true <- Info.password_authentication_confirmation_required?(changeset.resource), - {:ok, password_field} <- Info.password_authentication_password_field(changeset.resource), - {:ok, confirm_field} <- - Info.password_authentication_password_confirmation_field(changeset.resource), - password <- Changeset.get_argument(changeset, password_field), - confirmation <- Changeset.get_argument(changeset, confirm_field), - false <- password == confirmation do - {:error, InvalidArgument.exception(field: confirm_field, message: "does not match")} - else - :error -> {:error, "Password confirmation required, but not configured"} - _ -> :ok - end - end -end diff --git a/lib/ash_authentication/password_authentication/plug.ex b/lib/ash_authentication/password_authentication/plug.ex deleted file mode 100644 index e098795..0000000 --- a/lib/ash_authentication/password_authentication/plug.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.Plug do - @moduledoc """ - Handlers for incoming HTTP requests. - - AshAuthentication is written with an eye towards OAuth which uses a two-phase - request/callback process which can be used to register and sign in an user in - a single flow. This doesn't really work that well with - `PasswordAuthentication` which has seperate "registration" and "sign-in" - actions. - - We handle both registration and sign in by passing an "action" parameter along - with the form data. - """ - import AshAuthentication.Plug.Helpers, only: [private_store: 2] - alias AshAuthentication.PasswordAuthentication - alias Plug.Conn - - @doc """ - Handle the callback phase. - - Handles both sign-in and registration actions via the same endpoint. - """ - @spec handle(Conn.t(), any) :: Conn.t() - def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do - params - |> Map.get(to_string(config.subject_name), %{}) - |> do_action(config.resource) - |> case do - {:ok, user} when is_struct(user, config.resource) -> - private_store(conn, {:success, user}) - - {:error, changeset} -> - private_store(conn, {:failure, changeset}) - end - end - - def handle(conn, _opts), do: conn - - defp do_action(%{"action" => "sign_in"} = attrs, resource), - do: PasswordAuthentication.sign_in_action(resource, attrs) - - defp do_action(%{"action" => "register"} = attrs, resource), - do: PasswordAuthentication.register_action(resource, attrs) - - defp do_action(_attrs, _resource), do: {:error, "No action provided"} -end diff --git a/lib/ash_authentication/password_authentication/transformer.ex b/lib/ash_authentication/password_authentication/transformer.ex deleted file mode 100644 index 09db3c7..0000000 --- a/lib/ash_authentication/password_authentication/transformer.ex +++ /dev/null @@ -1,215 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.Transformer do - @moduledoc """ - The PasswordAuthentication Authentication transformer. - - Scans the resource and checks that all the fields and actions needed are - present. - - ## What it's looking for. - - In order for password authentication to work we need a few basic things to be present on the - resource, but we _can_ generate almost everything we need, even if we do - generate some actions, etc, we still must validate them because we want to - allow the user to be able to overwrite as much as possible. - - You can manually implement as much (or as little) of these as you wish. - - Here's a (simplified) list of what it's validating: - - * The main `AshAuthentication` extension is present on the resource. - * There is an identity field configured (either by the user or by default) and - that a writable attribute with the same name of the appropriate type exists. - * There is a hashed password field configured (either by the user or by - default) and that a writable attribute with the same name of the appropriate - type exists. - * That the configured hash provider actually implements the - `AshAuthentication.HashProvider` behaviour. - * That there is a read action called `sign_in` (or other name based on - configuration) and that it has the following properties: - - it takes an argument of the same name and type as the configured identity - field. - - it takes an argument of the same name and type as the configured password - field. - - it has the `PasswordAuthentication.SignInPreparation` preparation present. - * That there is a create action called `register` (or other name based on - configuration) and that it has the following properties: - - it takes an argument of the same name and type as the configured identity field. - - it takes an argument of the same name and type as the configured password field. - - it takes an argument of the same name and type as the configured password confirmation field if confirmation is enabled. - - it has the `PasswordAuthentication.HashPasswordChange` change present. - - it has the `GenerateTokenChange` change present. - - it has the `PasswordAuthentication.PasswordConfirmationValidation` validation present. - - ## Future improvements. - - * Allow default constraints on password fields to be configurable. - """ - - use Spark.Dsl.Transformer - - alias AshAuthentication.PasswordAuthentication.{ - HashPasswordChange, - Info, - PasswordConfirmationValidation, - SignInPreparation - } - - alias Ash.{Resource, Type} - alias AshAuthentication.GenerateTokenChange - alias Spark.Dsl.Transformer - import AshAuthentication.PasswordAuthentication.UserValidations - import AshAuthentication.Utils - import AshAuthentication.Validations - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - with :ok <- validate_extension(dsl_state, AshAuthentication), - {:ok, dsl_state} <- validate_identity_field(dsl_state), - {:ok, dsl_state} <- validate_hashed_password_field(dsl_state), - {:ok, register_action_name} <- - Info.password_authentication_register_action_name(dsl_state), - {:ok, dsl_state} <- - maybe_build_action( - dsl_state, - register_action_name, - &build_register_action(&1, register_action_name) - ), - {:ok, dsl_state} <- validate_register_action(dsl_state), - {:ok, sign_in_action_name} <- - Info.password_authentication_sign_in_action_name(dsl_state), - {:ok, dsl_state} <- - maybe_build_action( - dsl_state, - sign_in_action_name, - &build_sign_in_action(&1, sign_in_action_name) - ), - {:ok, dsl_state} <- validate_sign_in_action(dsl_state), - :ok <- validate_hash_provider(dsl_state) do - authentication = - Transformer.get_persisted(dsl_state, :authentication) - |> Map.update( - :providers, - [AshAuthentication.PasswordAuthentication], - &[AshAuthentication.PasswordAuthentication | &1] - ) - - dsl_state = - dsl_state - |> Transformer.persist(:authentication, authentication) - - {:ok, dsl_state} - end - end - - @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 - - defp build_register_action(dsl_state, action_name) do - with {:ok, hashed_password_field} <- - Info.password_authentication_hashed_password_field(dsl_state), - {:ok, password_field} <- Info.password_authentication_password_field(dsl_state), - {:ok, confirm_field} <- - Info.password_authentication_password_confirmation_field(dsl_state), - confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state) do - password_opts = [ - type: Type.String, - allow_nil?: false, - constraints: [min_length: 8], - sensitive?: true - ] - - arguments = - [ - Transformer.build_entity!( - Resource.Dsl, - [:actions, :create], - :argument, - Keyword.put(password_opts, :name, password_field) - ) - ] - |> maybe_append( - confirmation_required?, - Transformer.build_entity!( - Resource.Dsl, - [:actions, :create], - :argument, - Keyword.put(password_opts, :name, confirm_field) - ) - ) - - changes = - [] - |> maybe_append( - confirmation_required?, - Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate, - validation: PasswordConfirmationValidation - ) - ) - |> Enum.concat([ - Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, - change: HashPasswordChange - ), - Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, - change: GenerateTokenChange - ) - ]) - - Transformer.build_entity(Resource.Dsl, [:actions], :create, - name: action_name, - arguments: arguments, - changes: changes, - allow_nil_input: [hashed_password_field] - ) - end - end - - defp build_sign_in_action(dsl_state, action_name) do - with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state), - {:ok, password_field} <- Info.password_authentication_password_field(dsl_state) do - identity_attribute = Resource.Info.attribute(dsl_state, identity_field) - - arguments = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, - name: identity_field, - type: identity_attribute.type, - allow_nil?: false - ), - Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, - name: password_field, - type: Type.String, - allow_nil?: false, - sensitive?: true - ) - ] - - preparations = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare, - preparation: SignInPreparation - ) - ] - - Transformer.build_entity(Resource.Dsl, [:actions], :read, - name: action_name, - arguments: arguments, - preparations: preparations, - get?: true - ) - end - end -end diff --git a/lib/ash_authentication/password_authentication/user_validations.ex b/lib/ash_authentication/password_authentication/user_validations.ex deleted file mode 100644 index 1e06708..0000000 --- a/lib/ash_authentication/password_authentication/user_validations.ex +++ /dev/null @@ -1,196 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.UserValidations do - @moduledoc """ - Provides validations for the "user" resource. - - See the module docs for `AshAuthentication.PasswordAuthentication.Transformer` - for more information. - """ - alias Ash.Resource.Actions - alias AshAuthentication.{GenerateTokenChange, HashProvider} - - alias AshAuthentication.PasswordAuthentication.{ - HashPasswordChange, - Info, - PasswordConfirmationValidation, - SignInPreparation - } - - alias Spark.{Dsl, Dsl.Transformer, Error.DslError} - import AshAuthentication.Validations - import AshAuthentication.Validations.Action - import AshAuthentication.Validations.Attribute - - @doc """ - Validate that the configured hash provider implements the `HashProvider` - behaviour. - """ - @spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()} - def validate_hash_provider(dsl_state) do - case Info.password_authentication_hash_provider(dsl_state) do - {:ok, hash_provider} -> - validate_module_implements_behaviour(hash_provider, HashProvider) - - :error -> - {:error, - DslError.exception( - path: [:password_authentication, :hash_provider], - message: "A hash provider must be set in your password authentication resource" - )} - end - end - - @doc """ - Validates information about the sign in action. - """ - @spec validate_sign_in_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} - def validate_sign_in_action(dsl_state) do - with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state), - {:ok, password_field} <- Info.password_authentication_password_field(dsl_state), - {:ok, action_name} <- Info.password_authentication_sign_in_action_name(dsl_state), - {:ok, action} <- validate_action_exists(dsl_state, action_name), - :ok <- validate_identity_argument(dsl_state, action, identity_field), - :ok <- validate_password_argument(action, password_field), - :ok <- validate_action_has_preparation(action, SignInPreparation) do - {:ok, dsl_state} - end - end - - @doc """ - Validates information about the register action. - """ - @spec validate_register_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} - def validate_register_action(dsl_state) do - with {:ok, password_field} <- Info.password_authentication_password_field(dsl_state), - {:ok, password_confirmation_field} <- - Info.password_authentication_password_confirmation_field(dsl_state), - {:ok, hashed_password_field} <- - Info.password_authentication_hashed_password_field(dsl_state), - confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state), - {:ok, action_name} <- Info.password_authentication_register_action_name(dsl_state), - {:ok, action} <- validate_action_exists(dsl_state, action_name), - :ok <- validate_allow_nil_input(action, hashed_password_field), - :ok <- validate_password_argument(action, password_field), - :ok <- - validate_password_confirmation_argument( - action, - password_confirmation_field, - confirmation_required? - ), - :ok <- validate_action_has_change(action, HashPasswordChange), - :ok <- validate_action_has_change(action, GenerateTokenChange), - :ok <- - validate_action_has_validation( - action, - PasswordConfirmationValidation, - confirmation_required? - ) do - {:ok, dsl_state} - end - end - - @doc """ - Validate that the action allows nil input for the provided field. - """ - @spec validate_allow_nil_input(Actions.action(), atom) :: :ok | {:error, Exception.t()} - def validate_allow_nil_input(action, field) do - allowed_nil_fields = Map.get(action, :allow_nil_input, []) - - if field in allowed_nil_fields do - :ok - else - {:error, - DslError.exception( - path: [:actions, :allow_nil_input], - message: - "Expected the action `#{inspect(action.name)}` to allow nil input for the field `#{inspect(field)}`" - )} - end - end - - @doc """ - Optionally validates that the action has a validation. - """ - @spec validate_action_has_validation(Actions.action(), module, really? :: boolean) :: - :ok | {:error, Exception.t()} - def validate_action_has_validation(_, _, false), do: :ok - - def validate_action_has_validation(action, validation, _), - do: validate_action_has_validation(action, validation) - - @doc """ - Validate the identity argument. - """ - @spec validate_identity_argument(Dsl.t(), Actions.action(), atom) :: - :ok | {:error, Exception.t()} - def validate_identity_argument(dsl_state, action, identity_field) do - identity_attribute = Ash.Resource.Info.attribute(dsl_state, identity_field) - validate_action_argument_option(action, identity_field, :type, [identity_attribute.type]) - end - - @doc """ - Validate the password argument. - """ - @spec validate_password_argument(Actions.action(), atom) :: :ok | {:error, Exception.t()} - def validate_password_argument(action, password_field) do - with :ok <- validate_action_argument_option(action, password_field, :type, [Ash.Type.String]) do - validate_action_argument_option(action, password_field, :sensitive?, [true]) - end - end - - @doc """ - Optionally validates the password confirmation argument. - """ - @spec validate_password_confirmation_argument(Actions.action(), atom, really? :: boolean) :: - :ok | {:error, Exception.t()} - def validate_password_confirmation_argument(_, _, false), do: :ok - - def validate_password_confirmation_argument(action, confirm_field, _), - do: validate_password_argument(action, confirm_field) - - @doc """ - Validate the identity field in the user resource. - """ - @spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} - def validate_identity_field(dsl_state) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state), - {:ok, attribute} <- find_attribute(dsl_state, identity_field), - :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), - :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]), - :ok <- validate_attribute_unique_constraint(dsl_state, [identity_field], resource) do - {:ok, dsl_state} - end - end - - @doc """ - Validate the hashed password field on the user resource. - """ - @spec validate_hashed_password_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} - def validate_hashed_password_field(dsl_state) do - with {:ok, resource} <- persisted_option(dsl_state, :module), - {:ok, hashed_password_field} <- identity_option(dsl_state, :hashed_password_field), - {:ok, attribute} <- find_attribute(dsl_state, hashed_password_field), - :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), - :ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]) do - {:ok, dsl_state} - end - end - - defp identity_option(dsl_state, option) do - case Transformer.get_option(dsl_state, [:password_authentication], option) do - nil -> {:error, {:unknown_option, option}} - value -> {:ok, value} - end - end - - defp validate_module_implements_behaviour(module, behaviour) do - if Spark.implements_behaviour?(module, behaviour), - do: :ok, - else: - {:error, - "Expected `#{inspect(module)}` to implement the `#{inspect(behaviour)}` behaviour"} - rescue - _ -> - {:error, "Expected `#{inspect(module)}` to implement the `#{inspect(behaviour)}` behaviour"} - end -end diff --git a/lib/ash_authentication/password_reset.ex b/lib/ash_authentication/password_reset.ex deleted file mode 100644 index 5a130dd..0000000 --- a/lib/ash_authentication/password_reset.ex +++ /dev/null @@ -1,212 +0,0 @@ -defmodule AshAuthentication.PasswordReset do - @default_lifetime_days 3 - - @dsl [ - %Spark.Dsl.Section{ - name: :password_reset, - describe: "Configure password reset behaviour", - schema: [ - token_lifetime: [ - type: :pos_integer, - doc: """ - How long should the reset token be valid, in hours. - - Defaults to #{@default_lifetime_days} days. - """, - default: @default_lifetime_days * 24 - ], - request_password_reset_action_name: [ - type: :atom, - doc: """ - The name to use for the action which generates a password reset token. - """, - default: :request_password_reset - ], - password_reset_action_name: [ - type: :atom, - doc: """ - The name to use for the action which actually resets the user's password. - """, - default: :reset_password - ], - sender: [ - type: - {:spark_function_behaviour, AshAuthentication.Sender, - {AshAuthentication.SenderFunction, 2}}, - doc: """ - How to send the password reset instructions to the user. - - Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. - - Accepts a module, module and opts, or a function that takes a record, reset token and options. - - See `AshAuthentication.Sender` for more information. - """, - required: true - ] - ] - } - ] - - @moduledoc """ - Allow users to reset their passwords. - - This extension provides a mechanism to allow users to reset their password as - in your typical "forgotten password" flow. - - This requires the `AshAuthentication.PasswordAuthentication` extension to be - present, in order to be able to update the password. - - ## Senders - - You can set the DSL's `sender` key to be either a three-arity anonymous - function or a module which implements the `AshAuthentication.Sender` - behaviour. This callback can be used to send password reset instructions to - the user via the system of your choice. See `AshAuthentication.Sender` for - more information. - - ## Usage - - ```elixir - defmodule MyApp.Accounts.Users do - use Ash.Resource, - extensions: [ - AshAuthentication.PasswordAuthentication, - AshAuthentication.PasswordReset - ] - - attributes do - uuid_primary_key :id - attribute :email, :ci_string, allow_nil?: false - end - - password_reset do - token_lifetime 24 - sender MyApp.ResetRequestSender - end - end - ``` - - ## Endpoints - - * `request` - send the identity field nested below the subject name (eg - `%{"user" => %{"email" => "marty@mcfly.me"}}`). If the resource supports - password resets then the success callback will be called with a `nil` user - and token regardless of whether the user could be found. If the user is - found then the `sender` will be called. - * `callback` - attempt to perform a password reset. Should be called with the - reset token, password and password confirmation if confirmation is enabled, - nested below the subject name (eg `%{"user" => %{"reset_token" => "abc123", - "password" => "back to 1985", "password_confirmation" => "back to 1975"}}`). - If the password was successfully changed then the relevant user will be - returned to the `success` callback. - - ## DSL Documentation - - ### Index - - #{Spark.Dsl.Extension.doc_index(@dsl)} - - ### Docs - - #{Spark.Dsl.Extension.doc(@dsl)} - """ - - use Spark.Dsl.Extension, - sections: @dsl, - transformers: [AshAuthentication.PasswordReset.Transformer] - - use AshAuthentication.Provider - - alias Ash.{Changeset, Query, Resource} - alias AshAuthentication.{Jwt, PasswordReset} - - @doc """ - Request a password reset for a user. - - If the record supports password resets then the reset token will be generated and sent. - - ## Example - - iex> request_password_reset(MyApp.Accounts.User, %{"email" => "marty@mcfly.me"}) - :ok - """ - @spec request_password_reset(Resource.t(), params) :: :ok | {:error, any} - when params: %{required(String.t()) => String.t()} - def request_password_reset(resource, params) do - with true <- enabled?(resource), - {:ok, action} <- PasswordReset.Info.request_password_reset_action_name(resource), - {:ok, api} <- AshAuthentication.Info.authentication_api(resource), - query <- Query.for_read(resource, action, params), - {:ok, _} <- api.read(query) do - :ok - else - {:error, reason} -> {:error, reason} - _ -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"} - end - end - - @doc """ - Reset a user's password. - - Given a reset token, password and _maybe_ password confirmation, validate and - change the user's password. - - ## Example - - iex> reset_password(MyApp.Accounts.User, params) - {:ok, %MyApp.Accounts.User{}} - """ - @spec reset_password(Resource.t(), params) :: {:ok, Resource.record()} | {:error, Changeset.t()} - when params: %{required(String.t()) => String.t()} - def reset_password(resource, params) do - with true <- enabled?(resource), - {:ok, token} <- Map.fetch(params, "reset_token"), - {:ok, %{"sub" => subject}, config} <- Jwt.verify(token, resource), - {:ok, user} <- AshAuthentication.subject_to_resource(subject, config), - {:ok, action} <- PasswordReset.Info.password_reset_action_name(config.resource), - {:ok, api} <- AshAuthentication.Info.authentication_api(resource) do - user - |> Changeset.for_update(action, params) - |> api.update() - else - false -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"} - :error -> {:error, "Invalid reset token"} - {:error, reason} -> {:error, reason} - end - end - - @doc """ - Generate a reset token for a user. - """ - @spec reset_token_for(Resource.record()) :: {:ok, String.t()} | :error - def reset_token_for(user) do - resource = user.__struct__ - - with true <- enabled?(resource), - {:ok, lifetime} <- PasswordReset.Info.token_lifetime(resource), - {:ok, action} <- PasswordReset.Info.password_reset_action_name(resource), - {:ok, token, _claims} <- - Jwt.token_for_record(user, %{"act" => action}, token_lifetime: lifetime) do - {:ok, token} - else - _ -> :error - end - end - - @doc """ - Handle the request phase. - - Handles a HTTP request for a password reset. - """ - @impl true - defdelegate request_plug(conn, any), to: PasswordReset.Plug, as: :request - - @doc """ - Handle the callback phase. - - Handles a HTTP password change request. - """ - @impl true - defdelegate callback_plug(conn, any), to: PasswordReset.Plug, as: :callback -end diff --git a/lib/ash_authentication/password_reset/html.ex b/lib/ash_authentication/password_reset/html.ex deleted file mode 100644 index 006b488..0000000 --- a/lib/ash_authentication/password_reset/html.ex +++ /dev/null @@ -1,94 +0,0 @@ -defmodule AshAuthentication.PasswordReset.Html do - @moduledoc """ - Renders a very basic form for password resetting. - - These are mainly used for testing, and you should instead write your own or - use the widgets in `ash_authentication_phoenix`. - """ - - require EEx - alias AshAuthentication.{PasswordAuthentication, PasswordReset} - - EEx.function_from_string( - :defp, - :render_request, - ~s""" -
-
- <%= if @legend do %><%= @legend %><% end %> - -
- -
-
- """, - [:assigns] - ) - - EEx.function_from_string( - :defp, - :render_reset, - ~s""" -
-
- <%= if @legend do %><%= @legend %><% end %> - -
- -
- <%= if @confirmation_required? do %> - -
- <% end %> - -
-
- """, - [:assigns] - ) - - @defaults [method: "POST", legend: nil] - - @type options :: [method_option | action_option] - - @typedoc """ - The HTTP method used to submit the form. - - Defaults to `#{inspect(Keyword.get(@defaults, :method))}`. - """ - @type method_option :: {:method, String.t()} - - @typedoc """ - The path/URL to which the form should be submitted. - """ - @type action_option :: {:action, String.t()} - - @doc """ - Render a reset request. - """ - @spec request(module, options) :: String.t() - def request(resource, options) do - resource - |> build_assigns(Keyword.put(options, :legend, "Request password reset")) - |> render_request() - end - - @doc """ - Render a reset form - """ - @spec callback(module, options) :: String.t() - def callback(resource, options) do - resource - |> build_assigns(Keyword.put(options, :legend, "Reset password")) - |> render_reset() - end - - defp build_assigns(resource, options) do - @defaults - |> Keyword.merge(options) - |> Map.new() - |> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource)) - |> Map.merge(PasswordReset.Info.options(resource)) - |> Map.merge(AshAuthentication.Info.authentication_options(resource)) - end -end diff --git a/lib/ash_authentication/password_reset/info.ex b/lib/ash_authentication/password_reset/info.ex deleted file mode 100644 index a73964c..0000000 --- a/lib/ash_authentication/password_reset/info.ex +++ /dev/null @@ -1,9 +0,0 @@ -defmodule AshAuthentication.PasswordReset.Info do - @moduledoc """ - Generated configuration functions based on a resource's DSL configuration. - """ - - use AshAuthentication.InfoGenerator, - extension: AshAuthentication.PasswordReset, - sections: [:password_reset] -end diff --git a/lib/ash_authentication/password_reset/plug.ex b/lib/ash_authentication/password_reset/plug.ex deleted file mode 100644 index 0501f84..0000000 --- a/lib/ash_authentication/password_reset/plug.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule AshAuthentication.PasswordReset.Plug do - @moduledoc """ - Handlers for incoming HTTP requests. - """ - - import AshAuthentication.Plug.Helpers, only: [private_store: 2] - alias AshAuthentication.PasswordReset - alias Plug.Conn - - @doc """ - Handle an inbound password reset request. - """ - @spec request(Conn.t(), any) :: Conn.t() - def request(%{params: params, private: %{authenticator: config}} = conn, _opts) do - params = - params - |> Map.get(to_string(config.subject_name), %{}) - - case PasswordReset.request_password_reset(config.resource, params) do - :ok -> - private_store(conn, {:success, nil}) - - {:error, reason} -> - private_store(conn, {:failure, reason}) - end - end - - @doc """ - Handle an inbound password reset. - """ - @spec callback(Conn.t(), any) :: Conn.t() - def callback(%{params: params, private: %{authenticator: config}} = conn, _opts) do - params = - params - |> Map.get(to_string(config.subject_name), %{}) - - case PasswordReset.reset_password(config.resource, params) do - {:ok, user} when is_struct(user, config.resource) -> - private_store(conn, {:success, user}) - - {:error, reason} -> - private_store(conn, {:failure, reason}) - end - end -end diff --git a/lib/ash_authentication/password_reset/request_password_reset_preparation.ex b/lib/ash_authentication/password_reset/request_password_reset_preparation.ex deleted file mode 100644 index b2a5c36..0000000 --- a/lib/ash_authentication/password_reset/request_password_reset_preparation.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule AshAuthentication.PasswordReset.RequestPasswordResetPreparation do - @moduledoc """ - Prepare a query for a password reset request. - - This preparation performs three jobs, one before the query executes and two - after. - - Firstly, it constraints the query to match the identity field passed to the - action. - - Secondly, if there is a user returned by the query, then generate a reset - token and publish a notification. Always returns an empty result. - """ - use Ash.Resource.Preparation - alias Ash.{Query, Resource.Preparation} - alias AshAuthentication.{PasswordAuthentication, PasswordReset} - require Ash.Query - - @doc false - @impl true - @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() - def prepare(query, _opts, _context) do - {:ok, identity_field} = - PasswordAuthentication.Info.password_authentication_identity_field(query.resource) - - {:ok, {sender, send_opts}} = PasswordReset.Info.sender(query.resource) - - identity = Query.get_argument(query, identity_field) - - query - |> Query.filter(ref(^identity_field) == ^identity) - |> Query.after_action(fn - _query, [user] -> - case PasswordReset.reset_token_for(user) do - {:ok, token} -> sender.send(user, token, send_opts) - _ -> nil - end - - {:ok, []} - - _, _ -> - {:ok, []} - end) - end -end diff --git a/lib/ash_authentication/password_reset/transformer.ex b/lib/ash_authentication/password_reset/transformer.ex deleted file mode 100644 index ae42e74..0000000 --- a/lib/ash_authentication/password_reset/transformer.ex +++ /dev/null @@ -1,217 +0,0 @@ -defmodule AshAuthentication.PasswordReset.Transformer do - @moduledoc """ - The PasswordReset transformer. - - Scans the resource and checks that all the fields and actions needed are - present. - """ - - use Spark.Dsl.Transformer - - alias AshAuthentication.PasswordReset.{ - Info, - RequestPasswordResetPreparation, - ResetTokenValidation - } - - alias Ash.{Resource, Type} - alias AshAuthentication.PasswordAuthentication, as: PA - alias AshAuthentication.{GenerateTokenChange, Sender} - alias Spark.Dsl.Transformer - - import AshAuthentication.Utils - import AshAuthentication.Validations - import AshAuthentication.Validations.Action - - @doc false - @impl true - @spec transform(map) :: - :ok - | {:ok, map()} - | {:error, term()} - | {:warn, map(), String.t() | [String.t()]} - | :halt - def transform(dsl_state) do - with :ok <- validate_extension(dsl_state, AshAuthentication), - :ok <- validate_extension(dsl_state, PA), - :ok <- validate_token_generation_enabled(dsl_state), - {:ok, {sender, _opts}} <- Info.sender(dsl_state), - :ok <- validate_behaviour(sender, Sender), - {:ok, request_action_name} <- Info.request_password_reset_action_name(dsl_state), - {:ok, dsl_state} <- - maybe_build_action( - dsl_state, - request_action_name, - &build_request_action(&1, request_action_name) - ), - :ok <- validate_request_action(dsl_state, request_action_name), - {:ok, change_action_name} <- Info.password_reset_action_name(dsl_state), - {:ok, dsl_state} <- - maybe_build_action( - dsl_state, - change_action_name, - &build_change_action(&1, change_action_name) - ), - :ok <- validate_change_action(dsl_state, change_action_name) do - authentication = - Transformer.get_persisted(dsl_state, :authentication) - |> Map.update( - :providers, - [AshAuthentication.PasswordReset], - &[AshAuthentication.PasswordReset | &1] - ) - - dsl_state = - dsl_state - |> Transformer.persist(:authentication, authentication) - - {:ok, dsl_state} - else - :error -> {:error, "Configuration error"} - {:error, reason} -> {:error, reason} - end - end - - @doc false - @impl true - @spec after?(module) :: boolean - def after?(AshAuthentication.Transformer), do: true - def after?(PA.Transformer), do: true - def after?(_), do: false - - @doc false - @impl true - @spec before?(module) :: boolean - def before?(Resource.Transformers.DefaultAccept), do: true - def before?(_), do: false - - defp build_request_action(dsl_state, action_name) do - with {:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state) do - identity_attribute = Resource.Info.attribute(dsl_state, identity_field) - - arguments = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, - name: identity_field, - type: identity_attribute.type, - allow_nil?: false - ) - ] - - preparations = [ - Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare, - preparation: RequestPasswordResetPreparation - ) - ] - - Transformer.build_entity(Resource.Dsl, [:actions], :read, - name: action_name, - arguments: arguments, - preparations: preparations - ) - end - end - - defp build_change_action(dsl_state, action_name) do - with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state), - {:ok, confirm_field} <- - PA.Info.password_authentication_password_confirmation_field(dsl_state), - confirmation_required? <- - PA.Info.password_authentication_confirmation_required?(dsl_state) do - password_opts = [ - type: Type.String, - allow_nil?: false, - constraints: [min_length: 8], - sensitive?: true - ] - - arguments = - [ - Transformer.build_entity!( - Resource.Dsl, - [:actions, :update], - :argument, - name: :reset_token, - type: Type.String, - sensitive?: true - ), - Transformer.build_entity!( - Resource.Dsl, - [:actions, :update], - :argument, - Keyword.put(password_opts, :name, password_field) - ) - ] - |> maybe_append( - confirmation_required?, - Transformer.build_entity!( - Resource.Dsl, - [:actions, :update], - :argument, - Keyword.put(password_opts, :name, confirm_field) - ) - ) - - changes = - [ - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate, - validation: ResetTokenValidation - ) - ] - |> maybe_append( - confirmation_required?, - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate, - validation: PA.PasswordConfirmationValidation - ) - ) - |> Enum.concat([ - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, - change: PA.HashPasswordChange - ), - Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, - change: GenerateTokenChange - ) - ]) - - Transformer.build_entity(Resource.Dsl, [:actions], :update, - name: action_name, - arguments: arguments, - changes: changes, - accept: [] - ) - end - end - - defp validate_request_action(dsl_state, action_name) do - with {:ok, action} <- validate_action_exists(dsl_state, action_name), - {:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state), - :ok <- PA.UserValidations.validate_identity_argument(dsl_state, action, identity_field) do - validate_action_has_preparation(action, RequestPasswordResetPreparation) - end - end - - defp validate_change_action(dsl_state, action_name) do - with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state), - {:ok, password_confirmation_field} <- - PA.Info.password_authentication_password_confirmation_field(dsl_state), - confirmation_required? <- - PA.Info.password_authentication_confirmation_required?(dsl_state), - {:ok, action} <- validate_action_exists(dsl_state, action_name), - :ok <- validate_action_has_validation(action, ResetTokenValidation), - :ok <- validate_action_has_change(action, PA.HashPasswordChange), - :ok <- PA.UserValidations.validate_password_argument(action, password_field), - :ok <- - PA.UserValidations.validate_password_confirmation_argument( - action, - password_confirmation_field, - confirmation_required? - ), - :ok <- - PA.UserValidations.validate_action_has_validation( - action, - PA.PasswordConfirmationValidation, - confirmation_required? - ) do - validate_action_has_change(action, GenerateTokenChange) - end - end -end diff --git a/lib/ash_authentication/plug.ex b/lib/ash_authentication/plug.ex index 0fdbb95..ebfd94a 100644 --- a/lib/ash_authentication/plug.ex +++ b/lib/ash_authentication/plug.ex @@ -8,13 +8,13 @@ defmodule AshAuthentication.Plug do defmodule MyAppWeb.AuthPlug do use AshAuthentication.Plug, otp_app: :my_app - def handle_success(conn, user, _token) do + def handle_success(conn, _activity, user, _token) do conn |> store_in_session(user) |> send_resp(200, "Welcome back #{user.name}") end - def handle_failure(conn, reason) do + def handle_failure(conn, _activity, reason) do conn |> send_resp(401, "Better luck next time") end @@ -69,21 +69,17 @@ defmodule AshAuthentication.Plug do do useful things like session and query param fetching. """ - alias Ash.{Changeset, Error, Resource} + alias Ash.Resource alias AshAuthentication.Plug.{Defaults, Helpers, Macros} alias Plug.Conn require Macros - @type authenticator_config :: %{ - api: module, - provider: module, - resource: module, - subject: atom - } + @type activity :: {atom, atom} + @type token :: String.t() @doc """ When authentication has been succesful, this callback will be called with the - conn, the authenticated resource and a token. + conn, the successful activity, the authenticated resource and a token. This allows you to choose what action to take as appropriate for your application. @@ -92,19 +88,21 @@ defmodule AshAuthentication.Plug do "Access granted" message to the user. You almost definitely want to override this behaviour. """ - @callback handle_success(Conn.t(), Resource.record(), token :: String.t()) :: Conn.t() + @callback handle_success(Conn.t(), activity, Resource.record() | nil, token | nil) :: Conn.t() @doc """ When there is any failure during authentication this callback is called. - Note that this includes not just authentication failures, but even simple - 404s. + Note that this includes not just authentication failures but potentially + route-not-found errors also. The default implementation simply returns a 401 status with the message "Access denied". You almost definitely want to override this. """ - @callback handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t() + @callback handle_failure(Conn.t(), activity, any) :: Conn.t() + @doc false + @spec __using__(keyword) :: Macro.t() defmacro __using__(opts) do otp_app = opts @@ -135,12 +133,12 @@ defmodule AshAuthentication.Plug do Macros.define_revoke_bearer_tokens(unquote(otp_app)) @impl true - defdelegate handle_success(conn, user, token), to: Defaults + defdelegate handle_success(conn, activity, user, token), to: Defaults @impl true - defdelegate handle_failure(conn, error), to: Defaults + defdelegate handle_failure(conn, activity, error), to: Defaults - defoverridable handle_success: 3, handle_failure: 2 + defoverridable handle_success: 4, handle_failure: 3 @impl true defdelegate init(opts), to: Router diff --git a/lib/ash_authentication/plug/defaults.ex b/lib/ash_authentication/plug/defaults.ex index 63acae5..4dc9d6a 100644 --- a/lib/ash_authentication/plug/defaults.ex +++ b/lib/ash_authentication/plug/defaults.ex @@ -4,7 +4,7 @@ defmodule AshAuthentication.Plug.Defaults do `handle_failure/2` used in generated authentication plugs. """ - alias Ash.{Changeset, Error, Resource} + alias Ash.Resource alias Plug.Conn import AshAuthentication.Plug.Helpers import Plug.Conn @@ -15,9 +15,9 @@ defmodule AshAuthentication.Plug.Defaults do Calls `AshAuthentication.Plug.Helpers.store_in_session/2` then sends a basic 200 response. """ - @spec handle_success(Conn.t(), Resource.record(), token :: String.t()) :: + @spec handle_success(Conn.t(), {atom, atom}, Resource.record() | nil, String.t() | nil) :: Conn.t() - def handle_success(conn, user, _token) do + def handle_success(conn, _activity, user, _token) do conn |> store_in_session(user) |> send_resp(200, "Access granted") @@ -28,8 +28,8 @@ defmodule AshAuthentication.Plug.Defaults do Sends a very basic 401 response. """ - @spec handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t() - def handle_failure(conn, _) do + @spec handle_failure(Conn.t(), {atom, atom}, any) :: Conn.t() + def handle_failure(conn, _, _) do conn |> send_resp(401, "Access denied") end diff --git a/lib/ash_authentication/plug/dispatcher.ex b/lib/ash_authentication/plug/dispatcher.ex index cdfe9ba..4d6a3da 100644 --- a/lib/ash_authentication/plug/dispatcher.ex +++ b/lib/ash_authentication/plug/dispatcher.ex @@ -4,9 +4,13 @@ defmodule AshAuthentication.Plug.Dispatcher do """ @behaviour Plug + alias AshAuthentication.Strategy alias Plug.Conn + import AshAuthentication.Plug.Helpers, only: [get_authentication_result: 1] - @type config :: {:request | :callback, [AshAuthentication.Plug.authenticator_config()], module} + @type config :: {atom, Strategy.t(), module} | module + + @unsent ~w[unset set set_chunked set_file]a @doc false @impl true @@ -14,57 +18,44 @@ defmodule AshAuthentication.Plug.Dispatcher do def init([config]), do: config @doc """ - Match the `subject_name` and `provider` of the incoming request to a provider and - call the appropriate plug with the configuration. + Send the request to the correct strategy and then return the result. """ @impl true @spec call(Conn.t(), config | any) :: Conn.t() - def call(conn, {phase, routes, return_to}) do - conn - |> dispatch(phase, routes) - |> return(return_to) - end + def call(conn, {phase, strategy, return_to}) do + activity = {strategy.name, phase} - defp dispatch( - %{params: %{"subject_name" => subject_name, "provider" => provider}} = conn, - phase, - routes - ) do - case Map.get(routes, {subject_name, provider}) do - config when is_map(config) -> - conn = Conn.put_private(conn, :authenticator, config) - - case phase do - :request -> config.provider.request_plug(conn, []) - :callback -> config.provider.callback_plug(conn, []) - end - - _ -> + strategy + |> Strategy.plug(phase, conn) + |> get_authentication_result() + |> case do + {conn, _} when conn.state not in @unsent -> conn + + {conn, :ok} -> + return_to.handle_success(conn, activity, nil, nil) + + {conn, {:ok, user}} when is_binary(user.__metadata__.token) -> + return_to.handle_success(conn, activity, user, user.__metadata__.token) + + {conn, {:ok, user}} -> + return_to.handle_success(conn, activity, user, nil) + + {conn, :error} -> + return_to.handle_failure(conn, activity, nil) + + {conn, {:error, reason}} -> + return_to.handle_failure(conn, activity, reason) + + conn when conn.state not in @unsent -> + conn + + conn -> + return_to.handle_failure(conn, activity, :no_authentication_result) end end - defp dispatch(conn, _phase, _routes), do: conn - - defp return(%{state: :sent} = conn, _return_to), do: conn - - defp return( - %{ - private: %{ - authentication_result: {:success, user}, - authenticator: %{resource: resource} - } - } = conn, - return_to - ) - when is_struct(user, resource), - do: return_to.handle_success(conn, user, Map.get(user.__metadata__, :token)) - - defp return(%{private: %{authentication_result: {:success, nil}}} = conn, return_to), - do: return_to.handle_success(conn, nil, nil) - - defp return(%{private: %{authentication_result: {:failure, reason}}} = conn, return_to), - do: return_to.handle_failure(conn, reason) - - defp return(conn, return_to), do: return_to.handle_failure(conn, nil) + def call(conn, return_to) do + return_to.handle_failure(conn, {nil, nil}, :not_found) + end end diff --git a/lib/ash_authentication/plug/helpers.ex b/lib/ash_authentication/plug/helpers.ex index 57535f6..3919d47 100644 --- a/lib/ash_authentication/plug/helpers.ex +++ b/lib/ash_authentication/plug/helpers.ex @@ -13,8 +13,8 @@ defmodule AshAuthentication.Plug.Helpers do @spec store_in_session(Conn.t(), Resource.record()) :: Conn.t() def store_in_session(conn, user) when is_struct(user) do - subject_name = AshAuthentication.Info.authentication_subject_name!(user.__struct__) - subject = AshAuthentication.resource_to_subject(user) + subject_name = Info.authentication_subject_name!(user.__struct__) + subject = AshAuthentication.user_to_subject(user) Conn.put_session(conn, subject_name, subject) end @@ -26,19 +26,20 @@ defmodule AshAuthentication.Plug.Helpers do """ @spec load_subjects([AshAuthentication.subject()], module) :: map def load_subjects(subjects, otp_app) when is_list(subjects) do - configurations = + resources = otp_app |> AshAuthentication.authenticated_resources() - |> Stream.map(&{to_string(&1.subject_name), &1}) + |> Stream.map(&{to_string(Info.authentication_subject_name!(&1)), &1}) |> Map.new() subjects |> Enum.reduce(%{}, fn subject, result -> subject = URI.parse(subject) - with {:ok, config} <- Map.fetch(configurations, subject.path), - {:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do - current_subject_name = current_subject_name(config.subject_name) + with {:ok, resource} <- Map.fetch(resources, subject.path), + {:ok, user} <- AshAuthentication.subject_to_user(subject, resource), + {:ok, subject_name} <- Info.authentication_subject_name(resource) do + current_subject_name = current_subject_name(subject_name) Map.put(result, current_subject_name, user) else @@ -60,11 +61,12 @@ defmodule AshAuthentication.Plug.Helpers do def retrieve_from_session(conn, otp_app) do otp_app |> AshAuthentication.authenticated_resources() - |> Enum.reduce(conn, fn config, conn -> - current_subject_name = current_subject_name(config.subject_name) + |> Stream.map(&{&1, Info.authentication_options(&1)}) + |> Enum.reduce(conn, fn {resource, options}, conn -> + current_subject_name = current_subject_name(options.subject_name) - with subject when is_binary(subject) <- Conn.get_session(conn, config.subject_name), - {:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do + with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name), + {:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do Conn.assign(conn, current_subject_name, user) else _ -> @@ -89,9 +91,10 @@ defmodule AshAuthentication.Plug.Helpers do |> Stream.filter(&String.starts_with?(&1, "Bearer ")) |> Stream.map(&String.replace_leading(&1, "Bearer ", "")) |> Enum.reduce(conn, fn token, conn -> - with {:ok, %{"sub" => subject}, config} <- Jwt.verify(token, otp_app), - {:ok, user} <- AshAuthentication.subject_to_resource(subject, config), - current_subject_name <- current_subject_name(config.subject_name) do + with {:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, otp_app), + {:ok, user} <- AshAuthentication.subject_to_user(subject, resource), + {:ok, subject_name} <- Info.authentication_subject_name(resource), + current_subject_name <- current_subject_name(subject_name) do conn |> Conn.assign(current_subject_name, user) else @@ -112,8 +115,8 @@ defmodule AshAuthentication.Plug.Helpers do |> Stream.filter(&String.starts_with?(&1, "Bearer ")) |> Stream.map(&String.replace_leading(&1, "Bearer ", "")) |> Enum.reduce(conn, fn token, conn -> - with {:ok, config} <- Jwt.token_to_resource(token, otp_app), - {:ok, revocation_resource} <- Info.tokens_revocation_resource(config.resource), + with {:ok, resource} <- Jwt.token_to_resource(token, otp_app), + {:ok, revocation_resource} <- Info.authentication_tokens_revocation_resource(resource), :ok <- TokenRevocation.revoke(revocation_resource, token) do conn else @@ -170,17 +173,28 @@ defmodule AshAuthentication.Plug.Helpers do This is used by authentication plug handlers to store their result for passing back to the dispatcher. """ - @spec private_store(Conn.t(), {:success, nil | Resource.record()} | {:failure, any}) :: Conn.t() + @spec store_authentication_result( + Conn.t(), + :ok | {:ok, Resource.record()} | :error | {:error, any} + ) :: + Conn.t() - def private_store(conn, {:success, nil}), - do: Conn.put_private(conn, :authentication_result, {:success, nil}) + def store_authentication_result(conn, :ok), + do: Conn.put_private(conn, :authentication_result, {:ok, nil}) - def private_store(conn, {:success, record}) - when is_struct(record, conn.private.authenticator.resource), - do: Conn.put_private(conn, :authentication_result, {:success, record}) + def store_authentication_result(conn, {:ok, record}), + do: Conn.put_private(conn, :authentication_result, {:ok, record}) - def private_store(conn, {:failure, reason}), - do: Conn.put_private(conn, :authentication_result, {:failure, reason}) + def store_authentication_result(conn, :error), + do: Conn.put_private(conn, :authentication_result, :error) + + def store_authentication_result(conn, {:error, reason}), + do: Conn.put_private(conn, :authentication_result, {:error, reason}) + + def get_authentication_result(%{private: %{authentication_result: result}} = conn), + do: {conn, result} + + def get_authentication_result(conn), do: conn # Dyanamically generated atoms are generally frowned upon, but in this case # the `subject_name` is a statically configured atom, so should be fine. diff --git a/lib/ash_authentication/plug/macros.ex b/lib/ash_authentication/plug/macros.ex index 0e1bff6..10377a9 100644 --- a/lib/ash_authentication/plug/macros.ex +++ b/lib/ash_authentication/plug/macros.ex @@ -1,6 +1,6 @@ defmodule AshAuthentication.Plug.Macros do @moduledoc """ - Generators used within `AshAuthentication.Plug.__using_/1`. + Generators used within `use AshAuthentication.Plug`. """ alias Ash.Api diff --git a/lib/ash_authentication/plug/router.ex b/lib/ash_authentication/plug/router.ex index 90bb6a7..564fea4 100644 --- a/lib/ash_authentication/plug/router.ex +++ b/lib/ash_authentication/plug/router.ex @@ -6,6 +6,8 @@ defmodule AshAuthentication.Plug.Router do Used internally by `AshAuthentication.Plug`. """ + alias AshAuthentication.{Info, Strategy} + @doc false @spec __using__(keyword) :: Macro.t() defmacro __using__(opts) do @@ -19,47 +21,30 @@ defmodule AshAuthentication.Plug.Router do |> Keyword.fetch!(:return_to) |> Macro.expand_once(__CALLER__) - routes = - otp_app - |> AshAuthentication.authenticated_resources() - |> Stream.flat_map(fn config -> - subject_name = - config.subject_name - |> to_string() - - config - |> Map.get(:providers, []) - |> Stream.map(fn provider -> - config = - config - |> Map.delete(:providers) - |> Map.put(:provider, provider) - - {{subject_name, provider.provides(config.resource)}, config} - end) - end) - |> Map.new() - |> Macro.escape() - quote do + require Ash.Api.Info use Plug.Router plug(:match) plug(:dispatch) - match("/:subject_name/:provider", - to: AshAuthentication.Plug.Dispatcher, - init_opts: [{:request, unquote(routes), unquote(return_to)}] - ) + routes = + unquote(otp_app) + |> Application.compile_env(:ash_apis, []) + |> Stream.flat_map(&Ash.Api.Info.depend_on_resources(&1)) + |> Stream.filter(&(AshAuthentication in Spark.extensions(&1))) + |> Stream.flat_map(&Info.authentication_strategies/1) + |> Stream.flat_map(fn strategy -> + strategy + |> Strategy.routes() + |> Stream.map(fn {path, phase} -> {path, {phase, strategy, unquote(return_to)}} end) + end) + |> Map.new() - match("/:subject_name/:provider/callback", - to: AshAuthentication.Plug.Dispatcher, - init_opts: [{:callback, unquote(routes), unquote(return_to)}] - ) + for {path, config} <- routes do + match(path, to: AshAuthentication.Plug.Dispatcher, init_opts: [config]) + end - match(_, - to: AshAuthentication.Plug.Dispatcher, - init_opts: [{:noop, [], unquote(return_to)}] - ) + match(_, to: AshAuthentication.Plug.Dispatcher, init_opts: [unquote(return_to)]) end end end diff --git a/lib/ash_authentication/provider.ex b/lib/ash_authentication/provider.ex deleted file mode 100644 index d9bfca8..0000000 --- a/lib/ash_authentication/provider.ex +++ /dev/null @@ -1,128 +0,0 @@ -defmodule AshAuthentication.Provider do - @moduledoc false - alias Ash.Resource - alias Plug.Conn - - @doc """ - The name of the provider for routing purposes, eg: "github". - """ - @callback provides(Resource.t()) :: String.t() - - @doc """ - Given some credentials for a potentially existing user, verify the credentials - and generate a token. - - In the case of OAuth style providers, this is the only action that is likely to be called. - """ - @callback sign_in_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any} - - @doc """ - Given some information about a potential user of the system attempt to create the record. - - Only used by the "password authentication" provider at this time. - """ - @callback register_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any} - - @doc """ - Whether the provider has a separate registration step. - """ - @callback has_register_step?(Resource.t()) :: boolean - - @doc """ - A function plug which can handle the callback phase. - """ - @callback callback_plug(Conn.t(), any) :: Conn.t() - - @doc """ - A function plug which can handle the request phase. - """ - @callback request_plug(Conn.t(), any) :: Conn.t() - - @doc """ - Is this extension enabled for this resource? - """ - @callback enabled?(Resource.t()) :: boolean - - defmacro __using__(_) do - quote do - @behaviour AshAuthentication.Provider - - @doc """ - The name of the provider to be used in routes. - - The default implementation derives it from the module name removing any - "Authentication" suffix. - - Overridable. - """ - @impl true - @spec provides(Resource.t()) :: String.t() - def provides(_resource) do - __MODULE__ - |> Module.split() - |> List.last() - |> String.trim_trailing("Authentication") - |> Macro.underscore() - end - - @doc """ - Handle a request for this extension to sign in a user. - - Defaults to returning an error. Overridable. - """ - @impl true - def sign_in_action(_resource, _attributes), - do: {:error, "Sign in not supported by `#{inspect(__MODULE__)}`"} - - @doc """ - Handle a request for this extension to register a user. - - Defaults to returning an error. Overridable. - """ - @impl true - def register_action(_resource, _attributes), - do: {:error, "Registration not supported by `#{inspect(__MODULE__)}`"} - - @doc """ - Handle an inbound request to the `request` path. - - Defaults to returning the `conn` unchanged. Overridable. - """ - @impl true - def request_plug(conn, _config), do: conn - - @doc """ - Handle an inbound request to the `callback` path. - - Defaults to returning the `conn` unchanged. Overridable. - """ - @impl true - def callback_plug(conn, _config), do: conn - - @doc """ - Does this extension require a separate register step? - - Defaults to `false`. Overridable. - """ - @impl true - def has_register_step?(_resource), do: false - - @doc """ - Is `resource` supported by this provider? - - Defaults to `false`. Overridable. - """ - @impl true - @spec enabled?(Resource.t()) :: boolean - def enabled?(resource), do: __MODULE__ in Spark.extensions(resource) - - defoverridable provides: 1, - sign_in_action: 2, - register_action: 2, - request_plug: 2, - callback_plug: 2, - has_register_step?: 1, - enabled?: 1 - end - end -end diff --git a/lib/ash_authentication/provider_identity/info.ex b/lib/ash_authentication/provider_identity/info.ex deleted file mode 100644 index 0cb54c9..0000000 --- a/lib/ash_authentication/provider_identity/info.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule AshAuthentication.ProviderIdentity.Info do - @moduledoc """ - Generated configuration functions based on a resource's token DSL - configuration. - """ - - use AshAuthentication.InfoGenerator, - extension: AshAuthentication.ProviderIdentity, - sections: [:provider_identity] -end diff --git a/lib/ash_authentication/secret.ex b/lib/ash_authentication/secret.ex index 9cca360..16de21a 100644 --- a/lib/ash_authentication/secret.ex +++ b/lib/ash_authentication/secret.ex @@ -10,16 +10,22 @@ defmodule AshAuthentication.Secret do defmodule MyApp.GetSecret do use AshAuthentication.Secret - def secret_for([:oauth2_authentication, :client_id], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_id) - def secret_for([:oauth2_authentication, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_secret) + def secret_for([:authentication, :strategies, :oauth2, :client_id], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_id) + def secret_for([:authentication, :strategies, :oauth2, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth_client_secret) end - defmodule MyApp.User do - use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication] + defmodule MyApp.Accounts.User do + use Ash.Resource, extensions: [AshAuthentication] - oauth2_authentication do - client_id MyApp.GetSecret - client_secret MyApp.GetSecret + authentication do + api MyApp.Accounts + + strategies do + oauth2 do + client_id MyApp.GetSecret + client_secret MyApp.GetSecret + end + end end end ``` @@ -28,11 +34,17 @@ defmodule AshAuthentication.Secret do ```elixir defmodule MyApp.User do - use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.OAuth2Authentication] + use Ash.Resource, extensions: [AshAuthentication] - oauth2_authentication do - client_id fn _secret, _resource, _opts -> - Application.fetch_env(:my_app, :oauth_client_id) + authentication do + api MyApp.Accounts + + strategies do + oauth2 do + client_id fn _secret, _resource, _opts -> + Application.fetch_env(:my_app, :oauth_client_id) + end + end end end end @@ -43,8 +55,7 @@ defmodule AshAuthentication.Secret do Because you may wish to reuse this module for a number of different providers and resources, the first argument passed to the callback is the "secret name", it contains the "path" to the option being set. The path is made up of a list - containing the DSL section name (`oauth2_authentication` etc) as an atom and - the property name as an atom. + containing the DSL path to the secret. """ alias Ash.Resource diff --git a/lib/ash_authentication/secret_function.ex b/lib/ash_authentication/secret_function.ex index 50317fc..b2a3e9f 100644 --- a/lib/ash_authentication/secret_function.ex +++ b/lib/ash_authentication/secret_function.ex @@ -19,7 +19,7 @@ defmodule AshAuthentication.SecretFunction do fun.(secret_name, resource, opts) {{m, f, a}, _opts} when is_atom(m) and is_atom(f) and is_list(a) -> - apply(m, f, [secret_name, resource, a]) + apply(m, f, [secret_name, resource | a]) {nil, opts} -> raise "Invalid options given to `secret_for/3` callback: `#{inspect(opts)}`." diff --git a/lib/ash_authentication/sender.ex b/lib/ash_authentication/sender.ex index 78201f2..b3bd730 100644 --- a/lib/ash_authentication/sender.ex +++ b/lib/ash_authentication/sender.ex @@ -18,7 +18,6 @@ defmodule AshAuthentication.Sender do defmodule MyApp.PasswordResetSender do use AshAuthentication.PasswordReset.Sender import Swoosh.Email - alias MyAppWeb.Router.Helpers, as: Routes def send(user, reset_token, _opts) do new() @@ -33,7 +32,7 @@ defmodule AshAuthentication.Sender do Someone (maybe you) has requested a password reset for your account. If you did not initiate this request then please ignore this email.

- + Click here to reset ") @@ -42,24 +41,39 @@ defmodule AshAuthentication.Sender do end defmodule MyApp.Accounts.User do - use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest] + use Ash.Resource, extensions: [AshAuthentication] - password_reset do - sender MyApp.PasswordResetSender + authentication do + api MyApp.Accounts + + strategies do + password :password do + resettable do + sender MyApp.PasswordResetSender + end + end + end end end ``` You can also implment it directly as a function: - ```elixir defmodule MyApp.Accounts.User do - use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest] + use Ash.Resource, extensions: [AshAuthentication] - password_reset do - sender fn user, token, _opt -> - MyApp.Mailer.send_password_reset_email(user, token) + authentication do + api MyApp.Accounts + + strategies do + password :password do + resettable do + sender fn user, token -> + MyApp.Mailer.send_password_reset_email(user, token) + end + end + end end end end diff --git a/lib/ash_authentication/strategies/confirmation.ex b/lib/ash_authentication/strategies/confirmation.ex new file mode 100644 index 0000000..ec7e648 --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation.ex @@ -0,0 +1,172 @@ +defmodule AshAuthentication.Strategy.Confirmation do + @default_lifetime_days 3 + + @moduledoc """ + Strategy for authenticating sensitive changes. + + Sometimes when creating a new user, or changing a sensitive attribute (such as + their email address) you may want to for the user to confirm by way of sending + them a confirmation token to prove that it was really them that took the + action. + + See the DSL documentation for `AshAuthentication` for information on how to + configure it. + """ + + defstruct token_lifetime: nil, + monitor_fields: [], + confirmed_at_field: :confirmed_at, + confirm_on_create?: true, + confirm_on_update?: true, + inhibit_updates?: false, + sender: nil, + confirm_action_name: :confirm, + resource: nil, + provider: :confirmation, + name: :confirm + + alias Ash.Changeset + alias AshAuthentication.{Jwt, Strategy.Confirmation} + + @type t :: %Confirmation{ + token_lifetime: hours :: pos_integer, + monitor_fields: [atom], + confirmed_at_field: atom, + confirm_on_create?: boolean, + confirm_on_update?: boolean, + inhibit_updates?: boolean, + sender: nil | {module, keyword}, + confirm_action_name: atom, + resource: module, + provider: :confirmation, + name: :confirm + } + + @doc """ + Generate a confirmation token for a changeset. + + This will generate a token with the `"act"` claim set to the confirmation + action for the strategy, and the `"chg"` claim will contain any changes. + + FIXME: The "chg" claim should encrypt the contents of the changes so as to not + leak users' private details. + """ + @spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error + def confirmation_token(strategy, changeset) do + changes = + strategy.monitor_fields + |> Stream.filter(&Changeset.changing_attribute?(changeset, &1)) + |> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))}) + |> Map.new() + + claims = %{"act" => strategy.confirm_action_name, "chg" => changes} + token_lifetime = strategy.token_lifetime * 3600 + + case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do + {:ok, token, _claims} -> {:ok, token} + :error -> :error + end + end + + @doc false + @spec schema :: keyword + def schema do + [ + token_lifetime: [ + type: :pos_integer, + doc: """ + How long should the confirmation token be valid, in hours. + + Defaults to #{@default_lifetime_days} days. + """, + default: @default_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 confirmation token 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: false + ], + sender: [ + type: + {:spark_function_behaviour, AshAuthentication.Sender, + {AshAuthentication.SenderFunction, 2}}, + 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. + + 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/strategies/confirmation/actions.ex b/lib/ash_authentication/strategies/confirmation/actions.ex new file mode 100644 index 0000000..692d1c9 --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation/actions.ex @@ -0,0 +1,30 @@ +defmodule AshAuthentication.Strategy.Confirmation.Actions do + @moduledoc """ + Actions for the confirmation strategy. + + Provides the code interface for working with resources via confirmation. + """ + + alias Ash.{Changeset, Resource} + alias AshAuthentication.{Errors.InvalidToken, Info, Jwt, Strategy.Confirmation} + + @doc """ + Attempt to confirm a user. + """ + @spec confirm(Confirmation.t(), map) :: {:ok, Resource.record()} | {:error, any} + def confirm(strategy, params) do + with {:ok, api} <- Info.authentication_api(strategy.resource), + {:ok, token} <- Map.fetch(params, "confirm"), + {:ok, %{"sub" => subject}, _} <- Jwt.verify(token, strategy.resource), + {:ok, user} <- AshAuthentication.subject_to_user(subject, strategy.resource) do + user + |> Changeset.new() + |> Changeset.set_context(%{strategy: strategy}) + |> Changeset.for_update(strategy.confirm_action_name, params) + |> api.update() + else + :error -> {:error, InvalidToken.exception(type: :confirmation)} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/ash_authentication/strategies/confirmation/confirm_change.ex b/lib/ash_authentication/strategies/confirmation/confirm_change.ex new file mode 100644 index 0000000..03c2253 --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation/confirm_change.ex @@ -0,0 +1,50 @@ +defmodule AshAuthentication.Strategy.Confirmation.ConfirmChange do + @moduledoc """ + Performs a change based on the contents of a confirmation token. + """ + + use Ash.Resource.Change + alias AshAuthentication.Jwt + + alias Ash.{ + Changeset, + Error.Changes.InvalidArgument, + Error.Framework.AssumptionFailed, + Resource.Change + } + + @doc false + @impl true + @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() + def change(changeset, _opts, _context) do + case Map.fetch(changeset.context, :strategy) do + {:ok, strategy} -> + do_change(changeset, strategy) + + :error -> + raise AssumptionFailed, message: "Strategy is missing from the changeset context." + end + end + + defp do_change(changeset, strategy) do + changeset + |> Changeset.before_action(fn changeset -> + with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm), + {:ok, %{"act" => action, "chg" => changes}, _} <- + Jwt.verify(token, changeset.resource), + true <- to_string(strategy.confirm_action_name) == action do + allowed_changes = + if strategy.inhibit_updates?, + do: Map.take(changes, Enum.map(strategy.monitor_fields, &to_string/1)), + else: %{} + + changeset + |> Changeset.change_attributes(allowed_changes) + |> Changeset.change_attribute(strategy.confirmed_at_field, DateTime.utc_now()) + else + _ -> + raise InvalidArgument, field: :confirm, message: "is not valid" + end + end) + end +end diff --git a/lib/ash_authentication/strategies/confirmation/confirmation_hook_change.ex b/lib/ash_authentication/strategies/confirmation/confirmation_hook_change.ex new file mode 100644 index 0000000..11bae47 --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation/confirmation_hook_change.ex @@ -0,0 +1,97 @@ +defmodule AshAuthentication.Strategy.Confirmation.ConfirmationHookChange do + @moduledoc """ + Triggers a confirmation flow when one of the monitored fields is changed. + + Optionally inhibits changes to monitored fields on update. + """ + + use Ash.Resource.Change + alias Ash.{Changeset, Resource.Change} + alias AshAuthentication.{Info, Strategy.Confirmation} + + @doc false + @impl true + @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() + def change(changeset, _opts, _context) do + case Info.strategy(changeset.resource, :confirm) do + {:ok, strategy} -> + do_change(changeset, strategy) + + :error -> + changeset + end + end + + defp do_change(changeset, strategy) do + changeset + |> Changeset.before_action(fn changeset -> + changeset + |> not_confirm_action(strategy) + |> should_confirm_action_type(strategy) + |> monitored_field_changing(strategy) + |> changes_would_be_valid() + |> maybe_inhibit_updates(strategy) + |> maybe_perform_confirmation(strategy, changeset) + end) + end + + defp not_confirm_action(%Changeset{} = changeset, strategy) + when changeset.action != strategy.confirm_action_name, + do: changeset + + defp not_confirm_action(_changeset, _strategy), do: nil + + defp should_confirm_action_type(%Changeset{} = changeset, strategy) + when changeset.action_type == :create and strategy.confirm_on_create?, + do: changeset + + defp should_confirm_action_type(%Changeset{} = changeset, strategy) + when changeset.action_type == :update and strategy.confirm_on_update?, + do: changeset + + defp should_confirm_action_type(_changeset, _strategy), do: nil + + defp monitored_field_changing(%Changeset{} = changeset, strategy) do + if Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)), + do: changeset, + else: nil + end + + defp monitored_field_changing(_changeset, _strategy), do: nil + + defp changes_would_be_valid(%Changeset{} = changeset) when changeset.valid?, do: changeset + defp changes_would_be_valid(_), do: nil + + defp maybe_inhibit_updates(%Changeset{} = changeset, strategy) + when changeset.action_type == :update and strategy.inhibit_updates? do + strategy.monitor_fields + |> Enum.reduce(changeset, &Changeset.clear_change(&2, &1)) + end + + defp maybe_inhibit_updates(changeset, _strategy), do: changeset + + defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset) do + changeset + |> Changeset.after_action(fn _changeset, user -> + strategy + |> Confirmation.confirmation_token(original_changeset) + |> case do + {:ok, token} -> + {sender, send_opts} = strategy.sender + sender.send(user, token, send_opts) + + metadata = + user.__metadata__ + |> Map.put(:confirmation_token, token) + + {:ok, %{user | __metadata__: metadata}} + + _ -> + {:ok, user} + end + end) + end + + defp maybe_perform_confirmation(_changeset, _strategy, original_changeset), + do: original_changeset +end diff --git a/lib/ash_authentication/strategies/confirmation/plug.ex b/lib/ash_authentication/strategies/confirmation/plug.ex new file mode 100644 index 0000000..845de58 --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation/plug.ex @@ -0,0 +1,22 @@ +defmodule AshAuthentication.Strategy.Confirmation.Plug do + @moduledoc """ + Handlers for incoming OAuth2 HTTP requests. + """ + + alias AshAuthentication.{Strategy, Strategy.Confirmation} + alias Plug.Conn + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + + @doc """ + Attempt to perform a confirmation. + """ + @spec confirm(Conn.t(), Confirmation.t()) :: Conn.t() + def confirm(conn, strategy) do + result = + strategy + |> Strategy.action(:confirm, conn.params) + + conn + |> store_authentication_result(result) + end +end diff --git a/lib/ash_authentication/strategies/confirmation/strategy.ex b/lib/ash_authentication/strategies/confirmation/strategy.ex new file mode 100644 index 0000000..c1f3842 --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation/strategy.ex @@ -0,0 +1,49 @@ +defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Confirmation do + @moduledoc """ + Implementation of `AshAuthentication.Strategy` for + `AshAuthentication.Strategy.Confirmation`. + """ + + alias Ash.Resource + alias AshAuthentication.{Info, Strategy, Strategy.Confirmation} + alias Plug.Conn + + @typedoc "The request phases supposed by this strategy" + @type phase :: :confirm + + @typedoc "The actions supported by this strategy" + @type action :: :confirm + + @doc false + @spec phases(Confirmation.t()) :: [phase] + def phases(_), do: [:confirm] + + @doc false + @spec actions(Confirmation.t()) :: [action] + def actions(_), do: [:confirm] + + @doc false + @spec method_for_phase(Confirmation.t(), phase) :: Strategy.http_method() + def method_for_phase(_, _), do: :get + + @doc false + @spec routes(Confirmation.t()) :: [Strategy.route()] + def routes(strategy) do + subject_name = Info.authentication_subject_name!(strategy.resource) + + path = + [subject_name, strategy.name] + |> Enum.map(&to_string/1) + |> Path.join() + + [{"/#{path}", :confirm}] + end + + @doc false + @spec plug(Confirmation.t(), phase, Conn.t()) :: Conn.t() + def plug(strategy, :confirm, conn), do: Confirmation.Plug.confirm(conn, strategy) + + @doc false + @spec action(Confirmation.t(), action, map) :: {:ok, Resource.record()} | {:error, any} + def action(strategy, :confirm, params), do: Confirmation.Actions.confirm(strategy, params) +end diff --git a/lib/ash_authentication/strategies/confirmation/transformer.ex b/lib/ash_authentication/strategies/confirmation/transformer.ex new file mode 100644 index 0000000..ae9832f --- /dev/null +++ b/lib/ash_authentication/strategies/confirmation/transformer.ex @@ -0,0 +1,222 @@ +defmodule AshAuthentication.Strategy.Confirmation.Transformer do + @moduledoc """ + DSL transformer for confirmation strategy. + + Ensures that there is only ever one present and that it is correctly + configured. + """ + + use Spark.Dsl.Transformer + alias Ash.{Resource, Type} + alias AshAuthentication.{GenerateTokenChange, Info, Sender, Strategy.Confirmation} + alias Spark.{Dsl.Transformer, Error.DslError} + 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() + |> Enum.filter(&is_struct(&1, Confirmation)) + |> case do + [] -> + {:ok, dsl_state} + + [strategy] -> + transform_strategy(strategy, dsl_state) + + [_ | _] -> + {:error, + DslError.exception( + path: [:authentication, :strategies, :confirmation], + message: "Multiple confirmation strategies are not supported" + )} + end + end + + defp transform_strategy(strategy, dsl_state) do + with :ok <- validate_token_generation_enabled(dsl_state), + {:ok, {sender, _opts}} <- Map.fetch(strategy, :sender), + :ok <- validate_behaviour(sender, Sender), + :ok <- validate_monitor_fields(dsl_state, strategy), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + strategy.confirm_action_name, + &build_confirm_action(&1, strategy) + ), + :ok <- validate_confirm_action(dsl_state, strategy), + {:ok, dsl_state} <- + maybe_build_attribute( + dsl_state, + strategy.confirmed_at_field, + &build_confirmed_at_attribute(&1, strategy) + ), + :ok <- validate_confirmed_at_attribute(dsl_state, strategy), + {:ok, dsl_state} <- maybe_build_change(dsl_state, Confirmation.ConfirmationHookChange), + {:ok, resource} <- persisted_option(dsl_state, :module) do + dsl_state = + dsl_state + |> Transformer.replace_entity( + [:authentication, :strategies], + %{strategy | resource: resource}, + &(&1.name == strategy.name) + ) + + {:ok, dsl_state} + else + {:error, reason} when is_binary(reason) -> + {:error, + DslError.exception(path: [:authentication, :strategies, :confirmation], message: reason)} + + {:error, reason} -> + {:error, reason} + + :error -> + {:error, + DslError.exception( + path: [:authentication, :strategies, :confirmation], + message: "Configuration error" + )} + end + end + + defp validate_monitor_fields(_dsl_state, %{monitor_fields: []}), + do: + {:error, + DslError.exception( + path: [:authentication, :strategies, :confirmation], + message: "You should be monitoring at least one field" + )} + + defp validate_monitor_fields(dsl_state, strategy) do + Enum.reduce_while(strategy.monitor_fields, :ok, fn field, :ok -> + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, field), + :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), + :ok <- maybe_validate_eager_checking(dsl_state, strategy, field, resource) do + {:cont, :ok} + else + {:error, reason} -> {:halt, {:error, reason}} + end + end) + end + + defp maybe_validate_eager_checking(_dsl_state, %{inhibit_updates?: false}, _, _), do: :ok + + defp maybe_validate_eager_checking(dsl_state, _strategy, field, resource) do + dsl_state + |> Resource.Info.identities() + |> Enum.find(&(&1.keys == [field])) + |> case do + %{eager_check_with: nil} -> + {:error, + DslError.exception( + path: [:identities, :identity], + message: + "The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated." + )} + + _ -> + :ok + end + end + + defp build_confirm_action(_dsl_state, strategy) do + arguments = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument, + name: :confirm, + type: Type.String, + allow_nil?: false + ) + ] + + changes = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, + change: Confirmation.ConfirmChange + ), + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, + change: GenerateTokenChange + ) + ] + + Transformer.build_entity(Resource.Dsl, [:actions], :update, + name: strategy.confirm_action_name, + accept: strategy.monitor_fields, + arguments: arguments, + changes: changes + ) + end + + defp validate_confirm_action(dsl_state, strategy) do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.confirm_action_name), + :ok <- validate_action_has_change(action, Confirmation.ConfirmChange), + :ok <- validate_action_argument_option(action, :confirm, :allow_nil?, [false]), + :ok <- validate_action_has_change(action, GenerateTokenChange) do + validate_action_argument_option(action, :confirm, :type, [Type.String]) + end + end + + defp build_confirmed_at_attribute(_dsl_state, strategy) do + Transformer.build_entity(Resource.Dsl, [:attributes], :attribute, + name: strategy.confirmed_at_field, + type: Type.UtcDatetimeUsec, + allow_nil?: true, + writable?: true + ) + end + + defp validate_confirmed_at_attribute(dsl_state, strategy) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, strategy.confirmed_at_field), + :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), + :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]), + :ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do + :ok + else + :error -> + {:error, + DslError.exception( + path: [:confirmation], + message: "The `confirmed_at_field` option must be set." + )} + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_build_change(dsl_state, change_module) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + changes <- Resource.Info.changes(resource), + false <- change_module in changes, + {:ok, change} <- + Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do + {:ok, Transformer.add_entity(dsl_state, [:changes], change)} + else + true -> {:ok, dsl_state} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/ash_authentication/strategies/oauth2.ex b/lib/ash_authentication/strategies/oauth2.ex new file mode 100644 index 0000000..6693042 --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2.ex @@ -0,0 +1,272 @@ +defmodule AshAuthentication.Strategy.OAuth2 do + import AshAuthentication.Dsl + + @moduledoc """ + Strategy for authenticating using an OAuth 2.0 server as the source of truth. + + This strategy wraps the excellent [`assent`](https://hex.pm/packages/assent) + package, which provides OAuth 2.0 capabilities. + + In order to use OAuth 2.0 authentication on your resource, it needs to meet + the following minimum criteria: + + 1. Have a primary key. + 2. Provide a strategy-specific action, either register or sign-in. + 3. Provide configuration for OAuth2 destinations, secrets, etc. + + ### Example: + + ```elixir + defmodule MyApp.Accounts.User do + use Ash.Resource, + extensions: [AshAuthentication] + + attributes do + uuid_primary_key :id + attribute :email, :ci_string, allow_nil?: false + end + + authentication do + api MyApp.Accounts + + strategies do + oauth2 :example do + client_id "OAuth Client ID" + redirect_uri "https://my.app/" + client_secret "My Super Secret Secret" + site "https://auth.example.com/" + end + end + end + ``` + + ## Secrets and runtime configuration + + In order to use OAuth 2.0 you need to provide a varying number of secrets and + other configuration which may change based on runtime environment. The + `AshAuthentication.Secret` behaviour is provided to accomodate this. This + allows you to provide configuration either directly on the resource (ie as a + string), as an anonymous function, or as a module. + + > ### Warning {: .warning} + > + > We **strongly** urge you not to sure actual secrets in your code or + > repository. + + ### Examples: + + Providing configuration as an anonymous function: + + ```elixir + oauth2 do + client_secret fn _path, resource -> + Application.fetch_env(:my_app, resource, :oauth2_client_secret) + end + end + ``` + + Providing configuration as a module: + + ```elixir + defmodule MyApp.Secrets do + use AshAuthentication.Secret + + def secret_for([:authentication, :strategies, :example, :client_secret], MyApp.User, _opts), do: Application.fetch_env(:my_app, :oauth2_client_secret) + end + + # and in your stragegies: + + oauth2 :example do + client_secret MyApp.Secrets + end + ``` + + ## User identities + + Because your users can be signed in via multiple providers at once, you can + specify an `identity_resource` in the DSL configuration which points to a + seperate Ash resource which has the `AshAuthentication.UserIdentity` extension + present. This resource will be used to store details of the providers in use + by each user and a relationship will be added to the user resource. + + Setting the `identity_resource` will cause extra validations to be applied to + your resource so that changes are tracked correctly on sign-in or + registration. + + ## Actions + + When using an OAuth 2.0 provider you need to declare either a "register" or + "sign-in" action. The reason for this is that it's not possible for us to + know ahead of time how you want to manage the link between your user resources + and the "user info" provided by the OAuth server. + + Both actions receive the following two arguments: + + 1. `user_info` - a map with string keys containing the [OpenID Successful + UserInfo + response](https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse). + Usually this will be used to populate your email, nickname or other + identifying field. + 2. `oauth_tokens` a map with string keys containing the [OpenID Successful + Token + response](https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse) + (or similar). + + The actions themselves can be interacted with directly via the + `AshAuthentication.Strategy` protocol, but you are more likely to interact + with them via the web/plugs. + + ### Sign-in + + The sign-in action is called when a successful OAuth2 callback is received. + You should use it to constrain the query to the correct user based on the + arguments provided. + + This action is only needed when the `registration_enabled?` DSL settings is + set to `false`. + + ### Registration + + The register action is a little more complicated than the sign-in action, + because we cannot tell the difference between a new user and a returning user + (they all use the same OAuth flow). In order to handle this your register + action must be defined as an upset with a configured `upsert_identity` (see + example below). + + ### Examples: + + Providing sign-in to users who already exist in the database (and by extension + rejecting new users): + + ```elixir + defmodule MyApp.Accounts.User do + attributes do + uuid_primary_key :id + attribute :email, :ci_string, allow_nil?: false + end + + actions do + read :sign_in_with_example do + argument :user_info, :map, allow_nil?: false + argument :oauth_tokens, :map, allow_nil?: false + prepare AshAuthentication.Strategy.OAuth2.SignInPreparation + + filter expr(email == get_path(^arg(:user_info), [:email])) + end + end + + authentication do + api MyApp.Accounts + + strategies do + oauth2 :example do + registration_enabled? false + end + end + end + ``` + + Providing registration or sign-in to all comers: + + ```elixir + defmodule MyApp.Accounts.User do + attributes do + uuid_primary_key :id + attribute :email, :ci_string, allow_nil?: false + end + + actions do + create :register_with_oauth2 do + argument :user_info, :map, allow_nil?: false + argument :oauth_tokens, :map, allow_nil?: false + upsert? true + upsert_identity :email + + change AshAuthentication.GenerateTokenChange + change fn changeset, _ctx -> + user_info = Ash.Changeset.get_argument(changeset, :user_info) + + changeset + |> Changeset.change_attribute(:email, user_info["email"]) + end + end + end + + authentication do + api MyApp.Accounts + + strategies do + oauth2 :example do + end + end + end + ``` + + ## Plugs + + OAuth 2.0 is (usually) a browser-based flow. This means that you're most + likely to interact with this strategy via it's plugs. There are two phases to + authentication with OAuth 2.0: + + 1. The request phase, where the user's browser is redirected to the remote + authentication provider for authentication. + 2. The callback phase, where the provider redirects the user back to your app + to create a local database record, session, etc. + + + ## DSL Documentation + + #{Spark.Dsl.Extension.doc_entity(strategy(:oauth2))} + """ + + defstruct client_id: nil, + site: nil, + auth_method: :client_secret_post, + client_secret: nil, + authorize_path: nil, + token_path: nil, + user_path: nil, + private_key: nil, + redirect_uri: nil, + authorization_params: [], + registration_enabled?: true, + register_action_name: nil, + sign_in_action_name: nil, + identity_resource: false, + identity_relationship_name: :identities, + identity_relationship_user_id_attribute: :user_id, + provider: :oauth2, + name: nil, + resource: nil + + alias AshAuthentication.Strategy.OAuth2 + + @type secret :: nil | String.t() | {module, keyword} + + @type t :: %OAuth2{ + client_id: secret, + site: secret, + auth_method: + nil + | :client_secret_basic + | :client_secret_post + | :client_secret_jwt + | :private_key_jwt, + client_secret: secret, + authorize_path: secret, + token_path: secret, + user_path: secret, + private_key: secret, + redirect_uri: secret, + authorization_params: keyword, + registration_enabled?: boolean, + register_action_name: atom, + sign_in_action_name: atom, + identity_resource: module | false, + identity_relationship_name: atom, + identity_relationship_user_id_attribute: atom, + provider: atom, + name: atom, + resource: module + } +end diff --git a/lib/ash_authentication/strategies/oauth2/actions.ex b/lib/ash_authentication/strategies/oauth2/actions.ex new file mode 100644 index 0000000..6887c5e --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/actions.ex @@ -0,0 +1,64 @@ +defmodule AshAuthentication.Strategy.OAuth2.Actions do + @moduledoc """ + Actions for the oauth2 strategy. + + Provides the code interface for working with resources via an OAuth2 strategy. + """ + + alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource} + alias AshAuthentication.{Errors, Info, Strategy.OAuth2} + + @doc """ + Attempt to sign in a user. + """ + @spec sign_in(OAuth2.t(), map) :: {:ok, Resource.record()} | {:error, any} + def sign_in(%OAuth2{} = strategy, _params) when strategy.registration_enabled?, + do: + {:error, + NoSuchAction.exception( + resource: strategy.resource, + action: strategy.sign_in_action_name, + type: :read + )} + + def sign_in(%OAuth2{} = strategy, params) do + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Query.new() + |> Query.set_context(%{strategy: strategy}) + |> Query.for_read(strategy.sign_in_action_name, params) + |> api.read() + |> case do + {:ok, [user]} -> {:ok, user} + _ -> {:error, Errors.AuthenticationFailed.exception([])} + end + end + + @doc """ + Attempt to register a new user. + """ + @spec register(OAuth2.t(), map) :: {:ok, Resource.record()} | {:error, any} + def register(%OAuth2{} = strategy, params) when strategy.registration_enabled? do + api = Info.authentication_api!(strategy.resource) + action = Resource.Info.action(strategy.resource, strategy.register_action_name, :create) + + strategy.resource + |> Changeset.new() + |> Changeset.set_context(%{strategy: strategy}) + |> Changeset.for_create(strategy.register_action_name, params, + upsert?: true, + upsert_identity: action.upsert_identity + ) + |> api.create() + end + + def register(%OAuth2{} = strategy, _params), + do: + {:error, + NoSuchAction.exception( + resource: strategy.resource, + action: strategy.register_action_name, + type: :create + )} +end diff --git a/lib/ash_authentication/strategies/oauth2/default.ex b/lib/ash_authentication/strategies/oauth2/default.ex new file mode 100644 index 0000000..143fc12 --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/default.ex @@ -0,0 +1,18 @@ +defmodule AshAuthentication.Strategy.OAuth2.Default do + @moduledoc """ + Sets default values for values which can be configured at runtime and are not set. + """ + + use AshAuthentication.Secret + + @doc false + @impl true + @spec secret_for([atom], Ash.Resource.t(), keyword) :: {:ok, String.t()} | :error + def secret_for(path, _resource, _opts), do: path |> Enum.reverse() |> List.first() |> default() + + @doc false + @spec default(atom) :: {:ok, String.t()} + def default(:authorize_path), do: {:ok, "/oauth/authorize"} + def default(:token_path), do: {:ok, "/oauth/access_token"} + def default(:user_path), do: {:ok, "/user"} +end diff --git a/lib/ash_authentication/strategies/oauth2/identity_change.ex b/lib/ash_authentication/strategies/oauth2/identity_change.ex new file mode 100644 index 0000000..e694306 --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/identity_change.ex @@ -0,0 +1,47 @@ +defmodule AshAuthentication.Strategy.OAuth2.IdentityChange do + @moduledoc """ + Updates the identity resource when a user is registered. + """ + + use Ash.Resource.Change + alias AshAuthentication.UserIdentity + alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change} + import AshAuthentication.Utils, only: [is_falsy: 1] + + @doc false + @impl true + @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() + def change(changeset, _opts, _context) do + case Map.fetch(changeset.context, :strategy) do + {:ok, strategy} -> + do_change(changeset, strategy) + + :error -> + {:error, + AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")} + end + end + + defp do_change(changeset, strategy) when is_falsy(strategy.identity_resource), do: changeset + + defp do_change(changeset, strategy) do + changeset + |> Changeset.after_action(fn changeset, user -> + strategy.identity_resource + |> UserIdentity.Actions.upsert(%{ + user_info: Changeset.get_argument(changeset, :user_info), + oauth_tokens: Changeset.get_argument(changeset, :oauth_tokens), + strategy: strategy.name, + user_id: user.id + }) + |> case do + {:ok, _identity} -> + user + |> changeset.api.load(strategy.identity_relationship_name) + + {:error, reason} -> + {:error, reason} + end + end) + end +end diff --git a/lib/ash_authentication/strategies/oauth2/plug.ex b/lib/ash_authentication/strategies/oauth2/plug.ex new file mode 100644 index 0000000..d8584e6 --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/plug.ex @@ -0,0 +1,155 @@ +defmodule AshAuthentication.Strategy.OAuth2.Plug do + @moduledoc """ + Handlers for incoming OAuth2 HTTP requests. + """ + + alias Ash.Error.Framework.AssumptionFailed + alias AshAuthentication.{Errors, Info, Strategy, Strategy.OAuth2} + alias Assent.{Config, HTTPAdapter.Mint} + alias Assent.Strategy.OAuth2, as: Assent + alias Plug.Conn + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + import Plug.Conn + + @doc """ + Perform the request phase of OAuth2. + + Builds a redirection URL based on the provider configuration and redirects the + user to that endpoint. + """ + @spec request(Conn.t(), OAuth2.t()) :: Conn.t() + def request(conn, strategy) do + with {:ok, config} <- config_for(strategy), + {:ok, session_key} <- session_key(strategy), + {:ok, %{session_params: session_params, url: url}} <- Assent.authorize_url(config) do + conn + |> put_session(session_key, session_params) + |> put_resp_header("location", url) + |> send_resp(:found, "Redirecting to #{strategy.name}") + else + {:error, reason} -> store_authentication_result(conn, {:error, reason}) + end + end + + @doc """ + Perform the callback phase of OAuth2. + + Responds to a user being redirected back from the remote authentication + provider, and validates the passed options, ultimately registering or + signing-in a user if the authentication was successful. + """ + @spec callback(Conn.t(), OAuth2.t()) :: Conn.t() + def callback(conn, strategy) do + with {:ok, session_key} <- session_key(strategy), + {:ok, config} <- config_for(strategy), + session_params when is_map(session_params) <- get_session(conn, session_key), + conn <- delete_session(conn, session_key), + config <- Config.put(config, :session_params, session_params), + {:ok, %{user: user, token: token}} <- Assent.callback(config, conn.params), + {:ok, user} <- + register_or_sign_in_user(strategy, %{user_info: user, oauth_tokens: token}) do + store_authentication_result(conn, {:ok, user}) + else + nil -> store_authentication_result(conn, {:error, nil}) + {:error, reason} -> store_authentication_result(conn, {:error, reason}) + end + end + + defp config_for(strategy) do + with {:ok, client_id} <- fetch_secret(strategy, :client_id), + {:ok, site} <- fetch_secret(strategy, :site), + {:ok, redirect_uri} <- build_redirect_uri(strategy), + {:ok, authorize_url} <- build_uri(strategy, :authorize_path), + {:ok, token_url} <- build_uri(strategy, :token_path), + {:ok, user_url} <- build_uri(strategy, :user_path) do + config = + [ + auth_method: strategy.auth_method, + client_id: client_id, + client_secret: get_secret(strategy, :client_secret), + private_key: get_secret(strategy, :private_key), + jwt_algorithm: Info.authentication_tokens_signing_algorithm(strategy.resource), + authorization_params: strategy.authorization_params, + redirect_uri: redirect_uri, + site: site, + authorize_url: authorize_url, + token_url: token_url, + user_url: user_url, + http_adapter: Mint + ] + |> Enum.reject(&is_nil(elem(&1, 1))) + + {:ok, config} + end + end + + defp register_or_sign_in_user(strategy, params) when strategy.registration_enabled?, + do: Strategy.action(strategy, :register, params) + + defp register_or_sign_in_user(strategy, params), do: Strategy.action(strategy, :sign_in, params) + + # We need to temporarily store some information about the request in the + # session so that we can verify that there hasn't been a CSRF-related attack. + defp session_key(strategy) do + case Info.authentication_subject_name(strategy.resource) do + {:ok, subject_name} -> + {:ok, "#{subject_name}/#{strategy.name}"} + + :error -> + {:error, + AssumptionFailed.exception( + message: "Resource `#{inspect(strategy.resource)}` has no subject name" + )} + end + end + + defp fetch_secret(strategy, secret_name) do + path = [:authentication, :strategies, strategy.name, secret_name] + + with {:ok, {secret_module, secret_opts}} <- Map.fetch(strategy, secret_name), + {:ok, secret} when is_binary(secret) and byte_size(secret) > 0 <- + secret_module.secret_for(path, strategy.resource, secret_opts) do + {:ok, secret} + else + {:ok, secret} when is_binary(secret) -> {:ok, secret} + _ -> {:error, Errors.MissingSecret.exception(path: path, resource: strategy.resource)} + end + end + + defp get_secret(strategy, secret_name) do + case fetch_secret(strategy, secret_name) do + {:ok, secret} -> secret + _ -> nil + end + end + + defp build_redirect_uri(strategy) do + with {:ok, subject_name} <- Info.authentication_subject_name(strategy.resource), + {:ok, redirect_uri} <- fetch_secret(strategy, :redirect_uri), + {:ok, uri} <- URI.new(redirect_uri) do + path = + Path.join([uri.path || "/", to_string(subject_name), to_string(strategy.name), "callback"]) + + {:ok, to_string(%URI{uri | path: path})} + else + :error -> + {:error, + AssumptionFailed.exception( + message: "Resource `#{inspect(strategy.resource)}` has no subject name" + )} + + {:error, reason} -> + {:error, reason} + end + end + + defp build_uri(strategy, secret_name) do + with {:ok, site} <- fetch_secret(strategy, :site), + {:ok, uri} <- URI.new(site), + {:ok, path} <- fetch_secret(strategy, secret_name) do + path = Path.join(uri.path || "/", path) + + {:ok, to_string(%URI{uri | path: path})} + end + end +end diff --git a/lib/ash_authentication/strategies/oauth2/sign_in_preparation.ex b/lib/ash_authentication/strategies/oauth2/sign_in_preparation.ex new file mode 100644 index 0000000..2f18c8b --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/sign_in_preparation.ex @@ -0,0 +1,70 @@ +defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do + @moduledoc """ + Prepare a query for sign in + + Performs three main tasks: + + 1. Ensures that there is only one matching user record returned, otherwise + returns an authentication failed error. + 2. Generates an access token if token generation is enabled. + 3. Updates the user identity resource, if one is enabled. + """ + use Ash.Resource.Preparation + alias Ash.{Error.Framework.AssumptionFailed, Query, Resource.Preparation} + alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, UserIdentity} + require Ash.Query + import AshAuthentication.Utils, only: [is_falsy: 1] + + @doc false + @impl true + @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() + def prepare(query, _opts, _context) do + case Map.fetch(query.context, :strategy) do + :error -> + {:error, + AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")} + + {:ok, strategy} -> + query + |> Query.after_action(fn + query, [user] -> + with {:ok, user} <- maybe_update_identity(user, query, strategy) do + {:ok, [maybe_generate_token(user)]} + end + + _, _ -> + {:error, AuthenticationFailed.exception(query: query)} + end) + end + end + + defp maybe_update_identity(user, _query, strategy) when is_falsy(strategy.identity_resource), + do: user + + defp maybe_update_identity(user, query, strategy) do + strategy.identity_resource + |> UserIdentity.Actions.upsert(%{ + user_info: Query.get_argument(query, :user_info), + oauth_tokens: Query.get_argument(query, :oauth_tokens), + strategy: strategy.name, + user_id: user.id + }) + |> case do + {:ok, _identity} -> + user + |> query.api.load(strategy.identity_relationship_name) + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_generate_token(user) do + if AshAuthentication.Info.authentication_tokens_enabled?(user.__struct__) do + {:ok, token, _claims} = Jwt.token_for_user(user) + %{user | __metadata__: Map.put(user.__metadata__, :token, token)} + else + user + end + end +end diff --git a/lib/ash_authentication/strategies/oauth2/strategy.ex b/lib/ash_authentication/strategies/oauth2/strategy.ex new file mode 100644 index 0000000..4d2fec7 --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/strategy.ex @@ -0,0 +1,62 @@ +defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.OAuth2 do + @moduledoc """ + Implmentation of `AshAuthentication.Strategy` for + `AshAuthentication.Strategy.OAuth2`. + """ + + alias Ash.Resource + alias AshAuthentication.{Info, Strategy, Strategy.OAuth2} + alias Plug.Conn + + @typedoc "The request phases supported by this strategy" + @type phase :: :request | :callback + + @typedoc "The actions supported by this strategy" + @type action :: :register | :sign_in + + @doc false + @spec phases(OAuth2.t()) :: [phase] + def phases(_), do: [:request, :callback] + + @doc false + @spec actions(OAuth2.t()) :: [action] + def actions(%OAuth2{registration_enabled?: true}), do: [:register] + def actions(%OAuth2{registration_enabled?: false}), do: [:sign_in] + + @doc false + @spec method_for_phase(OAuth2.t(), phase) :: Strategy.http_method() + def method_for_phase(_, :request), do: :get + def method_for_phase(_, :callback), do: :post + + @doc """ + Return a list of routes for use by the strategy. + """ + @spec routes(OAuth2.t()) :: [Strategy.route()] + def routes(strategy) do + subject_name = Info.authentication_subject_name!(strategy.resource) + + [request: nil, callback: :callback] + |> Enum.map(fn {phase, suffix} -> + path = + [subject_name, strategy.name, suffix] + |> Enum.map(&to_string/1) + |> Path.join() + + {"/#{path}", phase} + end) + end + + @doc """ + Handle HTTP requests. + """ + @spec plug(OAuth2.t(), phase, Conn.t()) :: Conn.t() + def plug(strategy, :request, conn), do: OAuth2.Plug.request(conn, strategy) + def plug(strategy, :callback, conn), do: OAuth2.Plug.callback(conn, strategy) + + @doc """ + Perform actions. + """ + @spec action(OAuth2.t(), action, map) :: {:ok, Resource.record()} | {:error, any} + def action(strategy, :register, params), do: OAuth2.Actions.register(strategy, params) + def action(strategy, :sign_in, params), do: OAuth2.Actions.sign_in(strategy, params) +end diff --git a/lib/ash_authentication/strategies/oauth2/transformer.ex b/lib/ash_authentication/strategies/oauth2/transformer.ex new file mode 100644 index 0000000..355bef4 --- /dev/null +++ b/lib/ash_authentication/strategies/oauth2/transformer.ex @@ -0,0 +1,173 @@ +defmodule AshAuthentication.Strategy.OAuth2.Transformer do + @moduledoc """ + DSL transformer for oauth2 strategies. + + Iterates through any oauth2 strategies and ensures that all the correct + actions and settings are in place. + """ + + use Spark.Dsl.Transformer + alias Ash.{Resource, Type} + alias AshAuthentication.{GenerateTokenChange, Info, Strategy.OAuth2} + alias Spark.{Dsl.Transformer, Error.DslError} + 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 + with strategy <- set_defaults(strategy), + {:ok, dsl_state} <- maybe_build_identity_relationship(dsl_state, strategy), + :ok <- maybe_validate_register_action(dsl_state, strategy), + :ok <- maybe_validate_sign_in_action(dsl_state, strategy), + {:ok, resource} <- persisted_option(dsl_state, :module) do + dsl_state = + dsl_state + |> Transformer.replace_entity( + [:authentication, :strategies], + %{strategy | resource: resource}, + &(&1.name == strategy.name) + ) + + {:ok, dsl_state} + else + {:error, reason} when is_binary(reason) -> + {:error, + DslError.exception(path: [:authentication, :strategies, strategy.name], message: reason)} + + {:error, reason} -> + {:error, reason} + end + end + + defp set_defaults(strategy) do + default_secret = {OAuth2.Default, []} + + strategy + |> maybe_set_field(:authorize_path, default_secret) + |> maybe_set_field(:token_path, default_secret) + |> maybe_set_field(:user_path, default_secret) + |> maybe_set_field_lazy(:register_action_name, &:"register_with_#{&1.name}") + |> maybe_set_field_lazy(:sign_in_action_name, &:"sign_in_with_#{&1.name}") + end + + defp maybe_build_identity_relationship(dsl_state, strategy) + when is_falsy(strategy.identity_resource), + do: {:ok, dsl_state} + + defp maybe_build_identity_relationship(dsl_state, strategy) do + maybe_build_relationship( + dsl_state, + strategy.identity_relationship_name, + &build_identity_relationship(&1, strategy) + ) + end + + defp build_identity_relationship(_dsl_state, strategy) do + Transformer.build_entity(Resource.Dsl, [:relationships], :has_many, + name: strategy.identity_relationship_name, + destination: strategy.identity_resource, + destination_attribute: strategy.identity_relationship_user_id_attribute + ) + end + + defp maybe_validate_register_action(dsl_state, strategy) when strategy.registration_enabled? do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.register_action_name), + :ok <- validate_action_has_argument(action, :user_info), + :ok <- validate_action_argument_option(action, :user_info, :type, [Type.Map, :map]), + :ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]), + :ok <- validate_action_has_argument(action, :oauth_tokens), + :ok <- + validate_action_argument_option(action, :oauth_tokens, :type, [Type.Map, :map]), + :ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]), + :ok <- maybe_validate_action_has_token_change(dsl_state, action), + :ok <- validate_field_in_values(action, :upsert?, [true]), + :ok <- + validate_field_with( + action, + :upsert_identity, + &(is_atom(&1) and not is_falsy(&1)), + "Expected `upsert_identity` to be set" + ), + :ok <- maybe_validate_action_has_identity_change(action, strategy) do + :ok + else + :error -> + {:error, "Unable to validate register action"} + + {:error, reason} when is_binary(reason) -> + {:error, "`#{inspect(strategy.register_action_name)}` action: #{reason}"} + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_validate_register_action(_dsl_state, _strategy), do: :ok + + defp maybe_validate_action_has_token_change(dsl_state, action) do + if Info.authentication_tokens_enabled?(dsl_state) do + validate_action_has_change(action, GenerateTokenChange) + else + :ok + end + end + + defp maybe_validate_action_has_identity_change(_action, strategy) + when is_falsy(strategy.identity_resource), + do: :ok + + defp maybe_validate_action_has_identity_change(action, _strategy), + do: validate_action_has_change(action, OAuth2.IdentityChange) + + defp maybe_validate_sign_in_action(_dsl_state, strategy) when strategy.registration_enabled?, + do: :ok + + defp maybe_validate_sign_in_action(dsl_state, strategy) do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name), + :ok <- validate_action_has_argument(action, :user_info), + :ok <- validate_action_argument_option(action, :user_info, :type, [Ash.Type.Map, :map]), + :ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]), + :ok <- validate_action_has_argument(action, :oauth_tokens), + :ok <- + validate_action_argument_option(action, :oauth_tokens, :type, [Ash.Type.Map, :map]), + :ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]), + :ok <- validate_action_has_preparation(action, OAuth2.SignInPreparation) do + :ok + else + :error -> {:error, "Unable to validate sign in action"} + {:error, reason} -> {:error, reason} + end + end +end diff --git a/lib/ash_authentication/strategies/password.ex b/lib/ash_authentication/strategies/password.ex new file mode 100644 index 0000000..6ab2bd6 --- /dev/null +++ b/lib/ash_authentication/strategies/password.ex @@ -0,0 +1,148 @@ +defmodule AshAuthentication.Strategy.Password do + import AshAuthentication.Dsl + + @moduledoc """ + Strategy for authenticating using local resources as the source of truth. + + In order to use password authentication your resource needs to meet the + following minimum requirements: + + 1. Have a primary key. + 2. A uniquely constrained identity field (eg `username` or `email`). + 3. A sensitive string field within which to store the hashed password. + + There are other options documented in the DSL. + + ### Example: + + ```elixir + defmodule MyApp.Accounts.User do + use Ash.Resource, + extensions: [AshAuthentication] + + attributes do + uuid_primary_key :id + attribute :email, :ci_string, allow_nil?: false + attribute :hashed_password, :string, allow_nil?: false, sensitive?: true + end + + authentication do + api MyApp.Accounts + + strategies do + password do + identity_field :email + hashed_password_field :hashed_password + end + end + end + + identities do + identity :unique_email, [:email] + end + end + ``` + + ## Actions + + By default the password strategy will automatically generate the register, + sign-in, reset-request and reset actions for you, however you're free to + define them yourself. If you do, then the action will be validated to ensure + that all the needed configuration is present. + + If you wish to work with the actions directly from your code you can do so via + the `AshAuthentication.Strategy` protocol. + + ### Examples: + + Interacting with the actions directly: + + iex> strategy = Info.strategy!(Example.User, :password) + ...> {:ok, marty} = Strategy.action(strategy, :register, %{"username" => "marty", "password" => "outatime1985", "password_confirmation" => "outatime1985"}) + ...> marty.username |> to_string() + "marty" + + ...> {:ok, user} = Strategy.action(strategy, :sign_in, %{"username" => "outatime1985", "password" => "outatime1985"}) + ...> user.username |> to_string() + "marty" + + ## Plugs + + The password strategy provides plug endpoints for all four actions, although + only sign-in and register will be reported by `Strategy.routes/1` if the + strategy is not configured as resettable. + + If you wish to work with the plugs directly, you can do so via the + `AshAuthentication.Strategy` protocol. + + ### Examples: + + Dispatching to plugs directly: + + iex> strategy = Info.strategy!(Example.User, :password) + ...> conn = conn(:post, "/user/password/register", %{"user" => %{"username" => "marty", "password" => "outatime1985", "password_confirmation" => "outatime1985"}}) + ...> conn = Strategy.plug(strategy, :register, conn) + ...> {_conn, {:ok, marty}} = Plug.Helpers.get_authentication_result(conn) + ...> marty.username |> to_string() + "marty" + + ...> conn = conn(:post, "/user/password/reset_request", %{"user" => %{"username" => "marty"}}) + ...> conn = Strategy.plug(strategy, :reset_request, conn) + ...> {_conn, :ok} = Plug.Helpers.get_authentication_result(conn) + + ## DSL Documentation + + #{Spark.Dsl.Extension.doc_entity(strategy(:password))} + """ + + defstruct identity_field: :username, + hashed_password_field: :hashed_password_field, + hash_provider: AshAuthentication.BcryptProvider, + confirmation_required?: false, + password_field: :password, + password_confirmation_field: :password_confirmation, + register_action_name: nil, + sign_in_action_name: nil, + resettable: [], + name: nil, + provider: :password, + resource: nil + + alias Ash.Resource + alias AshAuthentication.{Jwt, Strategy.Password} + + @type t :: %Password{ + identity_field: atom, + hashed_password_field: atom, + hash_provider: module, + confirmation_required?: boolean, + password_field: atom, + password_confirmation_field: atom, + register_action_name: atom, + sign_in_action_name: atom, + resettable: [Password.Resettable.t()], + name: atom, + provider: atom, + resource: module + } + + @doc """ + Generate a reset token for a user. + + Used by `AshAuthentication.Strategy.Password.RequestPasswordResetPreparation`. + """ + @spec reset_token_for(t(), Resource.record()) :: {:ok, String.t()} | :error + def reset_token_for( + %Password{resettable: [%Password.Resettable{} = resettable]} = _strategy, + user + ) do + case Jwt.token_for_user(user, %{"act" => resettable.password_reset_action_name}, + token_lifetime: resettable.token_lifetime * 3600 + ) do + {:ok, token, _claims} -> {:ok, token} + :error -> :error + end + end + + def reset_token_for(_strategy, _user), do: :error +end diff --git a/lib/ash_authentication/strategies/password/actions.ex b/lib/ash_authentication/strategies/password/actions.ex new file mode 100644 index 0000000..cd546b4 --- /dev/null +++ b/lib/ash_authentication/strategies/password/actions.ex @@ -0,0 +1,94 @@ +defmodule AshAuthentication.Strategy.Password.Actions do + @moduledoc """ + Actions for the password strategy + + Provides the code interface for working with resources via a password + strategy. + """ + + alias Ash.{Changeset, Error.Invalid.NoSuchAction, Query, Resource} + alias AshAuthentication.{Errors, Info, Jwt, Strategy.Password} + + @doc """ + Attempt to sign in a user. + """ + @spec sign_in(Password.t(), map) :: + {:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()} + def sign_in(%Password{} = strategy, params) do + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Query.new() + |> Query.set_context(%{strategy: strategy}) + |> Query.for_read(strategy.sign_in_action_name, params) + |> api.read() + |> case do + {:ok, [user]} -> {:ok, user} + _ -> {:error, Errors.AuthenticationFailed.exception([])} + end + end + + @doc """ + Attempt to register a new user. + """ + @spec register(Password.t(), map) :: {:ok, Resource.record()} | {:error, any} + def register(%Password{} = strategy, params) do + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Changeset.new() + |> Changeset.set_context(%{strategy: strategy}) + |> Changeset.for_create(strategy.register_action_name, params) + |> api.create() + end + + @doc """ + Request a password reset. + """ + @spec reset_request(Password.t(), map) :: :ok | {:error, any} + def reset_request( + %Password{resettable: [%Password.Resettable{} = resettable]} = strategy, + params + ) do + api = Info.authentication_api!(strategy.resource) + + strategy.resource + |> Query.new() + |> Query.set_context(%{strategy: strategy}) + |> Query.for_read(resettable.request_password_reset_action_name, params) + |> api.read() + |> case do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + def reset_request(%Password{} = strategy, _params), + do: + {:error, + NoSuchAction.exception(resource: strategy.resource, action: :reset_request, type: :read)} + + @doc """ + Attempt to change a user's password using a reset token. + """ + @spec reset(Password.t(), map) :: {:ok, Resource.record()} | {:error, any} + def reset(%Password{resettable: [%Password.Resettable{} = resettable]} = strategy, params) do + with {:ok, token} <- Map.fetch(params, "reset_token"), + {:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, strategy.resource), + {:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do + api = Info.authentication_api!(resource) + + user + |> Changeset.new() + |> Changeset.set_context(%{strategy: strategy}) + |> Changeset.for_update(resettable.password_reset_action_name, params) + |> api.update() + else + {:error, %Changeset{} = changeset} -> {:error, changeset} + _ -> {:error, Errors.InvalidToken.exception(type: :reset)} + end + end + + def reset(%Password{} = strategy, _params), + do: {:error, NoSuchAction.exception(resource: strategy.resource, action: :reset, type: :read)} +end diff --git a/lib/ash_authentication/strategies/password/hash_password_change.ex b/lib/ash_authentication/strategies/password/hash_password_change.ex new file mode 100644 index 0000000..469b7b8 --- /dev/null +++ b/lib/ash_authentication/strategies/password/hash_password_change.ex @@ -0,0 +1,32 @@ +defmodule AshAuthentication.Strategy.Password.HashPasswordChange do + @moduledoc """ + Set the hash based on the password input. + + Uses the configured `AshAuthentication.HashProvider` to generate a hash of the + user's password input and store it in the changeset. + """ + + use Ash.Resource.Change + alias Ash.{Changeset, Error.Framework.AssumptionFailed, Resource.Change} + + @doc false + @impl true + @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() + def change(changeset, _opts, _) do + changeset + |> Changeset.before_action(fn changeset -> + with {:ok, strategy} <- Map.fetch(changeset.context, :strategy), + value when is_binary(value) <- + Changeset.get_argument(changeset, strategy.password_field), + {:ok, hash} <- strategy.hash_provider.hash(value) do + Changeset.change_attribute(changeset, strategy.hashed_password_field, hash) + else + :error -> + raise AssumptionFailed, message: "Error hashing password." + + _ -> + changeset + end + end) + end +end diff --git a/lib/ash_authentication/strategies/password/password_confirmation_validation.ex b/lib/ash_authentication/strategies/password/password_confirmation_validation.ex new file mode 100644 index 0000000..5412f9c --- /dev/null +++ b/lib/ash_authentication/strategies/password/password_confirmation_validation.ex @@ -0,0 +1,45 @@ +defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do + @moduledoc """ + Validate that the password and password confirmation match. + + This check is only performed when the `confirmation_required?` DSL option is set to `true`. + """ + + use Ash.Resource.Validation + alias Ash.{Changeset, Error.Changes.InvalidArgument, Error.Framework.AssumptionFailed} + + @doc """ + Validates that the password and password confirmation fields contain + equivalent values - if confirmation is required. + """ + @impl true + @spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()} + def validate(changeset, _) do + case Map.fetch(changeset.context, :strategy) do + {:ok, %{confirmation_required?: true} = strategy} -> + validate_password_confirmation(changeset, strategy) + + {:ok, _} -> + :ok + + :error -> + {:error, + AssumptionFailed.exception(message: "Strategy is missing from the changeset context.")} + end + end + + defp validate_password_confirmation(changeset, strategy) do + password = Changeset.get_argument(changeset, strategy.password_field) + confirmation = Changeset.get_argument(changeset, strategy.password_confirmation_field) + + if password == confirmation do + :ok + else + {:error, + InvalidArgument.exception( + field: strategy.password_confirmation_field, + message: "does not match" + )} + end + end +end diff --git a/lib/ash_authentication/strategies/password/plug.ex b/lib/ash_authentication/strategies/password/plug.ex new file mode 100644 index 0000000..96b0161 --- /dev/null +++ b/lib/ash_authentication/strategies/password/plug.ex @@ -0,0 +1,80 @@ +defmodule AshAuthentication.Strategy.Password.Plug do + @moduledoc """ + Plugs for the password strategy. + + Handles registration, sign-in and password resets. + """ + + alias AshAuthentication.{Info, Strategy, Strategy.Password} + alias Plug.Conn + import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2] + + @doc "Handle a registration request" + @spec register(Conn.t(), Password.t()) :: Conn.t() + def register(conn, strategy) do + params = + conn + |> subject_params(strategy) + + result = + strategy + |> Strategy.action(:register, params) + + conn + |> store_authentication_result(result) + end + + @doc "Handle a sign-in request" + @spec sign_in(Conn.t(), Password.t()) :: Conn.t() + def sign_in(conn, strategy) do + params = + conn + |> subject_params(strategy) + + result = + strategy + |> Strategy.action(:sign_in, params) + + conn + |> store_authentication_result(result) + end + + @doc "Handle a reset request request" + @spec reset_request(Conn.t(), Password.t()) :: Conn.t() + def reset_request(conn, strategy) do + params = + conn + |> subject_params(strategy) + + result = + strategy + |> Strategy.action(:reset_request, params) + + conn + |> store_authentication_result(result) + end + + @doc "Handle a reset request" + @spec reset(Conn.t(), Password.t()) :: Conn.t() + def reset(conn, strategy) do + params = + conn + |> subject_params(strategy) + + result = + strategy + |> Strategy.action(:reset, params) + + conn + |> store_authentication_result(result) + end + + defp subject_params(conn, strategy) do + subject_name = + strategy.resource + |> Info.authentication_subject_name!() + |> to_string() + + Map.get(conn.params, subject_name, %{}) + end +end diff --git a/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex b/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex new file mode 100644 index 0000000..7650e3a --- /dev/null +++ b/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex @@ -0,0 +1,44 @@ +defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do + @moduledoc """ + Prepare a query for a password reset request. + This preparation performs three jobs, one before the query executes and two + after. + Firstly, it constraints the query to match the identity field passed to the + action. + Secondly, if there is a user returned by the query, then generate a reset + token and publish a notification. Always returns an empty result. + """ + use Ash.Resource.Preparation + alias Ash.{Query, Resource.Preparation} + alias AshAuthentication.Strategy.Password + require Ash.Query + + @doc false + @impl true + @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() + def prepare(query, _opts, _context) do + strategy = Map.fetch!(query.context, :strategy) + + if Enum.any?(strategy.resettable) do + identity_field = strategy.identity_field + identity = Query.get_argument(query, identity_field) + + query + |> Query.filter(ref(^identity_field) == ^identity) + |> Query.after_action(&after_action(&1, &2, strategy)) + else + query + end + end + + defp after_action(_query, [user], %{resettable: [%{sender: {sender, send_opts}}]} = strategy) do + case Password.reset_token_for(strategy, user) do + {:ok, token} -> sender.send(user, token, send_opts) + _ -> nil + end + + {:ok, []} + end + + defp after_action(_query, _, _), do: {:ok, []} +end diff --git a/lib/ash_authentication/password_reset/reset_token_validation.ex b/lib/ash_authentication/strategies/password/reset_token_validation.ex similarity index 59% rename from lib/ash_authentication/password_reset/reset_token_validation.ex rename to lib/ash_authentication/strategies/password/reset_token_validation.ex index 42d03ca..e85d2a1 100644 --- a/lib/ash_authentication/password_reset/reset_token_validation.ex +++ b/lib/ash_authentication/strategies/password/reset_token_validation.ex @@ -1,20 +1,21 @@ -defmodule AshAuthentication.PasswordReset.ResetTokenValidation do +defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do @moduledoc """ Validate that the token is a valid password reset request token. """ use Ash.Resource.Validation alias Ash.{Changeset, Error.Changes.InvalidArgument} - alias AshAuthentication.{Jwt, PasswordReset.Info} + alias AshAuthentication.Jwt @doc false @impl true @spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()} def validate(changeset, _) do - with token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token), + with {:ok, strategy} <- Map.fetch(changeset.context, :strategy), + token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token), {:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource), - {:ok, resource_action} <- Info.password_reset_action_name(changeset.resource), - true <- to_string(resource_action) == token_action do + {:ok, [resettable]} <- Map.fetch(strategy, :resettable), + true <- to_string(resettable.password_reset_action_name) == token_action do :ok else _ -> diff --git a/lib/ash_authentication/strategies/password/resettable.ex b/lib/ash_authentication/strategies/password/resettable.ex new file mode 100644 index 0000000..4896dfc --- /dev/null +++ b/lib/ash_authentication/strategies/password/resettable.ex @@ -0,0 +1,76 @@ +defmodule AshAuthentication.Strategy.Password.Resettable do + @moduledoc """ + The entity used to store password reset information. + """ + + @default_lifetime_days 3 + + defstruct token_lifetime: @default_lifetime_days * 24, + request_password_reset_action_name: nil, + password_reset_action_name: nil, + sender: nil + + @type t :: %__MODULE__{ + token_lifetime: hours :: pos_integer, + request_password_reset_action_name: atom, + 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, 2}}, + doc: """ + How to send the password reset instructions to the user. + + Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. + + Accepts a module, module and opts, or a function that takes a record, reset token and options. + + See `AshAuthentication.Sender` for more information. + """, + required: true + ] + ] + } + end +end diff --git a/lib/ash_authentication/password_authentication/sign_in_preparation.ex b/lib/ash_authentication/strategies/password/sign_in_preparation.ex similarity index 61% rename from lib/ash_authentication/password_authentication/sign_in_preparation.ex rename to lib/ash_authentication/strategies/password/sign_in_preparation.ex index dfb0e47..bde2aa9 100644 --- a/lib/ash_authentication/password_authentication/sign_in_preparation.ex +++ b/lib/ash_authentication/strategies/password/sign_in_preparation.ex @@ -1,4 +1,4 @@ -defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do +defmodule AshAuthentication.Strategy.Password.SignInPreparation do @moduledoc """ Prepare a query for sign in @@ -13,32 +13,30 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do an authentication failed error. """ use Ash.Resource.Preparation - alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, PasswordAuthentication.Info} + alias AshAuthentication.{Errors.AuthenticationFailed, Jwt} alias Ash.{Query, Resource.Preparation} require Ash.Query @doc false @impl true @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() - def prepare(query, _opts, _) do - {:ok, identity_field} = Info.password_authentication_identity_field(query.resource) - {:ok, password_field} = Info.password_authentication_password_field(query.resource) - {:ok, hasher} = Info.password_authentication_hash_provider(query.resource) - + def prepare(query, _opts, _context) do + strategy = Map.fetch!(query.context, :strategy) + identity_field = strategy.identity_field identity = Query.get_argument(query, identity_field) query |> Query.filter(ref(^identity_field) == ^identity) |> Query.after_action(fn query, [record] -> - password = Query.get_argument(query, password_field) + password = Query.get_argument(query, strategy.password_field) - if hasher.valid?(password, record.hashed_password), + if strategy.hash_provider.valid?(password, record.hashed_password), do: {:ok, [maybe_generate_token(record)]}, else: auth_failed(query) _, _ -> - hasher.simulate() + strategy.hash_provider.simulate() auth_failed(query) end) end @@ -46,8 +44,8 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do defp auth_failed(query), do: {:error, AuthenticationFailed.exception(query: query)} defp maybe_generate_token(record) do - if AshAuthentication.Info.tokens_enabled?(record.__struct__) do - {:ok, token, _claims} = Jwt.token_for_record(record) + if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do + {:ok, token, _claims} = Jwt.token_for_user(record) %{record | __metadata__: Map.put(record.__metadata__, :token, token)} else record diff --git a/lib/ash_authentication/strategies/password/strategy.ex b/lib/ash_authentication/strategies/password/strategy.ex new file mode 100644 index 0000000..9c21c2d --- /dev/null +++ b/lib/ash_authentication/strategies/password/strategy.ex @@ -0,0 +1,73 @@ +defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do + @moduledoc """ + Implementation of `AshAuthentication.Strategy` for + `AshAuthentication.Strategy.Password`. + + Because the password strategy can optionally provide password reset + functionality it provides more than the usual number of routes, actions, etc. + """ + + alias Ash.Resource + alias AshAuthentication.{Info, Strategy, Strategy.Password} + alias Plug.Conn + + @typedoc """ + The possible request phases for the password strategy. + + Only the first two will be used if password resets are disabled. + """ + @type phase :: :register | :sign_in | :reset_request | :reset + + @doc false + @spec phases(Password.t()) :: [phase] + def phases(%{resettable: []}), do: [:register, :sign_in] + def phases(_strategy), do: [:register, :sign_in, :reset_request, :reset] + + @doc false + @spec actions(Password.t()) :: [phase] + def actions(strategy), do: phases(strategy) + + @doc false + @spec method_for_phase(Password.t(), phase) :: Strategy.http_method() + def method_for_phase(_, _), do: :post + + @doc """ + Return a list of routes for use by the strategy. + """ + @spec routes(Password.t()) :: [Strategy.route()] + def routes(strategy) do + subject_name = Info.authentication_subject_name!(strategy.resource) + + strategy + |> phases() + |> Enum.map(fn phase -> + path = + [subject_name, strategy.name, phase] + |> Enum.map(&to_string/1) + |> Path.join() + + {"/#{path}", phase} + end) + end + + @doc """ + Handle HTTP requests. + """ + @spec plug(Password.t(), phase, Conn.t()) :: Conn.t() + def plug(strategy, :register, conn), do: Password.Plug.register(conn, strategy) + def plug(strategy, :sign_in, conn), do: Password.Plug.sign_in(conn, strategy) + def plug(strategy, :reset_request, conn), do: Password.Plug.reset_request(conn, strategy) + def plug(strategy, :reset, conn), do: Password.Plug.reset(conn, strategy) + + @doc """ + Perform actions. + """ + @spec action(Password.t(), phase, map) :: {:ok, Resource.record()} | {:error, any} + def action(strategy, :register, params), do: Password.Actions.register(strategy, params) + def action(strategy, :sign_in, params), do: Password.Actions.sign_in(strategy, params) + + def action(strategy, :reset_request, params), + do: Password.Actions.reset_request(strategy, params) + + def action(strategy, :reset, params), do: Password.Actions.reset(strategy, params) +end diff --git a/lib/ash_authentication/strategies/password/transformer.ex b/lib/ash_authentication/strategies/password/transformer.ex new file mode 100644 index 0000000..cac97ee --- /dev/null +++ b/lib/ash_authentication/strategies/password/transformer.ex @@ -0,0 +1,418 @@ +defmodule AshAuthentication.Strategy.Password.Transformer do + @moduledoc """ + DSL transformer for the password strategy. + + Iterates through any password authentication strategies and ensures that all + the correct actions and settings are in place. + """ + + use Spark.Dsl.Transformer + + alias Ash.{Resource, Type} + alias AshAuthentication.{GenerateTokenChange, HashProvider, Info, Sender, Strategy.Password} + alias Spark.{Dsl.Transformer, Error.DslError} + 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 + with :ok <- validate_identity_field(strategy.identity_field, dsl_state), + :ok <- validate_hashed_password_field(strategy.hashed_password_field, dsl_state), + strategy <- + maybe_set_field_lazy(strategy, :register_action_name, &:"register_with_#{&1.name}"), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + strategy.register_action_name, + &build_register_action(&1, strategy) + ), + :ok <- validate_register_action(dsl_state, strategy), + strategy <- + maybe_set_field_lazy(strategy, :sign_in_action_name, &:"sign_in_with_#{&1.name}"), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + strategy.sign_in_action_name, + &build_sign_in_action(&1, strategy) + ), + :ok <- validate_sign_in_action(dsl_state, strategy), + :ok <- validate_behaviour(strategy.hash_provider, HashProvider), + {:ok, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy), + {:ok, resource} <- persisted_option(dsl_state, :module) do + dsl_state = + dsl_state + |> Transformer.replace_entity( + [:authentication, :strategies], + %{strategy | resource: resource}, + &(&1.name == strategy.name) + ) + + {:ok, dsl_state} + end + end + + defp validate_identity_field(identity_field, dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, identity_field), + :ok <- validate_attribute_option(attribute, resource, :writable?, [true]), + :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do + validate_attribute_unique_constraint(dsl_state, [identity_field], resource) + end + end + + defp validate_hashed_password_field(hashed_password_field, dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, hashed_password_field), + :ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do + validate_attribute_option(attribute, resource, :sensitive?, [true]) + end + end + + defp build_register_action(_dsl_state, strategy) do + password_opts = [ + type: Type.String, + allow_nil?: false, + constraints: [min_length: 8], + sensitive?: true + ] + + arguments = + [ + Transformer.build_entity!( + Resource.Dsl, + [:actions, :create], + :argument, + Keyword.put(password_opts, :name, strategy.password_field) + ) + ] + |> maybe_append( + strategy.confirmation_required?, + Transformer.build_entity!( + Resource.Dsl, + [:actions, :create], + :argument, + Keyword.put(password_opts, :name, strategy.password_confirmation_field) + ) + ) + + changes = + [] + |> maybe_append( + strategy.confirmation_required?, + Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate, + validation: Password.PasswordConfirmationValidation + ) + ) + |> Enum.concat([ + Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, + change: Password.HashPasswordChange + ), + Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, + change: GenerateTokenChange + ) + ]) + + Transformer.build_entity(Resource.Dsl, [:actions], :create, + name: strategy.register_action_name, + arguments: arguments, + changes: changes, + allow_nil_input: [strategy.hashed_password_field] + ) + end + + defp validate_register_action(dsl_state, strategy) do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.register_action_name), + :ok <- validate_allow_nil_input(action, strategy.hashed_password_field), + :ok <- validate_password_argument(action, strategy.password_field, true), + :ok <- + validate_password_argument( + action, + strategy.password_confirmation_field, + strategy.confirmation_required? + ), + :ok <- validate_action_has_change(action, Password.HashPasswordChange), + :ok <- validate_action_has_change(action, GenerateTokenChange) do + validate_action_has_validation( + action, + Password.PasswordConfirmationValidation, + strategy.confirmation_required? + ) + end + end + + defp validate_allow_nil_input(action, field) do + allowed_nil_fields = Map.get(action, :allow_nil_input, []) + + if field in allowed_nil_fields do + :ok + else + {:error, + DslError.exception( + path: [:actions, :allow_nil_input], + message: + "Expected the action `#{inspect(action.name)}` to allow nil input for the field `#{inspect(field)}`" + )} + end + end + + defp validate_password_argument(action, field, true) do + with :ok <- validate_action_argument_option(action, field, :type, [Ash.Type.String]) do + validate_action_argument_option(action, field, :sensitive?, [true]) + end + end + + defp validate_password_argument(_action, _field, _), do: :ok + + defp validate_action_has_validation(action, validation, true), + do: validate_action_has_validation(action, validation) + + defp validate_action_has_validation(_action, _validation, _), do: :ok + + defp build_sign_in_action(dsl_state, strategy) do + identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field) + + arguments = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, + name: strategy.identity_field, + type: identity_attribute.type, + allow_nil?: false + ), + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, + name: strategy.password_field, + type: Type.String, + allow_nil?: false, + sensitive?: true + ) + ] + + preparations = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare, + preparation: Password.SignInPreparation + ) + ] + + Transformer.build_entity(Resource.Dsl, [:actions], :read, + name: strategy.sign_in_action_name, + arguments: arguments, + preparations: preparations, + get?: true + ) + end + + defp validate_sign_in_action(dsl_state, strategy) do + with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name), + :ok <- validate_identity_argument(dsl_state, action, strategy.identity_field), + :ok <- validate_password_argument(action, strategy.password_field, true) do + validate_action_has_preparation(action, Password.SignInPreparation) + end + end + + defp validate_identity_argument(dsl_state, action, identity_field) do + identity_attribute = Ash.Resource.Info.attribute(dsl_state, identity_field) + validate_action_argument_option(action, identity_field, :type, [identity_attribute.type]) + end + + defp maybe_transform_resettable(dsl_state, %{resettable: []} = strategy), + do: {:ok, dsl_state, strategy} + + defp maybe_transform_resettable(dsl_state, %{resettable: [resettable]} = strategy) do + with {:ok, {sender, _opts}} <- Map.fetch(resettable, :sender), + :ok <- validate_behaviour(sender, Sender), + resettable <- + maybe_set_field_lazy( + resettable, + :request_password_reset_action_name, + fn _ -> :"request_password_reset_with_#{strategy.name}" end + ), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + resettable.request_password_reset_action_name, + &build_reset_request_action(&1, resettable, strategy) + ), + :ok <- validate_reset_request_action(dsl_state, resettable, strategy), + resettable <- + maybe_set_field_lazy( + resettable, + :password_reset_action_name, + fn _ -> :"password_reset_with_#{strategy.name}" end + ), + {:ok, dsl_state} <- + maybe_build_action( + dsl_state, + resettable.password_reset_action_name, + &build_reset_action(&1, resettable, strategy) + ), + :ok <- validate_reset_action(dsl_state, resettable, strategy) do + {:ok, dsl_state, %{strategy | resettable: [resettable]}} + else + :error -> + {:error, + DslError.exception( + path: [:authentication, :strategies, :password, :resettable], + message: "A `sender` is required." + )} + + {:error, reason} -> + {:error, reason} + end + end + + defp maybe_transform_resettable(_dsl_state, %{resettable: [_ | _]}), + do: + DslError.exception( + path: [:authentication, :strategies, :password], + message: "Only one `resettable` entity may be present." + ) + + defp build_reset_request_action(dsl_state, resettable, strategy) do + identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field) + + arguments = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument, + name: strategy.identity_field, + type: identity_attribute.type, + allow_nil?: false + ) + ] + + preparations = [ + Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare, + preparation: Password.RequestPasswordResetPreparation + ) + ] + + Transformer.build_entity(Resource.Dsl, [:actions], :read, + name: resettable.request_password_reset_action_name, + arguments: arguments, + preparations: preparations + ) + end + + defp validate_reset_request_action(dsl_state, resettable, strategy) do + with {:ok, action} <- + validate_action_exists(dsl_state, resettable.request_password_reset_action_name), + :ok <- validate_identity_argument(dsl_state, action, strategy.identity_field) do + validate_action_has_preparation(action, Password.RequestPasswordResetPreparation) + end + end + + defp build_reset_action(_dsl_state, resettable, strategy) do + password_opts = [ + type: Type.String, + allow_nil?: false, + constraints: [min_length: 8], + sensitive?: true + ] + + arguments = + [ + Transformer.build_entity!( + Resource.Dsl, + [:actions, :update], + :argument, + name: :reset_token, + type: Type.String, + sensitive?: true + ), + Transformer.build_entity!( + Resource.Dsl, + [:actions, :update], + :argument, + Keyword.put(password_opts, :name, strategy.password_field) + ) + ] + |> maybe_append( + strategy.confirmation_required?, + Transformer.build_entity!( + Resource.Dsl, + [:actions, :update], + :argument, + Keyword.put(password_opts, :name, strategy.password_confirmation_field) + ) + ) + + changes = + [ + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate, + validation: Password.ResetTokenValidation + ) + ] + |> maybe_append( + strategy.confirmation_required?, + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate, + validation: Password.PasswordConfirmationValidation + ) + ) + |> Enum.concat([ + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, + change: Password.HashPasswordChange + ), + Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change, + change: GenerateTokenChange + ) + ]) + + Transformer.build_entity(Resource.Dsl, [:actions], :update, + name: resettable.password_reset_action_name, + arguments: arguments, + changes: changes, + accept: [] + ) + end + + defp validate_reset_action(dsl_state, resettable, strategy) do + with {:ok, action} <- + validate_action_exists(dsl_state, resettable.password_reset_action_name), + :ok <- validate_action_has_validation(action, Password.ResetTokenValidation), + :ok <- validate_action_has_change(action, Password.HashPasswordChange), + :ok <- validate_password_argument(action, strategy.password_field, true), + :ok <- + validate_password_argument( + action, + strategy.password_confirmation_field, + strategy.confirmation_required? + ), + :ok <- + validate_action_has_validation( + action, + Password.PasswordConfirmationValidation, + strategy.confirmation_required? + ) do + validate_action_has_change(action, GenerateTokenChange) + end + end +end diff --git a/lib/ash_authentication/strategy.ex b/lib/ash_authentication/strategy.ex new file mode 100644 index 0000000..3484693 --- /dev/null +++ b/lib/ash_authentication/strategy.ex @@ -0,0 +1,115 @@ +defprotocol AshAuthentication.Strategy do + @moduledoc """ + The protocol used for interacting with authentication strategies. + + Any new Authentication strategy must implement this protocol. + """ + + alias Ash.Resource + alias Plug.Conn + + @typedoc "A path to match in web requests" + @type path :: String.t() + + @typedoc """ + The \"phase\" of the request. + + Usually `:request` or `:callback` but can be any atom. + """ + @type phase :: atom + + @typedoc """ + The name of an individual action supported by the strategy. + + This maybe not be the action name on the underlying resource, which may be + generated, but the name that the strategy itself calls the action. + """ + @type action :: atom + + @typedoc """ + An individual route. + + Eg: `{"/user/password/sign_in", :sign_in}` + """ + @type route :: {path, phase} + + @type http_method :: + :get | :head | :post | :put | :delete | :connect | :options | :trace | :patch + + @doc """ + Return a list of phases supported by the strategy. + + ## Example + + iex> strategy = Info.strategy!(Example.User, :password) + ...> phases(strategy) + [:register, :sign_in, :reset_request, :reset] + """ + @spec phases(t) :: [phase] + def phases(strategy) + + @doc """ + Return a list of actions supported by the strategy. + + ## Example + + iex> strategy = Info.strategy!(Example.User, :password) + ...> actions(strategy) + [:register, :sign_in, :reset_request, :reset] + """ + @spec actions(t) :: [action] + def actions(strategy) + + @doc """ + Used to build the routing table to route web requests to request phases for + each strategy. + + ## Example + + iex> strategy = Info.strategy!(Example.User, :password) + ...> routes(strategy) + [ + {"/user/password/register", :register}, + {"/user/password/sign_in", :sign_in}, + {"/user/password/reset_request", :reset_request}, + {"/user/password/reset", :reset} + ] + """ + @spec routes(t) :: [route] + def routes(strategy) + + @doc """ + Return the HTTP method for a phase. + + ## Example + + iex> strategy = Info.strategy!(Example.User, :oauth2) + ...> method_for_phase(strategy, :request) + :get + + """ + @spec method_for_phase(t, phase) :: http_method + def method_for_phase(t, phase) + + @doc """ + Handle requests routed to the strategy. + + Each phase will be an atom (ie the second element in the route tuple). + + See `phases/1` for a list of phases supported by the strategy. + """ + @spec plug(t, phase, Conn.t()) :: Conn.t() + def plug(strategy, phase, conn) + + @doc """ + Perform an named action. + + Different strategies are likely to implement a number of different actions + depending on their configuration. Calling them via this function will ensure + that the context is correctly set, etc. + + See `actions/1` for a list of actions provided by the strategy. + """ + @spec action(t, action, params :: map) :: :ok | {:ok, Resource.record()} | {:error, any} + def action(strategy, action_name, params) +end diff --git a/lib/ash_authentication/token_revocation.ex b/lib/ash_authentication/token_revocation.ex index 67f2c39..8f26228 100644 --- a/lib/ash_authentication/token_revocation.ex +++ b/lib/ash_authentication/token_revocation.ex @@ -18,9 +18,10 @@ defmodule AshAuthentication.TokenRevocation do @moduledoc """ An Ash extension which generates the defaults for a token revocation resource. - The token revocation resource is used to store the Json Web Token ID an expiry - times of any tokens which have been revoked. These will be removed once the - expiry date has passed, so should only ever be a fairly small number of rows. + The token revocation resource is used to store the Json Web Token ID (jti) and + expiry times of any tokens which have been revoked. These will be removed + once the expiry date has passed, so should only ever be a fairly small number + of rows. ## Storage @@ -75,10 +76,17 @@ defmodule AshAuthentication.TokenRevocation do @doc """ Revoke a token. + + ## Example + + iex> {token, _} = build_token() + ...> revoke(Example.TokenRevocation, token) + :ok + """ @spec revoke(Resource.t(), token :: String.t()) :: :ok | {:error, any} def revoke(resource, token) do - with {:ok, api} <- Info.api(resource) do + with {:ok, api} <- Info.revocation_api(resource) do resource |> Changeset.for_create(:revoke_token, %{token: token}) |> api.create(upsert?: true) @@ -92,10 +100,19 @@ defmodule AshAuthentication.TokenRevocation do @doc """ Find out if (via it's JTI) a token has been revoked? + + ## Example + + iex> {token, %{"jti" => jti}} = build_token() + ...> revoked?(Example.TokenRevocation, jti) + false + ...> revoke(Example.TokenRevocation, token) + ...> revoked?(Example.TokenRevocation, jti) + true """ @spec revoked?(Resource.t(), jti :: String.t()) :: boolean def revoked?(resource, jti) do - with {:ok, api} <- Info.api(resource) do + with {:ok, api} <- Info.revocation_api(resource) do resource |> Query.for_read(:revoked, %{jti: jti}) |> api.read() @@ -117,7 +134,7 @@ defmodule AshAuthentication.TokenRevocation do ## Note - Sadly this function iterates over all expired revocations and delete them + Sadly this function iterates over all expired revocations and deletes them individually because Ash (as of v2.1.0) does not yet support bulk actions and we can't just drop down to Ecto because we can't assume that the user's resource uses an Ecto-backed data layer. @@ -130,7 +147,7 @@ defmodule AshAuthentication.TokenRevocation do DataLayer.transaction( resource, fn -> - with {:ok, api} <- Info.api(resource), + with {:ok, api} <- Info.revocation_api(resource), query <- Query.for_read(resource, :expired), {:ok, expired} <- api.read(query) do expired @@ -159,7 +176,7 @@ defmodule AshAuthentication.TokenRevocation do """ @spec remove_revocation(Resource.record()) :: :ok | {:error, any} def remove_revocation(revocation) do - with {:ok, api} <- Info.api(revocation.__struct__) do + with {:ok, api} <- Info.revocation_api(revocation.__struct__) do revocation |> Changeset.for_destroy(:expire) |> api.destroy() diff --git a/lib/ash_authentication/token_revocation/expunger.ex b/lib/ash_authentication/token_revocation/expunger.ex index a2e1c1a..1a046ad 100644 --- a/lib/ash_authentication/token_revocation/expunger.ex +++ b/lib/ash_authentication/token_revocation/expunger.ex @@ -2,7 +2,7 @@ defmodule AshAuthentication.TokenRevocation.Expunger do @default_period_hrs 12 @moduledoc """ - A genserver which periodically removes expired token revocations. + A `GenServer` which periodically removes expired token revocations. Scans all token revocation resources every #{@default_period_hrs} hours and removes any expired token revocations. diff --git a/lib/ash_authentication/token_revocation/info.ex b/lib/ash_authentication/token_revocation/info.ex index 540ae35..8f0a22f 100644 --- a/lib/ash_authentication/token_revocation/info.ex +++ b/lib/ash_authentication/token_revocation/info.ex @@ -1,7 +1,7 @@ defmodule AshAuthentication.TokenRevocation.Info do @moduledoc """ - Generated configuration functions based on a resource's token DSL - configuration. + Introspection functions for the `AshAuthentication.TokenRevocation` Ash + extension. """ use AshAuthentication.InfoGenerator, diff --git a/lib/ash_authentication/transformer.ex b/lib/ash_authentication/transformer.ex index 3d4f607..8e6fd2e 100644 --- a/lib/ash_authentication/transformer.ex +++ b/lib/ash_authentication/transformer.ex @@ -29,8 +29,8 @@ defmodule AshAuthentication.Transformer do @spec transform(map) :: :ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt def transform(dsl_state) do - with {:ok, api} <- validate_api_presence(dsl_state), - :ok <- validate_at_least_one_authentication_provider(dsl_state), + with {:ok, _api} <- validate_api_presence(dsl_state), + :ok <- validate_at_least_one_strategy(dsl_state), {:ok, get_by_subject_action_name} <- Info.authentication_get_by_subject_action_name(dsl_state), {:ok, dsl_state} <- @@ -42,15 +42,8 @@ defmodule AshAuthentication.Transformer do :ok <- validate_read_action(dsl_state, get_by_subject_action_name), :ok <- validate_token_revocation_resource(dsl_state), subject_name <- find_or_generate_subject_name(dsl_state) do - authentication = - dsl_state - |> Transformer.get_persisted(:authentication, %{providers: []}) - |> Map.put(:subject_name, subject_name) - |> Map.put(:api, api) - dsl_state = dsl_state - |> Transformer.persist(:authentication, authentication) |> Transformer.set_option([:authentication], :subject_name, subject_name) {:ok, dsl_state} @@ -109,7 +102,11 @@ defmodule AshAuthentication.Transformer do end defp validate_api_presence(dsl_state) do - case Transformer.get_option(dsl_state, [:authentication], :api) do + with api when not is_nil(api) <- Transformer.get_option(dsl_state, [:authentication], :api), + true <- function_exported?(api, :spark_is, 0), + Ash.Api <- api.spark_is() do + {:ok, api} + else nil -> {:error, DslError.exception( @@ -117,25 +114,28 @@ defmodule AshAuthentication.Transformer do message: "An API module must be present" )} - api -> - {:ok, api} + _ -> + {:error, + DslError.exception( + path: [:authentication, :api], + message: "Module is not an Ash.Api." + )} end end - defp validate_at_least_one_authentication_provider(dsl_state) do + defp validate_at_least_one_strategy(dsl_state) do ok? = dsl_state - |> Transformer.get_persisted(:extensions, []) - |> Enum.any?(&Spark.implements_behaviour?(&1, AshAuthentication.Provider)) + |> Transformer.get_entities([:authentication, :strategies]) + |> Enum.any?() if ok?, do: :ok, else: {:error, DslError.exception( - path: [:extensions], - message: - "At least one authentication provider extension must also be present. See the documentation for more information." + path: [:authentication, :strategies], + message: "Expected at least one authentication strategy" )} end diff --git a/lib/ash_authentication/provider_identity.ex b/lib/ash_authentication/user_identity.ex similarity index 77% rename from lib/ash_authentication/provider_identity.ex rename to lib/ash_authentication/user_identity.ex index a999c0b..2b07fcb 100644 --- a/lib/ash_authentication/provider_identity.ex +++ b/lib/ash_authentication/user_identity.ex @@ -1,7 +1,7 @@ -defmodule AshAuthentication.ProviderIdentity do +defmodule AshAuthentication.UserIdentity do @dsl [ %Spark.Dsl.Section{ - name: :provider_identity, + name: :user_identity, describe: "Configure identity options for this resource", schema: [ api: [ @@ -19,10 +19,10 @@ defmodule AshAuthentication.ProviderIdentity do doc: "The name of the `uid` attribute on this resource.", default: :uid ], - provider_attribute_name: [ + strategy_attribute_name: [ type: :atom, - doc: "The name of the `provider` attribute on this resource.", - default: :provider + doc: "The name of the `strategy` attribute on this resource.", + default: :strategy ], user_id_attribute_name: [ type: :atom, @@ -69,21 +69,21 @@ defmodule AshAuthentication.ProviderIdentity do ] @moduledoc """ - An Ash extension which generates the default provider identities resource. + An Ash extension which generates the default user identities resource. - The provider identities resource is used to store information returned by - remote authentication providers (such as those provided by OAuth2) and maps - them to your user resource(s). This provides the following benefits: + The user identities resource is used to store information returned by remote + authentication strategies (such as those provided by OAuth2) and maps them to + your user resource(s). This provides the following benefits: - 1. A user can be signed in to multiple authentication providers at once. + 1. A user can be signed in to multiple authentication strategies at once. 2. For those provides which support it AshAuthentication can handle automatic refreshing of tokens. ## Storage - Provider identities are expected to be relatively long-lived (although they're + User identities are expected to be relatively long-lived (although they're deleted on log out), so should probably be stored using a permanent data layer - sush as `AshPostgres`. + sush as `ash_postgres`. ## Usage @@ -95,9 +95,9 @@ defmodule AshAuthentication.ProviderIdentity do defmodule MyApp.Accounts.UserIdentity do use Ash.Resource, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication.ProviderIdentity] + extensions: [AshAuthentication.UserIdentity] - provider_identity do + user_identity do api MyApp.Accounts user_resource MyApp.Accounts.User end @@ -110,7 +110,7 @@ defmodule AshAuthentication.ProviderIdentity do ``` If you intend to operate with multiple user resources, you will need to define - multiple provider identity resources. + multiple user identity resources. ## Dsl @@ -125,5 +125,5 @@ defmodule AshAuthentication.ProviderIdentity do use Spark.Dsl.Extension, sections: @dsl, - transformers: [AshAuthentication.ProviderIdentity.Transformer] + transformers: [AshAuthentication.UserIdentity.Transformer] end diff --git a/lib/ash_authentication/provider_identity/actions.ex b/lib/ash_authentication/user_identity/actions.ex similarity index 62% rename from lib/ash_authentication/provider_identity/actions.ex rename to lib/ash_authentication/user_identity/actions.ex index af8cc62..e7adfb8 100644 --- a/lib/ash_authentication/provider_identity/actions.ex +++ b/lib/ash_authentication/user_identity/actions.ex @@ -1,22 +1,23 @@ -defmodule AshAuthentication.ProviderIdentity.Actions do +defmodule AshAuthentication.UserIdentity.Actions do @moduledoc """ Code interface for provider identity actions. - Allows you to interact with ProviderIdentity resources without having to mess + Allows you to interact with UserIdentity resources without having to mess around with changesets, apis, etc. These functions are delegated to from - within `AshAuthentication.ProviderIdentity`. + within `AshAuthentication.UserIdentity`. """ alias Ash.{Changeset, Resource} - alias AshAuthentication.ProviderIdentity + alias AshAuthentication.UserIdentity @doc """ Upsert an identity for a user. """ @spec upsert(Resource.t(), map) :: {:ok, Resource.record()} | {:error, term} def upsert(resource, attributes) do - with {:ok, api} <- ProviderIdentity.Info.api(resource), - {:ok, upsert_action_name} <- ProviderIdentity.Info.upsert_action_name(resource), + with {:ok, api} <- UserIdentity.Info.user_identity_api(resource), + {:ok, upsert_action_name} <- + UserIdentity.Info.user_identity_upsert_action_name(resource), action when is_map(action) <- Resource.Info.action(resource, upsert_action_name) do resource |> Changeset.for_create(upsert_action_name, attributes, diff --git a/lib/ash_authentication/user_identity/info.ex b/lib/ash_authentication/user_identity/info.ex new file mode 100644 index 0000000..6181ba2 --- /dev/null +++ b/lib/ash_authentication/user_identity/info.ex @@ -0,0 +1,10 @@ +defmodule AshAuthentication.UserIdentity.Info do + @moduledoc """ + Introspection functions for the `AshAuthentication.UserIdentity` Ash + extension. + """ + + use AshAuthentication.InfoGenerator, + extension: AshAuthentication.UserIdentity, + sections: [:user_identity] +end diff --git a/lib/ash_authentication/provider_identity/transformer.ex b/lib/ash_authentication/user_identity/transformer.ex similarity index 81% rename from lib/ash_authentication/provider_identity/transformer.ex rename to lib/ash_authentication/user_identity/transformer.ex index 37749d4..0ea181c 100644 --- a/lib/ash_authentication/provider_identity/transformer.ex +++ b/lib/ash_authentication/user_identity/transformer.ex @@ -1,13 +1,13 @@ -defmodule AshAuthentication.ProviderIdentity.Transformer do +defmodule AshAuthentication.UserIdentity.Transformer do @moduledoc """ - The provider identity transformer. + The user identity transformer. - Sets up the default schema and actions for a provider identity resource. + Sets up the default schema and actions for a user identity resource. """ use Spark.Dsl.Transformer alias Ash.{Resource, Type} - alias AshAuthentication.ProviderIdentity + alias AshAuthentication.UserIdentity alias Spark.{Dsl.Transformer, Error.DslError} import AshAuthentication.Utils import AshAuthentication.Validations @@ -44,25 +44,29 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do default: &Ash.UUID.generate/0 ), :ok <- validate_id_field(dsl_state, :id), - {:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state), - {:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state), - {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state), - {:ok, access_token} <- ProviderIdentity.Info.access_token_attribute_name(dsl_state), + {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state), + {:ok, strategy} <- + UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state), + {:ok, user_id} <- + UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state), + {:ok, access_token} <- + UserIdentity.Info.user_identity_access_token_attribute_name(dsl_state), {:ok, access_token_expires_at} <- - ProviderIdentity.Info.access_token_expires_at_attribute_name(dsl_state), - {:ok, refresh_token} <- ProviderIdentity.Info.refresh_token_attribute_name(dsl_state), + UserIdentity.Info.user_identity_access_token_expires_at_attribute_name(dsl_state), + {:ok, refresh_token} <- + UserIdentity.Info.user_identity_refresh_token_attribute_name(dsl_state), {:ok, dsl_state} <- - maybe_build_attribute(dsl_state, provider, Type.String, + maybe_build_attribute(dsl_state, strategy, Type.String, allow_nil?: false, writable?: true ), - :ok <- validate_provider_field(dsl_state, provider), + :ok <- validate_strategy_field(dsl_state, strategy), {:ok, dsl_state} <- maybe_build_attribute(dsl_state, uid, Type.String, allow_nil?: false, writable?: true), :ok <- validate_uid_field(dsl_state, uid), - {:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, provider]), + {:ok, dsl_state} <- maybe_build_identity(dsl_state, [user_id, uid, strategy]), :ok <- - validate_attribute_unique_constraint(dsl_state, [user_id, uid, provider], resource), + validate_attribute_unique_constraint(dsl_state, [user_id, uid, strategy], resource), {:ok, dsl_state} <- maybe_build_attribute(dsl_state, access_token, Type.String, allow_nil?: true, @@ -81,8 +85,9 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do writable?: true ), :ok <- validate_token_field(dsl_state, refresh_token), - {:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state), - {:ok, user_relationship} <- ProviderIdentity.Info.user_relationship_name(dsl_state), + {:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state), + {:ok, user_relationship} <- + UserIdentity.Info.user_identity_user_relationship_name(dsl_state), {:ok, dsl_state} <- maybe_build_relationship( dsl_state, @@ -90,11 +95,13 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do &build_user_relationship(&1, user_relationship, user_resource) ), :ok <- validate_user_relationship(dsl_state, user_relationship, user_resource), - {:ok, upsert_action} <- ProviderIdentity.Info.upsert_action_name(dsl_state), + {:ok, upsert_action} <- + UserIdentity.Info.user_identity_upsert_action_name(dsl_state), {:ok, dsl_state} <- maybe_build_action(dsl_state, upsert_action, &build_upsert_action(&1, upsert_action)), :ok <- validate_upsert_action(dsl_state, upsert_action), - {:ok, destroy_action} <- ProviderIdentity.Info.destroy_action_name(dsl_state), + {:ok, destroy_action} <- + UserIdentity.Info.user_identity_destroy_action_name(dsl_state), {:ok, dsl_state} <- maybe_build_action( dsl_state, @@ -103,7 +110,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do ), :ok <- validate_destroy_action(dsl_state, destroy_action), - {:ok, read_action} <- ProviderIdentity.Info.read_action_name(dsl_state), + {:ok, read_action} <- + UserIdentity.Info.user_identity_read_action_name(dsl_state), {:ok, dsl_state} <- maybe_build_action(dsl_state, read_action, &build_read_action(&1, read_action)), :ok <- validate_read_action(dsl_state, read_action) do @@ -112,11 +120,11 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do end defp validate_api_presence(dsl_state) do - case Transformer.get_option(dsl_state, [:provider_identity], :api) do + case Transformer.get_option(dsl_state, [:user_identity], :api) do nil -> {:error, DslError.exception( - path: [:provider_identity, :api], + path: [:user_identity, :api], message: "An API module must be present" )} @@ -135,7 +143,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do end end - defp validate_provider_field(dsl_state, field_name) do + defp validate_strategy_field(dsl_state, field_name) do with {:ok, resource} <- persisted_option(dsl_state, :module), {:ok, attribute} <- find_attribute(dsl_state, field_name), :ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]), @@ -190,7 +198,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do defp build_user_relationship(dsl_state, name, destination) do with {:ok, id_attr} <- find_pk(destination), {:ok, api} <- AshAuthentication.Info.authentication_api(destination), - {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state) do + {:ok, user_id} <- + UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state) do Transformer.build_entity(Resource.Dsl, [:relationships], :belongs_to, name: name, destination: destination, @@ -209,7 +218,8 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do with {:ok, id_attr} <- find_pk(destination), {:ok, api} <- AshAuthentication.Info.authentication_api(destination), {:ok, relationship} <- find_relationship(dsl_state, name), - {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state), + {:ok, user_id} <- + UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state), :ok <- validate_field_in_values(relationship, :destination, [destination]), :ok <- validate_field_in_values(relationship, :destination_attribute, [id_attr.name]), :ok <- validate_field_in_values(relationship, :source_attribute, [user_id]), @@ -219,11 +229,13 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do end defp build_upsert_action(dsl_state, action_name) do - with {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state), - {:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state), - {:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state), - {:ok, identity} <- find_identity(dsl_state, [user_id, uid, provider]), - {:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state), + with {:ok, user_id} <- + UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state), + {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state), + {:ok, strategy} <- + UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state), + {:ok, identity} <- find_identity(dsl_state, [user_id, uid, strategy]), + {:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state), {:ok, user_resource_id} <- find_pk(user_resource) do arguments = [ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument, @@ -245,7 +257,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do changes = [ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change, - change: ProviderIdentity.UpsertIdentityChange + change: UserIdentity.UpsertIdentityChange ) ] @@ -255,7 +267,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do upsert_identity: identity.name, arguments: arguments, changes: changes, - accept: [provider] + accept: [strategy] ) end end @@ -266,22 +278,24 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do :ok <- validate_action_argument_option(action, :user_info, :allow_nil?, [false]), :ok <- validate_action_argument_option(action, :oauth_tokens, :type, [:map, Type.Map]), :ok <- validate_action_argument_option(action, :oauth_tokens, :allow_nil?, [false]), - :ok <- validate_action_has_change(action, ProviderIdentity.UpsertIdentityChange), + :ok <- validate_action_has_change(action, UserIdentity.UpsertIdentityChange), :ok <- validate_field_in_values(action, :type, [:create]), :ok <- validate_field_in_values(action, :upsert?, [true]), - {:ok, user_id} <- ProviderIdentity.Info.user_id_attribute_name(dsl_state), - {:ok, user_resource} <- ProviderIdentity.Info.user_resource(dsl_state), + {:ok, user_id} <- + UserIdentity.Info.user_identity_user_id_attribute_name(dsl_state), + {:ok, user_resource} <- UserIdentity.Info.user_identity_user_resource(dsl_state), {:ok, user_resource_id} <- find_pk(user_resource), :ok <- validate_action_argument_option(action, user_id, :type, [user_resource_id.type]), :ok <- validate_action_argument_option(action, user_id, :allow_nil?, [false]), - {:ok, uid} <- ProviderIdentity.Info.uid_attribute_name(dsl_state), - {:ok, provider} <- ProviderIdentity.Info.provider_attribute_name(dsl_state), - {:ok, identity} <- find_identity(dsl_state, [uid, user_id, provider]), + {:ok, uid} <- UserIdentity.Info.user_identity_uid_attribute_name(dsl_state), + {:ok, strategy} <- + UserIdentity.Info.user_identity_strategy_attribute_name(dsl_state), + {:ok, identity} <- find_identity(dsl_state, [uid, user_id, strategy]), :ok <- validate_field_in_values(action, :upsert_identity, [identity.name]) do :ok else {:error, reason} when is_binary(reason) -> - {:error, DslError.exception(path: [:provider_identity], message: reason)} + {:error, DslError.exception(path: [:user_identity], message: reason)} {:error, reason} -> {:error, reason} @@ -289,7 +303,7 @@ defmodule AshAuthentication.ProviderIdentity.Transformer do :error -> {:error, DslError.exception( - path: [:provider_identity], + path: [:user_identity], message: "Configuration error while validating upsert action." )} end diff --git a/lib/ash_authentication/provider_identity/upsert_identity_change.ex b/lib/ash_authentication/user_identity/upsert_identity_change.ex similarity index 82% rename from lib/ash_authentication/provider_identity/upsert_identity_change.ex rename to lib/ash_authentication/user_identity/upsert_identity_change.ex index 3b0e63e..9236158 100644 --- a/lib/ash_authentication/provider_identity/upsert_identity_change.ex +++ b/lib/ash_authentication/user_identity/upsert_identity_change.ex @@ -1,6 +1,6 @@ -defmodule AshAuthentication.ProviderIdentity.UpsertIdentityChange do +defmodule AshAuthentication.UserIdentity.UpsertIdentityChange do @moduledoc """ - A change which upserts a user's identity into the identity provider resource. + A change which upserts a user's identity into the user identity resource. Expects the following arguments: @@ -9,18 +9,21 @@ defmodule AshAuthentication.ProviderIdentity.UpsertIdentityChange do - `oauth_tokens` a map with string keys containing the OAuth2 token response. - `user_id` the ID of the user this identity relates to. - - `provider` the name of the provider. + - `strategy` the name of the strategy. + + This is usually dynamically inserted into a generated action, however you can + add it to your own action if needed. """ use Ash.Resource.Change alias Ash.{Changeset, Resource.Change} - alias AshAuthentication.ProviderIdentity.Info + alias AshAuthentication.UserIdentity.Info @doc false @impl true @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t() def change(changeset, _opts, _context) do - cfg = Info.options(changeset.resource) + cfg = Info.user_identity_options(changeset.resource) user_info = Changeset.get_argument(changeset, :user_info) oauth_tokens = Changeset.get_argument(changeset, :oauth_tokens) diff --git a/lib/ash_authentication/utils.ex b/lib/ash_authentication/utils.ex index 1e5e48b..113f9df 100644 --- a/lib/ash_authentication/utils.ex +++ b/lib/ash_authentication/utils.ex @@ -130,4 +130,22 @@ defmodule AshAuthentication.Utils do relationship -> {:ok, relationship} end end + + @doc """ + Optionally set a field in a map. + + Like `Map.put_new/3` except that it overwrites fields if their contents are + falsy. + """ + @spec maybe_set_field(map, any, any) :: map + def maybe_set_field(map, field, value) when is_falsy(:erlang.map_get(field, map)), + do: Map.put(map, field, value) + + def maybe_set_field(map, _field, _value), do: map + + def maybe_set_field_lazy(map, field, generator) + when is_falsy(:erlang.map_get(field, map)) and is_function(generator, 1), + do: Map.put(map, field, generator.(map)) + + def maybe_set_field_lazy(map, _field, _generator), do: map end diff --git a/lib/ash_authentication/validations.ex b/lib/ash_authentication/validations.ex index eea0a71..d8b8921 100644 --- a/lib/ash_authentication/validations.ex +++ b/lib/ash_authentication/validations.ex @@ -113,7 +113,7 @@ defmodule AshAuthentication.Validations do """ @spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()} def validate_token_generation_enabled(dsl_state) do - if AshAuthentication.Info.tokens_enabled?(dsl_state), + if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state), do: :ok, else: {:error, @@ -156,4 +156,18 @@ defmodule AshAuthentication.Validations do "The `#{inspect(extension)}` extension must also be present on this resource for password authentication to work." )} end + + @doc """ + Build an attribute if not present. + """ + @spec maybe_build_attribute(Dsl.t(), atom, (Dsl.t() -> {:ok, Attribute.t()})) :: {:ok, Dsl.t()} + def maybe_build_attribute(dsl_state, attribute_name, builder) do + with {:error, _} <- find_attribute(dsl_state, attribute_name), + {:ok, attribute} <- builder.(dsl_state) do + {:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)} + else + {:ok, attribute} when is_struct(attribute, Attribute) -> {:ok, dsl_state} + {:error, reason} -> {:error, reason} + end + end end diff --git a/mix.exs b/mix.exs index 4ed4005..9130587 100644 --- a/mix.exs +++ b/mix.exs @@ -30,8 +30,13 @@ defmodule AshAuthentication.MixProject do groups_for_modules: [ Extensions: [ AshAuthentication, - AshAuthentication.PasswordAuthentication, - AshAuthentication.TokenRevocation + AshAuthentication.TokenRevocation, + AshAuthentication.UserIdentity + ], + Strategies: [ + AshAuthentication.Strategy, + AshAuthentication.Strategy.Password, + AshAuthentication.Strategy.OAuth2 ], Cryptography: [ AshAuthentication.HashProvider, @@ -39,9 +44,7 @@ defmodule AshAuthentication.MixProject do AshAuthentication.Jwt, AshAuthentication.Jwt.Config ], - "Password Authentication": ~r/^AshAuthentication\.PasswordAuthentication.*/, Plug: ~r/^AshAuthentication\.Plug.*/, - "Token Revocation": ~r/^AshAuthentication\.TokenRevocation.*/, Internals: ~r/^AshAuthentication.*/ ] ] @@ -51,7 +54,8 @@ defmodule AshAuthentication.MixProject do def package do [ maintainers: [ - "James Harton " + "James Harton ", + "Zach Daniel " ], licenses: ["MIT"], links: %{ @@ -78,6 +82,7 @@ defmodule AshAuthentication.MixProject do defp deps do [ {:ash, "~> 2.4"}, + {:spark, "~> 0.2.12"}, {:jason, "~> 1.4"}, {:joken, "~> 2.5"}, {:plug, "~> 1.13"}, @@ -86,9 +91,8 @@ defmodule AshAuthentication.MixProject do {:castore, "~> 0.1"}, {:bcrypt_elixir, "~> 3.0"}, {:absinthe_plug, "~> 1.5", only: [:dev, :test]}, - # These two can be changed back to hex once the next release goes out. - {:ash_graphql, github: "ash-project/ash_graphql", only: [:dev, :test]}, - {:ash_json_api, github: "ash-project/ash_json_api", only: [:dev, :test]}, + {:ash_graphql, "~> 0.21", only: [:dev, :test]}, + {:ash_json_api, "~> 0.30", only: [:dev, :test]}, {:ash_postgres, "~> 1.1", only: [:dev, :test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index 5b43d50..9992dc1 100644 --- a/mix.lock +++ b/mix.lock @@ -1,10 +1,10 @@ %{ "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, - "ash": {:hex, :ash, "2.4.10", "c3d17521515b05559ef1a592b421a6120d15679c07e84df4cd80b8df08088542", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 0.2 and >= 0.2.10", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e31630ccb9a42f092bc5ca0181d7cf34adef5a48df923ea42dc4256822bbbd89"}, - "ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []}, - "ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []}, - "ash_postgres": {:hex, :ash_postgres, "1.1.2", "1afd8ac43e68de8a92d22c8e8f3c36552665bc1c91dcdc4e5945d4e88c606bbe", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "0877e0d5e7ff36b7c6b0f22ce95ed8980695a0ba309cba77c60cf911c9678854"}, + "ash": {:hex, :ash, "2.4.2", "ba579e6654c32b1da49f17938d2f1445066f27e61eedbf0fae431b816b49d1be", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, ">= 0.2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "da8f94a19cf29617526ca2b1a75f6fae804c1db7c825b49982c603f503a615bd"}, + "ash_graphql": {:hex, :ash_graphql, "0.21.0", "b22b7786895552ef7bd4082815da5d895529dc9f81606a4a195f5790d163ebde", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 2.4.0", [hex: :ash, repo: "hexpm", optional: false]}, {:dataloader, "~> 1.0", [hex: :dataloader, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8b71047d86d7c279d4b6e185a33239a0a06486a0d574e9cc58628234e07aa5fc"}, + "ash_json_api": {:hex, :ash_json_api, "0.30.1", "54e60c4862eee35ed8a9a925e5c99be2b80e36a2507355bdb0f0974defe82a8d", [:mix], [{:ash, "~> 2.0", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b8b4827aa02de75a9a48d2941e813947da46b7dbfdd84cd20959dbaec103f830"}, + "ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"}, "assent": {:hex, :assent, "0.2.1", "46ad0ed92b72330f38c60bc03c528e8408475dc386f48d4ecd18833cfa581b9f", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "58c558b6029ffa287e15b38c8e07cd99f0b24e4846c52abad0c0a6225c4873bc"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, @@ -24,13 +24,13 @@ "docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"}, "doctor": {:hex, :doctor, "0.20.0", "2a8ff8f87eaf3fc78f20ffcfa7a3181f2bdb6a115a4abd52582e6156a89649a5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "36ba43bdf7d799c41e1dc00b3429eb48bc5d4dc3f63b181ca1aa8829ec638862"}, "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, - "ecto": {:hex, :ecto, "3.9.2", "017db3bc786ff64271108522c01a5d3f6ba0aea5c84912cfb0dd73bf13684108", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "21466d5177e09e55289ac7eade579a642578242c7a3a9f91ad5c6583337a9d15"}, - "ecto_sql": {:hex, :ecto_sql, "3.9.1", "9bd5894eecc53d5b39d0c95180d4466aff00e10679e13a5cfa725f6f85c03c22", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fd470a4fff2e829bbf9dcceb7f3f9f6d1e49b4241e802f614de6b8b67c51118"}, + "ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"}, + "ecto_sql": {:hex, :ecto_sql, "3.9.0", "2bb21210a2a13317e098a420a8c1cc58b0c3421ab8e3acfa96417dab7817918c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8f3f720073b8b1ac4c978be25fa7960ed7fd44997420c304a4a2e200b596453"}, "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"}, "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "ef2401be49e8471abb13ad1805067231973fecca", []}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"}, - "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, + "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"}, "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"}, diff --git a/priv/repo/migrations/20221002235526_migrate_resources1.exs b/priv/repo/migrations/20221002235526_migrate_resources1.exs index 6271d98..2c68cd2 100644 --- a/priv/repo/migrations/20221002235526_migrate_resources1.exs +++ b/priv/repo/migrations/20221002235526_migrate_resources1.exs @@ -8,7 +8,7 @@ defmodule Example.Repo.Migrations.MigrateResources1 do use Ecto.Migration def up do - create table(:user_with_username, primary_key: false) do + create table(:user, primary_key: false) do add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true add :username, :citext, null: false add :hashed_password, :text, null: false @@ -18,6 +18,6 @@ defmodule Example.Repo.Migrations.MigrateResources1 do end def down do - drop table(:user_with_username) + drop table(:user) end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20221020042559_add_token_revocation_table.exs b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs index e476790..d47001a 100644 --- a/priv/repo/migrations/20221020042559_add_token_revocation_table.exs +++ b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs @@ -8,7 +8,7 @@ defmodule Example.Repo.Migrations.AddTokenRevocationTable do use Ecto.Migration def up do - create unique_index(:user_with_username, [:username], + create unique_index(:user, [:username], name: "user_with_username_username_index" ) @@ -21,8 +21,8 @@ defmodule Example.Repo.Migrations.AddTokenRevocationTable do def down do drop table(:token_revocations) - drop_if_exists unique_index(:user_with_username, [:username], + drop_if_exists unique_index(:user, [:username], name: "user_with_username_username_index" ) end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs b/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs index ccc515c..99d2247 100644 --- a/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs +++ b/priv/repo/migrations/20221104032457_add_confirmed_at_to_user_wuth_username.exs @@ -8,14 +8,14 @@ defmodule Example.Repo.Migrations.AddConfirmedAtToUserWuthUsername do use Ecto.Migration def up do - alter table(:user_with_username) do + alter table(:user) do add :confirmed_at, :utc_datetime_usec end end def down do - alter table(:user_with_username) do + alter table(:user) do remove :confirmed_at end end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs b/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs index 08e5edf..d4ac631 100644 --- a/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs +++ b/priv/repo/migrations/20221107021255_remove_non_null_from_hashed_password.exs @@ -8,14 +8,14 @@ defmodule Example.Repo.Migrations.RemoveNonNullFromHashedPassword do use Ecto.Migration def up do - alter table(:user_with_username) do + alter table(:user) do modify :hashed_password, :text, null: true end end def down do - alter table(:user_with_username) do + alter table(:user) do modify :hashed_password, :text, null: false end end -end \ No newline at end of file +end diff --git a/priv/repo/migrations/20221109212946_add_user_identitis_table.exs b/priv/repo/migrations/20221109212946_add_user_identitis_table.exs index 49daf76..efdfb51 100644 --- a/priv/repo/migrations/20221109212946_add_user_identitis_table.exs +++ b/priv/repo/migrations/20221109212946_add_user_identitis_table.exs @@ -13,11 +13,11 @@ defmodule Example.Repo.Migrations.AddUserIdentitisTable do add :access_token_expires_at, :utc_datetime_usec add :access_token, :text add :uid, :text, null: false - add :provider, :text, null: false + add :strategy, :text, null: false add :id, :uuid, null: false, primary_key: true add :user_id, - references(:user_with_username, + references(:user, column: :id, name: "user_identities_user_id_fkey", type: :uuid, @@ -25,18 +25,18 @@ defmodule Example.Repo.Migrations.AddUserIdentitisTable do ) end - create unique_index(:user_identities, [:provider, :uid, :user_id], - name: "user_identities_unique_on_provider_and_uid_and_user_id_index" + create unique_index(:user_identities, [:strategy, :uid, :user_id], + name: "user_identities_unique_on_strategy_and_uid_and_user_id_index" ) end def down do - drop_if_exists unique_index(:user_identities, [:provider, :uid, :user_id], - name: "user_identities_unique_on_provider_and_uid_and_user_id_index" + drop_if_exists unique_index(:user_identities, [:strategy, :uid, :user_id], + name: "user_identities_unique_on_strategy_and_uid_and_user_id_index" ) drop constraint(:user_identities, "user_identities_user_id_fkey") drop table(:user_identities) end -end \ No newline at end of file +end diff --git a/priv/resource_snapshots/repo/user_identities/20221109212946.json b/priv/resource_snapshots/repo/user_identities/20221109212946.json index 2b8f698..edfaeb6 100644 --- a/priv/resource_snapshots/repo/user_identities/20221109212946.json +++ b/priv/resource_snapshots/repo/user_identities/20221109212946.json @@ -78,7 +78,7 @@ "on_delete": null, "on_update": null, "schema": "public", - "table": "user_with_username" + "table": "user" }, "size": null, "source": "user_id", @@ -111,4 +111,4 @@ "repo": "Elixir.Example.Repo", "schema": null, "table": "user_identities" -} \ No newline at end of file +} diff --git a/priv/resource_snapshots/repo/user_with_username/20221002235526.json b/priv/resource_snapshots/repo/user_with_username/20221002235526.json index 07a45d6..ad521ea 100644 --- a/priv/resource_snapshots/repo/user_with_username/20221002235526.json +++ b/priv/resource_snapshots/repo/user_with_username/20221002235526.json @@ -65,5 +65,5 @@ }, "repo": "Elixir.Example.Repo", "schema": null, - "table": "user_with_username" -} \ No newline at end of file + "table": "user" +} diff --git a/priv/resource_snapshots/repo/user_with_username/20221020042559.json b/priv/resource_snapshots/repo/user_with_username/20221020042559.json index 19c8214..e0ed3d2 100644 --- a/priv/resource_snapshots/repo/user_with_username/20221020042559.json +++ b/priv/resource_snapshots/repo/user_with_username/20221020042559.json @@ -74,5 +74,5 @@ }, "repo": "Elixir.Example.Repo", "schema": null, - "table": "user_with_username" -} \ No newline at end of file + "table": "user" +} diff --git a/priv/resource_snapshots/repo/user_with_username/20221104032457.json b/priv/resource_snapshots/repo/user_with_username/20221104032457.json index 46a67c9..1e1ae1c 100644 --- a/priv/resource_snapshots/repo/user_with_username/20221104032457.json +++ b/priv/resource_snapshots/repo/user_with_username/20221104032457.json @@ -84,5 +84,5 @@ }, "repo": "Elixir.Example.Repo", "schema": null, - "table": "user_with_username" -} \ No newline at end of file + "table": "user" +} diff --git a/priv/resource_snapshots/repo/user_with_username/20221107021255.json b/priv/resource_snapshots/repo/user_with_username/20221107021255.json index 7a2a178..16f3a7f 100644 --- a/priv/resource_snapshots/repo/user_with_username/20221107021255.json +++ b/priv/resource_snapshots/repo/user_with_username/20221107021255.json @@ -84,5 +84,5 @@ }, "repo": "Elixir.Example.Repo", "schema": null, - "table": "user_with_username" -} \ No newline at end of file + "table": "user" +} diff --git a/test/ash_authentication/bcrypt_provider_test.exs b/test/ash_authentication/bcrypt_provider_test.exs new file mode 100644 index 0000000..0cf136e --- /dev/null +++ b/test/ash_authentication/bcrypt_provider_test.exs @@ -0,0 +1,6 @@ +defmodule AshAuthentication.BcryptProviderTest do + @moduledoc false + use ExUnit.Case, async: true + import AshAuthentication.BcryptProvider + doctest AshAuthentication.BcryptProvider +end diff --git a/test/ash_authentication/confirmation_test.exs b/test/ash_authentication/confirmation_test.exs deleted file mode 100644 index fb2d794..0000000 --- a/test/ash_authentication/confirmation_test.exs +++ /dev/null @@ -1,47 +0,0 @@ -defmodule AshAuthentication.ConfirmationTest do - @moduledoc false - use AshAuthentication.DataCase, async: true - alias Ash.Changeset - alias AshAuthentication.Confirmation - - describe "confirmation_token_for/2" do - test "it returns an error when passed a resource which doesn't support confirmation" do - token_revocation = build_token_revocation() - changeset = Changeset.for_update(token_revocation, :update, %{jti: Ecto.UUID.generate()}) - - assert {:error, reason} = Confirmation.confirmation_token_for(changeset, token_revocation) - assert reason =~ ~r/confirmation not supported/i - end - - test "it returns a confirmation token" do - user = build_user() - changeset = Changeset.for_update(user, :update, %{username: username()}) - - assert {:ok, token} = Confirmation.confirmation_token_for(changeset, user) - assert token =~ ~r/^[\w\._-]+$/ - end - end - - describe "confirm/2" do - test "creates can be confirmed" do - user = build_user() - - refute user.confirmed_at - - token = user.__metadata__.confirmation_token - - assert token =~ ~r/^[\w\._-]+$/ - - assert {:ok, updated_user} = - Confirmation.confirm(Example.UserWithUsername, %{"confirm" => token}) - - assert updated_user.id == user.id - - assert_in_delta( - DateTime.to_unix(updated_user.confirmed_at), - DateTime.to_unix(DateTime.utc_now()), - 1.0 - ) - end - end -end diff --git a/test/ash_authentication/jwt/config_test.exs b/test/ash_authentication/jwt/config_test.exs index a580491..b5b4410 100644 --- a/test/ash_authentication/jwt/config_test.exs +++ b/test/ash_authentication/jwt/config_test.exs @@ -6,7 +6,7 @@ defmodule AshAuthentication.Jwt.ConfigTest do describe "default_claims/1" do test "it is a token config" do - claims = Config.default_claims(Example.UserWithUsername) + claims = Config.default_claims(Example.User) assert is_map(claims) assert Enum.all?(claims, fn {name, config} -> @@ -54,20 +54,20 @@ defmodule AshAuthentication.Jwt.ConfigTest do TokenRevocation |> stub(:revoked?, fn _, _ -> false end) - assert Config.validate_jti("fake jti", nil, %{resource: Example.UserWithUsername}) + assert Config.validate_jti("fake jti", nil, Example.User) end test "is false when the token has been revoked" do TokenRevocation |> stub(:revoked?, fn _, _ -> true end) - assert Config.validate_jti("fake jti", nil, %{resource: Example.UserWithUsername}) + assert Config.validate_jti("fake jti", nil, Example.User) end end describe "token_signer/1" do test "it returns a signer configuration" do - assert %Joken.Signer{} = Config.token_signer(Example.UserWithUsername) + assert %Joken.Signer{} = Config.token_signer(Example.User) end end end diff --git a/test/ash_authentication/jwt_test.exs b/test/ash_authentication/jwt_test.exs index 77096d0..3dd4d94 100644 --- a/test/ash_authentication/jwt_test.exs +++ b/test/ash_authentication/jwt_test.exs @@ -1,6 +1,6 @@ defmodule AshAuthentication.JwtTest do @moduledoc false - use AshAuthentication.DataCase, async: true + use DataCase, async: true alias AshAuthentication.Jwt describe "default_algorithm/0" do @@ -29,10 +29,10 @@ defmodule AshAuthentication.JwtTest do end end - describe "token_for_record/1" do + describe "token_for_user/1" do test "correctly generates and signs tokens" do user = build_user() - assert {:ok, token, claims} = Jwt.token_for_record(user) + assert {:ok, token, claims} = Jwt.token_for_user(user) now = DateTime.utc_now() |> DateTime.to_unix() @@ -43,21 +43,21 @@ defmodule AshAuthentication.JwtTest do assert claims["iss"] =~ ~r/^AshAuthentication v\d\.\d\.\d$/ assert claims["jti"] =~ ~r/^[0-9a-z]+$/ assert_in_delta(claims["nbf"], now, 1.5) - assert claims["sub"] == "user_with_username?id=#{user.id}" + assert claims["sub"] == "user?id=#{user.id}" end end describe "verify/2" do test "it is successful when given a valid token and the correct otp app" do - {:ok, token, actual_claims} = build_user() |> Jwt.token_for_record() + {:ok, token, actual_claims} = build_user() |> Jwt.token_for_user() - assert {:ok, validated_claims, config} = Jwt.verify(token, :ash_authentication) + assert {:ok, validated_claims, resource} = Jwt.verify(token, :ash_authentication) assert validated_claims == actual_claims - assert config.resource == Example.UserWithUsername + assert resource == Example.User end test "it is unsuccessful when the token signature isn't correct" do - {:ok, token, _} = build_user() |> Jwt.token_for_record() + {:ok, token, _} = build_user() |> Jwt.token_for_user() # mangle the token. [header, payload, signature] = String.split(token, ".") @@ -67,7 +67,7 @@ defmodule AshAuthentication.JwtTest do end test "it is unsuccessful when the token has been revoked" do - {:ok, token, _} = build_user() |> Jwt.token_for_record() + {:ok, token, _} = build_user() |> Jwt.token_for_user() AshAuthentication.TokenRevocation.revoke(Example.TokenRevocation, token) diff --git a/test/ash_authentication/oauth2_authentication_text.exs b/test/ash_authentication/oauth2_authentication_text.exs deleted file mode 100644 index 34aa958..0000000 --- a/test/ash_authentication/oauth2_authentication_text.exs +++ /dev/null @@ -1,5 +0,0 @@ -defmodule AshAuthentication.OAuth2AuthenticationTest do - @moduledoc false - use ExUnit.Case, async: true - doctest AshAuthentication.OAuth2Authentication -end diff --git a/test/ash_authentication/password_authentication/action_test.exs b/test/ash_authentication/password_authentication/action_test.exs deleted file mode 100644 index 9896573..0000000 --- a/test/ash_authentication/password_authentication/action_test.exs +++ /dev/null @@ -1,166 +0,0 @@ -defmodule AshAuthentication.PasswordAuthentication.ActionTest do - @moduledoc false - use AshAuthentication.DataCase, async: true - alias Ash.{Changeset, Query} - alias AshAuthentication.PasswordAuthentication.Info - - describe "register action" do - @describetag resource: Example.UserWithUsername - setup :resource_config - - test "password confirmation is verified", %{config: config, resource: resource} do - assert {:error, error} = - resource - |> Changeset.for_create(:register, %{ - config.identity_field => username(), - config.password_field => password(), - config.password_confirmation_field => password() - }) - |> Example.create() - - assert Exception.message(error) =~ "#{config.password_confirmation_field}: does not match" - end - - test "users can be created", %{config: config, resource: resource} do - password = password() - - attrs = %{ - config.identity_field => username(), - config.password_field => password, - config.password_confirmation_field => password - } - - assert {:ok, user} = - resource - |> Changeset.for_create(:register, attrs) - |> Example.create() - - refute is_nil(user.id) - - created_username = user |> Map.fetch!(config.identity_field) |> to_string() - - assert created_username == Map.get(attrs, config.identity_field) - end - - test "the password is hashed correctly", %{config: config, resource: resource} do - password = password() - - assert user = - resource - |> Changeset.for_create(:register, %{ - config.identity_field => username(), - config.password_field => password, - config.password_confirmation_field => password - }) - |> Example.create!() - - assert {:ok, hashed} = Map.fetch(user, config.hashed_password_field) - assert hashed != password - - assert config.hash_provider.valid?(password, hashed) - end - end - - describe "sign_in action" do - @describetag resource: Example.UserWithUsername - setup :resource_config - - test "when the user doesn't exist, it returns an empty result", %{ - config: config, - resource: resource - } do - assert {:error, _} = - resource - |> Query.for_read(:sign_in, %{ - config.identity_field => username(), - config.password_field => password() - }) - |> Example.read() - end - - test "when the user exists, but the password is incorrect, it returns an empty result", %{ - config: config, - resource: resource - } do - username = username() - password = password() - - resource - |> Changeset.for_create(:register, %{ - config.identity_field => username, - config.password_field => password, - config.password_confirmation_field => password - }) - |> Example.create!() - - assert {:error, _} = - resource - |> Query.for_read(:sign_in, %{ - config.identity_field => username, - config.password_field => password() - }) - |> Example.read() - end - - test "when the user exists, and the password is correct, it returns the user", %{ - config: config, - resource: resource - } do - username = username() - password = password() - - expected = - resource - |> Changeset.for_create(:register, %{ - config.identity_field => username, - config.password_field => password, - config.password_confirmation_field => password - }) - |> Example.create!() - - assert {:ok, [actual]} = - resource - |> Query.for_read(:sign_in, %{ - config.identity_field => username, - config.password_field => password - }) - |> Example.read() - - assert actual.id == expected.id - end - - test "when the user exists, and the password is correct it generates a token", %{ - config: config, - resource: resource - } do - username = username() - password = password() - - resource - |> Changeset.for_create(:register, %{ - config.identity_field => username, - config.password_field => password, - config.password_confirmation_field => password - }) - |> Example.create!() - - assert {:ok, [user]} = - resource - |> Query.for_read(:sign_in, %{ - config.identity_field => username, - config.password_field => password - }) - |> Example.read() - - assert is_binary(user.__metadata__.token) - end - end - - defp resource_config(%{resource: resource}) do - config = - resource - |> Info.password_authentication_options() - - {:ok, config: config} - end -end diff --git a/test/ash_authentication/password_authentication/identity_test.exs b/test/ash_authentication/password_authentication/identity_test.exs deleted file mode 100644 index 7ff5541..0000000 --- a/test/ash_authentication/password_authentication/identity_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -defmodule AshAuthentication.IdentityTest do - @moduledoc false - use AshAuthentication.DataCase, async: true - alias Ash.Error - alias AshAuthentication.{PasswordAuthentication, PasswordAuthentication.Info} - - describe "sign_in_action/2" do - @describetag resource: Example.UserWithUsername - setup :resource_config - - test "when provided invalid credentials", %{resource: resource, config: config} do - assert {:error, error} = - PasswordAuthentication.sign_in_action(resource, %{ - config.identity_field => username(), - config.password_field => password() - }) - - assert Error.error_messages(error.errors, "", false) =~ "Authentication failed" - end - - test "when provided valid credentials", %{resource: resource, config: config} do - username = username() - password = password() - - {:ok, expected} = - PasswordAuthentication.register_action(resource, %{ - config.identity_field => username, - config.password_field => password, - config.password_confirmation_field => password - }) - - assert {:ok, actual} = - PasswordAuthentication.sign_in_action(resource, %{ - config.identity_field => username, - config.password_field => password - }) - - assert actual.id == expected.id - end - end - - defp resource_config(%{resource: resource}) do - config = - resource - |> Info.password_authentication_options() - - {:ok, config: config} - end -end diff --git a/test/ash_authentication/password_reset_test.exs b/test/ash_authentication/password_reset_test.exs deleted file mode 100644 index 5c3a0a0..0000000 --- a/test/ash_authentication/password_reset_test.exs +++ /dev/null @@ -1,106 +0,0 @@ -defmodule AshAuthentication.PasswordResetTest do - @moduledoc false - use AshAuthentication.DataCase, async: true - alias AshAuthentication.PasswordReset - import ExUnit.CaptureLog - - describe "enabled?/1" do - test "is false when the resource doesn't support password resets" do - refute PasswordReset.enabled?(Example.TokenRevocation) - end - - test "it is true when the resource does support password resets" do - assert PasswordReset.enabled?(Example.UserWithUsername) - end - end - - describe "reset_password_request/1" do - test "when the user is found, it returns ok" do - user = build_user() - - assert :ok = - PasswordReset.request_password_reset(Example.UserWithUsername, %{ - "username" => user.username - }) - end - - test "when the user is not found, it returns ok" do - assert :ok = - PasswordReset.request_password_reset(Example.UserWithUsername, %{ - "username" => username() - }) - end - - test "when the user is found it sends the reset instructions" do - user = build_user() - - log = - capture_log(fn -> - PasswordReset.request_password_reset(Example.UserWithUsername, %{ - "username" => user.username - }) - end) - - assert log =~ ~r/Password reset request/i - end - - test "when the user is not found, it doesn't send reset instructions" do - refute capture_log(fn -> - PasswordReset.request_password_reset(Example.UserWithUsername, %{ - "username" => username() - }) - end) =~ ~r/Password reset request/i - end - end - - describe "reset_password/2" do - test "when the reset token is valid, it can change the password" do - user = build_user() - {:ok, token} = PasswordReset.reset_token_for(user) - password = password() - - attrs = %{ - "reset_token" => token, - "password" => password, - "password_confirmation" => password - } - - {:ok, new_user} = PasswordReset.reset_password(Example.UserWithUsername, attrs) - - assert new_user.hashed_password != user.hashed_password - end - - test "when the reset token is invalid, it doesn't change the password" do - user = build_user() - - password = password() - - attrs = %{ - "reset_token" => Ecto.UUID.generate(), - "password" => password, - "password_confirmation" => password - } - - assert {:error, _} = PasswordReset.reset_password(Example.UserWithUsername, attrs) - - {:ok, reloaded_user} = Example.get(Example.UserWithUsername, id: user.id) - assert reloaded_user.hashed_password == user.hashed_password - end - end - - describe "reset_token_for/1" do - test "when given a resource which supports password resets, it generates a token" do - assert {:ok, token} = - build_user() - |> PasswordReset.reset_token_for() - - assert token =~ ~r/^[\w\.-]+$/ - end - - test "when given a resource which doesn't support password resets, it returns an error" do - assert :error = - build_token_revocation() - |> PasswordReset.reset_token_for() - end - end -end diff --git a/test/ash_authentication/plug/defaults_test.exs b/test/ash_authentication/plug/defaults_test.exs index cb62de5..e47474c 100644 --- a/test/ash_authentication/plug/defaults_test.exs +++ b/test/ash_authentication/plug/defaults_test.exs @@ -1,7 +1,7 @@ defmodule AshAuthentication.Plug.DefaultsTest do @moduledoc false - use AshAuthentication.DataCase, async: true - alias AshAuthentication.{Plug.Defaults, SessionPipeline} + use DataCase, async: true + alias AshAuthentication.{Plug.Defaults} import Plug.Test, only: [conn: 3] setup do @@ -19,7 +19,7 @@ defmodule AshAuthentication.Plug.DefaultsTest do conn = conn - |> Defaults.handle_success(user, user.__metadata__.token) + |> Defaults.handle_success({nil, nil}, user, user.__metadata__.token) assert conn.status == 200 assert conn.resp_body =~ ~r/access granted/i @@ -30,7 +30,7 @@ defmodule AshAuthentication.Plug.DefaultsTest do test "it returns 401 and a basic message", %{conn: conn} do conn = conn - |> Defaults.handle_failure(:arbitrary_reason) + |> Defaults.handle_failure({nil, nil}, :arbitrary_reason) assert conn.status == 401 assert conn.resp_body =~ ~r/access denied/i diff --git a/test/ash_authentication/plug/helpers_test.exs b/test/ash_authentication/plug/helpers_test.exs index c8cbc04..441c212 100644 --- a/test/ash_authentication/plug/helpers_test.exs +++ b/test/ash_authentication/plug/helpers_test.exs @@ -1,7 +1,7 @@ defmodule AshAuthentication.Plug.HelpersTest do @moduledoc false - use AshAuthentication.DataCase, async: true - alias AshAuthentication.{Jwt, Plug.Helpers, SessionPipeline} + use DataCase, async: true + alias AshAuthentication.{Jwt, Plug.Helpers} import Plug.Test, only: [conn: 3] alias Plug.Conn @@ -17,38 +17,38 @@ defmodule AshAuthentication.Plug.HelpersTest do describe "store_in_session/2" do test "it stores the user in the session", %{conn: conn} do user = build_user() - subject = AshAuthentication.resource_to_subject(user) + subject = AshAuthentication.user_to_subject(user) conn = conn |> Helpers.store_in_session(user) - assert conn.private.plug_session["user_with_username"] == subject + assert conn.private.plug_session["user"] == subject end end describe "load_subjects/2" do test "it loads the subjects listed" do user = build_user() - subject = AshAuthentication.resource_to_subject(user) + subject = AshAuthentication.user_to_subject(user) rx_users = Helpers.load_subjects([subject], :ash_authentication) - assert rx_users[:current_user_with_username].id == user.id + assert rx_users[:current_user].id == user.id end end describe "retrieve_from_session/2" do test "it loads any subjects stored in the session", %{conn: conn} do user = build_user() - subject = AshAuthentication.resource_to_subject(user) + subject = AshAuthentication.user_to_subject(user) conn = conn - |> Conn.put_session("user_with_username", subject) + |> Conn.put_session("user", subject) |> Helpers.retrieve_from_session(:ash_authentication) - assert conn.assigns.current_user_with_username.id == user.id + assert conn.assigns.current_user.id == user.id end end @@ -61,7 +61,7 @@ defmodule AshAuthentication.Plug.HelpersTest do |> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}") |> Helpers.retrieve_from_bearer(:ash_authentication) - assert conn.assigns.current_user_with_username.id == user.id + assert conn.assigns.current_user.id == user.id end end @@ -89,8 +89,8 @@ defmodule AshAuthentication.Plug.HelpersTest do conn = conn - |> Conn.assign(:current_user_with_username, user) - |> Helpers.set_actor(:user_with_username) + |> Conn.assign(:current_user, user) + |> Helpers.set_actor(:user) assert PlugHelpers.get_actor(conn) == user end @@ -98,22 +98,22 @@ defmodule AshAuthentication.Plug.HelpersTest do test "it sets the actor to `nil` otherwise", %{conn: conn} do conn = conn - |> Helpers.set_actor(:user_with_username) + |> Helpers.set_actor(:user) refute PlugHelpers.get_actor(conn) end end - describe "private_store/2" do + describe "store_authentication_result/2" do test "it stores the authentication result in the conn's private", %{conn: conn} do user = build_user() conn = conn |> Conn.put_private(:authenticator, %{resource: user.__struct__}) - |> Helpers.private_store({:success, user}) + |> Helpers.store_authentication_result({:ok, user}) - assert conn.private.authentication_result == {:success, user} + assert conn.private.authentication_result == {:ok, user} end end end diff --git a/test/ash_authentication/plug_test.exs b/test/ash_authentication/plug_test.exs index 5f527f6..6e3cb32 100644 --- a/test/ash_authentication/plug_test.exs +++ b/test/ash_authentication/plug_test.exs @@ -1,9 +1,9 @@ defmodule AshAuthentication.PlugTest do @moduledoc false - use AshAuthentication.DataCase, async: true + use DataCase, async: true use Mimic alias AshAuthentication.Plug.{Defaults, Helpers} - alias AshAuthentication.SessionPipeline + alias Example.AuthPlug import Plug.Test, only: [conn: 3] @@ -16,11 +16,10 @@ defmodule AshAuthentication.PlugTest do %{status: status, resp_body: resp} = :post - |> conn("/user_with_username/password/callback", %{ - "user_with_username" => %{ + |> conn("/user/password/sign_in", %{ + "user" => %{ "username" => to_string(user.username), - "password" => password, - "action" => "sign_in" + "password" => password } }) |> SessionPipeline.call([]) @@ -40,11 +39,10 @@ defmodule AshAuthentication.PlugTest do %{status: status, resp_body: resp} = :post - |> conn("/user_with_username/password/callback", %{ - "user_with_username" => %{ + |> conn("/user/password/sign_in", %{ + "user" => %{ "username" => username(), - "password" => password(), - "action" => "sign_in" + "password" => password() } }) |> SessionPipeline.call([]) @@ -54,7 +52,7 @@ defmodule AshAuthentication.PlugTest do assert status == 401 assert resp["status"] == "failure" - assert resp["reason"] =~ ~r/Forbidden/ + assert resp["reason"] =~ ~r/Forbidden/i end end @@ -109,12 +107,12 @@ defmodule AshAuthentication.PlugTest do Helpers |> expect(:set_actor, fn rx_conn, subject_name -> - assert subject_name == :user_with_username + assert subject_name == :user assert conn == rx_conn end) conn - |> AuthPlug.set_actor(:user_with_username) + |> AuthPlug.set_actor(:user) end end @@ -147,14 +145,14 @@ defmodule AshAuthentication.PlugTest do token = Ecto.UUID.generate() Defaults - |> expect(:handle_success, fn rx_conn, rx_user, rx_token -> + |> expect(:handle_success, fn rx_conn, {nil, nil}, rx_user, rx_token -> assert rx_conn == conn assert rx_user == user assert rx_token == token end) conn - |> WithDefaults.handle_success(user, token) + |> WithDefaults.handle_success({nil, nil}, user, token) end test "it uses the default handle_failure/2" do @@ -162,13 +160,13 @@ defmodule AshAuthentication.PlugTest do reason = Ecto.UUID.generate() Defaults - |> expect(:handle_failure, fn rx_conn, rx_reason -> + |> expect(:handle_failure, fn rx_conn, {nil, nil}, rx_reason -> assert rx_conn == conn assert rx_reason == reason end) conn - |> WithDefaults.handle_failure(reason) + |> WithDefaults.handle_failure({nil, nil}, reason) end end end diff --git a/test/ash_authentication/strategies/confirmation/actions_test.exs b/test/ash_authentication/strategies/confirmation/actions_test.exs new file mode 100644 index 0000000..9117835 --- /dev/null +++ b/test/ash_authentication/strategies/confirmation/actions_test.exs @@ -0,0 +1,53 @@ +defmodule AshAuthentication.Strategy.Confirmation.ActionsTest do + @moduledoc false + use DataCase, async: true + + alias Ash.Changeset + alias AshAuthentication.{Info, Strategy.Confirmation, Strategy.Confirmation.Actions} + + describe "confirm/2" do + test "it returns an error when there is no corresponding user" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + user = build_user() + + changeset = + user + |> Changeset.for_update(:update, %{"username" => username()}) + + {:ok, token} = Confirmation.confirmation_token(strategy, changeset) + + Example.Repo.delete!(user) + + assert {:error, error} = Actions.confirm(strategy, %{"confirm" => token}) + assert Exception.message(error) == "record not found" + end + + test "it returns an error when the token is invalid" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + + assert {:error, error} = Actions.confirm(strategy, %{"confirm" => Ecto.UUID.generate()}) + assert Exception.message(error) == "Invalid confirmation token" + end + + test "it updates the confirmed_at field" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + user = build_user() + new_username = username() + + changeset = + user + |> Changeset.for_update(:update, %{"username" => new_username}) + + {:ok, token} = Confirmation.confirmation_token(strategy, changeset) + + assert {:ok, confirmed_user} = Actions.confirm(strategy, %{"confirm" => token}) + + assert confirmed_user.id == user.id + assert to_string(confirmed_user.username) == new_username + + assert_in_delta DateTime.to_unix(confirmed_user.confirmed_at), + DateTime.to_unix(DateTime.utc_now()), + 1.0 + end + end +end diff --git a/test/ash_authentication/strategies/confirmation/plug_test.exs b/test/ash_authentication/strategies/confirmation/plug_test.exs new file mode 100644 index 0000000..97265ae --- /dev/null +++ b/test/ash_authentication/strategies/confirmation/plug_test.exs @@ -0,0 +1,82 @@ +defmodule AshAuthentication.Strategy.Confirmation.PlugTest do + @moduledoc false + use DataCase, async: true + import Plug.Test + + alias Ash.Changeset + + alias AshAuthentication.{ + Info, + Plug.Helpers, + Strategy.Confirmation, + Strategy.Confirmation.Plug + } + + describe "confirm/2" do + test "it returns an error when there is no corresponding user" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + user = build_user() + + {:ok, token} = + Confirmation.confirmation_token( + strategy, + Changeset.for_update(user, :update, %{"username" => username()}) + ) + + Example.Repo.delete!(user) + + params = %{ + "confirm" => token + } + + assert {_conn, {:error, error}} = + :get + |> conn("/", params) + |> Plug.confirm(strategy) + |> Helpers.get_authentication_result() + + assert Exception.message(error) == "record not found" + end + + test "it returns an error when the token is invalid" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + + params = %{ + "confirm" => Ecto.UUID.generate() + } + + assert {_conn, {:error, error}} = + :get + |> conn("/", params) + |> Plug.confirm(strategy) + |> Helpers.get_authentication_result() + + assert Exception.message(error) == "Invalid confirmation token" + end + + test "it returns a successful result" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + user = build_user() + + refute user.confirmed_at + + {:ok, token} = + Confirmation.confirmation_token( + strategy, + Changeset.for_update(user, :update, %{"username" => username()}) + ) + + params = %{ + "confirm" => token + } + + assert {_conn, {:ok, confirmed_user}} = + :get + |> conn("/", params) + |> Plug.confirm(strategy) + |> Helpers.get_authentication_result() + + assert confirmed_user.confirmed_at + end + end +end diff --git a/test/ash_authentication/strategies/confirmation/strategy_test.exs b/test/ash_authentication/strategies/confirmation/strategy_test.exs new file mode 100644 index 0000000..d900e5a --- /dev/null +++ b/test/ash_authentication/strategies/confirmation/strategy_test.exs @@ -0,0 +1,59 @@ +defmodule AshAuthentication.Strategy.Confirmation.StrategyTest do + @moduledoc false + use ExUnit.Case, async: true + + alias AshAuthentication.{Info, Strategy, Strategy.Confirmation} + + use Mimic + import Plug.Test + + describe "Strategy.phases/1" do + test "it returns the correct phase" do + assert [:confirm] = Strategy.phases(%Confirmation{}) + end + end + + describe "Strategy.actions/1" do + test "it returns the correct action" do + assert [:confirm] = Strategy.actions(%Confirmation{}) + end + end + + describe "Strategy.routes/1" do + test "it returns the correct route" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + + assert [{"/user/confirm", :confirm}] = Strategy.routes(strategy) + end + end + + describe "Strategy.plug/3" do + test "it delegates to `Confirmation.Plug.confirm/2` for the confirm phase" do + conn = conn(:get, "/") + strategy = %Confirmation{} + + Confirmation.Plug + |> expect(:confirm, fn rx_conn, rx_strategy -> + assert rx_conn == conn + assert rx_strategy == strategy + end) + + Strategy.plug(strategy, :confirm, conn) + end + end + + describe "Strategy.action/3" do + test "it delegates to `Confirmation.Actions.confirm/2` for the confirm action" do + strategy = %Confirmation{} + params = %{"confirm" => Ecto.UUID.generate()} + + Confirmation.Actions + |> expect(:confirm, fn rx_strategy, rx_params -> + assert rx_strategy == strategy + assert rx_params == params + end) + + Strategy.action(strategy, :confirm, params) + end + end +end diff --git a/test/ash_authentication/strategies/confirmation_test.exs b/test/ash_authentication/strategies/confirmation_test.exs new file mode 100644 index 0000000..248b078 --- /dev/null +++ b/test/ash_authentication/strategies/confirmation_test.exs @@ -0,0 +1,22 @@ +defmodule AshAuthentication.Strategy.ConfirmationTest do + @moduledoc false + use DataCase, async: true + alias Ash.Changeset + alias AshAuthentication.{Info, Jwt, Strategy.Confirmation} + doctest Confirmation + + describe "confirmation_token/2" do + test "it generates a confirmation token" do + {:ok, strategy} = Info.strategy(Example.User, :confirm) + user = build_user() + + new_username = username() + changeset = Changeset.for_update(user, :update, %{"username" => new_username}) + + assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset) + assert {:ok, claims} = Jwt.peek(token) + assert claims["act"] == to_string(strategy.confirm_action_name) + assert claims["chg"] == %{"username" => new_username} + end + end +end diff --git a/test/ash_authentication/strategies/oauth2/actions_test.exs b/test/ash_authentication/strategies/oauth2/actions_test.exs new file mode 100644 index 0000000..12812ba --- /dev/null +++ b/test/ash_authentication/strategies/oauth2/actions_test.exs @@ -0,0 +1,113 @@ +defmodule AshAuthentication.Strategy.OAuth2.ActionsTest do + @moduledoc false + use DataCase, async: true + + alias AshAuthentication.{Info, Jwt, Strategy.OAuth2.Actions} + + describe "sign_in/2" do + test "it returns an error when registration is enabled" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + + assert {:error, error} = + Actions.sign_in(strategy, %{"user_info" => %{}, "oauth_tokens" => %{}}) + + assert Exception.message(error) =~ ~r/no such action :sign_in_with_oauth2/i + end + + test "it signs in an existing user when registration is disabled" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + strategy = %{strategy | registration_enabled?: false} + user = build_user() + + assert {:ok, signed_in_user} = + Actions.sign_in(strategy, %{ + "user_info" => %{ + "nickname" => user.username, + "uid" => user.id, + "sub" => "user:#{user.id}" + }, + "oauth_tokens" => %{ + "access_token" => Ecto.UUID.generate(), + "expires_in" => 86_400, + "refresh_token" => Ecto.UUID.generate() + } + }) + + assert signed_in_user.id == user.id + assert {:ok, claims} = Jwt.peek(signed_in_user.__metadata__.token) + assert claims["sub"] =~ "user?id=#{user.id}" + end + + test "it denies sign in for non-existing users when registration is disabled" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + strategy = %{strategy | registration_enabled?: false} + + assert {:error, error} = + Actions.sign_in(strategy, %{ + "user_info" => %{ + "nickname" => username(), + "uid" => Ecto.UUID.generate(), + "sub" => "user:#{Ecto.UUID.generate()}" + }, + "oauth_tokens" => %{ + "access_token" => Ecto.UUID.generate(), + "expires_in" => 86_400, + "refresh_token" => Ecto.UUID.generate() + } + }) + + assert Exception.message(error) =~ ~r/authentication failed/i + end + end + + describe "register/2" do + test "it registers a non-existing user when registration is enabled" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + + username = username() + id = Ecto.UUID.generate() + + assert {:ok, user} = + Actions.register(strategy, %{ + "user_info" => %{ + "nickname" => username, + "uid" => id, + "sub" => "user:#{id}" + }, + "oauth_tokens" => %{ + "access_token" => Ecto.UUID.generate(), + "expires_in" => 86_400, + "refresh_token" => Ecto.UUID.generate() + } + }) + + assert to_string(user.username) == username + assert {:ok, claims} = Jwt.peek(user.__metadata__.token) + assert claims["sub"] =~ "user?id=#{user.id}" + end + + test "it signs in an existing user when registration is enabled" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + + user = build_user() + + assert {:ok, signed_in_user} = + Actions.register(strategy, %{ + "user_info" => %{ + "nickname" => user.username, + "uid" => user.id, + "sub" => "user:#{user.id}" + }, + "oauth_tokens" => %{ + "access_token" => Ecto.UUID.generate(), + "expires_in" => 86_400, + "refresh_token" => Ecto.UUID.generate() + } + }) + + assert signed_in_user.id == user.id + assert {:ok, claims} = Jwt.peek(signed_in_user.__metadata__.token) + assert claims["sub"] =~ "user?id=#{user.id}" + end + end +end diff --git a/test/ash_authentication/strategies/oauth2/plug_test.exs b/test/ash_authentication/strategies/oauth2/plug_test.exs new file mode 100644 index 0000000..558a4b4 --- /dev/null +++ b/test/ash_authentication/strategies/oauth2/plug_test.exs @@ -0,0 +1,31 @@ +defmodule AshAuthentication.Strategy.OAuth2.PlugTest do + @moduledoc false + use DataCase, async: true + import Plug.Conn + import Plug.Test + + alias AshAuthentication.{Info, Strategy.OAuth2.Plug} + + describe "request/2" do + test "it builds the redirect url and redirects the user" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + + assert conn = + :get + |> conn("/", %{}) + |> SessionPipeline.call([]) + |> Plug.request(strategy) + + assert conn.status == 302 + assert {"location", location} = Enum.find(conn.resp_headers, &(elem(&1, 0) == "location")) + assert String.starts_with?(location, "https://example.com/authorize?") + session = get_session(conn, "user/oauth2") + assert session.state =~ ~r/.+/ + end + end + + describe "callback/2" do + @tag skip: "not exactly sure the best way to test this" + test "it signs in or registers the user" + end +end diff --git a/test/ash_authentication/strategies/oauth2/strategy_test.exs b/test/ash_authentication/strategies/oauth2/strategy_test.exs new file mode 100644 index 0000000..023caa6 --- /dev/null +++ b/test/ash_authentication/strategies/oauth2/strategy_test.exs @@ -0,0 +1,93 @@ +defmodule AshAuthentication.Strategy.OAuth2.StrategyTest do + @moduledoc false + use ExUnit.Case, async: true + + alias AshAuthentication.{Info, Strategy, Strategy.OAuth2} + + use Mimic + import Plug.Test + + describe "Strategy.phases/1" do + test "it returns the correct phases" do + phases = + %OAuth2{} + |> Strategy.phases() + |> MapSet.new() + + assert MapSet.equal?(phases, MapSet.new(~w[request callback]a)) + end + end + + describe "Strategy.actions/1" do + test "it returns only register when registration is enabled" do + assert [:register] = Strategy.actions(%OAuth2{}) + end + + test "it returns only sign_in when registration is disabled" do + assert [:sign_in] = Strategy.actions(%OAuth2{registration_enabled?: false}) + end + end + + describe "Strategy.method_for_phase/2" do + test "it is get for the request phase" do + assert :get = Strategy.method_for_phase(%OAuth2{}, :request) + end + + test "it is post for the callback phase" do + assert :post = Strategy.method_for_phase(%OAuth2{}, :callback) + end + end + + describe "Strategy.routes/1" do + test "it returns the correct routes" do + {:ok, strategy} = Info.strategy(Example.User, :oauth2) + + routes = + strategy + |> Strategy.routes() + |> MapSet.new() + + assert MapSet.equal?( + routes, + MapSet.new([ + {"/user/oauth2", :request}, + {"/user/oauth2/callback", :callback} + ]) + ) + end + end + + describe "Strategy.plug/3" do + for phase <- ~w[request callback]a do + test "it delegates to `OAuth2.Plug.#{phase}/2` for the #{phase} phase" do + conn = conn(:get, "/") + strategy = %OAuth2{} + + OAuth2.Plug + |> expect(unquote(phase), fn rx_conn, rx_strategy -> + assert rx_conn == conn + assert rx_strategy == strategy + end) + + Strategy.plug(strategy, unquote(phase), conn) + end + end + end + + describe "Strategy.action/3" do + for action <- ~w[register sign_in]a do + test "it delegates to `OAuth2.Actions.#{action}/2` for the #{action} action" do + strategy = %OAuth2{} + params = %{"user_info" => %{}, "oauth_tokens" => %{}} + + OAuth2.Actions + |> expect(unquote(action), fn rx_strategy, rx_params -> + assert rx_strategy == strategy + assert rx_params == params + end) + + Strategy.action(strategy, unquote(action), params) + end + end + end +end diff --git a/test/ash_authentication/strategies/oauth2_test.exs b/test/ash_authentication/strategies/oauth2_test.exs new file mode 100644 index 0000000..e317ae8 --- /dev/null +++ b/test/ash_authentication/strategies/oauth2_test.exs @@ -0,0 +1,6 @@ +defmodule AshAuthentication.Strategy.OAuth2Test do + @moduledoc false + use DataCase, async: true + alias AshAuthentication.Strategy.OAuth2 + doctest OAuth2 +end diff --git a/test/ash_authentication/strategies/password/actions_test.exs b/test/ash_authentication/strategies/password/actions_test.exs new file mode 100644 index 0000000..616f4ae --- /dev/null +++ b/test/ash_authentication/strategies/password/actions_test.exs @@ -0,0 +1,149 @@ +defmodule AshAuthentication.Strategy.Password.ActionsTest do + @moduledoc false + use DataCase + import ExUnit.CaptureLog + + alias AshAuthentication.{ + Errors.AuthenticationFailed, + Info, + Jwt, + Strategy.Password, + Strategy.Password.Actions + } + + describe "sign_in/2" do + test "it signs the user in when the username and password are correct" do + user = build_user() + {:ok, strategy} = Info.strategy(Example.User, :password) + + assert {:ok, user} = + Actions.sign_in(strategy, %{ + "username" => user.username, + "password" => user.__metadata__.password + }) + + assert {:ok, claims} = Jwt.peek(user.__metadata__.token) + assert claims["sub"] =~ "user?id=#{user.id}" + end + + test "it returns an error when the username is correct but the password isn't" do + user = build_user() + {:ok, strategy} = Info.strategy(Example.User, :password) + + assert {:error, %AuthenticationFailed{}} = + Actions.sign_in(strategy, %{"username" => user.username, "password" => password()}) + end + + test "it returns an error when the username and password are incorrect" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + assert {:error, %AuthenticationFailed{}} = + Actions.sign_in(strategy, %{"username" => username(), "password" => password()}) + end + end + + describe "register/2" do + test "it can register a new user" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + username = username() + password = password() + + assert {:ok, user} = + Actions.register(strategy, %{ + "username" => username, + "password" => password, + "password_confirmation" => password + }) + + assert strategy.hash_provider.valid?(password, user.hashed_password) + + assert {:ok, claims} = Jwt.peek(user.__metadata__.token) + assert claims["sub"] =~ "user?id=#{user.id}" + end + + test "it returns an error if the user already exists" do + user = build_user() + {:ok, strategy} = Info.strategy(Example.User, :password) + + password = password() + + assert {:error, error} = + Actions.register(strategy, %{ + "username" => user.username, + "password" => password, + "password_confirmation" => password + }) + + assert Exception.message(error) =~ ~r/username: has already been taken/ + end + + test "it returns an error when the password and confirmation don't match" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + assert {:error, error} = + Actions.register(strategy, %{ + "username" => username(), + "password" => password(), + "password_confirmation" => password() + }) + + assert Exception.message(error) =~ ~r/password_confirmation: does not match/ + end + end + + describe "reset_request/2" do + test "it generates a reset token when a matching user exists and the strategy is resettable" do + user = build_user() + {:ok, strategy} = Info.strategy(Example.User, :password) + + log = + capture_log(fn -> + assert :ok = Actions.reset_request(strategy, %{"username" => user.username()}) + end) + + assert log =~ ~r/password reset request for user #{user.username}/i + end + + test "it doesn't generate a reset token when no matching user exists and the strategy is resettable" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + log = + capture_log(fn -> + assert :ok = Actions.reset_request(strategy, %{"username" => username()}) + end) + + refute log =~ ~r/password reset request for user/i + end + + test "it returns an error when the strategy is not resettable" do + {:ok, strategy} = Info.strategy(Example.User, :password) + strategy = %{strategy | resettable: []} + + assert {:error, error} = Actions.reset_request(strategy, %{"username" => username()}) + assert Exception.message(error) =~ ~r/no such action/i + end + end + + describe "reset/2" do + test "it resets the password when given a valid reset token" do + user = build_user() + {:ok, strategy} = Info.strategy(Example.User, :password) + assert {:ok, token} = Password.reset_token_for(strategy, user) + + new_password = password() + + params = %{ + "reset_token" => token, + "password" => new_password, + "password_confirmation" => new_password + } + + assert {:ok, updated_user} = Actions.reset(strategy, params) + + assert user.id == updated_user.id + assert user.hashed_password != updated_user.hashed_password + assert strategy.hash_provider.valid?(new_password, updated_user.hashed_password) + end + end +end diff --git a/test/ash_authentication/strategies/password/plug_test.exs b/test/ash_authentication/strategies/password/plug_test.exs new file mode 100644 index 0000000..13a1ee0 --- /dev/null +++ b/test/ash_authentication/strategies/password/plug_test.exs @@ -0,0 +1,192 @@ +defmodule AshAuthentication.Strategy.Password.PlugTest do + @moduledoc false + use DataCase + import ExUnit.CaptureLog + import Plug.Test + + alias AshAuthentication.{ + Info, + Plug.Helpers, + Strategy.Password, + Strategy.Password.Plug + } + + describe "register/2" do + test "when given valid parameters, it registers a new user" do + {:ok, strategy} = Info.strategy(Example.User, :password) + username = username() + password = password() + + params = %{ + "user" => %{ + "username" => username, + "password" => password, + "password_confirmation" => password + } + } + + assert {_conn, {:ok, user}} = + :post + |> conn("/", params) + |> Plug.register(strategy) + |> Helpers.get_authentication_result() + + assert to_string(user.username) == username + assert strategy.hash_provider.valid?(password, user.hashed_password) + end + + test "when given invalid parameters, it returns an error" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + params = %{ + "user" => %{ + "username" => username() + } + } + + assert {_conn, {:error, error}} = + :post + |> conn("/", params) + |> Plug.register(strategy) + |> Helpers.get_authentication_result() + + assert Exception.message(error) =~ ~r/argument password_confirmation is required/ + end + end + + describe "sign_in/2" do + test "it signs the user in when given valid credentials" do + {:ok, strategy} = Info.strategy(Example.User, :password) + password = password() + user = build_user(password: password, password_confirmation: password) + + params = %{ + "user" => %{ + "username" => user.username, + "password" => password + } + } + + assert {_conn, {:ok, signed_in_user}} = + :post + |> conn("/", params) + |> Plug.sign_in(strategy) + |> Helpers.get_authentication_result() + + assert signed_in_user.id == user.id + end + + test "it returns an error when the user is not present" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + params = %{ + "user" => %{ + "username" => username(), + "password" => password() + } + } + + assert {_conn, {:error, error}} = + :post + |> conn("/", params) + |> Plug.sign_in(strategy) + |> Helpers.get_authentication_result() + + assert Exception.message(error) =~ ~r/authentication failed/i + end + + test "it returns an error when the password is incorrect" do + {:ok, strategy} = Info.strategy(Example.User, :password) + password = password() + user = build_user(password: password, password_confirmation: password) + + params = %{ + "user" => %{ + "username" => user.username, + "password" => password() + } + } + + assert {_conn, {:error, error}} = + :post + |> conn("/", params) + |> Plug.sign_in(strategy) + |> Helpers.get_authentication_result() + + assert Exception.message(error) =~ ~r/authentication failed/i + end + end + + describe "reset_request/2" do + test "it sends a reset token when the user exists" do + {:ok, strategy} = Info.strategy(Example.User, :password) + user = build_user() + + params = %{ + "user" => %{ + "username" => user.username + } + } + + log = + capture_log(fn -> + assert {_conn, {:ok, nil}} = + :post + |> conn("/", params) + |> Plug.reset_request(strategy) + |> Helpers.get_authentication_result() + end) + + assert log =~ ~r/password reset request for user #{user.username}/i + end + + test "it doesn't send a reset token if the user doesn't exist" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + params = %{ + "user" => %{ + "username" => username() + } + } + + log = + capture_log(fn -> + assert {_conn, {:ok, nil}} = + :post + |> conn("/", params) + |> Plug.reset_request(strategy) + |> Helpers.get_authentication_result() + end) + + refute log =~ ~r/password reset request/i + end + end + + describe "reset/2" do + test "it resets the user's password when presented with a correct reset token" do + {:ok, strategy} = Info.strategy(Example.User, :password) + user = build_user() + assert {:ok, token} = Password.reset_token_for(strategy, user) + + new_password = password() + + params = %{ + "user" => %{ + "reset_token" => token, + "password" => new_password, + "password_confirmation" => new_password + } + } + + assert {_conn, {:ok, updated_user}} = + :post + |> conn("/", params) + |> Plug.reset(strategy) + |> Helpers.get_authentication_result() + + assert user.id == updated_user.id + assert user.hashed_password != updated_user.hashed_password + assert strategy.hash_provider.valid?(new_password, updated_user.hashed_password) + end + end +end diff --git a/test/ash_authentication/strategies/password/strategy_test.exs b/test/ash_authentication/strategies/password/strategy_test.exs new file mode 100644 index 0000000..fecc1e2 --- /dev/null +++ b/test/ash_authentication/strategies/password/strategy_test.exs @@ -0,0 +1,144 @@ +defmodule AshAuthentication.Strategy.Password.StrategyTest do + @moduledoc false + use ExUnit.Case, async: true + + alias AshAuthentication.{ + Info, + Strategy, + Strategy.Password, + Strategy.Password.Resettable + } + + use Mimic + import Plug.Test + + describe "Strategy.phases/1" do + test "it returns the correct phases when the strategy supports resetting" do + strategy = %Password{resettable: [%Resettable{}]} + + phases = + strategy + |> Strategy.phases() + |> MapSet.new() + + assert MapSet.equal?(phases, MapSet.new(~w[register sign_in reset_request reset]a)) + end + + test "it returns the correct phases when the strategy doesn't suport resetting" do + strategy = %Password{} + + phases = + strategy + |> Strategy.phases() + |> MapSet.new() + + assert MapSet.equal?(phases, MapSet.new(~w[register sign_in]a)) + end + end + + describe "Strategy.actions/1" do + test "it returns the correct actions when the strategy supports resetting" do + strategy = %Password{resettable: [%Resettable{}]} + + actions = + strategy + |> Strategy.actions() + |> MapSet.new() + + assert MapSet.equal?(actions, MapSet.new(~w[register sign_in reset_request reset]a)) + end + + test "it returns the correct actions when the strategy doesn't suport resetting" do + strategy = %Password{} + + actions = + strategy + |> Strategy.actions() + |> MapSet.new() + + assert MapSet.equal?(actions, MapSet.new(~w[register sign_in]a)) + end + end + + describe "Strategy.method_for_phase/2" do + for phase <- ~w[register sign_in reset_request reset]a do + test "it is post for the #{phase} phase" do + assert :post == + %Password{} + |> Strategy.method_for_phase(unquote(phase)) + end + end + end + + describe "Strategy.routes/1" do + test "it returns the correct routes when the strategy supports resetting" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + routes = + strategy + |> Strategy.routes() + |> MapSet.new() + + assert MapSet.equal?( + routes, + MapSet.new([ + {"/user/password/register", :register}, + {"/user/password/reset", :reset}, + {"/user/password/reset_request", :reset_request}, + {"/user/password/sign_in", :sign_in} + ]) + ) + end + + test "it returns the correct routes when the strategy isn't resettable" do + {:ok, strategy} = Info.strategy(Example.User, :password) + + routes = + %{strategy | resettable: []} + |> Strategy.routes() + |> MapSet.new() + + assert MapSet.equal?( + routes, + MapSet.new([ + {"/user/password/register", :register}, + {"/user/password/sign_in", :sign_in} + ]) + ) + end + end + + describe "Strategy.plug/3" do + for phase <- ~w[register sign_in reset_request reset]a do + test "it delegates to `Password.Plug.#{phase}/2` for the #{phase} phase" do + conn = conn(:get, "/") + strategy = %Password{} + + Password.Plug + |> expect(unquote(phase), fn rx_conn, rx_strategy -> + assert rx_conn == conn + assert rx_strategy == strategy + end) + + Strategy.plug(strategy, unquote(phase), conn) + end + end + end + + describe "Strategy.action/3" do + for action <- ~w[register sign_in reset_request reset]a do + test "it delegates to `Password.Actions.#{action}/2` for the #{action} action" do + strategy = %Password{} + params = %{"username" => Faker.Internet.user_name()} + + Password.Actions + |> expect(unquote(action), fn rx_strategy, rx_params -> + assert rx_strategy == strategy + assert rx_params == params + end) + + Strategy.action(strategy, unquote(action), params) + end + end + end +end diff --git a/test/ash_authentication/strategies/password_test.exs b/test/ash_authentication/strategies/password_test.exs new file mode 100644 index 0000000..a3cc952 --- /dev/null +++ b/test/ash_authentication/strategies/password_test.exs @@ -0,0 +1,30 @@ +defmodule AshAuthentication.Strategy.PasswordTest do + @moduledoc false + use DataCase, async: true + + import Plug.Test + + alias AshAuthentication.{ + Info, + Jwt, + Plug, + Strategy, + Strategy.Password, + Strategy.Password.Resettable + } + + doctest Password + + 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} + strategy = %Password{resettable: [resettable], resource: user.__struct__} + + assert {:ok, token} = Password.reset_token_for(strategy, user) + + assert {:ok, claims} = Jwt.peek(token) + assert claims["act"] == to_string(resettable.password_reset_action_name) + end + end +end diff --git a/test/ash_authentication/strategy_test.exs b/test/ash_authentication/strategy_test.exs new file mode 100644 index 0000000..2365208 --- /dev/null +++ b/test/ash_authentication/strategy_test.exs @@ -0,0 +1,7 @@ +defmodule AshAuthentication.StrategyTest do + @moduledoc false + use DataCase, async: true + alias AshAuthentication.Info + import AshAuthentication.Strategy + doctest AshAuthentication.Strategy +end diff --git a/test/ash_authentication/token_revocation_test.exs b/test/ash_authentication/token_revocation_test.exs index f1896c6..8d2e47e 100644 --- a/test/ash_authentication/token_revocation_test.exs +++ b/test/ash_authentication/token_revocation_test.exs @@ -1,7 +1,9 @@ defmodule AshAuthentication.TokenRevocationTest do @moduledoc false - use AshAuthentication.DataCase, async: true + use DataCase, async: true + import AshAuthentication.TokenRevocation alias AshAuthentication.{Jwt, TokenRevocation} + doctest AshAuthentication.TokenRevocation describe "revoke/2" do test "it revokes tokens" do @@ -14,10 +16,10 @@ defmodule AshAuthentication.TokenRevocationTest do end end - defp build_token do + def build_token do {:ok, token, claims} = build_user() - |> Jwt.token_for_record() + |> Jwt.token_for_user() {token, claims} end diff --git a/test/ash_authentication_test.exs b/test/ash_authentication_test.exs index 2be257c..89d7c24 100644 --- a/test/ash_authentication_test.exs +++ b/test/ash_authentication_test.exs @@ -1,21 +1,12 @@ defmodule AshAuthenticationTest do @moduledoc false - use ExUnit.Case + use DataCase, async: true + import AshAuthentication doctest AshAuthentication describe "authenticated_resources/0" do test "it correctly locates all authenticatable resources" do - assert [ - %{ - api: Example, - providers: providers, - resource: Example.UserWithUsername, - subject_name: :user_with_username - } - ] = AshAuthentication.authenticated_resources(:ash_authentication) - - assert AshAuthentication.PasswordAuthentication in providers - assert AshAuthentication.PasswordReset in providers + assert [Example.User] = authenticated_resources(:ash_authentication) end end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 6419a3d..2e8a79b 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -1,4 +1,4 @@ -defmodule AshAuthentication.DataCase do +defmodule DataCase do @moduledoc """ This module defines the setup for tests requiring access to the application's data layer. @@ -10,7 +10,7 @@ defmodule AshAuthentication.DataCase do we enable the SQL sandbox, so changes done to the database are reverted at the end of every test. If you are using PostgreSQL, you can even run database tests asynchronously - by setting `use AshAuthentication.DataCase, async: true`, although + by setting `use DataCase, async: true`, although this option is not recommended for other databases. """ @@ -24,12 +24,12 @@ defmodule AshAuthentication.DataCase do import Ecto import Ecto.Changeset import Ecto.Query - import AshAuthentication.DataCase + import DataCase end end setup tags do - AshAuthentication.DataCase.setup_sandbox(tags) + DataCase.setup_sandbox(tags) :ok end @@ -68,7 +68,7 @@ defmodule AshAuthentication.DataCase do def password, do: Faker.Lorem.words(4) |> Enum.join(" ") @doc "User factory" - @spec build_user(keyword) :: Example.UserWithUsername.t() | no_return + @spec build_user(keyword) :: Example.User.t() | no_return def build_user(attrs \\ []) do password = password() @@ -79,9 +79,19 @@ defmodule AshAuthentication.DataCase do |> Map.put_new(:password, password) |> Map.put_new(:password_confirmation, password) - Example.UserWithUsername - |> Ash.Changeset.for_create(:register, attrs) - |> Example.create!() + {:ok, strategy} = AshAuthentication.Info.strategy(Example.User, :password) + + user = + Example.User + |> Ash.Changeset.new() + |> Ash.Changeset.set_context(%{strategy: strategy}) + |> Ash.Changeset.for_create(:register_with_password, attrs) + |> Example.create!() + + attrs + |> Enum.reduce(user, fn {field, value}, user -> + Ash.Resource.put_metadata(user, field, value) + end) end @doc "Token revocation factory" @@ -89,7 +99,7 @@ defmodule AshAuthentication.DataCase do def build_token_revocation do {:ok, token, _claims} = build_user() - |> AshAuthentication.Jwt.token_for_record() + |> AshAuthentication.Jwt.token_for_user() Example.TokenRevocation |> Ash.Changeset.for_create(:revoke_token, %{token: token}) diff --git a/test/support/example/auth_plug.ex b/test/support/example/auth_plug.ex index f670fbd..a23242c 100644 --- a/test/support/example/auth_plug.ex +++ b/test/support/example/auth_plug.ex @@ -4,16 +4,16 @@ defmodule Example.AuthPlug do @impl true - def handle_success(conn, nil, nil) do + def handle_success(conn, {strategy, phase}, nil, nil) do conn |> put_resp_header("content-type", "application/json") |> send_resp( 200, - Jason.encode!(%{status: :success}) + Jason.encode!(%{status: :success, strategy: strategy, phase: phase}) ) end - def handle_success(conn, user, token) do + def handle_success(conn, {strategy, phase}, user, token) do conn |> store_in_session(user) |> put_resp_header("content-type", "application/json") @@ -25,20 +25,24 @@ defmodule Example.AuthPlug do user: %{ id: user.id, username: user.username - } + }, + strategy: strategy, + phase: phase }) ) end @impl true - def handle_failure(conn, reason) do + def handle_failure(conn, {strategy, phase}, reason) do conn |> put_resp_header("content-type", "application/json") |> send_resp( 401, Jason.encode!(%{ status: :failure, - reason: inspect(reason) + reason: inspect(reason), + strategy: strategy, + phase: phase }) ) end diff --git a/test/support/example/registry.ex b/test/support/example/registry.ex index 86e2ea7..a52c074 100644 --- a/test/support/example/registry.ex +++ b/test/support/example/registry.ex @@ -3,7 +3,7 @@ defmodule Example.Registry do use Ash.Registry, extensions: [Ash.Registry.ResourceValidations] entries do - entry Example.UserWithUsername + entry Example.User entry Example.TokenRevocation entry Example.UserIdentity end diff --git a/test/support/example/schema.ex b/test/support/example/schema.ex index 0a440aa..ecabcb1 100644 --- a/test/support/example/schema.ex +++ b/test/support/example/schema.ex @@ -6,6 +6,9 @@ defmodule Example.Schema do use AshGraphql, apis: @apis + query do + end + def context(ctx) do AshGraphql.add_context(ctx, @apis) end diff --git a/test/support/example/user_with_username.ex b/test/support/example/user.ex similarity index 56% rename from test/support/example/user_with_username.ex rename to test/support/example/user.ex index 6ccbaae..8238304 100644 --- a/test/support/example/user_with_username.ex +++ b/test/support/example/user.ex @@ -1,13 +1,9 @@ -defmodule Example.UserWithUsername do +defmodule Example.User do @moduledoc false use Ash.Resource, data_layer: AshPostgres.DataLayer, extensions: [ AshAuthentication, - AshAuthentication.Confirmation, - AshAuthentication.PasswordAuthentication, - AshAuthentication.PasswordReset, - AshAuthentication.OAuth2Authentication, AshGraphql.Resource, AshJsonApi.Resource ] @@ -23,7 +19,7 @@ defmodule Example.UserWithUsername do } attributes do - uuid_primary_key(:id) + uuid_primary_key(:id, writable?: true) attribute(:username, :ci_string, allow_nil?: false) attribute(:hashed_password, :string, allow_nil?: true, sensitive?: true, private?: true) @@ -58,31 +54,18 @@ defmodule Example.UserWithUsername do change AshAuthentication.GenerateTokenChange change Example.GenericOAuth2Change - change AshAuthentication.OAuth2Authentication.IdentityChange + change AshAuthentication.Strategy.OAuth2.IdentityChange end read :sign_in_with_oauth2 do argument :user_info, :map, allow_nil?: false argument :oauth_tokens, :map, allow_nil?: false - prepare AshAuthentication.OAuth2Authentication.SignInPreparation + prepare AshAuthentication.Strategy.OAuth2.SignInPreparation filter expr(username == get_path(^arg(:user_info), [:nickname])) end end - code_interface do - define_for(Example) - end - - confirmation do - monitor_fields([:username]) - inhibit_updates?(true) - - sender(fn user, token -> - Logger.debug("Confirmation request for user #{user.username}, token #{inspect(token)}") - end) - end - graphql do type :user @@ -93,7 +76,7 @@ defmodule Example.UserWithUsername do end mutations do - create :register, :register + create :register, :register_with_password end end @@ -105,46 +88,68 @@ defmodule Example.UserWithUsername do get(:read) get(:current_user, route: "/me") index(:read) - post(:register) + post(:register_with_password) end end - oauth2_authentication do - client_id(fn _, _, _ -> {:ok, "made up"} end) - redirect_uri(fn _, _, _ -> {:ok, "http://localhost:4000/auth"} end) - client_secret(fn _, _, _ -> {:ok, "also made up"} end) - site(fn _, _, _ -> {:ok, "https://example.com"} end) - authorization_params(scope: "openid profile email") - auth_method(:client_secret_post) - identity_resource(Example.UserIdentity) - end - postgres do - table("user_with_username") + table("user") repo(Example.Repo) end authentication do api(Example) - end - password_authentication do - identity_field(:username) - hashed_password_field(:hashed_password) - end + tokens do + enabled?(true) + revocation_resource(Example.TokenRevocation) + end - password_reset do - sender(fn user, token -> - Logger.debug("Password reset request for user #{user.username}, token #{inspect(token)}") - end) + strategies do + confirmation do + monitor_fields([:username]) + inhibit_updates?(true) + + sender(fn user, token -> + Logger.debug("Confirmation request for user #{user.username}, token #{inspect(token)}") + end) + end + + password :password do + resettable do + sender(fn user, token -> + Logger.debug( + "Password reset request for user #{user.username}, token #{inspect(token)}" + ) + end) + end + end + + oauth2 :oauth2 do + client_id(&get_config/2) + redirect_uri(&get_config/2) + client_secret(&get_config/2) + site(&get_config/2) + authorize_path(&get_config/2) + token_path(&get_config/2) + user_path(&get_config/2) + authorization_params(scope: "openid profile email") + auth_method(:client_secret_post) + identity_resource(Example.UserIdentity) + end + end end identities do identity(:username, [:username], eager_check_with: Example) end - tokens do - enabled?(true) - revocation_resource(Example.TokenRevocation) + def get_config(path, _resource) do + value = + :ash_authentication + |> Application.get_all_env() + |> get_in(path) + + {:ok, value} end end diff --git a/test/support/example/user_identity.ex b/test/support/example/user_identity.ex index d5d1a0e..a3a78f2 100644 --- a/test/support/example/user_identity.ex +++ b/test/support/example/user_identity.ex @@ -2,11 +2,11 @@ defmodule Example.UserIdentity do @moduledoc false use Ash.Resource, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication.ProviderIdentity] + extensions: [AshAuthentication.UserIdentity] - provider_identity do + user_identity do api Example - user_resource(Example.UserWithUsername) + user_resource(Example.User) end postgres do diff --git a/test/support/session_pipeline.ex b/test/support/session_pipeline.ex index a68331d..24eb148 100644 --- a/test/support/session_pipeline.ex +++ b/test/support/session_pipeline.ex @@ -1,4 +1,4 @@ -defmodule AshAuthentication.SessionPipeline do +defmodule SessionPipeline do @moduledoc """ A simple plug pipeline that ensures that the session is set up ready to be consumed. """ diff --git a/test/test_helper.exs b/test/test_helper.exs index cbc84df..3bf96d2 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,10 @@ Mimic.copy(AshAuthentication.Plug.Defaults) Mimic.copy(AshAuthentication.Plug.Helpers) +Mimic.copy(AshAuthentication.Strategy.Confirmation.Actions) +Mimic.copy(AshAuthentication.Strategy.Confirmation.Plug) +Mimic.copy(AshAuthentication.Strategy.OAuth2.Actions) +Mimic.copy(AshAuthentication.Strategy.OAuth2.Plug) +Mimic.copy(AshAuthentication.Strategy.Password.Actions) +Mimic.copy(AshAuthentication.Strategy.Password.Plug) Mimic.copy(AshAuthentication.TokenRevocation) ExUnit.start(capture_log: true)