Current section

Files

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

lib/mix/tasks/ash_authentication.add_strategy.otp.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.Otp do
use Igniter.Mix.Task
@example "mix ash_authentication.add_strategy.otp"
@shortdoc "Adds one-time password (OTP) authentication to your user resource"
@moduledoc """
#{@shortdoc}
Adds the OTP strategy to your user resource — passwordless authentication
via a short code (e.g. `XKPTMH`) sent via email or SMS.
Brute-force protection is required by the strategy. By default this task
composes an `audit_log` add-on (creating one if it doesn't already exist)
and wires `brute_force_strategy {:audit_log, :audit_log}` into the
strategy.
## Example
```bash
#{@example}
```
## Options
* `--user`, `-u` - The user resource. Defaults to `YourApp.Accounts.User`
* `--identity-field`, `-i` - The field on the user resource that will be
used to identify the user. Defaults to `email`
* `--name`, `-n` - The name of the OTP strategy. Defaults to `otp`.
## Notes
`registration_enabled?: true` (sign-in becomes an upsert) is not currently
supported by this task because the verifier forbids the `:audit_log`
brute-force strategy in registration mode, and the alternatives
(`:rate_limit` or `{:preparation, ...}`) require user-supplied
configuration. Add the strategy by hand and follow the OTP tutorial if you
need that mode.
"""
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
group: :ash,
example: @example,
extra_args?: false,
only: nil,
positional: [],
composes: [
"ash_authentication.add_add_on.audit_log"
],
schema: [
accounts: :string,
user: :string,
identity_field: :string,
name: :string,
registration: :boolean
],
aliases: [
a: :accounts,
u: :user,
i: :identity_field,
n: :name
],
defaults: [
identity_field: "email",
name: "otp"
]
}
end
def igniter(igniter) do
options = parse_options(igniter)
if options[:registration] do
Mix.shell().error("""
`--registration` is not supported by this task.
When `registration_enabled?` is true, the OTP strategy cannot use the
`:audit_log` brute-force strategy (the verifier rejects it). The
alternatives are `:rate_limit` (requires the AshRateLimiter extension
and a backend) or `{:preparation, MyMod}` (requires you to write a
preparation module). Both need decisions this generator can't make
safely on your behalf.
Add the OTP strategy by hand and follow the OTP tutorial:
https://hexdocs.pm/ash_authentication/otp.html
""")
exit({:shutdown, 1})
end
case Igniter.Project.Module.module_exists(igniter, options[:user]) do
{true, igniter} ->
igniter
|> otp(options)
|> AshAuthentication.Igniter.codegen_for_strategy(:otp)
{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(:name, :otp, &String.to_atom/1)
|> Keyword.update!(:accounts, &AshAuthentication.Igniter.maybe_parse_module/1)
|> Keyword.update!(:user, &AshAuthentication.Igniter.maybe_parse_module/1)
end
# sobelow_skip ["DOS.BinToAtom"]
defp otp(igniter, options) do
sender = Module.concat(options[:user], Senders.SendOtp)
strategy_name = options[:name]
identity_field = options[:identity_field]
request_action = :"request_#{strategy_name}"
sign_in_action = :"sign_in_with_#{strategy_name}"
igniter
|> Ash.Resource.Igniter.add_new_attribute(options[:user], identity_field, """
attribute #{inspect(identity_field)}, :ci_string do
allow_nil? false
public? true
end
""")
|> AshAuthentication.Igniter.ensure_identity(options[:user], identity_field)
|> AshAuthentication.Igniter.ensure_get_by_action(options[:user], identity_field)
|> compose_audit_log(options)
|> Ash.Resource.Igniter.add_new_action(options[:user], request_action, """
action #{inspect(request_action)} do
argument #{inspect(identity_field)}, :ci_string do
allow_nil? false
description "The identity to send a one-time password to."
end
run AshAuthentication.Strategy.Otp.Request
description "Send a one-time password to a user if they exist."
end
""")
|> Ash.Resource.Igniter.add_new_action(options[:user], sign_in_action, """
read #{inspect(sign_in_action)} do
description "Sign in a user with a one-time password."
get? true
argument #{inspect(identity_field)}, :ci_string do
allow_nil? false
end
argument :otp, :string do
allow_nil? false
sensitive? true
end
prepare AshAuthentication.Strategy.Otp.SignInPreparation
metadata :token, :string do
allow_nil? false
end
end
""")
|> AshAuthentication.Igniter.add_new_strategy(
options[:user],
:otp,
strategy_name,
"""
otp #{inspect(strategy_name)} do
identity_field #{inspect(identity_field)}
brute_force_strategy {:audit_log, :audit_log}
sender #{inspect(sender)}
end
"""
)
|> create_new_otp_sender(sender, options)
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
defp create_new_otp_sender(igniter, sender, _options) do
Igniter.Project.Module.create_module(
igniter,
sender,
~s'''
@moduledoc """
Sends a one-time password code to the user.
"""
use AshAuthentication.Sender
@impl true
def send(user, otp_code, _opts) do
# The `user` argument is the user record matching the identity that
# was submitted. The `otp_code` is the short code that should be
# sent to them — typically by email or SMS.
IO.puts("""
Hello, \#{user.email}! Your sign-in code is:
\#{otp_code}
This code expires in 10 minutes.
""")
end
''',
on_exists: :warning
)
end
end
else
defmodule Mix.Tasks.AshAuthentication.AddStrategy.Otp do
@shortdoc "Adds one-time password (OTP) authentication to your user resource"
@moduledoc @shortdoc
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_authentication.add_strategy.otp' 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