Packages
ash_authentication
5.0.0-rc.8
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.
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/ash_authentication/strategies/otp/verifier.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.Otp.Verifier do
@moduledoc """
DSL verifier for OTP strategy.
"""
alias AshAuthentication.Strategy.Otp
alias Spark.Dsl.Verifier
alias Spark.Error.DslError
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
@doc false
@spec verify(Otp.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 <- validate_sign_in_action(dsl_state, strategy, identity_attribute),
:ok <- validate_generator(strategy),
:ok <- validate_otp_entropy(strategy) do
validate_brute_force_strategy(dsl_state, strategy)
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_request_action_type(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_request_action_type(%{type: :action}, _strategy), do: :ok
defp validate_request_action_type(%{type: actual, name: name}, strategy) do
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"Action `#{name}` must be type `:action`, got `:#{actual}`. " <>
"OTP request actions are generic actions; remove the action definition to use the auto-generated one " <>
"or define it as `action :#{name}, :ok do ... end` with `run AshAuthentication.Strategy.Otp.Request`."
)}
end
defp validate_sign_in_action(dsl_state, strategy, identity_attribute) 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.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_has_argument(action, strategy.otp_param_name),
:ok <-
validate_action_argument_option(action, strategy.otp_param_name, :type, [
:string,
Ash.Type.String
]),
:ok <-
validate_action_argument_option(action, strategy.otp_param_name, :allow_nil?, [false]) do
if strategy.registration_enabled? do
validate_action_has_change(action, Otp.SignInChange)
else
validate_action_has_preparation(action, Otp.SignInPreparation)
end
else
{:error, exception} when is_exception(exception) ->
{:error, exception}
{:error, message} ->
{:error,
DslError.exception(
path: [:actions, strategy.sign_in_action_name],
message: to_string(message)
)}
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}, strategy) do
expected = if strategy.registration_enabled?, do: :create, else: :read
{:error,
DslError.exception(
path: [:actions, strategy.sign_in_action_name],
message:
"Expected sign-in action to be a :#{expected} action when registration_enabled? is #{strategy.registration_enabled?}, got :#{type}."
)}
end
# Minimum number of distinct OTP codes required. Derived from
# NIST SP 800-63B §5.1.3.2, which requires at least 20 bits of entropy for
# out-of-band authentication secrets (2^20 = 1,048,576), and the common
# practice of 6-digit decimal OTPs (RFC 4226).
@min_otp_combinations 1_000_000
# Alphabet sizes for the built-in character sets in DefaultGenerator.
@alphabet_sizes %{
unambiguous_uppercase: 21,
unambiguous_alphanumeric: 27,
digits_only: 10,
uppercase_letters_only: 26
}
# Only validate entropy for the built-in generator. Custom generators receive
# `otp_length` and `otp_characters` as hints but we cannot force the generators
# to respect them, so we cannot reason about the actual code space they produce.
defp validate_otp_entropy(%{otp_generator: generator})
when not is_nil(generator) and generator != Otp.DefaultGenerator,
do: :ok
defp validate_otp_entropy(strategy) do
alphabet_size = @alphabet_sizes[strategy.otp_characters]
combinations = round(:math.pow(alphabet_size, strategy.otp_length))
if combinations >= @min_otp_combinations do
:ok
else
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: """
OTP configuration has insufficient entropy: #{combinations} possible codes \
(#{strategy.otp_length} characters from #{strategy.otp_characters} with \
#{alphabet_size} symbols).
At least #{@min_otp_combinations} combinations are required. \
Increase `otp_length` or switch to a larger character set \
(`:unambiguous_uppercase` or `:unambiguous_alphanumeric`).
"""
)}
end
end
defp validate_generator(%{otp_generator: nil}), do: :ok
defp validate_generator(%{otp_generator: Otp.DefaultGenerator}), do: :ok
defp validate_generator(strategy) do
generator = strategy.otp_generator
cond do
not Code.ensure_loaded?(generator) ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message: "The OTP generator module `#{inspect(generator)}` could not be loaded."
)}
not function_exported?(generator, :generate, 1) ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"The OTP generator module `#{inspect(generator)}` must export a `generate/1` function."
)}
not function_exported?(generator, :normalize, 1) ->
{:error,
DslError.exception(
path: [:authentication, :strategies, strategy.name],
message:
"The OTP generator module `#{inspect(generator)}` must export a `normalize/1` function."
)}
true ->
:ok
end
end
defp validate_brute_force_strategy(dsl_state, %{brute_force_strategy: :rate_limit} = strategy) do
with :ok <- validate_rate_limiter_extension(dsl_state, strategy) do
reduce_action_validations(strategy, fn action_name ->
validate_action_rate_limited(dsl_state, strategy, action_name)
end)
end
end
defp validate_brute_force_strategy(
dsl_state,
%{brute_force_strategy: {:audit_log, audit_log_name}} = strategy
) do
with :ok <- validate_audit_log_with_registration(dsl_state, strategy),
{:ok, audit_log} <- validate_audit_log_exists(dsl_state, strategy, audit_log_name) do
reduce_action_validations(strategy, fn action_name ->
validate_action_audit_logged(dsl_state, strategy, action_name, audit_log)
end)
end
end
defp validate_brute_force_strategy(
dsl_state,
%{brute_force_strategy: {:preparation, module}} = strategy
) do
validate_preparation_supports_query(dsl_state, strategy, module)
end
defp actions_to_protect(strategy),
do: [strategy.sign_in_action_name, strategy.request_action_name]
defp reduce_action_validations(strategy, validate) do
strategy
|> actions_to_protect()
|> Enum.reduce_while(:ok, fn action_name, :ok ->
to_reduce_result(validate.(action_name))
end)
end
defp to_reduce_result(:ok), do: {:cont, :ok}
defp to_reduce_result(error), do: {:halt, error}
defp validate_rate_limiter_extension(dsl_state, strategy) do
if AshRateLimiter in Spark.extensions(dsl_state) do
:ok
else
module = Verifier.get_persisted(dsl_state, :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_action_rate_limited(dsl_state, strategy, action_name) do
limited? =
dsl_state
|> Verifier.get_entities([:rate_limit])
|> Enum.any?(&(&1.action == action_name))
if limited? do
:ok
else
module = Verifier.get_persisted(dsl_state, :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(action_name)}` action 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_audit_log_with_registration(dsl_state, %{registration_enabled?: true} = strategy) do
module = Verifier.get_persisted(dsl_state, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The `{:audit_log, _}` brute force strategy is not supported when `registration_enabled?` is `true`.
Audit log protection requires an existing user record, but a registration sign-in may run before the user exists. Use `:rate_limit` (with `AshRateLimiter`) or `{:preparation, MyModule}` instead.
"""
)}
end
defp validate_audit_log_with_registration(_dsl_state, _strategy), do: :ok
defp validate_audit_log_exists(dsl_state, strategy, audit_log_name) do
module = Verifier.get_persisted(dsl_state, :module)
case AshAuthentication.Info.strategy(dsl_state, audit_log_name) do
{:ok, audit_log} when audit_log.provider == :audit_log ->
{:ok, audit_log}
{:ok, other_strategy} ->
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The brute force strategy is set to `{:audit_log, #{inspect(audit_log_name)}}`. There is a strategy named `#{inspect(audit_log_name)}` present, however it is a #{other_strategy.provider} strategy.
"""
)}
:error ->
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The brute force strategy is set to `{:audit_log, #{inspect(audit_log_name)}}`, however there is no audit-log add-on with that name.
"""
)}
end
end
defp validate_action_audit_logged(dsl_state, strategy, action_name, audit_log) do
logged_actions =
dsl_state
|> Verifier.get_persisted({:audit_log, audit_log.name, :actions}, [])
|> Enum.map(&elem(&1, 0))
if action_name in logged_actions do
:ok
else
module = Verifier.get_persisted(dsl_state, :module)
{:error,
DslError.exception(
module: module,
path: [:authentication, :strategies, strategy.name, :brute_force_strategy],
message: """
The brute force strategy is set to `{:audit_log, #{inspect(audit_log.name)}}`, however the action `#{inspect(action_name)}` is not logged by that audit log.
"""
)}
end
end
defp validate_preparation_supports_query(dsl_state, strategy, module) do
supported_subjects = get_preparation_supports(module)
required = required_preparation_subjects(strategy)
missing = required -- supported_subjects
if missing == [] do
:ok
else
resource_module = Verifier.get_persisted(dsl_state, :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: #{inspect(missing)}.
OTP applies the brute-force preparation to the request action (an `Ash.ActionInput`) #{sign_in_subject_description(strategy)}.
The preparation currently supports: #{inspect(supported_subjects)}
To fix this, implement the `supports/1` callback in your preparation module:
@impl true
def supports(_opts), do: #{inspect(required)}
"""
)}
end
end
defp required_preparation_subjects(%{registration_enabled?: true}), do: [Ash.ActionInput]
defp required_preparation_subjects(_strategy), do: [Ash.Query, Ash.ActionInput]
defp sign_in_subject_description(%{registration_enabled?: true}),
do: "(the sign-in action is a `:create` action and is not protected by this preparation)"
defp sign_in_subject_description(_strategy),
do: "and to the sign-in action (an `Ash.Query`)"
defp get_preparation_supports(module) when is_atom(module), do: module.supports([])
defp get_preparation_supports({module, opts}) when is_atom(module), do: module.supports(opts)
end