Packages
ash_authentication
5.0.0-rc.12
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
lib/mix/tasks/ash_authentication.upgrade.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.Upgrade do
@moduledoc false
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
# Groups allow for overlapping arguments for tasks by the same author
# See the generators guide for more.
group: :ash_authentication,
# *other* dependencies to add
# i.e `{:foo, "~> 2.0"}`
adds_deps: [],
# *other* dependencies to add and call their associated installers, if they exist
# i.e `{:foo, "~> 2.0"}`
installs: [],
# An example invocation
# example: __MODULE__.Docs.example(),
example: "example",
# a list of positional arguments, i.e `[:file]`
positional: [:from, :to],
# Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv
# This ensures your option schema includes options from nested tasks
composes: [],
# `OptionParser` schema
schema: [],
# Default values for the options in the `schema`
defaults: [],
# CLI aliases
aliases: [],
# A list of options in the schema that are required
required: []
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
positional = igniter.args.positional
options = igniter.args.options
upgrades =
%{
"4.4.9" => [&fix_token_is_revoked_action/2],
"4.13.4" => [&add_remember_me_to_magic_link_sign_in/2],
"5.0.0" => [
&fix_google_hd_field/2,
&convert_revoked_read_action_to_generic/2,
&convert_request_actions_to_generic/2,
&add_brute_force_protection/2,
&require_identity_resource/2
]
}
# For each version that requires a change, add it to this map
# Each key is a version that points at a list of functions that take an
# igniter and options (i.e. flags or other custom options).
# See the upgrades guide for more.
Igniter.Upgrades.run(igniter, positional.from, positional.to, upgrades,
custom_opts: options
)
end
def fix_token_is_revoked_action(igniter, _opts) do
case find_all_token_resources(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
maybe_fix_is_revoked_action(igniter, resource)
end)
end
end
@doc """
Fixes the Google strategy field name change from "google_hd" to "hd".
The Google strategy now uses OIDC which returns standard
claims. The hosted domain claim changed from "google_hd" to "hd".
"""
def fix_google_hd_field(igniter, _opts) do
igniter
|> replace_google_hd_strings()
|> Igniter.add_notice("""
Google Strategy Breaking Change:
The `email_verified` field in `user_info` is now a `boolean` instead of a `string`.
If your code checks `user_info["email_verified"] == "true"`, update it to:
user_info["email_verified"] == true
Please review your `register_with_google` action and any code that accesses
the `email_verified` field from Google OAuth responses.
""")
end
defp replace_google_hd_strings(igniter) do
igniter = Igniter.include_all_elixir_files(igniter)
igniter.rewrite
|> Rewrite.sources()
|> Enum.filter(&match?(%Rewrite.Source{filetype: %Rewrite.Source.Ex{}}, &1))
|> Enum.reduce(igniter, fn source, igniter ->
zipper =
source
|> Rewrite.Source.get(:quoted)
|> Sourceror.Zipper.zip()
case replace_google_hd_in_zipper(zipper) do
{:ok, new_zipper} ->
new_quoted = Sourceror.Zipper.topmost_root(new_zipper)
new_source = Igniter.update_source(source, igniter, :quoted, new_quoted)
%{igniter | rewrite: Rewrite.update!(igniter.rewrite, new_source)}
:unchanged ->
igniter
end
end)
end
defp replace_google_hd_in_zipper(zipper) do
{new_zipper, changed?} =
Sourceror.Zipper.traverse(zipper, false, fn zipper, changed? ->
if google_hd_string?(zipper) do
{replace_with_hd(zipper), true}
else
{zipper, changed?}
end
end)
if changed? do
{:ok, new_zipper}
else
:unchanged
end
end
defp google_hd_string?(%{node: "google_hd"}), do: true
defp google_hd_string?(%{node: {:__block__, meta, ["google_hd"]}}) when is_list(meta),
do: true
defp google_hd_string?(_), do: false
defp replace_with_hd(%{node: "google_hd"} = zipper) do
Sourceror.Zipper.replace(zipper, "hd")
end
defp replace_with_hd(%{node: {:__block__, meta, ["google_hd"]}} = zipper) do
Sourceror.Zipper.replace(zipper, {:__block__, meta, ["hd"]})
end
defp find_all_token_resources(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Ash.Resource),
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1),
{:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :extensions) do
extensions_contain_token_resource?(zipper)
end
end)
end
defp extensions_contain_token_resource?(zipper) do
if Igniter.Code.List.list?(zipper) do
match?(
{:ok, _},
Igniter.Code.List.move_to_list_item(
zipper,
&Igniter.Code.Common.nodes_equal?(&1, AshAuthentication.TokenResource)
)
)
else
Igniter.Code.Common.nodes_equal?(zipper, AshAuthentication.TokenResource)
end
end
defp maybe_fix_is_revoked_action(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :action, :revoked?),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper),
{:ok, zipper} <- remove_argument_option(zipper, :token, :allow_nil?),
{:ok, zipper} <- remove_argument_option(zipper, :jti, :allow_nil?) do
add_action_return_type(zipper, :boolean)
else
:error -> {:ok, zipper}
end
end)
end
defp move_to_action(zipper, type, name) do
Igniter.Code.Function.move_to_function_call(
zipper,
type,
2,
&Igniter.Code.Function.argument_equals?(&1, 0, name)
)
end
defp add_action_return_type(zipper, type) do
zipper = Sourceror.Zipper.top(zipper)
with {:ok, zipper} <- move_to_action(zipper, :action, :revoked?),
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1) do
{:ok,
Sourceror.Zipper.insert_left(
zipper,
quote do
unquote(type)
end
)}
end
end
defp remove_argument_option(zipper, argument_name, option) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call(
zipper,
:argument,
3,
&Igniter.Code.Function.argument_equals?(&1, 0, argument_name)
),
{:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 2) do
remove_option_from_argument(zipper, option)
end
end
defp remove_option_from_argument(zipper, option) do
if Igniter.Code.List.find_list_item_index(
zipper,
&Igniter.Code.Tuple.elem_equals?(&1, 0, :do)
) do
Igniter.Code.Common.within(zipper, fn zipper ->
{:ok,
Igniter.Code.Common.remove(
zipper,
&Igniter.Code.Function.function_call?(&1, option, 1)
)}
end)
else
Igniter.Code.List.remove_from_list(
zipper,
&Igniter.Code.Tuple.elem_equals?(&1, 0, option)
)
end
end
def add_remember_me_to_magic_link_sign_in(igniter, _opts) do
case find_resources_with_magic_link_and_remember_me(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
maybe_add_remember_me_to_magic_link_action(igniter, resource)
end)
end
end
defp find_resources_with_magic_link_and_remember_me(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
with {:ok, zipper} <- enter_auth_strategies(zipper),
true <- has_strategy?(zipper, :magic_link),
true <- has_strategy?(zipper, :remember_me) do
true
else
_ -> false
end
end)
end
@oauth2_family ~w[oauth2 oidc github google auth0 apple slack microsoft okta]a
def require_identity_resource(igniter, _opts) do
case find_resources_with_oauth2_strategies(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
resources
|> Enum.reduce(igniter, &ensure_identity_resource/2)
|> Igniter.add_notice("""
The user identity resource's unique key changed from
`(strategy, uid, user_id)` to `(strategy, uid)`, so that a provider's
`iss`/`sub` resolves to exactly one local user.
Run `mix ash.codegen require_user_identity_unique_key` (and then
`mix ash.migrate`) to generate the migration that swaps the unique
index.
IMPORTANT: the new index will fail to create if your data contains the
same `(strategy, uid)` linked to more than one user. That should not
happen under normal use, but if it does you must reconcile those rows
before migrating - it indicates a provider identity was linked to
multiple accounts.
""")
end
end
defp find_resources_with_oauth2_strategies(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
case enter_auth_strategies(zipper) do
{:ok, zipper} -> Enum.any?(@oauth2_family, &has_strategy?(zipper, &1))
_ -> false
end
end)
end
defp ensure_identity_resource(resource, igniter) do
identity_resource = conventional_identity_resource(resource)
igniter =
AshAuthentication.Igniter.ensure_user_identity_resource(
igniter,
resource,
identity_resource
)
Enum.reduce(
@oauth2_family,
igniter,
&wire_identity_resource(&2, resource, &1, identity_resource)
)
end
defp wire_identity_resource(igniter, resource, type, identity_resource) do
case AshAuthentication.Igniter.defines_strategy_of_type(igniter, resource, type) do
{igniter, true} ->
igniter
|> add_identity_resource_to_strategy(resource, type, identity_resource)
|> ensure_register_action_has_identity_change(resource, type)
{igniter, false} ->
igniter
end
end
defp conventional_identity_resource(resource) do
resource
|> Module.split()
|> :lists.droplast()
|> Enum.concat(["UserIdentity"])
|> Module.concat()
end
defp add_identity_resource_to_strategy(igniter, resource, type, identity_resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, zipper} <- enter_auth_strategies(zipper),
{:ok, strategy_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, type, [1, 2]),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(strategy_zipper),
:error <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
do_block_zipper,
:identity_resource,
1
) do
{:ok,
Igniter.Code.Common.add_code(
do_block_zipper,
"identity_resource #{inspect(identity_resource)}"
)}
else
_ -> {:ok, zipper}
end
end)
end
# sobelow_skip ["DOS.BinToAtom"]
defp ensure_register_action_has_identity_change(igniter, resource, type) do
action_name = :"register_with_#{type}"
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :create, action_name),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper),
false <- has_identity_change?(do_block_zipper) do
{:ok,
Igniter.Code.Common.add_code(
do_block_zipper,
"change AshAuthentication.Strategy.OAuth2.IdentityChange"
)}
else
_ -> {:ok, zipper}
end
end)
end
defp has_identity_change?(zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:change,
1,
fn change_zipper ->
case Igniter.Code.Function.move_to_nth_argument(change_zipper, 0) do
{:ok, arg_zipper} ->
arg_zipper
|> Sourceror.Zipper.node()
|> Sourceror.to_string()
|> String.contains?("IdentityChange")
_ ->
false
end
end
)
)
end
defp enter_auth_strategies(zipper) do
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:authentication,
1
),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
{:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:strategies,
1
) do
Igniter.Code.Common.move_to_do_block(zipper)
end
end
defp has_strategy?(zipper, strategy_type) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
strategy_type,
[1, 2]
)
)
end
defp maybe_add_remember_me_to_magic_link_action(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :create, :sign_in_with_magic_link),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper) do
do_block_zipper
|> maybe_add_remember_me_argument()
|> maybe_add_remember_me_change()
else
:error -> {:ok, zipper}
end
end)
end
defp maybe_add_remember_me_argument(zipper) do
if has_remember_me_argument?(zipper) do
zipper
else
add_remember_me_argument(zipper)
end
end
defp has_remember_me_argument?(zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:argument,
[2, 3],
&Igniter.Code.Function.argument_equals?(&1, 0, :remember_me)
)
)
end
defp add_remember_me_argument(zipper) do
argument_code = """
argument :remember_me, :boolean do
description "Whether to generate a remember me token"
allow_nil? true
end
"""
Igniter.Code.Common.add_code(zipper, argument_code)
end
defp maybe_add_remember_me_change(zipper) do
if has_remember_me_change?(zipper) do
{:ok, zipper}
else
{:ok, add_remember_me_change(zipper)}
end
end
defp has_remember_me_change?(zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:change,
1,
fn change_zipper ->
case Igniter.Code.Function.move_to_nth_argument(change_zipper, 0) do
{:ok, arg_zipper} ->
source = Sourceror.Zipper.node(arg_zipper) |> Sourceror.to_string()
String.contains?(source, "MaybeGenerateTokenChange")
_ ->
false
end
end
)
)
end
defp add_remember_me_change(zipper) do
change_code = """
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me}
"""
Igniter.Code.Common.add_code(zipper, change_code)
end
def convert_revoked_read_action_to_generic(igniter, _opts) do
case find_all_token_resources(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
maybe_convert_revoked_read_to_generic(igniter, resource)
end)
end
end
@doc """
Converts request actions from :read to :action type.
In AshAuthentication 5.0, both password reset request and magic link request
actions were changed from :read to :action type. This upgrade function helps
migrate existing custom actions.
"""
def convert_request_actions_to_generic(igniter, _opts) do
igniter
|> convert_password_reset_request_actions()
|> convert_magic_link_request_actions()
end
defp convert_password_reset_request_actions(igniter) do
case find_resources_with_password_resettable(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
convert_password_reset_request_action(igniter, resource)
end)
end
end
defp maybe_convert_revoked_read_to_generic(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :read, :revoked?),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(action_zipper) do
convert_read_to_generic_action(do_block_zipper)
else
:error -> {:ok, zipper}
end
end)
end
defp find_resources_with_password_resettable(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
with {:ok, zipper} <- enter_auth_strategies(zipper),
true <- has_strategy?(zipper, :password) do
password_has_resettable?(zipper)
else
_ -> false
end
end)
end
defp password_has_resettable?(zipper) do
with {:ok, password_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:password,
[1, 2]
),
{:ok, do_block} <- Igniter.Code.Common.move_to_do_block(password_zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
do_block,
:resettable,
1
)
)
else
_ -> false
end
end
defp convert_password_reset_request_action(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <-
move_to_action(zipper, :read, :request_password_reset_with_password),
{:ok, do_block} <- Igniter.Code.Common.move_to_do_block(action_zipper) do
identity_field = get_identity_field_from_action(do_block) || :email
zipper
|> ensure_get_by_action_exists(identity_field)
|> convert_read_action_to_generic(
:request_password_reset_with_password,
identity_field,
:password
)
else
:error -> {:ok, zipper}
end
end)
end
defp convert_read_to_generic_action(do_block_zipper) do
do_block_zipper =
Igniter.Code.Common.remove(
do_block_zipper,
&Igniter.Code.Function.function_call?(&1, :get?, 1)
)
do_block_zipper =
Igniter.Code.Common.remove(
do_block_zipper,
&Igniter.Code.Function.function_call?(&1, :prepare, 1)
)
do_block_zipper =
Igniter.Code.Common.add_code(
do_block_zipper,
"run AshAuthentication.TokenResource.IsRevoked"
)
zipper = Sourceror.Zipper.top(do_block_zipper)
with {:ok, zipper} <- move_to_action(zipper, :read, :revoked?) do
new_node =
Sourceror.Zipper.node(zipper)
|> replace_read_with_action()
|> insert_boolean_return_type()
{:ok, Sourceror.Zipper.replace(zipper, new_node)}
end
end
defp replace_read_with_action({:read, meta, args}) do
{:action, meta, args}
end
defp insert_boolean_return_type({:action, meta, [name | rest]}) do
{:action, meta, [name, :boolean | rest]}
end
defp convert_magic_link_request_actions(igniter) do
case find_resources_with_magic_link(igniter) do
{igniter, []} ->
igniter
{igniter, resources} ->
Enum.reduce(resources, igniter, fn resource, igniter ->
convert_magic_link_request_action(igniter, resource)
end)
end
end
defp find_resources_with_magic_link(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
case enter_auth_strategies(zipper) do
{:ok, zipper} -> has_strategy?(zipper, :magic_link)
_ -> false
end
end)
end
defp convert_magic_link_request_action(igniter, resource) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, action_zipper} <- move_to_action(zipper, :read, :request_magic_link),
{:ok, do_block} <- Igniter.Code.Common.move_to_do_block(action_zipper) do
identity_field = get_identity_field_from_action(do_block) || :email
zipper
|> ensure_get_by_action_exists(identity_field)
|> convert_read_action_to_generic(:request_magic_link, identity_field, :magic_link)
else
:error -> {:ok, zipper}
end
end)
end
defp get_identity_field_from_action(do_block) do
with {:ok, arg_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
do_block,
:argument,
[2, 3]
),
{:ok, name_zipper} <- Igniter.Code.Function.move_to_nth_argument(arg_zipper, 0) do
case Sourceror.Zipper.node(name_zipper) do
name when is_atom(name) -> name
_ -> nil
end
else
_ -> nil
end
end
defp ensure_get_by_action_exists(zipper, identity_field) do
action_name = :"get_by_#{identity_field}"
if action_exists?(zipper, :read, action_name) do
zipper
else
action_code = """
read :#{action_name} do
get_by :#{identity_field}
description "Look up a user by #{identity_field}."
end
"""
with {:ok, actions_zipper} <- move_to_actions_block(zipper),
{:ok, do_block} <- Igniter.Code.Common.move_to_do_block(actions_zipper) do
Igniter.Code.Common.add_code(do_block, action_code)
|> Sourceror.Zipper.top()
else
_ -> zipper
end
end
end
defp action_exists?(zipper, type, name) do
match?({:ok, _}, move_to_action(zipper, type, name))
end
defp move_to_actions_block(zipper) do
Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, :actions, 1)
end
defp convert_read_action_to_generic(zipper, action_name, identity_field, strategy_type) do
run_module =
case strategy_type do
:password -> "AshAuthentication.Strategy.Password.RequestPasswordReset"
:magic_link -> "AshAuthentication.Strategy.MagicLink.Request"
end
new_action_code = """
action :#{action_name} do
argument :#{identity_field}, :ci_string, allow_nil?: false
run {#{run_module}, action: :get_by_#{identity_field}}
description "Send #{strategy_type_description(strategy_type)} to a user if they exist."
end
"""
case move_to_action(zipper, :read, action_name) do
{:ok, action_zipper} ->
action_zipper
|> Sourceror.Zipper.remove()
|> Sourceror.Zipper.top()
|> replace_with_generic_action(new_action_code)
|> then(&{:ok, &1})
:error ->
{:ok, zipper}
end
end
defp replace_with_generic_action(zipper, new_action_code) do
with {:ok, actions_zipper} <- move_to_actions_block(zipper),
{:ok, do_block} <- Igniter.Code.Common.move_to_do_block(actions_zipper) do
Igniter.Code.Common.add_code(do_block, new_action_code)
|> Sourceror.Zipper.top()
else
_ -> zipper
end
end
defp strategy_type_description(:password), do: "password reset instructions"
defp strategy_type_description(:magic_link), do: "a magic link"
@doc """
Adds identity-keyed brute-force protection to password and magic link strategies.
For each resource that uses the `password` or `magic_link` strategy, this
upgrade ensures an `audit_log` add-on is present (composing the
`ash_authentication.add_add_on.audit_log` task to generate one if needed)
and adds `brute_force_strategy {:audit_log, <name>}` to each affected
strategy block.
"""
def add_brute_force_protection(igniter, _opts) do
case find_resources_needing_brute_force(igniter) do
{igniter, []} ->
igniter
{igniter, resources_with_strategies} ->
resources_with_strategies
|> Enum.reduce(igniter, &add_brute_force_to_resource/2)
|> Igniter.add_notice(brute_force_notice())
end
end
defp add_brute_force_to_resource({resource, strategies}, igniter) do
{igniter, audit_log_name} = ensure_audit_log(igniter, resource)
Enum.reduce(strategies, igniter, fn strategy_type, igniter ->
add_brute_force_to_strategy(igniter, resource, strategy_type, audit_log_name)
end)
end
defp brute_force_notice do
"""
Brute-force Protection:
Identity-keyed brute-force protection has been added to your password
and magic link strategies via the audit log add-on. Failed sign-in,
password reset request, and magic link request attempts are now
counted within a 5 minute window per identity, with requests blocked
after 5 failures.
Adjust `audit_log_window` and `audit_log_max_failures` on each
strategy if you need different thresholds. If your audit log uses
`exclude_actions` or `exclude_strategies` to skip the protected
actions, the compile-time verifier will report which actions need
to be tracked.
"""
end
defp find_resources_needing_brute_force(igniter) do
{igniter, resources} = find_resources_with_password_or_magic_link(igniter)
Enum.reduce(resources, {igniter, []}, &collect_strategies_needing_brute_force/2)
end
defp collect_strategies_needing_brute_force(resource, {igniter, acc}) do
{igniter, strategies_needing} =
Enum.reduce(
[:password, :magic_link],
{igniter, []},
&reduce_strategy_needing_brute_force(&1, &2, resource)
)
case strategies_needing do
[] -> {igniter, acc}
strategies -> {igniter, [{resource, Enum.reverse(strategies)} | acc]}
end
end
defp reduce_strategy_needing_brute_force(strategy_type, {igniter, strategies}, resource) do
case strategy_needs_brute_force?(igniter, resource, strategy_type) do
{igniter, true} -> {igniter, [strategy_type | strategies]}
{igniter, false} -> {igniter, strategies}
end
end
defp strategy_needs_brute_force?(igniter, resource, strategy_type) do
result =
Spark.Igniter.find(igniter, resource, fn _, zipper ->
check_strategy_for_brute_force(zipper, strategy_type)
end)
case result do
{:ok, igniter, _module, true} -> {igniter, true}
{:error, igniter} -> {igniter, false}
end
end
defp check_strategy_for_brute_force(zipper, strategy_type) do
with {:ok, strategies_zipper} <- enter_auth_strategies(zipper),
{:ok, strategy_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
strategies_zipper,
strategy_type,
[1, 2]
),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(strategy_zipper),
false <- has_brute_force_strategy?(do_block_zipper) do
{:ok, true}
else
_ -> :error
end
end
defp find_resources_with_password_or_magic_link(igniter) do
Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper ->
case enter_auth_strategies(zipper) do
{:ok, zipper} ->
has_strategy?(zipper, :password) or has_strategy?(zipper, :magic_link)
_ ->
false
end
end)
end
defp ensure_audit_log(igniter, resource) do
case find_audit_log_name(igniter, resource) do
{igniter, nil} ->
igniter =
Igniter.compose_task(
igniter,
"ash_authentication.add_add_on.audit_log",
["--user", inspect(resource)]
)
{igniter, :audit_log}
{igniter, name} ->
{igniter, name}
end
end
defp find_audit_log_name(igniter, resource) do
result =
Spark.Igniter.find(igniter, resource, fn _, zipper ->
with {:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:authentication,
1
),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
{:ok, zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:add_ons,
1
),
{:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
{:ok, audit_log_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
zipper,
:audit_log,
[1, 2]
) do
{:ok, audit_log_name(audit_log_zipper)}
else
_ -> :error
end
end)
case result do
{:ok, igniter, _module, name} -> {igniter, name}
{:error, igniter} -> {igniter, nil}
end
end
defp audit_log_name(zipper) do
case Igniter.Code.Function.move_to_nth_argument(zipper, 0) do
{:ok, name_zipper} ->
case Sourceror.Zipper.node(name_zipper) do
name when is_atom(name) -> name
{:__block__, _, [name]} when is_atom(name) -> name
_ -> :audit_log
end
_ ->
:audit_log
end
end
defp add_brute_force_to_strategy(igniter, resource, strategy_type, audit_log_name) do
Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
with {:ok, strategies_zipper} <- enter_auth_strategies(zipper),
{:ok, strategy_zipper} <-
Igniter.Code.Function.move_to_function_call_in_current_scope(
strategies_zipper,
strategy_type,
[1, 2]
),
{:ok, do_block_zipper} <- Igniter.Code.Common.move_to_do_block(strategy_zipper) do
updated_zipper =
Igniter.Code.Common.add_code(
do_block_zipper,
"brute_force_strategy {:audit_log, #{inspect(audit_log_name)}}"
)
{:ok, Sourceror.Zipper.top(updated_zipper)}
else
_ -> {:ok, zipper}
end
end)
end
defp has_brute_force_strategy?(do_block_zipper) do
match?(
{:ok, _},
Igniter.Code.Function.move_to_function_call_in_current_scope(
do_block_zipper,
:brute_force_strategy,
1
)
)
end
end
else
defmodule Mix.Tasks.AshAuthentication.Upgrade do
@moduledoc false
use Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'ash_authentication.upgrade' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end