Current section

Files

Jump to
ash_authentication lib mix tasks ash_authentication.add_strategy.recovery_code.ex
Raw

lib/mix/tasks/ash_authentication.add_strategy.recovery_code.ex

# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
# credo:disable-for-this-file Credo.Check.Design.AliasUsage
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.AshAuthentication.AddStrategy.RecoveryCode do
use Igniter.Mix.Task
@example "mix ash_authentication.add_strategy recovery_code"
@shortdoc "Adds the recovery code authentication strategy"
@moduledoc """
#{@shortdoc}
Creates a recovery code resource and adds the recovery code strategy
to the user resource. Recovery codes are one-time backup codes that
allow users to authenticate when their primary 2FA method (e.g. TOTP)
is unavailable.
## Example
```bash
#{@example}
```
## Options
* `--user`, `-u` - The user resource. Defaults to `YourApp.Accounts.User`
"""
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :ash,
example: @example,
extra_args?: false,
only: nil,
positional: [],
schema: [
accounts: :string,
user: :string,
identity_field: :string
],
aliases: [
a: :accounts,
u: :user,
i: :identity_field
],
defaults: [
identity_field: "email"
]
}
end
def igniter(igniter) do
options = parse_options(igniter)
case Igniter.Project.Module.module_exists(igniter, options[:user]) do
{true, igniter} ->
igniter
|> add_recovery_code_resource(options)
|> AshAuthentication.Igniter.codegen_for_strategy(:recovery_code)
{false, igniter} ->
Igniter.add_issue(igniter, """
User module #{inspect(options[:user])} was not found.
Perhaps you have not yet installed ash_authentication?
""")
end
end
defp parse_options(igniter) do
options =
igniter.args.options
|> Keyword.put_new_lazy(:accounts, fn ->
Igniter.Project.Module.module_name(igniter, "Accounts")
end)
options
|> Keyword.put_new_lazy(:user, fn ->
Module.concat(options[:accounts], User)
end)
|> Keyword.update(:identity_field, :email, &String.to_atom/1)
|> Keyword.update!(:accounts, &maybe_parse_module/1)
|> Keyword.update!(:user, &maybe_parse_module/1)
end
defp maybe_parse_module(value) when is_binary(value), do: Igniter.Project.Module.parse(value)
defp maybe_parse_module(value), do: value
defp add_recovery_code_resource(igniter, options) do
recovery_code_resource =
options[:user]
|> Module.split()
|> :lists.droplast()
|> Enum.concat([RecoveryCode])
|> Module.concat()
{exists?, igniter} = Igniter.Project.Module.module_exists(igniter, recovery_code_resource)
if exists? do
Igniter.add_issue(
igniter,
"""
Recovery code resource already exists: #{inspect(recovery_code_resource)}.
"""
)
else
extensions =
cond do
Code.ensure_loaded?(AshPostgres.DataLayer) -> "postgres"
Code.ensure_loaded?(AshSqlite.DataLayer) -> "sqlite"
true -> ""
end
igniter
|> Igniter.compose_task("ash.gen.resource", [
inspect(recovery_code_resource),
"--uuid-primary-key",
"id",
"--default-actions",
"read,destroy",
"--attribute",
"code:string:required:sensitive",
"--relationship",
"belongs_to:user:#{inspect(options[:user])}:required",
"--extend",
extensions
])
|> Ash.Resource.Igniter.add_new_action(recovery_code_resource, :create, """
create :create do
primary? true
accept [:code]
end
""")
|> Ash.Resource.Igniter.add_new_relationship(
options[:user],
:recovery_codes,
"""
has_many :recovery_codes, #{inspect(recovery_code_resource)}
"""
)
|> compose_audit_log(options)
|> Ash.Resource.Igniter.add_new_action(options[:user], :verify_with_recovery_code, """
action :verify_with_recovery_code do
argument :user, :struct do
allow_nil? false
sensitive? true
constraints instance_of: __MODULE__
end
argument :code, :string do
allow_nil? false
sensitive? true
end
prepare {AshAuthentication.AddOn.AuditLog.BruteForcePreparation,
action_name: :verify_with_recovery_code}
returns :term
transaction? true
run AshAuthentication.Strategy.RecoveryCode.VerifyAction
touches_resources [#{inspect(recovery_code_resource)}]
description "Verify a recovery code and return the user if valid, nil otherwise."
end
""")
|> Ash.Resource.Igniter.add_new_action(options[:user], :generate_recovery_code_codes, """
update :generate_recovery_code_codes do
require_atomic? false
accept []
argument :recovery_codes, {:array, :string} do
allow_nil? false
sensitive? true
default {AshAuthentication.Strategy.RecoveryCode.Actions, :generate_codes_list,
[12, 10, "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"]}
end
change {Ash.Resource.Change.CascadeDestroy, relationship: :recovery_codes, after_action?: false}
change {AshAuthentication.Strategy.RecoveryCode.HashRecoveryCodesChange, hash_provider: AshAuthentication.SHA256Provider}
change {Ash.Resource.Change.ManageRelationship,
argument: :recovery_codes,
relationship: :recovery_codes,
opts: [type: :create, value_is_key: :code]}
metadata :recovery_codes, {:array, :string}, allow_nil?: false
touches_resources [#{inspect(recovery_code_resource)}]
description "Generate new recovery codes for the user, replacing any existing codes."
end
""")
|> AshAuthentication.Igniter.add_new_strategy(
options[:user],
:recovery_code,
:recovery_code,
"""
recovery_code do
recovery_code_resource #{inspect(recovery_code_resource)}
brute_force_strategy {:audit_log, :audit_log}
end
"""
)
end
end
defp compose_audit_log(igniter, options) do
{igniter, has_audit_log?} =
AshAuthentication.Igniter.defines_add_on(igniter, options[:user], :audit_log, nil)
if has_audit_log? do
igniter
else
Igniter.compose_task(
igniter,
"ash_authentication.add_add_on.audit_log",
["--user", inspect(options[:user])]
)
end
end
end
else
defmodule Mix.Tasks.AshAuthentication.AddStrategy.RecoveryCode do
@shortdoc "Adds the recovery code authentication strategy"
@moduledoc @shortdoc
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_authentication.add_strategy.recovery_code' requires igniter to be run.
Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter
""")
exit({:shutdown, 1})
end
end
end