Current section

Files

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

lib/mix/tasks/ash_authentication.add_strategy.password.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.Password do
use Igniter.Mix.Task
@example "mix ash_authentication.add_strategy.password"
@shortdoc "Adds password authentication to your user resource"
@moduledoc """
#{@shortdoc}
## 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`
* `--hash-provider` - The hash provider to use, either `bcrypt` or `argon2`. Defaults to `bcrypt`.
"""
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.confirmation"
],
schema: [
accounts: :string,
user: :string,
identity_field: :string,
hash_provider: :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
|> password(options)
|> AshAuthentication.Igniter.add_remember_me_strategy(options[:user])
|> AshAuthentication.Igniter.codegen_for_strategy(:password)
{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, &AshAuthentication.Igniter.maybe_parse_module/1)
|> Keyword.update!(:user, &AshAuthentication.Igniter.maybe_parse_module/1)
end
defp password(igniter, options) do
hash_provider =
cond do
options[:hash_provider] |> to_string() |> String.downcase() == "bcrypt" ->
"AshAuthentication.BcryptProvider"
options[:hash_provider] |> to_string() |> String.downcase() == "argon2" ->
"AshAuthentication.Argon2Provider"
is_binary(options[:hash_provider]) ->
options[:hash_provider]
true ->
"AshAuthentication.BcryptProvider"
end
igniter =
if hash_provider == "AshAuthentication.BcryptProvider" do
Igniter.Project.Deps.add_dep(igniter, {:bcrypt_elixir, "~> 3.0"})
else
igniter
end
igniter =
if hash_provider == "AshAuthentication.Argon2Provider" do
Igniter.Project.Deps.add_dep(igniter, {:argon2_elixir, "~> 4.0"})
else
igniter
end
sender = Module.concat(options[:user], Senders.SendPasswordResetEmail)
{igniter, _, zipper} =
igniter
|> Igniter.Project.Module.find_module!(options[:user])
allow_nil_line =
zipper
|> Igniter.Code.Function.move_to_function_call(:magic_link, [1, 2])
|> case do
{:ok, _} -> ""
_ -> "allow_nil? false"
end
igniter
|> Ash.Resource.Igniter.add_new_attribute(options[:user], options[:identity_field], """
attribute #{inspect(options[:identity_field])}, :ci_string do
allow_nil? false
public? true
end
""")
|> Ash.Resource.Igniter.add_new_attribute(options[:user], :hashed_password, """
attribute :hashed_password, :string do
#{allow_nil_line}
sensitive? true
end
""")
|> AshAuthentication.Igniter.ensure_identity(options[:user], options[:identity_field])
|> AshAuthentication.Igniter.add_new_strategy(options[:user], :password, :password, """
password :password do
identity_field #{inspect(options[:identity_field])}
hash_provider #{hash_provider}
resettable do
sender #{inspect(sender)}
# these configurations will be the default in a future release
password_reset_action_name :reset_password_with_token
request_password_reset_action_name :request_password_reset_token
end
end
""")
|> Ash.Resource.Igniter.add_new_action(options[:user], :change_password, """
update :change_password do
# Use this action to allow users to change their password by providing
# their current password and a new password.
require_atomic? false
accept []
argument :current_password, :string, sensitive?: true, allow_nil?: false
argument :password, :string, sensitive?: true, allow_nil?: false, constraints: [min_length: 8]
argument :password_confirmation, :string, sensitive?: true, allow_nil?: false
validate confirm(:password, :password_confirmation)
validate {AshAuthentication.Strategy.Password.PasswordValidation, strategy_name: :password, password_argument: :current_password}
change {AshAuthentication.Strategy.Password.HashPasswordChange, strategy_name: :password}
end
""")
|> generate_sign_in_and_registration(options)
|> generate_reset(sender, options)
|> add_confirmation(options)
end
defp add_confirmation(igniter, options) do
if options[:identity_field] == :email do
Igniter.compose_task(
igniter,
"ash_authentication.add_add_on.confirmation",
[
"--user",
inspect(options[:user]),
"--identity-field",
to_string(options[:identity_field])
]
)
else
igniter
end
end
defp generate_reset(igniter, sender, options) do
igniter
|> create_reset_sender(sender, options)
|> Ash.Resource.Igniter.add_new_action(
options[:user],
:request_password_reset_token,
"""
action :request_password_reset_token do
description "Send password reset instructions to a user if they exist."
argument #{inspect(options[:identity_field])}, :ci_string do
allow_nil? false
end
# creates a reset token and invokes the relevant senders
run {AshAuthentication.Strategy.Password.RequestPasswordReset, action: #{inspect(:"get_by_#{options[:identity_field]}")}}
end
"""
)
|> AshAuthentication.Igniter.ensure_get_by_action(options[:user], options[:identity_field])
|> Ash.Resource.Igniter.add_new_action(options[:user], :reset_password_with_token, """
update :reset_password_with_token do
argument :reset_token, :string do
allow_nil? false
sensitive? true
end
argument :password, :string do
description "The proposed password for the user, in plain text."
allow_nil? false
constraints [min_length: 8]
sensitive? true
end
argument :password_confirmation, :string do
description "The proposed password for the user (again), in plain text."
allow_nil? false
sensitive? true
end
# validates the provided reset token
validate AshAuthentication.Strategy.Password.ResetTokenValidation
# validates that the password matches the confirmation
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
# Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange
# Generates an authentication token for the user
change AshAuthentication.GenerateTokenChange
end
""")
end
defp create_reset_sender(igniter, sender, _options) do
Igniter.Project.Module.create_module(
igniter,
sender,
~s'''
@moduledoc """
Sends a password reset email
"""
use AshAuthentication.Sender
@impl true
def send(_user, token, _) do
IO.puts("""
Click this link to reset your password:
/password-reset/\#{token}
""")
end
''',
on_exists: :warning
)
end
defp generate_sign_in_and_registration(igniter, options) do
igniter
|> Ash.Resource.Igniter.add_new_action(options[:user], :sign_in_with_password, """
read :sign_in_with_password do
description "Attempt to sign in using a #{options[:identity_field]} and password."
get? true
argument #{inspect(options[:identity_field])}, :ci_string do
description "The #{options[:identity_field]} to use for retrieving the user."
allow_nil? false
end
argument :password, :string do
description "The password to check for the matching user."
allow_nil? false
sensitive? true
end
argument :remember_me, :boolean do
description "Whether to generate a remember me token"
allow_nil? true
end
# validates the provided #{options[:identity_field]} and password and generates a token
prepare AshAuthentication.Strategy.Password.SignInPreparation
# generates a remember me token if the remember_me argument is true
prepare {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenPreparation,
strategy_name: :remember_me}
metadata :token, :string do
description "A JWT that can be used to authenticate the user."
allow_nil? false
end
end
""")
|> Ash.Resource.Igniter.add_new_action(options[:user], :sign_in_with_token, """
read :sign_in_with_token do
# In the generated sign in components, we validate the
# #{options[:identity_field]} and password directly in the LiveView
# and generate a short-lived token that can be used to sign in over
# a standard controller action, exchanging it for a standard token.
# This action performs that exchange. If you do not use the generated
# liveviews, you may remove this action, and set
# `sign_in_tokens_enabled? false` in the password strategy.
description "Attempt to sign in using a short-lived sign in token."
get? true
argument :token, :string do
description "The short-lived sign in token."
allow_nil? false
sensitive? true
end
# validates the provided sign in token and generates a token
prepare AshAuthentication.Strategy.Password.SignInWithTokenPreparation
metadata :token, :string do
description "A JWT that can be used to authenticate the user."
allow_nil? false
end
end
""")
|> Ash.Resource.Igniter.add_new_action(options[:user], :register_with_password, """
create :register_with_password do
description "Register a new user with a #{options[:identity_field]} and password."
argument #{inspect(options[:identity_field])}, :ci_string do
allow_nil? false
end
argument :password, :string do
description "The proposed password for the user, in plain text."
allow_nil? false
constraints [min_length: 8]
sensitive? true
end
argument :password_confirmation, :string do
description "The proposed password for the user (again), in plain text."
allow_nil? false
sensitive? true
end
# Sets the #{options[:identity_field]} from the argument
change set_attribute(#{inspect(options[:identity_field])}, arg(#{inspect(options[:identity_field])}))
# Hashes the provided password
change AshAuthentication.Strategy.Password.HashPasswordChange
# Generates an authentication token for the user
change AshAuthentication.GenerateTokenChange
# validates that the password matches the confirmation
validate AshAuthentication.Strategy.Password.PasswordConfirmationValidation
metadata :token, :string do
description "A JWT that can be used to authenticate the user."
allow_nil? false
end
end
""")
end
end
else
defmodule Mix.Tasks.AshAuthentication.AddStrategy.Password do
@shortdoc "Adds password authentication to your user resource"
@moduledoc @shortdoc
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_authentication.add_strategy.password' 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