Current section

Files

Jump to
ash_authentication lib ash_authentication strategies recovery_code actions.ex
Raw

lib/ash_authentication/strategies/recovery_code/actions.ex

# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication.Strategy.RecoveryCode.Actions do
@moduledoc """
Actions for the recovery code strategy.
Provides the code interface for recovery code verification and generation.
"""
alias Ash.{ActionInput, Changeset}
alias AshAuthentication.{Errors, Info, Strategy.RecoveryCode}
@doc """
Verify a recovery code for a user.
Takes a user and a recovery code, verifies against stored hashed codes,
deletes the code on success, and returns the authenticated user.
"""
@spec verify(RecoveryCode.t(), map, keyword) ::
{:ok, Ash.Resource.record()} | {:error, any}
def verify(strategy, params, options) do
options =
options
|> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end)
strategy.resource
|> ActionInput.new()
|> ActionInput.set_context(%{private: %{ash_authentication?: true}})
|> ActionInput.for_action(strategy.verify_action_name, params, options)
|> Ash.run_action()
|> case do
{:ok, user} when not is_nil(user) ->
{:ok, user}
{:ok, nil} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: :verify,
message: "Invalid recovery code"
}
)}
{:error, error} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: error
)}
end
end
@doc """
Generate new recovery codes for a user.
Deletes any existing recovery codes and generates new ones.
Returns the user with plaintext codes in `__metadata__.recovery_codes`.
"""
@spec generate(RecoveryCode.t(), map, keyword) ::
{:ok, Ash.Resource.record()} | {:error, any}
def generate(strategy, params, options) do
user = Map.get(params, :user) || Map.get(params, "user")
options =
options
|> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end)
user
|> Changeset.new()
|> Changeset.set_context(%{private: %{ash_authentication?: true}})
|> Changeset.for_update(strategy.generate_action_name, %{}, options)
|> Ash.update()
|> case do
{:ok, user} ->
{:ok, user}
{:error, error} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: error
)}
end
end
@doc """
Generate a list of random recovery codes using a CSPRNG.
"""
@spec generate_codes_list(pos_integer, pos_integer, String.t()) :: [String.t()]
def generate_codes_list(length, count, alphabet) do
Enum.map(1..count, fn _ -> generate_code(length, alphabet) end)
end
@doc """
Generate a single random recovery code using `:crypto.strong_rand_bytes/1`.
"""
@spec generate_code(pos_integer, String.t()) :: String.t()
def generate_code(length, alphabet) do
alphabet_list = String.graphemes(alphabet)
alphabet_size = length(alphabet_list)
bytes_needed = max(1, ceil(:math.log2(alphabet_size) / 8))
Enum.map_join(1..length, fn _ ->
random_int =
bytes_needed
|> :crypto.strong_rand_bytes()
|> :binary.decode_unsigned()
Enum.at(alphabet_list, rem(random_int, alphabet_size))
end)
end
end