Current section

Files

Jump to
ash_authentication lib ash_authentication add_ons confirmation confirmation_hook_change.ex
Raw

lib/ash_authentication/add_ons/confirmation/confirmation_hook_change.ex

# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.AddOn.Confirmation.ConfirmationHookChange do
@moduledoc """
Triggers a confirmation flow when one of the monitored fields is changed.
Optionally inhibits changes to monitored fields on update.
You can use this change in your actions where you want to send the user a
confirmation (or inhibit changes after confirmation). If you're not using one
of the actions generated by the confirmation add-on then you'll need to
manually pass the strategy name in the changeset context. Eg:
```elixir
Changeset.new(user, %{})
|> Changeset.set_context(%{strategy_name: :confirm})
|> Changeset.for_update(:update, params)
|> Accounts.update()
```
or by adding it statically to your action definition:
```elixir
update :change_email do
change set_context(%{strategy_name: :confirm})
change AshAuthentication.AddOn.Confirmation.ConfirmationHookChange
end
```
or by adding it as an option to the change definition:
```elixir
update :change_email do
change {AshAuthentication.AddOn.Confirmation.ConfirmationHookChange, strategy_name: :confirm}
end
```
"""
use Ash.Resource.Change
alias Ash.{Changeset, Error.Changes.InvalidChanges, Query, Resource.Change}
alias AshAuthentication.{AddOn.Confirmation, Info}
import Ash.Expr
require Logger
@doc false
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, options, context) do
case Info.find_strategy(changeset, context, options) do
{:ok, strategy} ->
do_change(changeset, strategy, context)
:error ->
changeset
end
end
defp do_change(changeset, strategy, context) do
auto_confirm? = changeset.action.name in strategy.auto_confirm_actions
changeset =
if auto_confirm? do
Changeset.change_attribute(changeset, strategy.confirmed_at_field, DateTime.utc_now())
else
changeset
end
changeset
|> handle_upserts(
strategy,
auto_confirm?
)
|> then(fn changeset ->
if auto_confirm? do
changeset
else
Ash.Changeset.before_action(changeset, &before_action(&1, strategy, context))
end
end)
end
@impl true
def atomic(changeset, opts, context) do
case Info.find_strategy(changeset, context, opts) do
{:ok, strategy} ->
atomic_confirmation(changeset, strategy, context)
:error ->
changeset
end
end
defp atomic_confirmation(changeset, strategy, context) do
auto_confirm? = changeset.action.name in strategy.auto_confirm_actions
if auto_confirm? do
{:atomic, %{strategy.confirmed_at_field => expr(now())}}
else
action_types =
if strategy.confirm_on_create? do
[:create]
else
[]
end
action_types =
if strategy.confirm_on_update? do
action_types ++ [:update]
else
action_types
end
cond do
changeset.action.type not in action_types ->
{:ok, changeset}
changeset.action.name == strategy.confirm_action_name ->
{:ok, changeset}
!Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)) ->
{:ok, changeset}
identity = cant_atomically_inhibit_updates(changeset.resource, strategy) ->
{:not_atomic,
"The confirmation strategy `:#{strategy.name}` cannot inhibit updates atomically, because the identity `#{identity.name}` requires checking in advance if the fields have been taken."}
true ->
{:ok,
maybe_perform_confirmation(changeset, strategy, changeset, context)
|> validate_identities(strategy, true)}
end
end
end
defp cant_atomically_inhibit_updates(resource, strategy) do
strategy.inhibit_updates? &&
Enum.find(Ash.Resource.Info.identities(resource), fn identity ->
Enum.any?(strategy.monitor_fields, &(&1 in identity.keys))
end)
end
defp before_action(changeset, strategy, context) do
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, context)
end
defp handle_upserts(
%{action: %{type: :create}, context: %{private: %{upsert?: true}}} = changeset,
strategy,
auto_confirm?
) do
changeset
|> ensure_confirmed_at_is_set(strategy, auto_confirm?)
|> prevent_hijacking(strategy)
end
defp handle_upserts(changeset, _, _), do: changeset
defp ensure_confirmed_at_is_set(changeset, strategy, auto_confirm?) do
if (auto_confirm? || changeset.action.name == strategy.confirm_action_name) &&
changeset.context[:private][:upsert_fields] do
update_in(
changeset.context[:private][:upsert_fields],
&(List.wrap(&1) ++ [strategy.confirmed_at_field])
)
else
changeset
end
end
defp prevent_hijacking(changeset, strategy) do
if strategy.prevent_hijacking? &&
Enum.any?(strategy.monitor_fields, &Changeset.changing_attribute?(changeset, &1)) do
add_hijack_filter(changeset, strategy)
else
# User accepted the risk 🤷‍♂️
changeset
end
end
defp add_hijack_filter(changeset, strategy) do
if Ash.DataLayer.data_layer_can?(changeset.resource, :expr_error) do
Ash.Changeset.filter(
changeset,
expr(
if is_nil(^ref(strategy.confirmed_at_field)) do
error(
AshAuthentication.Errors.CannotConfirmUnconfirmedUser,
%{resource: ^changeset.resource, confirmation_strategy: ^strategy.name}
)
else
true
end
)
)
else
Ash.Changeset.filter(
changeset,
expr(not is_nil(^ref(strategy.confirmed_at_field)))
)
end
end
defp not_confirm_action(%Changeset{} = changeset, strategy)
when changeset.action.name != 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
changeset
|> validate_identities(strategy)
|> case do
{:error, violated_identity} ->
Ash.Changeset.add_error(
changeset,
InvalidChanges.exception(
fields: violated_identity.keys,
message: "has already been taken"
)
)
changeset ->
Enum.reduce(strategy.monitor_fields, changeset, &Changeset.clear_change(&2, &1))
end
end
defp maybe_inhibit_updates(changeset, _strategy), do: changeset
defp validate_identities(changeset, strategy, after_action? \\ false) do
identities =
strategy.monitor_fields
|> Enum.flat_map(fn field ->
changeset.resource
|> Ash.Resource.Info.identities()
|> Enum.filter(&(field in &1.keys))
end)
cond do
identities == [] ->
changeset
after_action? ->
do_validate_identities_after_action(identities, changeset)
true ->
do_validate_identities(identities, changeset)
end
end
defp do_validate_identities_after_action(identities, changeset) do
Ash.Changeset.after_action(changeset, fn _changeset, result ->
case do_validate_identities(identities, changeset) do
{:error, violated_identity} ->
InvalidChanges.exception(
fields: violated_identity.keys,
message: "has already been taken"
)
_ ->
{:ok, result}
end
end)
end
defp do_validate_identities(identities, changeset) do
Enum.reduce_while(identities, changeset, fn identity, changeset ->
non_attrs =
Enum.reject(identity.keys, &Ash.Resource.Info.attribute(changeset.resource, &1))
changeset = %{changeset | data: Ash.load!(changeset.data, non_attrs, lazy?: true)}
filter =
Enum.map(identity.keys, &{&1, Ash.Changeset.get_attribute(changeset, &1)})
has_nil? =
Enum.any?(filter, fn {_k, v} -> is_nil(v) end)
if (identity.nils_distinct? && has_nil?) ||
!exists?(changeset, filter) do
{:cont, changeset}
else
{:halt, {:error, identity}}
end
end)
end
defp exists?(changeset, filter) do
changeset.resource
|> Query.do_filter(filter)
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Ash.exists?(tenant: changeset.tenant)
end
defp maybe_perform_confirmation(%Changeset{} = changeset, strategy, original_changeset, context) do
changeset
|> nil_confirmed_at_field(strategy)
|> Changeset.after_action(fn _changeset, user ->
strategy
|> Confirmation.confirmation_token(original_changeset, user, Ash.Context.to_opts(context))
|> case do
{:ok, token} ->
{sender, send_opts} = strategy.sender
send_opts
|> Keyword.put(:tenant, context.tenant)
|> Keyword.put(:changeset, original_changeset)
|> then(&sender.send(user, token, &1))
metadata =
user.__metadata__
|> Map.put(:confirmation_token, token)
{:ok, %{user | __metadata__: metadata}}
{:error, error} ->
Logger.error(
"Failed to generate confirmation token\n: #{Exception.format(:error, error)}"
)
{:ok, user}
end
end)
end
defp maybe_perform_confirmation(_changeset, _strategy, original_changeset, _context),
do: original_changeset
defp nil_confirmed_at_field(changeset, strategy) do
# If we're updating values, and we are inhibiting values on the changes (enforced at the call site)
# then we want to reset the confirmed_at_field to `nil`
# However, we do it `lazily` in case something they've done is already changing
# the `confirmed_at`. This might happen in a social sign up where the email has
# already been verified
if changeset.action.type == :update do
Changeset.force_change_new_attribute(changeset, strategy.confirmed_at_field, nil)
else
changeset
end
end
end