Packages
ash_authentication
5.0.0-rc.12
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
lib/ash_authentication/strategies/recovery_code/verifier.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.RecoveryCode.Verifier do
@moduledoc """
DSL verifier for the recovery_code strategy.
"""
alias Ash.Resource
alias AshAuthentication.AddOn.AuditLog.VerifierHelpers
alias AshAuthentication.Strategy.RecoveryCode
alias Spark.Dsl.Verifier
alias Spark.Error.DslError
import AshAuthentication.Validations, only: [warn_if_data_layer_cannot_lock: 2]
@doc false
@spec verify(RecoveryCode.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, dsl) do
with :ok <- validate_brute_force_strategy(dsl, strategy),
:ok <- validate_entropy(dsl, strategy),
:ok <- validate_code_alphabet(dsl, strategy),
:ok <- validate_recovery_code_resource(dsl, strategy) do
warn_if_data_layer_cannot_lock(
strategy.recovery_code_resource,
"Recovery-code verification reads stored codes inside a `SELECT FOR UPDATE` window when using a non-deterministic hash provider, so concurrent verifications can't both consume the same code."
)
end
end
defp validate_brute_force_strategy(dsl, strategy)
when strategy.brute_force_strategy == :rate_limit do
with :ok <- validate_rate_limiter_extension(dsl, strategy) do
validate_verify_rate_limit(dsl, strategy)
end
end
defp validate_brute_force_strategy(
dsl,
%{brute_force_strategy: {:audit_log, audit_log}} = strategy
) do
with {:ok, audit_log} <- VerifierHelpers.validate_audit_log_exists(dsl, strategy, audit_log) do
VerifierHelpers.validate_action_audit_logged(
dsl,
strategy,
strategy.verify_action_name,
audit_log
)
end
end
defp validate_brute_force_strategy(
dsl,
%{brute_force_strategy: {:preparation, module}} = strategy
) do
validate_preparation_supports_action_input(dsl, strategy, module)
end
defp validate_entropy(dsl, strategy) do
alphabet_size = String.length(strategy.code_alphabet)
entropy_bits = strategy.code_length * :math.log2(alphabet_size)
minimum = strategy.hash_provider.minimum_entropy()
if entropy_bits >= minimum do
:ok
else
module = Verifier.get_persisted(dsl, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name],
message: """
The recovery code configuration provides ~#{Float.round(entropy_bits, 1)} bits of entropy \
(code_length=#{strategy.code_length}, alphabet_size=#{alphabet_size}), but the hash provider \
`#{inspect(strategy.hash_provider)}` requires a minimum of #{minimum} bits.
Either increase `code_length`, use a richer `code_alphabet`, or use a slower hash provider \
like `AshAuthentication.BcryptProvider`.
"""
)}
end
end
defp validate_code_alphabet(dsl, strategy) do
graphemes = String.graphemes(strategy.code_alphabet)
unique_graphemes = Enum.uniq(graphemes)
cond do
length(graphemes) < 2 ->
module = Verifier.get_persisted(dsl, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :code_alphabet],
message: "The `code_alphabet` must contain at least 2 characters."
)}
length(graphemes) != length(unique_graphemes) ->
module = Verifier.get_persisted(dsl, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :code_alphabet],
message: "The `code_alphabet` must contain only unique characters."
)}
true ->
:ok
end
end
defp validate_recovery_code_resource(dsl, strategy) do
resource = strategy.recovery_code_resource
module = Verifier.get_persisted(dsl, :module)
with :ok <- validate_code_field(resource, strategy, module),
:ok <- validate_user_relationship_name(resource, strategy, module) do
validate_destroy_action(resource, strategy, module)
end
end
defp validate_code_field(resource, strategy, module) do
case Resource.Info.attribute(resource, strategy.code_field) do
nil ->
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :code_field],
message: """
The recovery code resource `#{inspect(resource)}` does not have an attribute named `#{inspect(strategy.code_field)}`.
Add the attribute to your recovery code resource:
attribute #{inspect(strategy.code_field)}, :string, sensitive?: true, allow_nil?: false
"""
)}
attribute ->
if attribute.sensitive? do
:ok
else
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :code_field],
message: """
The attribute `#{inspect(strategy.code_field)}` on `#{inspect(resource)}` must be marked as `sensitive?: true`.
"""
)}
end
end
end
defp validate_user_relationship_name(resource, strategy, module) do
case Resource.Info.relationship(resource, strategy.user_relationship_name) do
nil ->
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :user_relationship_name],
message: """
The recovery code resource `#{inspect(resource)}` does not have a relationship named `#{inspect(strategy.user_relationship_name)}`.
Add a belongs_to relationship:
belongs_to #{inspect(strategy.user_relationship_name)}, #{inspect(module)}, allow_nil?: false
"""
)}
relationship ->
if relationship.type == :belongs_to do
:ok
else
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :user_relationship_name],
message:
"The relationship `#{inspect(strategy.user_relationship_name)}` on `#{inspect(resource)}` must be a `belongs_to` relationship."
)}
end
end
end
defp validate_destroy_action(resource, strategy, module) do
if Resource.Info.action(resource, :destroy) do
:ok
else
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name],
message: """
The recovery code resource `#{inspect(resource)}` must have a `:destroy` action.
Add a destroy action:
actions do
defaults [:read, :destroy]
end
"""
)}
end
end
defp validate_rate_limiter_extension(dsl, strategy) do
if AshRateLimiter in Spark.extensions(dsl) do
:ok
else
module = Verifier.get_persisted(dsl, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The brute force strategy is set to `:rate_limit` however the `AshRateLimiter` extension is not present on this resource.
"""
)}
end
end
defp validate_verify_rate_limit(dsl, strategy) do
limited? =
dsl
|> Verifier.get_entities([:rate_limit])
|> Enum.any?(&(&1.action == strategy.verify_action_name))
if limited? do
:ok
else
module = Verifier.get_persisted(dsl, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The brute force strategy is set to `:rate_limit` however the `#{inspect(strategy.verify_action_name)}` is not configured with a rate limit.
See the ash_rate_limiter documentation for more information:
https://hexdocs.pm/ash_rate_limiter/readme.html
"""
)}
end
end
defp validate_preparation_supports_action_input(dsl, strategy, module) do
supported_subjects = get_preparation_supports(module)
if Ash.ActionInput in supported_subjects do
:ok
else
resource_module = Verifier.get_persisted(dsl, :module)
{:error,
DslError.exception(
module: resource_module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The brute force preparation `#{inspect(module)}` does not support `Ash.ActionInput`.
This is required for the verify (generic action).
The preparation currently supports: #{inspect(supported_subjects)}
To fix this, implement the `supports/0` callback in your preparation module:
@impl true
def supports, do: [Ash.ActionInput]
And ensure your `prepare/3` callback can handle ActionInput subjects.
"""
)}
end
end
defp get_preparation_supports(module) when is_atom(module) do
module.supports([])
end
defp get_preparation_supports({module, opts}) when is_atom(module) do
module.supports(opts)
end
end