Current section

Files

Jump to
ash_authentication lib ash_authentication add_ons confirmation transformer.ex
Raw

lib/ash_authentication/add_ons/confirmation/transformer.ex

# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.AddOn.Confirmation.Transformer do
@moduledoc """
DSL transformer for confirmation add-on.
Ensures that there is only ever one present and that it is correctly
configured.
"""
alias Ash.{Resource, Type}
alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
import AshAuthentication.Strategy.Custom.Helpers
@doc false
@spec transform(Confirmation.t(), map) ::
{:ok, Confirmation.t() | map} | {:error, Exception.t()}
def transform(strategy, dsl_state) do
with :ok <-
validate_token_generation_enabled(
dsl_state,
"Token generation must be enabled for confirmation to work."
),
:ok <- validate_monitor_fields(dsl_state, strategy),
strategy <- maybe_set_confirm_action_name(strategy),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
strategy.confirm_action_name,
&build_confirm_action(&1, strategy)
),
:ok <- warn_on_require_interaction(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, strategy_name: strategy.name}
),
{:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
dsl_state =
dsl_state
|> then(&register_strategy_actions([strategy.confirm_action_name], &1, strategy))
|> put_add_on(strategy)
{:ok, dsl_state}
else
{:error, reason} when is_binary(reason) ->
{:error,
DslError.exception(path: [:authentication, :add_ons, :confirmation], message: reason)}
{:error, reason} ->
{:error, reason}
:error ->
{:error,
DslError.exception(
path: [:authentication, :add_ons, :confirmation],
message: "Configuration error"
)}
end
end
defp warn_on_require_interaction(strategy) when strategy.require_interaction? do
:ok
end
defp warn_on_require_interaction(strategy) do
bypassing_error? =
Application.get_env(
:ash_authentication,
:bypass_require_interaction_for_confirmation?,
false
)
if bypassing_error? do
:ok
else
{:error,
DslError.exception(
path: [:authentication, :add_ons, :confirmation],
message: """
`require_interaction?` must be set to true on the
#{inspect(strategy.name)} confirmation for #{inspect(strategy.resource)}.
Without it, confirmation links use a `GET` endpoint for confirmation. Some
email clients and security tools (e.g., Outlook, virus scanners, and email
previewers) may automatically follow these links, unintentionally
confirming the account. This allows an attacker to register an account
using another user’s email and potentially have it auto-confirmed by the
victim’s email client.
If you are *positive* that you are not affected , and *absolutely
must*, you can bypass this warning by setting `config
:ash_authentication, :bypass_require_interaction_for_confirmation?,
true` in your configuration.
Read the security advisory and follow patch the steps closely if you need to upgrade:
https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-3988-q8q7-p787
"""
)}
end
end
defp validate_monitor_fields(_dsl_state, %{monitor_fields: []}),
do:
{:error,
DslError.exception(
path: [:authentication, :add_ons, :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 <- validate_attribute_option(attribute, resource, :public?, [true]) do
{:cont, :ok}
else
{:error, reason} -> {:halt, {:error, reason}}
end
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,
public?: true
)
]
changes = [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: Confirmation.ConfirmChange
),
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: GenerateTokenChange
)
]
metadata = [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :metadata,
name: :token,
type: :string,
allow_nil?: false
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: strategy.confirm_action_name,
accept: strategy.monitor_fields,
arguments: arguments,
metadata: metadata,
changes: changes,
require_atomic?: false
)
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_argument_option(action, :confirm, :type, [Type.String]),
:ok <- validate_action_has_change(action, GenerateTokenChange) do
accept_fields = MapSet.new(action.accept || [])
strategy.monitor_fields
|> MapSet.new()
|> MapSet.difference(accept_fields)
|> Enum.to_list()
|> case do
[] ->
:ok
_fields ->
{:error,
DslError.exception(
path: [:actions, action.name, :accept],
message: "The confirmation action must accept the monitored fields."
)}
end
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]) do
validate_attribute_option(attribute, resource, :allow_nil?, [true])
end
end
defp maybe_build_change(dsl_state, change_module) do
with changes <- Resource.Info.changes(dsl_state),
false <- Enum.any?(changes, &(&1.change == change_module)),
{:ok, change} <-
Transformer.build_entity(Resource.Dsl, [:changes], :change,
change: change_module,
on: [:create, :update]
) do
{:ok, Transformer.add_entity(dsl_state, [:changes], change)}
else
true -> {:ok, dsl_state}
{:error, reason} -> {:error, reason}
end
end
defp maybe_set_confirm_action_name(strategy)
when is_nil(strategy.confirm_action_name),
do: %{strategy | confirm_action_name: strategy.name}
defp maybe_set_confirm_action_name(strategy), do: strategy
end