feat(Confirmation): Add extension that allows a user to be confirmed when created or updated. (#27)

This commit is contained in:
James Harton 2022-11-04 21:05:47 +13:00 committed by GitHub
parent 50c4e832e3
commit 1d4bb00617
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 963 additions and 112 deletions

View file

@ -4,4 +4,6 @@ config :mime, :types, %{
"application/vnd.api+json" => ["json"]
}
config :ash, :utc_datetime_type, :datetime
import_config "#{config_env()}.exs"

View file

@ -0,0 +1,225 @@
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

View file

@ -0,0 +1,35 @@
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

View file

@ -0,0 +1,88 @@
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

View file

@ -0,0 +1,65 @@
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"""
<form method="<%= @method %>" action="<%= @action %>">
<fieldset>
<%= if @legend do %><legend><%= @legend %></legend><% end %>
<input type="text" name="confirm" placeholder="Confirmation token" />
<br />
<input type="submit" value="Confirm" />
</fieldset>
</form>
""",
[: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

View file

@ -0,0 +1,9 @@
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

View file

@ -0,0 +1,23 @@
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

View file

@ -0,0 +1,223 @@
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.PasswordAuthentication.GenerateTokenChange
alias AshAuthentication.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

View file

@ -193,6 +193,8 @@ defmodule AshAuthentication.InfoGenerator do
{:|, [], Enum.map(choices, &spec_for_type/1)}
end
defp spec_for_type({:list, subtype}), do: [spec_for_type(subtype)]
defp spec_for_type(:string),
do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}

View file

@ -50,7 +50,7 @@ defmodule AshAuthentication.Jwt do
"claims" are the decoded contents of a JWT. A map of (short) string keys to
string values.
"""
@type claims :: %{required(String.t()) => String.t()}
@type claims :: %{required(String.t()) => String.t() | number | boolean | claims}
@doc "The default signing algorithm"
@spec default_algorithm :: String.t()
@ -90,6 +90,12 @@ defmodule AshAuthentication.Jwt do
Joken.generate_and_sign(default_claims, extra_claims, signer)
end
@doc """
Given a token, read it's claims without validating.
"""
@spec peek(token) :: {:ok, claims} | {:error, any}
def peek(token), do: Joken.peek_claims(token)
@doc """
Given a token, verify it's signature and validate it's claims.
"""
@ -138,7 +144,7 @@ defmodule AshAuthentication.Jwt do
"""
@spec token_to_resource(token, module) :: {:ok, AshAuthentication.resource_config()} | :error
def token_to_resource(token, otp_app) do
with {:ok, %{"sub" => subject}} <- Joken.peek_claims(token),
with {:ok, %{"sub" => subject}} <- peek(token),
%URI{path: subject_name} <- URI.parse(subject) do
config_for_subject_name(subject_name, otp_app)
else

View file

@ -59,6 +59,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
alias Spark.Dsl.Transformer
import AshAuthentication.PasswordAuthentication.UserValidations
import AshAuthentication.Utils
import AshAuthentication.Validations
@doc false
@impl true
@ -69,7 +70,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
with :ok <- validate_authentication_extension(dsl_state),
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} <-

View file

@ -21,25 +21,6 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
@doc """
Validates at the `AshAuthentication` extension is also present on the
resource.
"""
@spec validate_authentication_extension(Dsl.t()) :: :ok | {:error, Exception.t()}
def validate_authentication_extension(dsl_state) do
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
if AshAuthentication in extensions,
do: :ok,
else:
{:error,
DslError.exception(
path: [:extensions],
message:
"The `AshAuthentication` extension must also be present on this resource for password authentication to work."
)}
end
@doc """
Validate that the configured hash provider implements the `HashProvider`
behaviour.

View file

@ -31,8 +31,8 @@ defmodule AshAuthentication.PasswordReset do
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.PasswordReset.Sender,
{AshAuthentication.PasswordReset.SenderFunction, 2}},
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 2}},
doc: """
How to send the password reset instructions to the user.
@ -40,7 +40,7 @@ defmodule AshAuthentication.PasswordReset do
Accepts a module, module and opts, or a function that takes a record, reset token and options.
See `AshAuthentication.PasswordReset.Sender` for more information.
See `AshAuthentication.Sender` for more information.
""",
required: true
]
@ -59,10 +59,11 @@ defmodule AshAuthentication.PasswordReset do
## Senders
You can set the DSL's `sender` key to be either a two-arity anonymous function
or a module which implements the `AshAuthentication.PasswordReset.Sender`
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.
the user via the system of your choice. See `AshAuthentication.Sender` for
more information.
## Usage
@ -130,13 +131,15 @@ defmodule AshAuthentication.PasswordReset do
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) do
resource
|> Query.for_read(action, params)
|> api.read()
{: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)}`"}
@ -191,6 +194,19 @@ defmodule AshAuthentication.PasswordReset do
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

View file

@ -17,7 +17,7 @@ defmodule AshAuthentication.PasswordReset.Plug do
|> Map.get(to_string(config.subject_name), %{})
case PasswordReset.request_password_reset(config.resource, params) do
{:ok, _} ->
:ok ->
private_store(conn, {:success, nil})
{:error, reason} ->

View file

@ -11,16 +11,16 @@ defmodule AshAuthentication.PasswordReset.Transformer do
alias AshAuthentication.PasswordReset.{
Info,
RequestPasswordResetPreparation,
ResetTokenValidation,
Sender
ResetTokenValidation
}
alias Ash.{Resource, Type}
alias AshAuthentication.PasswordAuthentication, as: PA
alias Spark.{Dsl.Transformer, Error.DslError}
alias AshAuthentication.Sender
alias Spark.Dsl.Transformer
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
@doc false
@ -32,10 +32,11 @@ defmodule AshAuthentication.PasswordReset.Transformer do
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
with :ok <- validate_authentication_extension(dsl_state),
:ok <- validate_password_authentication_extension(dsl_state),
with :ok <- validate_extension(dsl_state, AshAuthentication),
:ok <- validate_extension(dsl_state, PA),
:ok <- validate_token_generation_enabled(dsl_state),
:ok <- validate_sender(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(
@ -84,60 +85,6 @@ defmodule AshAuthentication.PasswordReset.Transformer do
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
defp validate_authentication_extension(dsl_state) do
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
if AshAuthentication in extensions,
do: :ok,
else:
{:error,
DslError.exception(
path: [:extensions],
message:
"The `AshAuthentication` extension must also be present on this resource in order to generate reset tokens."
)}
end
defp validate_password_authentication_extension(dsl_state) do
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
if PA in extensions,
do: :ok,
else:
{:error,
DslError.exception(
path: [:extensions],
message:
"The `AshAuthentication.PasswordAuthentication` extension must also be present on this resource in order to be able to change the user's password."
)}
end
defp validate_token_generation_enabled(dsl_state) do
if AshAuthentication.Info.tokens_enabled?(dsl_state),
do: :ok,
else:
{:error,
DslError.exception(
path: [:tokens],
message: "Token generation must be enabled for password resets to work."
)}
end
defp validate_sender(dsl_state) do
with {:ok, {sender, _opts}} <- Info.sender(dsl_state),
true <- Spark.implements_behaviour?(sender, Sender) do
:ok
else
_ ->
{:error,
DslError.exception(
path: [:password_reset],
message:
"`sender` must be a module that implements the `AshAuthentication.PasswordReset.Sender` behaviour."
)}
end
end
defp build_request_action(dsl_state, action_name) do
with {:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state) do
identity_attribute = Resource.Info.attribute(dsl_state, identity_field)

View file

@ -172,7 +172,8 @@ defmodule AshAuthentication.Plug.Helpers do
"""
@spec private_store(
Conn.t(),
{:success, nil | Resource.record()} | {:failure, nil | Changeset.t() | Error.t()}
{:success, nil | Resource.record()}
| {:failure, nil | String.t() | Changeset.t() | Error.t()}
) ::
Conn.t()

View file

@ -1,8 +1,8 @@
defmodule AshAuthentication.PasswordReset.Sender do
defmodule AshAuthentication.Sender do
@moduledoc ~S"""
A module to implement sending of the password reset token to a user.
A module to implement sending of a token to a user.
Allows you to glue sending of reset instructions to
Allows you to glue sending of 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.
@ -74,7 +74,7 @@ defmodule AshAuthentication.PasswordReset.Sender do
@spec __using__(any) :: Macro.t()
defmacro __using__(_) do
quote do
@behaviour AshAuthentication.PasswordReset.Sender
@behaviour AshAuthentication.Sender
end
end
end

View file

@ -1,10 +1,10 @@
defmodule AshAuthentication.PasswordReset.SenderFunction do
defmodule AshAuthentication.SenderFunction do
@moduledoc """
Implements `AshAuthentication.PasswordReset.Sender` for functions that are
provided to the DSL instead of modules.
Implements `AshAuthentication.Sender` for functions that are provided to the
DSL instead of modules.
"""
use AshAuthentication.PasswordReset.Sender
use AshAuthentication.Sender
alias Ash.Resource
@doc false

View file

@ -5,6 +5,7 @@ defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
use Ash.Resource.Change
alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change}
alias AshAuthentication.Jwt
@doc false
@impl true
@ -14,7 +15,7 @@ defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
|> Changeset.before_action(fn changeset ->
changeset
|> Changeset.get_argument(:token)
|> Joken.peek_claims()
|> Jwt.peek()
|> case do
{:ok, %{"jti" => jti, "exp" => exp}} ->
expires_at =
@ -24,6 +25,9 @@ defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
changeset
|> Changeset.change_attributes(jti: jti, expires_at: expires_at)
{:ok, _} ->
{:error, "Invalid token"}
{:error, reason} ->
{:error, InvalidArgument.exception(field: :token, message: to_string(reason))}
end

View file

@ -3,7 +3,7 @@ defmodule AshAuthentication.Validations do
Common validations shared by several transformers.
"""
import AshAuthentication.Utils
import AshAuthentication.{Sender, Utils}
alias Ash.Resource.Attribute
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
@ -84,4 +84,53 @@ defmodule AshAuthentication.Validations do
value -> {:ok, value}
end
end
@doc """
Ensure that token generation is enabled for the resource.
"""
@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),
do: :ok,
else:
{:error,
DslError.exception(
path: [:tokens],
message: "Token generation must be enabled for password resets to work."
)}
end
@doc """
Ensure that the named module implements a specific behaviour.
"""
@spec validate_behaviour(module, module) :: :ok | {:error, Exception.t()}
def validate_behaviour(module, behaviour) do
if Spark.implements_behaviour?(module, behaviour) do
:ok
else
{:error,
DslError.exception(
path: [:password_reset],
message: "`#{inspect(module)}` must implement the `#{inspect(behaviour)}` behaviour."
)}
end
end
@doc """
Validates that `extension` is present on the resource.
"""
@spec validate_extension(Dsl.t(), module) :: :ok | {:error, Exception.t()}
def validate_extension(dsl_state, extension) do
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
if extension in extensions,
do: :ok,
else:
{:error,
DslError.exception(
path: [:extensions],
message:
"The `#{inspect(extension)}` extension must also be present on this resource for password authentication to work."
)}
end
end

View file

@ -0,0 +1,21 @@
defmodule Example.Repo.Migrations.AddConfirmedAtToUserWuthUsername do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:user_with_username) do
add :confirmed_at, :utc_datetime_usec
end
end
def down do
alter table(:user_with_username) do
remove :confirmed_at
end
end
end

View file

@ -0,0 +1,88 @@
{
"attributes": [
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "confirmed_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "username",
"type": "citext"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "12E7392CC92EAE2AB421A89BA0C03F0A92D72FD6732BB8CA6914408B1C77F471",
"identities": [
{
"base_filter": null,
"index_name": "user_with_username_username_index",
"keys": [
"username"
],
"name": "username"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Example.Repo",
"schema": null,
"table": "user_with_username"
}

View file

@ -0,0 +1,47 @@
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

View file

@ -15,17 +15,17 @@ defmodule AshAuthentication.PasswordResetTest do
end
describe "reset_password_request/1" do
test "when the user is found, it returns an empty list" do
test "when the user is found, it returns ok" do
user = build_user()
assert {:ok, []} =
assert :ok =
PasswordReset.request_password_reset(Example.UserWithUsername, %{
"username" => user.username
})
end
test "when the user is not found, it returns an empty list" do
assert {:ok, []} =
test "when the user is not found, it returns ok" do
assert :ok =
PasswordReset.request_password_reset(Example.UserWithUsername, %{
"username" => username()
})

View file

@ -1,7 +1,7 @@
defmodule AshAuthentication.Plug.HelpersTest do
@moduledoc false
use AshAuthentication.DataCase, async: true
alias AshAuthentication.{Plug.Helpers, SessionPipeline}
alias AshAuthentication.{Jwt, Plug.Helpers, SessionPipeline}
import Plug.Test, only: [conn: 3]
alias Plug.Conn
@ -71,7 +71,7 @@ defmodule AshAuthentication.Plug.HelpersTest do
{:ok, %{"jti" => jti}} =
user.__metadata__.token
|> Joken.peek_claims()
|> Jwt.peek()
conn
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")

View file

@ -11,6 +11,10 @@ defmodule Example.TokenRevocation do
actions do
destroy :expire
update :update do
primary? true
end
end
postgres do

View file

@ -4,6 +4,7 @@ defmodule Example.UserWithUsername do
data_layer: AshPostgres.DataLayer,
extensions: [
AshAuthentication,
AshAuthentication.Confirmation,
AshAuthentication.PasswordAuthentication,
AshAuthentication.PasswordReset,
AshGraphql.Resource,
@ -43,12 +44,25 @@ defmodule Example.UserWithUsername do
get? true
manual Example.CurrentUserRead
end
update :update do
primary? true
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
@ -96,7 +110,7 @@ defmodule Example.UserWithUsername do
end
identities do
identity(:username, [:username])
identity(:username, [:username], eager_check_with: Example)
end
tokens do