Skip to content

Commit

Permalink
improvement: add ash_authentication.add_strategy task
Browse files Browse the repository at this point in the history
improvement: add atomic implementations for various changes/validations
  • Loading branch information
zachdaniel committed Oct 7, 2024
1 parent 56d6e65 commit 8ac7386
Show file tree
Hide file tree
Showing 12 changed files with 858 additions and 18 deletions.
2 changes: 1 addition & 1 deletion documentation/tutorials/get-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ AshAuthentication includes a supervisor which you should add to your
application's supervisor tree. This is used to run any periodic jobs related to
your authenticated resources (removing expired tokens, for example).

### Example
##### Example

```elixir
defmodule MyApp.Application do
Expand Down
5 changes: 5 additions & 0 deletions lib/ash_authentication/generate_token_change.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ defmodule AshAuthentication.GenerateTokenChange do
end)
end

@impl true
def atomic(changeset, options, context) do
{:ok, change(changeset, options, context)}
end

defp generate_token(purpose, record, strategy)
when is_integer(strategy.sign_in_token_lifetime) and purpose == :sign_in do
{:ok, token, _claims} =
Expand Down
93 changes: 93 additions & 0 deletions lib/ash_authentication/igniter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,97 @@ defmodule AshAuthentication.Igniter do
{:ok, Igniter.Code.Common.add_code(zipper, func)}
end)
end

@doc "Adds a new strategy to the authentication.strategies section of a resource"
@spec add_new_strategy(
Igniter.t(),
Ash.Resource.t(),
type :: atom,
name :: atom,
contents :: String.t()
) :: Igniter.t()
def add_new_strategy(igniter, resource, type, name, contents) do
{igniter, defines?} = defines_strategy(igniter, resource, type, name)

if defines? do
igniter
else
add_strategy(igniter, resource, contents)
end
end

@doc "Adds a strategy to the authentication.strategies section of a resource"
@spec add_strategy(
Igniter.t(),
Ash.Resource.t(),
contents :: String.t()
) :: Igniter.t()
def add_strategy(igniter, resource, contents) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:authentication, {:ok, zipper}} <-
{:authentication, enter_section(zipper, :authentication)},
{:strategies, _authentication_zipper, {:ok, zipper}} <-
{:strategies, zipper, enter_section(zipper, :strategies)} do
{:ok, Igniter.Code.Common.add_code(zipper, contents)}
else
{:authentication, :error} ->
{:ok,
Igniter.Code.Common.add_code(zipper, """
authentication do
strategies do
#{contents}
end
end
""")}

{:strategies, zipper, :error} ->
{:ok,
Igniter.Code.Common.add_code(zipper, """
strategies do
#{contents}
end
""")}
end
end)
end

@doc "Returns true if the given resource defines an attribute with the provided name"
@spec defines_strategy(Igniter.t(), Ash.Resource.t(), constructor :: atom(), name :: atom()) ::
{Igniter.t(), true | false}
def defines_strategy(igniter, resource, constructor, name) do
Spark.Igniter.find(igniter, resource, fn _, zipper ->
with {:ok, zipper} <- enter_section(zipper, :authentication),
{:ok, zipper} <- enter_section(zipper, :strategies),
{:ok, _zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
constructor,
[1, 2],
&Igniter.Code.Function.argument_equals?(&1, 0, name)
) do
{:ok, true}
else
_ ->
:error
end
end)
|> case do
{:ok, igniter, _module, _value} ->
{igniter, true}

{:error, igniter} ->
{igniter, false}
end
end

defp enter_section(zipper, name) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
name,
1
) do
Igniter.Code.Common.move_to_do_block(zipper)
end
end
end
59 changes: 44 additions & 15 deletions lib/ash_authentication/strategies/password/actions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -188,22 +188,51 @@ defmodule AshAuthentication.Strategy.Password.Actions do
params,
options
) do
options =
options
|> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end)
case Ash.Resource.Info.action(
strategy.resource,
resettable.request_password_reset_action_name
) do
nil ->
{:error,
NoSuchAction.exception(resource: strategy.resource, action: :reset_request, type: :read)}

strategy.resource
|> Query.new()
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Query.for_read(resettable.request_password_reset_action_name, params)
|> Ash.read(options)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
%{type: :read, name: action_name} ->
options =
options
|> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end)

strategy.resource
|> Query.new()
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Query.for_read(action_name, params, options)
|> Ash.read()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end

%{type: :action, name: action_name} ->
options =
options
|> Keyword.put_new_lazy(:domain, fn -> Info.domain!(strategy.resource) end)

strategy.resource
|> Ash.ActionInput.new()
|> Ash.ActionInput.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Ash.ActionInput.for_action(action_name, params, options)
|> Ash.run_action()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end

Expand Down
27 changes: 27 additions & 0 deletions lib/ash_authentication/strategies/password/hash_password_change.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,31 @@ defmodule AshAuthentication.Strategy.Password.HashPasswordChange do
end
end)
end

@impl true
def atomic(changeset, options, context) do
with {:ok, strategy} <- Info.find_strategy(changeset, context, options),
value when is_binary(value) <-
Changeset.get_argument(changeset, strategy.password_field) do
{:atomic, changeset,
%{
strategy.hashed_password_field =>
expr(lazy({__MODULE__, :hash_or_raise, [strategy.hash_provider, value]}))
}}
else
_ ->
changeset
end
end

@doc false
def hash_or_raise(hash_provider, value) do
case hash_provider.hash(value) do
{:ok, hash} ->
hash

:error ->
raise AssumptionFailed, message: "Error hashing password."
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ defmodule AshAuthentication.Strategy.Password.PasswordConfirmationValidation do
end
end

@impl true
def atomic(changeset, opts, context) do
validate(changeset, opts, context)
end

defp validate_password_confirmation(changeset, strategy) do
password = Changeset.get_argument(changeset, strategy.password_field)
confirmation = Changeset.get_argument(changeset, strategy.password_confirmation_field)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule AshAuthentication.Strategy.Password.RequestPasswordReset do
@moduledoc """
Requests a password reset.
This implementation performs three jobs:
1. looks up the user with the given action and field
2. if a matching user is found:
a. a reset token is generated
b. and the password reset sender is invoked
"""
use Ash.Resource.Actions.Implementation
alias AshAuthentication.{Info, Strategy.Password}
require Ash.Query
require Logger

@doc false
@impl true
def run(action_input, opts, context) do
read_action = opts[:action]

strategy = Info.strategy_for_action!(action_input.resource, action_input.action.name)

if strategy.resettable && strategy.resettable.sender do
identity_field = strategy.identity_field
identity = Ash.ActionInput.get_argument(action_input, identity_field)
select_for_senders = Info.authentication_select_for_senders!(action_input.resource)
{sender, send_opts} = strategy.resettable.sender

context =
if context[:private][:ash_authentication?] do
%{private: %{ash_authentication?: true}}
else
%{}
end

query_result =
action_input.resource
|> Ash.Query.new()
|> Ash.Query.set_context(context)
|> Ash.Query.for_read(read_action, %{
identity_field => identity
})
|> Ash.Query.ensure_selected(select_for_senders)
|> Ash.read_one()

with {:ok, user} when not is_nil(user) <- query_result,
{:ok, token} <- Password.reset_token_for(strategy, user) do
sender.send(user, token, send_opts)
else
{:ok, nil} ->
:ok

:error ->
Logger.warning("""
Something went wrong generating a token during password reset
for: #{inspect(action_input.resource)} `#{identity}`
""")

{:error, error} ->
Logger.warning("""
Something went wrong resetting password for #{inspect(action_input.resource)} `#{identity}`
#{Exception.format(:error, error)}
""")

:ok
end
else
:ok
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,9 @@ defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do
{:error, InvalidArgument.exception(field: :reset_token, message: "is not valid")}
end
end

@impl true
def atomic(changeset, opts, context) do
validate(changeset, opts, context)
end
end
6 changes: 5 additions & 1 deletion lib/ash_authentication/strategies/password/transformer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,11 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
with {:ok, action} <-
validate_action_exists(dsl_state, resettable.request_password_reset_action_name),
:ok <- validate_identity_argument(dsl_state, action, strategy.identity_field) do
validate_action_has_preparation(action, Password.RequestPasswordResetPreparation)
if action.type == :read do
validate_action_has_preparation(action, Password.RequestPasswordResetPreparation)
else
:ok
end
end
end

Expand Down
Loading

0 comments on commit 8ac7386

Please sign in to comment.