From bab9ec363e824971c84b5744a0a1a502a9f17f07 Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Fri, 4 Nov 2022 11:24:33 +1300 Subject: [PATCH] fix(PasswordReset): Generate the reset token using the target action, not the source action. (#25) * fix(PasswordReset): Generate the reset token using the target action, not the source action. Also improve tests. * improvement(PasswordReset): rework PasswordReset to be a provider in it's own right - this means it has it's own routes, etc. --- dev/dev_server/test_page.html.eex | 4 +- lib/ash_authentication/jwt.ex | 3 +- .../password_authentication.ex | 35 ++----- .../password_authentication/html.ex | 27 ++++-- .../password_authentication/plug.ex | 34 ++----- lib/ash_authentication/password_reset.ex | 13 ++- lib/ash_authentication/password_reset/html.ex | 94 +++++++++++++++++++ lib/ash_authentication/password_reset/plug.ex | 45 +++++++++ .../password_reset/reset_token_validation.ex | 2 +- .../password_reset/transformer.ex | 12 +++ lib/ash_authentication/plug/dispatcher.ex | 78 +++++++++------ lib/ash_authentication/plug/helpers.ex | 13 ++- lib/ash_authentication/plug/router.ex | 2 +- lib/ash_authentication/provider.ex | 94 ++++++++++++++++++- .../password_reset_test.exs | 16 ++++ test/ash_authentication/plug_test.exs | 2 +- test/ash_authentication_test.exs | 5 +- test/support/data_case.ex | 21 +++++ test/support/example/auth_plug.ex | 13 ++- 19 files changed, 402 insertions(+), 111 deletions(-) create mode 100644 lib/ash_authentication/password_reset/html.ex create mode 100644 lib/ash_authentication/password_reset/plug.ex diff --git a/dev/dev_server/test_page.html.eex b/dev/dev_server/test_page.html.eex index fa0492d..fec46b7 100644 --- a/dev/dev_server/test_page.html.eex +++ b/dev/dev_server/test_page.html.eex @@ -13,8 +13,8 @@

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

<%= for provider <- config.providers do %> - <%= Module.concat(provider, HTML).register(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides()}/callback", legend: "Register") %> - <%= Module.concat(provider, HTML).sign_in(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides()}/callback", legend: "Sign in") %> + <%= 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") %> <% end %> <% end %> diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex index f1327b8..9ed6bb9 100644 --- a/lib/ash_authentication/jwt.ex +++ b/lib/ash_authentication/jwt.ex @@ -67,7 +67,8 @@ defmodule AshAuthentication.Jwt do @doc """ Given a record, generate a signed JWT for use while authenticating. """ - @spec token_for_record(Resource.record()) :: {:ok, token, claims} | :error + @spec token_for_record(Resource.record(), extra_claims :: %{}, options :: keyword) :: + {:ok, token, claims} | :error def token_for_record(record, extra_claims \\ %{}, opts \\ []) do resource = record.__struct__ diff --git a/lib/ash_authentication/password_authentication.ex b/lib/ash_authentication/password_authentication.ex index 516db45..2d9ba02 100644 --- a/lib/ash_authentication/password_authentication.ex +++ b/lib/ash_authentication/password_authentication.ex @@ -111,15 +111,14 @@ defmodule AshAuthentication.PasswordAuthentication do #{Spark.Dsl.Extension.doc(@dsl)} """ - @behaviour AshAuthentication.Provider - use Spark.Dsl.Extension, sections: @dsl, transformers: [AshAuthentication.PasswordAuthentication.Transformer] + use AshAuthentication.Provider + alias Ash.Resource alias AshAuthentication.PasswordAuthentication - alias Plug.Conn @doc """ Attempt to sign in an user of the provided resource type. @@ -149,40 +148,24 @@ defmodule AshAuthentication.PasswordAuthentication do to: PasswordAuthentication.Actions, as: :register - @doc """ - Handle the request phase. - - The password authentication provider does nothing with the request phase, and just returns - the `conn` unmodified. - """ - @impl true - @spec request_plug(Conn.t(), any) :: Conn.t() - defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :request - @doc """ Handle the callback phase. Handles both sign-in and registration actions via the same endpoint. """ @impl true - @spec callback_plug(Conn.t(), any) :: Conn.t() - defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :callback + defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle @doc """ - Returns the name of the associated provider. + Handle the request phase. + + Handles both sign-in and registration actions via the same endpoint. """ @impl true - @spec provides :: String.t() - def provides, do: "password" + defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :handle @doc false @impl true - @spec has_register_step?(any) :: boolean - def has_register_step?(_), do: true - - @doc """ - Returns whether password authentication is enabled for the resource - """ - @spec enabled?(Resource.t()) :: boolean - def enabled?(resource), do: __MODULE__ in Spark.extensions(resource) + @spec has_register_step?(Resource.t()) :: boolean + def has_register_step?(_resource), do: true end diff --git a/lib/ash_authentication/password_authentication/html.ex b/lib/ash_authentication/password_authentication/html.ex index 5e405c1..a671499 100644 --- a/lib/ash_authentication/password_authentication/html.ex +++ b/lib/ash_authentication/password_authentication/html.ex @@ -1,8 +1,9 @@ -defmodule AshAuthentication.PasswordAuthentication.HTML do +defmodule AshAuthentication.PasswordAuthentication.Html do @moduledoc """ - Renders a very basic forms for using password authentication. + Renders a very basic form for using password authentication. - These are mainly used for testing. + These are mainly used for testing, and you should instead write your own or + use the widgets in `ash_authentication_phoenix`. """ require EEx @@ -35,13 +36,13 @@ defmodule AshAuthentication.PasswordAuthentication.HTML do
<%= if @legend do %><%= @legend %><% end %> - +

<%= if @confirmation_required? do %> -
+
<% end %>
@@ -69,8 +70,12 @@ defmodule AshAuthentication.PasswordAuthentication.HTML do @doc """ Render a basic HTML sign-in form. """ - @spec sign_in(module, options) :: String.t() - def sign_in(resource, options) do + @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() @@ -79,8 +84,12 @@ defmodule AshAuthentication.PasswordAuthentication.HTML do @doc """ Render a basic HTML registration form. """ - @spec register(module, options) :: String.t() - def register(resource, options) do + @spec request(module, options) :: String.t() + def request(resource, options) do + options = + options + |> Keyword.put_new(:legend, "Register") + resource |> build_assigns(options) |> render_register() diff --git a/lib/ash_authentication/password_authentication/plug.ex b/lib/ash_authentication/password_authentication/plug.ex index ab90d67..e098795 100644 --- a/lib/ash_authentication/password_authentication/plug.ex +++ b/lib/ash_authentication/password_authentication/plug.ex @@ -1,38 +1,27 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do @moduledoc """ - Handlers for incoming request and callback HTTP requests. + 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. + a single flow. This doesn't really work that well with + `PasswordAuthentication` which has seperate "registration" and "sign-in" + actions. - Here we simply ignore the request phase, which will cause an error to be - returned to the remote user if they somehow find themselves there. - - We use the "callback" phase to handle both registration and sign in by passing - an "action" parameter along with the form data. + 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, PasswordReset} + alias AshAuthentication.PasswordAuthentication alias Plug.Conn - @doc """ - Handle the request phase. - - The password authentication provider does nothing with the request phase, and just returns - the `conn` unmodified. - """ - @spec request(Conn.t(), any) :: Conn.t() - def request(conn, _opts), do: conn - @doc """ Handle the callback phase. Handles both sign-in and registration actions via the same endpoint. """ - @spec callback(Conn.t(), any) :: Conn.t() - def callback(%{params: params, private: %{authenticator: config}} = conn, _opts) do + @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) @@ -45,7 +34,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do end end - def callback(conn, _opts), do: conn + def handle(conn, _opts), do: conn defp do_action(%{"action" => "sign_in"} = attrs, resource), do: PasswordAuthentication.sign_in_action(resource, attrs) @@ -53,8 +42,5 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do defp do_action(%{"action" => "register"} = attrs, resource), do: PasswordAuthentication.register_action(resource, attrs) - defp do_action(%{"action" => "reset_password"} = attrs, resource), - do: PasswordReset.reset_password(resource, attrs) - defp do_action(_attrs, _resource), do: {:error, "No action provided"} end diff --git a/lib/ash_authentication/password_reset.ex b/lib/ash_authentication/password_reset.ex index 94836be..54b0b35 100644 --- a/lib/ash_authentication/password_reset.ex +++ b/lib/ash_authentication/password_reset.ex @@ -106,15 +106,11 @@ defmodule AshAuthentication.PasswordReset do sections: @dsl, transformers: [AshAuthentication.PasswordReset.Transformer] + use AshAuthentication.Provider + alias Ash.{Changeset, Query, Resource} alias AshAuthentication.{Jwt, PasswordReset} - @doc """ - Returns whether password reset is enabled for the resource - """ - @spec enabled?(Resource.t()) :: boolean - def enabled?(resource), do: __MODULE__ in Spark.extensions(resource) - @doc """ Request a password reset for a user. @@ -177,7 +173,7 @@ defmodule AshAuthentication.PasswordReset do with true <- enabled?(resource), {:ok, lifetime} <- PasswordReset.Info.token_lifetime(resource), - {:ok, action} <- PasswordReset.Info.request_password_reset_action_name(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} @@ -185,4 +181,7 @@ defmodule AshAuthentication.PasswordReset do _ -> :error end end + + defdelegate request_plug(conn, any), to: PasswordReset.Plug, as: :request + 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 new file mode 100644 index 0000000..006b488 --- /dev/null +++ b/lib/ash_authentication/password_reset/html.ex @@ -0,0 +1,94 @@ +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/plug.ex b/lib/ash_authentication/password_reset/plug.ex new file mode 100644 index 0000000..66af3c1 --- /dev/null +++ b/lib/ash_authentication/password_reset/plug.ex @@ -0,0 +1,45 @@ +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/reset_token_validation.ex b/lib/ash_authentication/password_reset/reset_token_validation.ex index 307a3e4..42d03ca 100644 --- a/lib/ash_authentication/password_reset/reset_token_validation.ex +++ b/lib/ash_authentication/password_reset/reset_token_validation.ex @@ -13,7 +13,7 @@ defmodule AshAuthentication.PasswordReset.ResetTokenValidation do def validate(changeset, _) do with token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token), {:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource), - {:ok, resource_action} <- Info.request_password_reset_action_name(changeset.resource), + {:ok, resource_action} <- Info.password_reset_action_name(changeset.resource), true <- to_string(resource_action) == token_action do :ok else diff --git a/lib/ash_authentication/password_reset/transformer.ex b/lib/ash_authentication/password_reset/transformer.ex index 4cbb1cb..2bf711b 100644 --- a/lib/ash_authentication/password_reset/transformer.ex +++ b/lib/ash_authentication/password_reset/transformer.ex @@ -52,6 +52,18 @@ defmodule AshAuthentication.PasswordReset.Transformer do &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"} diff --git a/lib/ash_authentication/plug/dispatcher.ex b/lib/ash_authentication/plug/dispatcher.ex index cedf8f7..02988c8 100644 --- a/lib/ash_authentication/plug/dispatcher.ex +++ b/lib/ash_authentication/plug/dispatcher.ex @@ -19,38 +19,54 @@ defmodule AshAuthentication.Plug.Dispatcher do """ @impl true @spec call(Conn.t(), config | any) :: Conn.t() - def call( - %{params: %{"subject_name" => subject_name, "provider" => provider}} = conn, - {phase, routes, return_to} - ) do - conn = - 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 - - _ -> - conn - end - - case conn do - %{state: :sent} -> - conn - - %{private: %{authentication_result: {:success, user}}} -> - return_to.handle_success(conn, user, Map.get(user.__metadata__, :token)) - - %{private: %{authentication_result: {:failure, reason}}} -> - return_to.handle_failure(conn, reason) - - _ -> - return_to.handle_failure(conn, nil) - end + def call(conn, {phase, routes, return_to}) do + conn + |> dispatch(phase, routes) + |> return(return_to) end def call(conn, {_phase, _routes, return_to}), do: return_to.handle_failure(conn) + + 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 + + _ -> + conn + 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) end diff --git a/lib/ash_authentication/plug/helpers.ex b/lib/ash_authentication/plug/helpers.ex index 386d1ae..35e7b42 100644 --- a/lib/ash_authentication/plug/helpers.ex +++ b/lib/ash_authentication/plug/helpers.ex @@ -11,13 +11,16 @@ defmodule AshAuthentication.Plug.Helpers do Store the user in the connections' session. """ @spec store_in_session(Conn.t(), Resource.record()) :: Conn.t() - def store_in_session(conn, user) do + + 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) Conn.put_session(conn, subject_name, subject) end + def store_in_session(conn, _), do: conn + @doc """ Given a list of subjects, turn as many as possible into users. """ @@ -169,15 +172,19 @@ defmodule AshAuthentication.Plug.Helpers do """ @spec private_store( Conn.t(), - {:success, Resource.record()} | {:failure, nil | Changeset.t() | Error.t()} + {:success, nil | Resource.record()} | {:failure, nil | Changeset.t() | Error.t()} ) :: Conn.t() + + def private_store(conn, {:success, nil}), + do: Conn.put_private(conn, :authentication_result, {:success, 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 private_store(conn, {:failure, reason}) - when is_nil(reason) or is_map(reason), + when is_nil(reason) or is_binary(reason) or is_map(reason), do: Conn.put_private(conn, :authentication_result, {:failure, reason}) # Dyanamically generated atoms are generally frowned upon, but in this case diff --git a/lib/ash_authentication/plug/router.ex b/lib/ash_authentication/plug/router.ex index 2527c3b..90bb6a7 100644 --- a/lib/ash_authentication/plug/router.ex +++ b/lib/ash_authentication/plug/router.ex @@ -35,7 +35,7 @@ defmodule AshAuthentication.Plug.Router do |> Map.delete(:providers) |> Map.put(:provider, provider) - {{subject_name, provider.provides()}, config} + {{subject_name, provider.provides(config.resource)}, config} end) end) |> Map.new() diff --git a/lib/ash_authentication/provider.ex b/lib/ash_authentication/provider.ex index 8328696..d9bfca8 100644 --- a/lib/ash_authentication/provider.ex +++ b/lib/ash_authentication/provider.ex @@ -6,7 +6,7 @@ defmodule AshAuthentication.Provider do @doc """ The name of the provider for routing purposes, eg: "github". """ - @callback provides() :: String.t() + @callback provides(Resource.t()) :: String.t() @doc """ Given some credentials for a potentially existing user, verify the credentials @@ -31,10 +31,98 @@ defmodule AshAuthentication.Provider do @doc """ A function plug which can handle the callback phase. """ - @callback callback_plug(Conn.t(), AshAuthentication.resource_config()) :: Conn.t() + @callback callback_plug(Conn.t(), any) :: Conn.t() @doc """ A function plug which can handle the request phase. """ - @callback request_plug(Conn.t(), AshAuthentication.resource_config()) :: Conn.t() + @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/test/ash_authentication/password_reset_test.exs b/test/ash_authentication/password_reset_test.exs index f3ab5e1..51f02ca 100644 --- a/test/ash_authentication/password_reset_test.exs +++ b/test/ash_authentication/password_reset_test.exs @@ -87,4 +87,20 @@ defmodule AshAuthentication.PasswordResetTest do 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_test.exs b/test/ash_authentication/plug_test.exs index 51ab70b..5f527f6 100644 --- a/test/ash_authentication/plug_test.exs +++ b/test/ash_authentication/plug_test.exs @@ -53,7 +53,7 @@ defmodule AshAuthentication.PlugTest do resp = Jason.decode!(resp) assert status == 401 - assert resp["status"] == "failed" + assert resp["status"] == "failure" assert resp["reason"] =~ ~r/Forbidden/ end end diff --git a/test/ash_authentication_test.exs b/test/ash_authentication_test.exs index 331f357..2be257c 100644 --- a/test/ash_authentication_test.exs +++ b/test/ash_authentication_test.exs @@ -8,11 +8,14 @@ defmodule AshAuthenticationTest do assert [ %{ api: Example, - providers: [AshAuthentication.PasswordAuthentication], + 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 end end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index a19e134..6419a3d 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -36,6 +36,7 @@ defmodule AshAuthentication.DataCase do @doc """ Sets up the sandbox based on the test tags. """ + @spec setup_sandbox(any) :: :ok def setup_sandbox(tags) do pid = Sandbox.start_owner!(Example.Repo, shared: not tags[:async]) on_exit(fn -> Sandbox.stop_owner(pid) end) @@ -49,6 +50,7 @@ defmodule AshAuthentication.DataCase do assert %{password: ["password is too short"]} = errors_on(changeset) """ + @spec errors_on(Ecto.Changeset.t()) :: %{atom => [any]} def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> @@ -57,9 +59,16 @@ defmodule AshAuthentication.DataCase do end) end + @doc "Generate a random username using Faker" + @spec username :: String.t() def username, do: Faker.Internet.user_name() + + @doc "Generate a random password using Faker" + @spec password :: String.t() def password, do: Faker.Lorem.words(4) |> Enum.join(" ") + @doc "User factory" + @spec build_user(keyword) :: Example.UserWithUsername.t() | no_return def build_user(attrs \\ []) do password = password() @@ -74,4 +83,16 @@ defmodule AshAuthentication.DataCase do |> Ash.Changeset.for_create(:register, attrs) |> Example.create!() end + + @doc "Token revocation factory" + @spec build_token_revocation :: Example.TokenRevocation.t() | no_return + def build_token_revocation do + {:ok, token, _claims} = + build_user() + |> AshAuthentication.Jwt.token_for_record() + + Example.TokenRevocation + |> Ash.Changeset.for_create(:revoke_token, %{token: token}) + |> Example.create!() + end end diff --git a/test/support/example/auth_plug.ex b/test/support/example/auth_plug.ex index efc7c80..f670fbd 100644 --- a/test/support/example/auth_plug.ex +++ b/test/support/example/auth_plug.ex @@ -3,6 +3,16 @@ defmodule Example.AuthPlug do use AshAuthentication.Plug, otp_app: :ash_authentication @impl true + + def handle_success(conn, nil, nil) do + conn + |> put_resp_header("content-type", "application/json") + |> send_resp( + 200, + Jason.encode!(%{status: :success}) + ) + end + def handle_success(conn, user, token) do conn |> store_in_session(user) @@ -10,6 +20,7 @@ defmodule Example.AuthPlug do |> send_resp( 200, Jason.encode!(%{ + status: :success, token: token, user: %{ id: user.id, @@ -26,7 +37,7 @@ defmodule Example.AuthPlug do |> send_resp( 401, Jason.encode!(%{ - status: "failed", + status: :failure, reason: inspect(reason) }) )