# 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