Current section

Files

Jump to
ash_authentication lib ash_authentication strategies magic_link verifier.ex
Raw

lib/ash_authentication/strategies/magic_link/verifier.ex

# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.MagicLink.Verifier do
@moduledoc """
DSL verifier for magic links.
"""
alias AshAuthentication.{AddOn.AuditLog.VerifierHelpers, Strategy.MagicLink}
alias Spark.Error.DslError
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
@doc false
@spec verify(MagicLink.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, dsl_state) do
with {:ok, identity_attribute} <- validate_identity_attribute(dsl_state, strategy),
:ok <- validate_request_action(dsl_state, strategy, identity_attribute),
:ok <- prevent_hijacking(dsl_state, strategy),
:ok <- validate_brute_force_strategy(dsl_state, strategy) do
validate_sign_in_action(dsl_state, strategy)
end
end
defp validate_brute_force_strategy(_dsl_state, %{brute_force_strategy: nil}), do: :ok
defp validate_brute_force_strategy(_dsl_state, %{brute_force_strategy: {:preparation, _}}),
do: :ok
defp validate_brute_force_strategy(
dsl_state,
%{brute_force_strategy: {:audit_log, audit_log_name}} = strategy
) do
with {:ok, audit_log} <-
VerifierHelpers.validate_audit_log_exists(dsl_state, strategy, audit_log_name) do
VerifierHelpers.validate_action_audit_logged(
dsl_state,
strategy,
strategy.request_action_name,
audit_log
)
end
end
defp validate_identity_attribute(dsl_state, strategy) do
with {:ok, identity_attribute} <- find_attribute(dsl_state, strategy.identity_field),
:ok <-
validate_attribute_unique_constraint(
dsl_state,
[strategy.identity_field],
strategy.resource
) do
{:ok, identity_attribute}
end
end
defp validate_request_action(dsl_state, strategy, identity_attribute) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.request_action_name),
:ok <- validate_action_has_argument(action, strategy.identity_field),
:ok <-
validate_action_argument_option(
action,
strategy.identity_field,
:type,
[identity_attribute.type]
),
:ok <-
validate_action_argument_option(action, strategy.identity_field, :allow_nil?, [
false
]),
:ok <- validate_action_type(action, :action, strategy),
{:ok, lookup_action} <- validate_action_exists(dsl_state, strategy.lookup_action_name),
:ok <- validate_action_has_argument(lookup_action, identity_attribute.name),
:ok <-
validate_action_argument_option(
lookup_action,
strategy.identity_field,
:type,
[identity_attribute.type]
),
:ok <-
validate_action_argument_option(lookup_action, strategy.identity_field, :allow_nil?, [
false
]) do
validate_action_option(lookup_action, :get?, [true])
end
end
defp validate_action_type(%{type: expected}, expected, _strategy), do: :ok
defp validate_action_type(%{type: actual, name: name}, expected, strategy) do
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"Action `#{name}` must be type :#{expected}, got :#{actual}. " <>
"Magic link request actions were changed from :read to :action in AshAuthentication 5.0. " <>
"See the upgrade guide for migration instructions."
)}
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_sign_in_action_type(action, strategy),
:ok <- validate_action_has_argument(action, strategy.token_param_name),
:ok <-
validate_action_argument_option(action, strategy.token_param_name, :type, [
:string,
Ash.Type.String
]),
:ok <-
validate_action_argument_option(action, strategy.token_param_name, :allow_nil?, [false]),
:ok <- validate_field_in_values(action, :type, [:read, :create]) do
if action.type == :read do
validate_action_has_preparation(action, MagicLink.SignInPreparation)
else
validate_action_has_change(action, MagicLink.SignInChange)
end
else
{:error, message} when is_binary(message) ->
{:error,
DslError.exception(
path: [:actions, :read, strategy.sign_in_action_name, :type],
message: message
)}
{:error, exception} when is_exception(exception) ->
{:error, exception}
end
end
defp validate_sign_in_action_type(%{type: :create}, %{registration_enabled?: true}), do: :ok
defp validate_sign_in_action_type(%{type: :read}, %{registration_enabled?: false}), do: :ok
defp validate_sign_in_action_type(%{type: type, name: name}, %{
name: strategy_name,
resource: resource,
identity_field: identity_field,
registration_enabled?: enabled?
}) do
message =
if enabled? do
"""
When registration is not enabled for magic link authentication,
the action: #{inspect(resource)}.#{name} must be a :create action.
Got an action of type: #{inspect(type)}.
You can either remove the action definition to use the default one,
or replace it with an action like this:
create :#{name} do
description "Sign in or register a user with magic link."
argument :token, :string do
description "The token from the magic link that was sent to the user"
allow_nil? false
end
upsert? true
upsert_identity :unique_#{identity_field}
upsert_fields [:#{identity_field}]
# Uses the information from the token to create or sign in the user
change AshAuthentication.Strategy.MagicLink.SignInChange
metadata :token, :string do
allow_nil? false
end
end
"""
else
"""
When registration is not enabled for magic link authentication,
the action: #{inspect(resource)}.#{name} must be a :read action.
Got an action of type: #{inspect(type)}.
You can either remove the action definition to use the default one,
or replace it with an action like this:
read :#{name} do
description "Sign in a user with magic link."
argument :token, :string do
description "The token from the magic link that was sent to the user"
allow_nil? false
end
# Uses the information from the token to sign in the user
prepare AshAuthentication.Strategy.MagicLink.SignInPreparation
metadata :token, :string do
allow_nil? false
end
end
"""
end
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy_name],
message: message
)}
end
defp prevent_hijacking(_dsl_state, %{prevent_hijacking?: false}), do: :ok
defp prevent_hijacking(_dsl_state, %{registration_enabled?: false}), do: :ok
defp prevent_hijacking(dsl_state, strategy) do
if could_be_hijacked?(dsl_state) && !has_confirmation_add_on?(dsl_state, strategy) do
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: """
If you have a magic strategy and a password strategy that support registration and share an identity field,
you must also have a confirmation add-on that monitors that identity field.
This is to prevent from account hijacking.
For more information, see the confirmation tutorial on hexdocs.
"""
)}
else
:ok
end
end
defp could_be_hijacked?(dsl_state) do
Enum.any?(AshAuthentication.Info.authentication_strategies(dsl_state), fn other_strategy ->
other_strategy.__struct__ == AshAuthentication.Strategy.Password &&
other_strategy.registration_enabled?
end)
end
defp has_confirmation_add_on?(dsl_state, strategy) do
Enum.any?(AshAuthentication.Info.authentication_add_ons(dsl_state), fn add_on ->
add_on.__struct__ == AshAuthentication.AddOn.Confirmation &&
strategy.identity_field in add_on.monitor_fields
end)
end
end