feat(PasswordReset): allow users to request and reset their password. (#22)

This commit is contained in:
James Harton 2022-11-02 18:18:20 +13:00 committed by GitHub
parent c4732c12a3
commit 0eca3274f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1126 additions and 327 deletions

View file

@ -2,6 +2,7 @@
ignore_modules: [ ignore_modules: [
~r/^Inspect\./, ~r/^Inspect\./,
~r/.Plug$/, ~r/.Plug$/,
~r/^Example/,
AshAuthentication.InfoGenerator, AshAuthentication.InfoGenerator,
AshAuthentication.Plug.Macros AshAuthentication.Plug.Macros
], ],

View file

@ -1,2 +1,2 @@
elixir 1.14.0 elixir 1.14.1
erlang 25.1 erlang 25.1.2

View file

@ -1,78 +1,80 @@
defmodule AshAuthentication do defmodule AshAuthentication do
import AshAuthentication.Utils, only: [to_sentence: 2] import AshAuthentication.Utils, only: [to_sentence: 2]
@authentication %Spark.Dsl.Section{ @dsl [
name: :authentication, %Spark.Dsl.Section{
describe: "Configure authentication for this resource", name: :authentication,
schema: [ describe: "Configure authentication for this resource",
subject_name: [ schema: [
type: :atom, subject_name: [
doc: """ type: :atom,
The subject name is used in generating token claims and in generating authentication routes. doc: """
The subject name is used in generating token claims and in generating authentication routes.
This needs to be unique system-wide and if not set will be inferred This needs to be unique system-wide and if not set will be inferred
from the resource name (ie `MyApp.Accounts.User` will have a subject from the resource name (ie `MyApp.Accounts.User` will have a subject
name of `user`). name of `user`).
""" """
], ],
api: [ api: [
type: {:behaviour, Ash.Api}, type: {:behaviour, Ash.Api},
doc: """ doc: """
The name of the Ash API to use to access this resource when registering/authenticating. The name of the Ash API to use to access this resource when registering/authenticating.
""", """,
required: true required: true
], ],
get_by_subject_action_name: [ get_by_subject_action_name: [
type: :atom, type: :atom,
doc: """ doc: """
The name of the read action used to retrieve records. The name of the read action used to retrieve records.
Used internally by `AshAuthentication.subject_to_resource/2`. If the Used internally by `AshAuthentication.subject_to_resource/2`. If the
action doesn't exist, one will be generated for you. action doesn't exist, one will be generated for you.
""", """,
default: :get_by_subject default: :get_by_subject
]
] ]
] },
} %Spark.Dsl.Section{
@tokens %Spark.Dsl.Section{ name: :tokens,
name: :tokens, describe: "Configure JWT settings for this resource",
describe: "Configure JWT settings for this resource", schema: [
schema: [ enabled?: [
enabled?: [ type: :boolean,
type: :boolean, doc: """
doc: """ Should JWTs be generated by this resource?
Should JWTs be generated by this resource? """,
""", default: false
default: false ],
], signing_algorithm: [
signing_algorithm: [ type: :string,
type: :string, doc: """
doc: """ The algorithm to use for token signing.
The algorithm to use for token signing.
Available signing algorithms are; Available signing algorithms are;
#{to_sentence(Joken.Signer.algorithms(), final: "and")}. #{to_sentence(Joken.Signer.algorithms(), final: "and")}.
""" """
], ],
token_lifetime: [ token_lifetime: [
type: :pos_integer, type: :pos_integer,
doc: """ doc: """
How long a token should be valid, in hours. How long a token should be valid, in hours.
""" """
], ],
revocation_resource: [ revocation_resource: [
type: {:behaviour, Ash.Resource}, type: {:behaviour, Ash.Resource},
doc: """ doc: """
The resource used to store token revocation information. The resource used to store token revocation information.
If token generation is enabled for this resource, we need a place to If token generation is enabled for this resource, we need a place to
store revocation information. This option is the name of an Ash store revocation information. This option is the name of an Ash
Resource which has the `AshAuthentication.TokenRevocation` extension Resource which has the `AshAuthentication.TokenRevocation` extension
present. present.
""" """
]
] ]
] }
} ]
@moduledoc """ @moduledoc """
AshAuthentication provides a turn-key authentication solution for folks using AshAuthentication provides a turn-key authentication solution for folks using
@ -134,25 +136,15 @@ defmodule AshAuthentication do
* OpenID Connect * OpenID Connect
## Authentication DSL ## DSL Documentation
### Index ### Index
#{Spark.Dsl.Extension.doc_index([@authentication])} #{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs ### Docs
#{Spark.Dsl.Extension.doc([@authentication])} #{Spark.Dsl.Extension.doc(@dsl)}
## Token DSL
### Index
#{Spark.Dsl.Extension.doc_index([@tokens])}
### Docs
#{Spark.Dsl.Extension.doc([@tokens])}
""" """
alias Ash.{Api, Query, Resource} alias Ash.{Api, Query, Resource}
@ -160,7 +152,7 @@ defmodule AshAuthentication do
alias Spark.Dsl.Extension alias Spark.Dsl.Extension
use Spark.Dsl.Extension, use Spark.Dsl.Extension,
sections: [@authentication, @tokens], sections: @dsl,
transformers: [AshAuthentication.Transformer] transformers: [AshAuthentication.Transformer]
require Ash.Query require Ash.Query
@ -188,14 +180,23 @@ defmodule AshAuthentication do
otp_app otp_app
|> Application.get_env(:ash_apis, []) |> Application.get_env(:ash_apis, [])
|> Stream.flat_map(&Api.Info.resources(&1)) |> Stream.flat_map(&Api.Info.resources(&1))
|> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)}) |> Stream.map(&resource_config/1)
|> Stream.reject(&(elem(&1, 1) == nil)) |> Stream.reject(&(&1 == :error))
|> Stream.map(fn {resource, config} ->
Map.put(config, :resource, resource)
end)
|> Enum.to_list() |> Enum.to_list()
end end
def resource_config(resource) do
resource
|> Extension.get_persisted(:authentication)
|> case do
nil ->
:error
config ->
Map.put(config, :resource, resource)
end
end
@doc """ @doc """
Return a subject string for an AshAuthentication resource. Return a subject string for an AshAuthentication resource.
""" """

View file

@ -17,115 +17,157 @@ defmodule AshAuthentication.InfoGenerator do
@doc false @doc false
@spec __using__(options) :: Macro.t() @spec __using__(options) :: Macro.t()
defmacro __using__(opts) do defmacro __using__(opts) do
extension = Keyword.fetch!(opts, :extension) extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__)
sections = Keyword.get(opts, :sections, []) sections = Keyword.get(opts, :sections, [])
prefix? = Keyword.get(opts, :prefix?, false) prefix? = Keyword.get(opts, :prefix?, false)
quote do quote do
require AshAuthentication.InfoGenerator
require unquote(extension) require unquote(extension)
end
for section <- sections do AshAuthentication.InfoGenerator.generate_config_functions(
quote do unquote(extension),
AshAuthentication.InfoGenerator.generate_options_function( unquote(sections),
unquote(extension), unquote(prefix?)
unquote(section), )
unquote(prefix?)
)
AshAuthentication.InfoGenerator.generate_config_functions( AshAuthentication.InfoGenerator.generate_options_functions(
unquote(extension), unquote(extension),
unquote(section), unquote(sections),
unquote(prefix?) unquote(prefix?)
) )
end
end end
end end
@doc false @doc """
@spec generate_config_functions(module, atom, boolean) :: Macro.t() Given an extension and a list of DSL sections, generate an options function
defmacro generate_config_functions(extension, section, prefix?) do which returns a map of all configured options for a resource (including
options = defaults).
extension """
|> Macro.expand_once(__CALLER__) @spec generate_options_functions(module, [atom], boolean) :: Macro.t()
|> apply(:sections, []) defmacro generate_options_functions(_extension, sections, false) when length(sections) > 1,
|> Enum.find(&(&1.name == section)) do: raise("Cannot generate options functions for more than one section without prefixes.")
|> Map.get(:schema, [])
for {name, opts} <- options do defmacro generate_options_functions(extension, sections, prefix?) do
pred? = name |> to_string() |> String.ends_with?("?") for {section, options} <- extension_sections_to_list(extension, sections) do
function_name = if prefix?, do: :"#{section}_#{name}", else: name function_name = if prefix?, do: :"#{section}_options", else: :options
if pred? do quote location: :keep do
generate_predicate_function(function_name, section, name, Keyword.get(opts, :doc, false)) @doc """
else #{unquote(section)} DSL options
spec = AshAuthentication.Utils.spec_for_option(opts)
quote do Returns a map containing the and any configured or default values.
unquote( """
generate_config_function( @spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any}
function_name, def unquote(function_name)(dsl_or_resource) do
section, import Spark.Dsl.Extension, only: [get_opt: 4]
name,
Keyword.get(opts, :doc, false),
spec
)
)
unquote( unquote(Macro.escape(options))
generate_config_bang_function( |> Stream.map(fn option ->
function_name, value =
section, dsl_or_resource
name, |> get_opt([option.section], option.name, Map.get(option, :default))
Keyword.get(opts, :doc, false),
spec {option.name, value}
) end)
) |> Stream.reject(&is_nil(elem(&1, 1)))
|> Map.new()
end end
end end
end end
end end
defp generate_predicate_function(function_name, section, name, doc) do @doc """
quote do Given an extension and a list of DSL sections generate individual config
@doc unquote(doc) functions for each option.
@spec unquote(function_name)(dsl_or_resource :: module | map) :: boolean """
def unquote(function_name)(dsl_or_resource) do @spec generate_config_functions(module, [atom], boolean) :: Macro.t()
import Spark.Dsl.Extension, only: [get_opt: 4] defmacro generate_config_functions(extension, sections, prefix?) do
get_opt(dsl_or_resource, [unquote(section)], unquote(name), false) for {_, options} <- extension_sections_to_list(extension, sections) do
for option <- options do
function_name = if prefix?, do: :"#{option.section}_#{option.name}", else: option.name
option
|> Map.put(:function_name, function_name)
|> generate_config_function()
end end
end end
end end
defp generate_config_function(function_name, section, name, doc, spec) do defp extension_sections_to_list(extension, sections) do
quote do extension.sections()
@doc unquote(doc) |> Stream.map(fn section ->
@spec unquote(function_name)(dsl_or_resource :: module | map) :: schema =
{:ok, unquote(spec)} | :error section.schema
|> Enum.map(fn {name, opts} ->
opts
|> Map.new()
|> Map.take(~w[type doc default]a)
|> Map.update!(:type, &spec_for_type/1)
|> Map.put(:pred?, name |> to_string() |> String.ends_with?("?"))
|> Map.put(:name, name)
|> Map.put(:section, section.name)
end)
def unquote(function_name)(dsl_or_resource) do {section.name, schema}
end)
|> Map.new()
|> Map.take(sections)
end
defp generate_config_function(%{pred?: true} = option) do
quote location: :keep do
@doc unquote(option.doc)
@spec unquote(option.function_name)(dsl_or_resource :: module | map) ::
unquote(option.type)
def unquote(option.function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4] import Spark.Dsl.Extension, only: [get_opt: 4]
case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do get_opt(
dsl_or_resource,
[unquote(option.section)],
unquote(option.name),
unquote(option.default)
)
end
end
end
defp generate_config_function(option) do
quote location: :keep do
@doc unquote(Map.get(option, :doc, false))
@spec unquote(option.function_name)(dsl_or_resource :: module | map) ::
{:ok, unquote(option.type)} | :error
def unquote(option.function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4]
case get_opt(
dsl_or_resource,
[unquote(option.section)],
unquote(option.name),
unquote(Map.get(option, :default, :error))
) do
:error -> :error :error -> :error
value -> {:ok, value} value -> {:ok, value}
end end
end end
end
end
defp generate_config_bang_function(function_name, section, name, doc, spec) do @doc unquote(Map.get(option, :doc, false))
quote do @spec unquote(:"#{option.function_name}!")(dsl_or_resource :: module | map) ::
@doc unquote(doc) unquote(option.type) | no_return
@spec unquote(:"#{function_name}!")(dsl_or_resource :: module | map) ::
unquote(spec) | no_return
def unquote(:"#{function_name}!")(dsl_or_resource) do def unquote(:"#{option.function_name}!")(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4] import Spark.Dsl.Extension, only: [get_opt: 4]
case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do case get_opt(
dsl_or_resource,
[unquote(option.section)],
unquote(option.name),
unquote(Map.get(option, :default, :error))
) do
:error -> :error ->
raise "No configuration for `#{unquote(name)}` present on `#{inspect(dsl_or_resource)}`." raise "No configuration for `#{unquote(option.name)}` present on `#{inspect(dsl_or_resource)}`."
value -> value ->
value value
@ -134,37 +176,25 @@ defmodule AshAuthentication.InfoGenerator do
end end
end end
@doc false defp spec_for_type({:behaviour, _module}), do: {:module, [], Elixir}
@spec generate_options_function(module, atom, boolean) :: Macro.t()
defmacro generate_options_function(extension, section, prefix?) do
options =
extension
|> Macro.expand_once(__CALLER__)
|> apply(:sections, [])
|> Enum.find(&(&1.name == section))
|> Map.get(:schema, [])
function_name = if prefix?, do: :"#{section}_options", else: :options defp spec_for_type({:spark_function_behaviour, behaviour, _}),
do: {spec_for_type({:behaviour, behaviour}), {:list, [], Elixir}}
quote do defp spec_for_type({:fun, arity}) do
@doc """ args =
The DSL options 0..(arity - 1)
|> Enum.map(fn _ -> {:any, [], Elixir} end)
Returns a map containing the schema and any configured or default values. [{:->, [], [args, {:any, [], Elixir}]}]
"""
@spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any}
def unquote(function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 3]
Enum.reduce(unquote(options), %{}, fn {name, opts}, result ->
with nil <- get_opt(dsl_or_resource, [unquote(section)], name),
nil <- Keyword.get(opts, :default) do
result
else
value -> Map.put(result, name, value)
end
end)
end
end
end end
defp spec_for_type({:or, choices}) do
{:|, [], Enum.map(choices, &spec_for_type/1)}
end
defp spec_for_type(:string),
do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
defp spec_for_type(terminal), do: {terminal, [], Elixir}
end end

View file

@ -68,14 +68,17 @@ defmodule AshAuthentication.Jwt do
Given a record, generate a signed JWT for use while authenticating. Given a record, generate a signed JWT for use while authenticating.
""" """
@spec token_for_record(Resource.record()) :: {:ok, token, claims} | :error @spec token_for_record(Resource.record()) :: {:ok, token, claims} | :error
def token_for_record(record) do def token_for_record(record, extra_claims \\ %{}, opts \\ []) do
resource = record.__struct__ resource = record.__struct__
default_claims = Config.default_claims(resource) default_claims = Config.default_claims(resource, opts)
signer = Config.token_signer(resource) signer = Config.token_signer(resource, opts)
subject = AshAuthentication.resource_to_subject(record) subject = AshAuthentication.resource_to_subject(record)
extra_claims = %{"sub" => subject}
extra_claims =
extra_claims
|> Map.put("sub", subject)
extra_claims = extra_claims =
case Map.fetch(record.__metadata__, :tenant) do case Map.fetch(record.__metadata__, :tenant) do
@ -89,9 +92,30 @@ defmodule AshAuthentication.Jwt do
@doc """ @doc """
Given a token, verify it's signature and validate it's claims. Given a token, verify it's signature and validate it's claims.
""" """
@spec verify(token, module) :: @spec verify(token, Ash.Resource.t() | module) ::
{:ok, claims, AshAuthentication.resource_config()} | :error {:ok, claims, AshAuthentication.resource_config()} | :error
def verify(token, otp_app) do def verify(token, otp_app_or_resource) do
if function_exported?(otp_app_or_resource, :spark_is, 0) &&
otp_app_or_resource.spark_is() == Ash.Resource do
verify_for_resource(token, otp_app_or_resource)
else
verify_for_otp_app(token, otp_app_or_resource)
end
end
defp verify_for_resource(token, resource) do
with config <- AshAuthentication.resource_config(resource),
signer <- Config.token_signer(resource),
{:ok, claims} <- Joken.verify(token, signer),
defaults <- Config.default_claims(resource),
{:ok, claims} <- Joken.validate(defaults, claims, config) do
{:ok, claims, config}
else
_ -> :error
end
end
defp verify_for_otp_app(token, otp_app) do
with {:ok, config} <- token_to_resource(token, otp_app), with {:ok, config} <- token_to_resource(token, otp_app),
signer <- Config.token_signer(config.resource), signer <- Config.token_signer(config.resource),
{:ok, claims} <- Joken.verify(token, signer), {:ok, claims} <- Joken.verify(token, signer),

View file

@ -14,11 +14,12 @@ defmodule AshAuthentication.Jwt.Config do
@doc """ @doc """
Generate the default claims for a specified resource. Generate the default claims for a specified resource.
""" """
@spec default_claims(Resource.t()) :: Joken.token_config() @spec default_claims(Resource.t(), keyword) :: Joken.token_config()
def default_claims(resource) do def default_claims(resource, opts \\ []) do
config = config =
resource resource
|> config() |> config()
|> Keyword.merge(opts)
{:ok, vsn} = :application.get_key(:ash_authentication, :vsn) {:ok, vsn} = :application.get_key(:ash_authentication, :vsn)
@ -108,9 +109,12 @@ defmodule AshAuthentication.Jwt.Config do
@doc """ @doc """
The signer used to sign the token on a per-resource basis. The signer used to sign the token on a per-resource basis.
""" """
@spec token_signer(Resource.t()) :: Signer.t() @spec token_signer(Resource.t(), keyword) :: Signer.t()
def token_signer(resource) do def token_signer(resource, opts \\ []) do
config = config(resource) config =
resource
|> config()
|> Keyword.merge(opts)
algorithm = Keyword.get_lazy(config, :signing_algorithm, &Jwt.default_algorithm/0) algorithm = Keyword.get_lazy(config, :signing_algorithm, &Jwt.default_algorithm/0)

View file

@ -1,65 +1,71 @@
defmodule AshAuthentication.PasswordAuthentication do defmodule AshAuthentication.PasswordAuthentication do
@password_authentication %Spark.Dsl.Section{ @dsl [
name: :password_authentication, %Spark.Dsl.Section{
describe: """ name: :password_authentication,
Configure password authentication authentication for this resource. describe: """
""", Configure password authentication authentication for this resource.
schema: [ """,
identity_field: [ schema: [
type: :atom, identity_field: [
doc: """ type: :atom,
The name of the attribute which uniquely identifies the user. Usually something like `username` or `email_address`. doc: """
""", The name of the attribute which uniquely identifies the actor.
default: :username
], Usually something like `username` or `email_address`.
hashed_password_field: [ """,
type: :atom, default: :username
doc: """ ],
The name of the attribute within which to store the user's password once it has been hashed. hashed_password_field: [
""", type: :atom,
default: :hashed_password doc: """
], The name of the attribute within which to store the user's password once it has been hashed.
hash_provider: [ """,
type: {:behaviour, AshAuthentication.HashProvider}, default: :hashed_password
doc: """ ],
A module which implements the `AshAuthentication.HashProvider` behaviour - which is used to provide cryptographic hashing of passwords. hash_provider: [
""", type: {:behaviour, AshAuthentication.HashProvider},
default: AshAuthentication.BcryptProvider doc: """
], A module which implements the `AshAuthentication.HashProvider` behaviour.
confirmation_required?: [
type: :boolean, Used to provide cryptographic hashing of passwords.
required: false, """,
doc: """ default: AshAuthentication.BcryptProvider
Whether a password confirmation field is required when registering or changing passwords. ],
""", confirmation_required?: [
default: true type: :boolean,
], required: false,
password_field: [ doc: """
type: :atom, Whether a password confirmation field is required when registering or changing passwords.
doc: """ """,
The name of the argument used to collect the user's password in plaintext when registering, checking or changing passwords. default: true
""", ],
default: :password password_field: [
], type: :atom,
password_confirmation_field: [ doc: """
type: :atom, The name of the argument used to collect the user's password in plaintext when registering, checking or changing passwords.
doc: """ """,
The name of the argument used to confirm the user's password in plaintext when registering or changing passwords. default: :password
""", ],
default: :password_confirmation password_confirmation_field: [
], type: :atom,
register_action_name: [ doc: """
type: :atom, The name of the argument used to confirm the user's password in plaintext when registering or changing passwords.
doc: "The name to use for the register action", """,
default: :register default: :password_confirmation
], ],
sign_in_action_name: [ register_action_name: [
type: :atom, type: :atom,
doc: "The name to use for the sign in action", doc: "The name to use for the register action",
default: :sign_in default: :register
],
sign_in_action_name: [
type: :atom,
doc: "The name to use for the sign in action",
default: :sign_in
]
] ]
] }
} ]
@moduledoc """ @moduledoc """
Authentication using your application as the source of truth. Authentication using your application as the source of truth.
@ -98,19 +104,20 @@ defmodule AshAuthentication.PasswordAuthentication do
### Index ### Index
#{Spark.Dsl.Extension.doc_index([@password_authentication])} #{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs ### Docs
#{Spark.Dsl.Extension.doc([@password_authentication])} #{Spark.Dsl.Extension.doc(@dsl)}
""" """
@behaviour AshAuthentication.Provider @behaviour AshAuthentication.Provider
use Spark.Dsl.Extension, use Spark.Dsl.Extension,
sections: [@password_authentication], sections: @dsl,
transformers: [AshAuthentication.PasswordAuthentication.Transformer] transformers: [AshAuthentication.PasswordAuthentication.Transformer]
alias Ash.Resource
alias AshAuthentication.PasswordAuthentication alias AshAuthentication.PasswordAuthentication
alias Plug.Conn alias Plug.Conn
@ -123,7 +130,7 @@ defmodule AshAuthentication.PasswordAuthentication do
{:ok, #MyApp.User<>} {:ok, #MyApp.User<>}
""" """
@impl true @impl true
@spec sign_in_action(module, map) :: {:ok, struct} | {:error, term} @spec sign_in_action(Resource.t(), map) :: {:ok, struct} | {:error, term}
defdelegate sign_in_action(resource, attributes), defdelegate sign_in_action(resource, attributes),
to: PasswordAuthentication.Actions, to: PasswordAuthentication.Actions,
as: :sign_in as: :sign_in
@ -137,7 +144,7 @@ defmodule AshAuthentication.PasswordAuthentication do
{:ok, #MyApp.User<>} {:ok, #MyApp.User<>}
""" """
@impl true @impl true
@spec register_action(module, map) :: {:ok, struct} | {:error, term} @spec register_action(Resource.t(), map) :: {:ok, struct} | {:error, term}
defdelegate register_action(resource, attributes), defdelegate register_action(resource, attributes),
to: PasswordAuthentication.Actions, to: PasswordAuthentication.Actions,
as: :register as: :register
@ -172,4 +179,10 @@ defmodule AshAuthentication.PasswordAuthentication do
@impl true @impl true
@spec has_register_step?(any) :: boolean @spec has_register_step?(any) :: boolean
def has_register_step?(_), do: true def has_register_step?(_), do: true
@doc """
Returns whether password authentication is enabled for the resource
"""
@spec enabled?(Resource.t()) :: boolean
def enabled?(resource), do: __MODULE__ in Spark.extensions(resource)
end end

View file

@ -20,7 +20,9 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
""" """
@spec sign_in(module, map) :: {:ok, struct} | {:error, term} @spec sign_in(module, map) :: {:ok, struct} | {:error, term}
def sign_in(resource, attributes) do def sign_in(resource, attributes) do
{:ok, action} = PasswordAuthentication.Info.sign_in_action_name(resource) {:ok, action} =
PasswordAuthentication.Info.password_authentication_sign_in_action_name(resource)
{:ok, api} = AshAuthentication.Info.authentication_api(resource) {:ok, api} = AshAuthentication.Info.authentication_api(resource)
resource resource
@ -43,7 +45,9 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
""" """
@spec register(module, map) :: {:ok, struct} | {:error, term} @spec register(module, map) :: {:ok, struct} | {:error, term}
def register(resource, attributes) do def register(resource, attributes) do
{:ok, action} = PasswordAuthentication.Info.register_action_name(resource) {:ok, action} =
PasswordAuthentication.Info.password_authentication_register_action_name(resource)
{:ok, api} = AshAuthentication.Info.authentication_api(resource) {:ok, api} = AshAuthentication.Info.authentication_api(resource)
resource resource

View file

@ -16,9 +16,9 @@ defmodule AshAuthentication.PasswordAuthentication.HashPasswordChange do
def change(changeset, _opts, _) do def change(changeset, _opts, _) do
changeset changeset
|> Changeset.before_action(fn changeset -> |> Changeset.before_action(fn changeset ->
{:ok, password_field} = Info.password_field(changeset.resource) {:ok, password_field} = Info.password_authentication_password_field(changeset.resource)
{:ok, hash_field} = Info.hashed_password_field(changeset.resource) {:ok, hash_field} = Info.password_authentication_hashed_password_field(changeset.resource)
{:ok, hasher} = Info.hash_provider(changeset.resource) {:ok, hasher} = Info.password_authentication_hash_provider(changeset.resource)
with value when is_binary(value) <- Changeset.get_argument(changeset, password_field), with value when is_binary(value) <- Changeset.get_argument(changeset, password_field),
{:ok, hash} <- hasher.hash(value) do {:ok, hash} <- hasher.hash(value) do

View file

@ -90,7 +90,7 @@ defmodule AshAuthentication.PasswordAuthentication.HTML do
@defaults @defaults
|> Keyword.merge(options) |> Keyword.merge(options)
|> Map.new() |> Map.new()
|> Map.merge(PasswordAuthentication.Info.options(resource)) |> Map.merge(PasswordAuthentication.Info.password_authentication_options(resource))
|> Map.merge(AshAuthentication.Info.authentication_options(resource)) |> Map.merge(AshAuthentication.Info.authentication_options(resource))
end end
end end

View file

@ -5,5 +5,6 @@ defmodule AshAuthentication.PasswordAuthentication.Info do
use AshAuthentication.InfoGenerator, use AshAuthentication.InfoGenerator,
extension: AshAuthentication.PasswordAuthentication, extension: AshAuthentication.PasswordAuthentication,
sections: [:password_authentication] sections: [:password_authentication],
prefix?: true
end end

View file

@ -13,11 +13,13 @@ defmodule AshAuthentication.PasswordAuthentication.PasswordConfirmationValidatio
Validates that the password and password confirmation fields contain Validates that the password and password confirmation fields contain
equivalent values - if confirmation is required. equivalent values - if confirmation is required.
""" """
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()} @spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
def validate(changeset, _) do def validate(changeset, _) do
with true <- Info.confirmation_required?(changeset.resource), with true <- Info.password_authentication_confirmation_required?(changeset.resource),
{:ok, password_field} <- Info.password_field(changeset.resource), {:ok, password_field} <- Info.password_authentication_password_field(changeset.resource),
{:ok, confirm_field} <- Info.password_confirmation_field(changeset.resource), {:ok, confirm_field} <-
Info.password_authentication_password_confirmation_field(changeset.resource),
password <- Changeset.get_argument(changeset, password_field), password <- Changeset.get_argument(changeset, password_field),
confirmation <- Changeset.get_argument(changeset, confirm_field), confirmation <- Changeset.get_argument(changeset, confirm_field),
false <- password == confirmation do false <- password == confirmation do

View file

@ -14,7 +14,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
an "action" parameter along with the form data. an "action" parameter along with the form data.
""" """
import AshAuthentication.Plug.Helpers, only: [private_store: 2] import AshAuthentication.Plug.Helpers, only: [private_store: 2]
alias AshAuthentication.PasswordAuthentication alias AshAuthentication.{PasswordAuthentication, PasswordReset}
alias Plug.Conn alias Plug.Conn
@doc """ @doc """
@ -52,4 +52,9 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
defp do_action(%{"action" => "register"} = attrs, resource), defp do_action(%{"action" => "register"} = attrs, resource),
do: PasswordAuthentication.register_action(resource, attrs) do: PasswordAuthentication.register_action(resource, attrs)
defp do_action(%{"action" => "reset_password"} = attrs, resource),
do: PasswordReset.reset_password(resource, attrs)
defp do_action(_attrs, _resource), do: {:error, "No action provided"}
end end

View file

@ -21,9 +21,9 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
@impl true @impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t() @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _) do def prepare(query, _opts, _) do
{:ok, identity_field} = Info.identity_field(query.resource) {:ok, identity_field} = Info.password_authentication_identity_field(query.resource)
{:ok, password_field} = Info.password_field(query.resource) {:ok, password_field} = Info.password_authentication_password_field(query.resource)
{:ok, hasher} = Info.hash_provider(query.resource) {:ok, hasher} = Info.password_authentication_hash_provider(query.resource)
identity = Query.get_argument(query, identity_field) identity = Query.get_argument(query, identity_field)

View file

@ -72,9 +72,23 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
with :ok <- validate_authentication_extension(dsl_state), with :ok <- validate_authentication_extension(dsl_state),
{:ok, dsl_state} <- validate_identity_field(dsl_state), {:ok, dsl_state} <- validate_identity_field(dsl_state),
{:ok, dsl_state} <- validate_hashed_password_field(dsl_state), {:ok, dsl_state} <- validate_hashed_password_field(dsl_state),
{:ok, dsl_state} <- maybe_build_action(dsl_state, :register, &build_register_action/1), {:ok, register_action_name} <-
Info.password_authentication_register_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
register_action_name,
&build_register_action(&1, register_action_name)
),
{:ok, dsl_state} <- validate_register_action(dsl_state), {:ok, dsl_state} <- validate_register_action(dsl_state),
{:ok, dsl_state} <- maybe_build_action(dsl_state, :sign_in, &build_sign_in_action/1), {:ok, sign_in_action_name} <-
Info.password_authentication_sign_in_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
sign_in_action_name,
&build_sign_in_action(&1, sign_in_action_name)
),
{:ok, dsl_state} <- validate_sign_in_action(dsl_state), {:ok, dsl_state} <- validate_sign_in_action(dsl_state),
:ok <- validate_hash_provider(dsl_state) do :ok <- validate_hash_provider(dsl_state) do
authentication = authentication =
@ -105,11 +119,13 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
def before?(Resource.Transformers.DefaultAccept), do: true def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false def before?(_), do: false
defp build_register_action(dsl_state) do defp build_register_action(dsl_state, action_name) do
with {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state), with {:ok, hashed_password_field} <-
{:ok, password_field} <- Info.password_field(dsl_state), Info.password_authentication_hashed_password_field(dsl_state),
{:ok, confirm_field} <- Info.password_confirmation_field(dsl_state), {:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
confirmation_required? <- Info.confirmation_required?(dsl_state) do {:ok, confirm_field} <-
Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state) do
password_opts = [ password_opts = [
type: Type.String, type: Type.String,
allow_nil?: false, allow_nil?: false,
@ -154,7 +170,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
]) ])
Transformer.build_entity(Resource.Dsl, [:actions], :create, Transformer.build_entity(Resource.Dsl, [:actions], :create,
name: :register, name: action_name,
arguments: arguments, arguments: arguments,
changes: changes, changes: changes,
allow_nil_input: [hashed_password_field] allow_nil_input: [hashed_password_field]
@ -162,9 +178,9 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
end end
end end
defp build_sign_in_action(dsl_state) do defp build_sign_in_action(dsl_state, action_name) do
with {:ok, identity_field} <- Info.identity_field(dsl_state), with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
{:ok, password_field} <- Info.password_field(dsl_state) do {:ok, password_field} <- Info.password_authentication_password_field(dsl_state) do
identity_attribute = Resource.Info.attribute(dsl_state, identity_field) identity_attribute = Resource.Info.attribute(dsl_state, identity_field)
arguments = [ arguments = [
@ -188,7 +204,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
] ]
Transformer.build_entity(Resource.Dsl, [:actions], :read, Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: :sign_in, name: action_name,
arguments: arguments, arguments: arguments,
preparations: preparations, preparations: preparations,
get?: true get?: true

View file

@ -46,7 +46,7 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
""" """
@spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()} @spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()}
def validate_hash_provider(dsl_state) do def validate_hash_provider(dsl_state) do
case Info.hash_provider(dsl_state) do case Info.password_authentication_hash_provider(dsl_state) do
{:ok, hash_provider} -> {:ok, hash_provider} ->
validate_module_implements_behaviour(hash_provider, HashProvider) validate_module_implements_behaviour(hash_provider, HashProvider)
@ -64,9 +64,10 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
""" """
@spec validate_sign_in_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} @spec validate_sign_in_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
def validate_sign_in_action(dsl_state) do def validate_sign_in_action(dsl_state) do
with {:ok, identity_field} <- Info.identity_field(dsl_state), with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
{:ok, password_field} <- Info.password_field(dsl_state), {:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, :sign_in), {:ok, action_name} <- Info.password_authentication_sign_in_action_name(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_identity_argument(dsl_state, action, identity_field), :ok <- validate_identity_argument(dsl_state, action, identity_field),
:ok <- validate_password_argument(action, password_field), :ok <- validate_password_argument(action, password_field),
:ok <- validate_action_has_preparation(action, SignInPreparation) do :ok <- validate_action_has_preparation(action, SignInPreparation) do
@ -79,11 +80,14 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
""" """
@spec validate_register_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} @spec validate_register_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
def validate_register_action(dsl_state) do def validate_register_action(dsl_state) do
with {:ok, password_field} <- Info.password_field(dsl_state), with {:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{:ok, password_confirmation_field} <- Info.password_confirmation_field(dsl_state), {:ok, password_confirmation_field} <-
{:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state), Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <- Info.confirmation_required?(dsl_state), {:ok, hashed_password_field} <-
{:ok, action} <- validate_action_exists(dsl_state, :register), Info.password_authentication_hashed_password_field(dsl_state),
confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state),
{:ok, action_name} <- Info.password_authentication_register_action_name(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_allow_nil_input(action, hashed_password_field), :ok <- validate_allow_nil_input(action, hashed_password_field),
:ok <- validate_password_argument(action, password_field), :ok <- validate_password_argument(action, password_field),
:ok <- :ok <-
@ -169,7 +173,7 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
@spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()} @spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
def validate_identity_field(dsl_state) do def validate_identity_field(dsl_state) do
with {:ok, resource} <- persisted_option(dsl_state, :module), with {:ok, resource} <- persisted_option(dsl_state, :module),
{:ok, identity_field} <- Info.identity_field(dsl_state), {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
{:ok, attribute} <- find_attribute(dsl_state, identity_field), {:ok, attribute} <- find_attribute(dsl_state, identity_field),
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]), :ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]), :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),

View file

@ -0,0 +1,166 @@
defmodule AshAuthentication.PasswordReset do
@default_lifetime_days 3
@dsl [
%Spark.Dsl.Section{
name: :password_reset,
describe: "Configure password reset behaviour",
schema: [
token_lifetime: [
type: :pos_integer,
doc: """
How long should the reset token be valid, in hours.
Defaults to #{@default_lifetime_days} days.
""",
default: @default_lifetime_days * 24
],
request_password_reset_action_name: [
type: :atom,
doc: """
The name to use for the action which generates a password reset token.
""",
default: :request_password_reset
],
password_reset_action_name: [
type: :atom,
doc: """
The name to use for the action which actually resets the user's password.
""",
default: :reset_password
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.PasswordReset.Sender,
{AshAuthentication.PasswordReset.SenderFunction, 2}},
doc: """
How to send the password reset instructions to the user.
Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application.
Accepts a module, module and opts, or a function that takes a record, reset token and options.
See `AshAuthentication.PasswordReset.Sender` for more information.
""",
required: true
]
]
}
]
@moduledoc """
Allow users to reset their passwords.
This extension provides a mechanism to allow users to reset their password as
in your typical "forgotten password" flow.
This requires the `AshAuthentication.PasswordAuthentication` extension to be
present, in order to be able to update the password.
## Senders
You can set the DSL's `sender` key to be either a two-arity anonymous function
or a module which implements the `AshAuthentication.PasswordReset.Sender`
behaviour. This callback can be used to send password reset instructions to
the user via the system of your choice.
## Usage
```elixir
defmodule MyApp.Accounts.Users do
use Ash.Resource,
extensions: [
AshAuthentication.PasswordAuthentication,
AshAuthentication.PasswordReset
]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
password_reset do
token_lifetime 24
sender MyApp.ResetRequestSender
end
end
```
Because you often want to submit the password reset token via the web, you can
also use the password authentication callback endpoint with an action of
"reset_password" and the reset password action will be called with the
included params.
## DSL Documentation
### Index
#{Spark.Dsl.Extension.doc_index(@dsl)}
### Docs
#{Spark.Dsl.Extension.doc(@dsl)}
"""
use Spark.Dsl.Extension,
sections: @dsl,
transformers: [AshAuthentication.PasswordReset.Transformer]
alias Ash.{Changeset, Resource}
alias AshAuthentication.{Jwt, PasswordReset}
@doc """
Returns whether password reset is enabled for the resource
"""
@spec enabled?(Resource.t()) :: boolean
def enabled?(resource), do: __MODULE__ in Spark.extensions(resource)
@doc """
Request a password reset for a user.
If the record supports password resets then the reset token will be generated and sent.
## Example
iex> user = MyApp.Accounts.get(MyApp.Accounts.User, email: "marty@mcfly.me")
...> request_password_reset(user)
:ok
"""
def request_password_reset(user) do
resource = user.__struct__
with true <- enabled?(resource),
{:ok, action} <- PasswordReset.Info.request_password_reset_action_name(resource),
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
user
|> Changeset.for_update(action, %{})
|> api.update()
else
{:error, reason} -> {:error, reason}
_ -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"}
end
end
@doc """
Reset a user's password.
Given a reset token, password and _maybe_ password confirmation, validate and
change the user's password.
"""
@spec reset_password(Resource.t(), params) :: {:ok, Resource.record()} | {:error, Changeset.t()}
when params: %{required(String.t()) => String.t()}
def reset_password(resource, params) do
with {:ok, token} <- Map.fetch(params, "reset_token"),
{:ok, %{"sub" => subject}, config} <- Jwt.verify(token, resource),
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
{:ok, action} <- PasswordReset.Info.password_reset_action_name(config.resource),
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
user
|> Changeset.for_update(action, params)
|> api.update()
else
:error -> {:error, "Invalid reset token"}
{:error, reason} -> {:error, reason}
end
end
end

View file

@ -0,0 +1,9 @@
defmodule AshAuthentication.PasswordReset.Info do
@moduledoc """
Generated configuration functions based on a resource's DSL configuration.
"""
use AshAuthentication.InfoGenerator,
extension: AshAuthentication.PasswordReset,
sections: [:password_reset]
end

View file

@ -0,0 +1,21 @@
defmodule AshAuthentication.PasswordReset.Notifier do
@moduledoc """
This is a moduledoc
"""
use Ash.Notifier
alias AshAuthentication.{PasswordReset, PasswordReset.Info}
@doc false
@impl true
def notify(notification) do
with true <- PasswordReset.enabled?(notification.resource),
{:ok, action} <- Info.request_password_reset_action_name(notification.resource),
true <- notification.action.name == action,
{:ok, {sender, send_opts}} <- Info.sender(notification.resource),
{:ok, reset_token} <- Map.fetch(notification.data.__metadata__, :reset_token) do
sender.send(notification.data, reset_token, send_opts)
end
:ok
end
end

View file

@ -0,0 +1,35 @@
defmodule AshAuthentication.PasswordReset.RequestPasswordResetAction do
@moduledoc """
A manually implemented action which generates a reset token for a user.
"""
use Ash.Resource.ManualUpdate
alias Ash.{Changeset, Resource, Resource.ManualUpdate}
alias AshAuthentication.{Jwt, PasswordReset.Info}
@doc false
@impl true
@spec update(Changeset.t(), keyword, ManualUpdate.context()) ::
{:ok, Resource.record()} | {:error, any}
def update(changeset, _opts, _context) do
lifetime = Info.token_lifetime!(changeset.resource)
action =
changeset.action
|> Map.fetch!(:name)
|> to_string()
{:ok, token, _claims} =
changeset.data
|> Jwt.token_for_record(%{"act" => action}, token_lifetime: lifetime)
metadata =
changeset.data.__metadata__
|> Map.put(:reset_token, token)
data =
changeset.data
|> Map.put(:__metadata__, metadata)
{:ok, data}
end
end

View file

@ -0,0 +1,24 @@
defmodule AshAuthentication.PasswordReset.ResetTokenValidation do
@moduledoc """
Validate that the token is a valid password reset request token.
"""
use Ash.Resource.Validation
alias Ash.{Changeset, Error.Changes.InvalidArgument}
alias AshAuthentication.{Jwt, PasswordReset.Info}
@doc false
@impl true
@spec validate(Changeset.t(), keyword) :: :ok | {:error, Exception.t()}
def validate(changeset, _) do
with token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token),
{:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource),
{:ok, resource_action} <- Info.request_password_reset_action_name(changeset.resource),
true <- to_string(resource_action) == token_action do
:ok
else
_ ->
{:error, InvalidArgument.exception(field: :reset_token, message: "is not valid")}
end
end
end

View file

@ -0,0 +1,80 @@
defmodule AshAuthentication.PasswordReset.Sender do
@moduledoc ~S"""
A module to implement sending of the password reset token to a user.
Allows you to glue sending of reset instructions to
[swoosh](https://hex.pm/packages/swoosh),
[ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system
is appropriate for your application.
Note that the return value and any failures are ignored. If you need retry
logic, etc, then you should implement it in your sending system.
## Example
Implementing as a module:
```elixir
defmodule MyApp.PasswordResetSender do
use AshAuthentication.PasswordReset.Sender
import Swoosh.Email
alias MyAppWeb.Router.Helpers, as: Routes
def send(user, reset_token, _opts) do
new()
|> to({user.name, user.email})
|> from({"Doc Brown", "emmet@brown.inc"})
|> subject("Password reset instructions")
|> html_body("
<h1>Password reset instructions</h1>
<p>
Hi #{user.name},<br />
Someone (maybe you) has requested a password reset for your account.
If you did not initiate this request then please ignore this email.
</p>
<a href="#{Routes.auth_url(MyAppWeb.Endpoint, :reset_password, token: reset_token)}">
Click here to reset
</a>
")
|> MyApp.Mailer.deliver()
end
end
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest]
password_reset do
sender MyApp.PasswordResetSender
end
end
```
You can also implment it directly as a function:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordRest]
password_reset do
sender fn user, token, _opt ->
MyApp.Mailer.send_password_reset_email(user, token)
end
end
end
```
"""
alias Ash.Resource
@callback send(user :: Resource.record(), reset_token :: String.t(), opts :: list) :: :ok
@doc false
@spec __using__(any) :: Macro.t()
defmacro __using__(_) do
quote do
@behaviour AshAuthentication.PasswordReset.Sender
end
end
end

View file

@ -0,0 +1,28 @@
defmodule AshAuthentication.PasswordReset.SenderFunction do
@moduledoc """
Implements `AshAuthentication.PasswordReset.Sender` for functions that are
provided to the DSL instead of modules.
"""
use AshAuthentication.PasswordReset.Sender
alias Ash.Resource
@doc false
@impl true
@spec send(Resource.record(), String.t(), list()) :: :ok
def send(user, token, fun: {m, f, a}) do
apply(m, f, [user, token | a])
:ok
end
def send(user, token, fun: fun) when is_function(fun, 2) do
fun.(user, token)
:ok
end
def send(user, token, [fun: fun] = opts) when is_function(fun, 3) do
opts = Keyword.delete(opts, :fun)
fun.(user, token, opts)
:ok
end
end

View file

@ -0,0 +1,246 @@
defmodule AshAuthentication.PasswordReset.Transformer do
@moduledoc """
The PasswordReset transformer.
Scans the resource and checks that all the fields and actions needed are
present.
"""
use Spark.Dsl.Transformer
alias AshAuthentication.PasswordReset.{
Info,
Notifier,
RequestPasswordResetAction,
ResetTokenValidation,
Sender
}
alias Ash.{Resource, Type}
alias AshAuthentication.PasswordAuthentication, as: PA
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations.Action
@doc false
@impl true
@spec transform(map) ::
:ok
| {:ok, map()}
| {:error, term()}
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
with :ok <- validate_authentication_extension(dsl_state),
:ok <- validate_password_authentication_extension(dsl_state),
:ok <- validate_token_generation_enabled(dsl_state),
:ok <- validate_sender(dsl_state),
{:ok, request_action_name} <- Info.request_password_reset_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
request_action_name,
&build_request_action(&1, request_action_name)
),
:ok <- validate_request_action(dsl_state, request_action_name),
{:ok, change_action_name} <- Info.password_reset_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
change_action_name,
&build_change_action(&1, change_action_name)
),
:ok <- validate_change_action(dsl_state, change_action_name),
{:ok, dsl_state} <- maybe_add_notifier(dsl_state, Notifier) do
{:ok, dsl_state}
else
:error -> {:error, "Configuration error"}
{:error, reason} -> {:error, reason}
end
end
@doc false
@impl true
@spec after?(module) :: boolean
def after?(AshAuthentication.Transformer), do: true
def after?(PA.Transformer), do: true
def after?(_), do: false
@doc false
@impl true
@spec before?(module) :: boolean
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
defp validate_authentication_extension(dsl_state) do
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
if AshAuthentication in extensions,
do: :ok,
else:
{:error,
DslError.exception(
path: [:extensions],
message:
"The `AshAuthentication` extension must also be present on this resource in order to generate reset tokens."
)}
end
defp validate_password_authentication_extension(dsl_state) do
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
if PA in extensions,
do: :ok,
else:
{:error,
DslError.exception(
path: [:extensions],
message:
"The `AshAuthentication.PasswordAuthentication` extension must also be present on this resource in order to be able to change the user's password."
)}
end
defp validate_token_generation_enabled(dsl_state) do
if AshAuthentication.Info.tokens_enabled?(dsl_state),
do: :ok,
else:
{:error,
DslError.exception(
path: [:tokens],
message: "Token generation must be enabled for password resets to work."
)}
end
defp validate_sender(dsl_state) do
with {:ok, {sender, _opts}} <- Info.sender(dsl_state),
true <- Spark.implements_behaviour?(sender, Sender) do
:ok
else
_ ->
{:error,
DslError.exception(
path: [:password_reset],
message:
"`sender` must be a module that implements the `AshAuthentication.PasswordReset.Sender` behaviour."
)}
end
end
defp build_request_action(_dsl_state, action_name) do
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: action_name,
manual: RequestPasswordResetAction,
accept: []
)
end
defp build_change_action(dsl_state, action_name) do
with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state),
{:ok, confirm_field} <-
PA.Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <-
PA.Info.password_authentication_confirmation_required?(dsl_state) do
password_opts = [
type: Type.String,
allow_nil?: false,
constraints: [min_length: 8],
sensitive?: true
]
arguments =
[
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
name: :reset_token,
type: Type.String,
sensitive?: true
),
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
Keyword.put(password_opts, :name, password_field)
)
]
|> maybe_append(
confirmation_required?,
Transformer.build_entity!(
Resource.Dsl,
[:actions, :update],
:argument,
Keyword.put(password_opts, :name, confirm_field)
)
)
changes =
[
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
validation: ResetTokenValidation
)
]
|> maybe_append(
confirmation_required?,
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :validate,
validation: PA.PasswordConfirmationValidation
)
)
|> Enum.concat([
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
change: PA.HashPasswordChange
)
])
Transformer.build_entity(Resource.Dsl, [:actions], :update,
name: action_name,
arguments: arguments,
changes: changes,
accept: []
)
end
end
defp validate_request_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name) do
validate_action_has_manual(action, RequestPasswordResetAction)
end
end
defp validate_change_action(dsl_state, action_name) do
with {:ok, password_field} <- PA.Info.password_authentication_password_field(dsl_state),
{:ok, password_confirmation_field} <-
PA.Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <-
PA.Info.password_authentication_confirmation_required?(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_action_has_validation(action, ResetTokenValidation),
:ok <- validate_action_has_change(action, PA.HashPasswordChange),
:ok <- PA.UserValidations.validate_password_argument(action, password_field),
:ok <-
PA.UserValidations.validate_password_confirmation_argument(
action,
password_confirmation_field,
confirmation_required?
) do
PA.UserValidations.validate_action_has_validation(
action,
PA.PasswordConfirmationValidation,
confirmation_required?
)
end
end
defp maybe_add_notifier(dsl_state, notifier) do
notifiers =
dsl_state
|> Transformer.get_persisted(:notifiers, [])
|> MapSet.new()
|> MapSet.put(notifier)
|> Enum.to_list()
{:ok, Transformer.persist(dsl_state, :notifiers, notifiers)}
end
end

View file

@ -45,25 +45,6 @@ defmodule AshAuthentication.Utils do
def maybe_append(collection, test, _element) when test in [nil, false], do: collection def maybe_append(collection, test, _element) when test in [nil, false], do: collection
def maybe_append(collection, _test, element), do: Enum.concat(collection, [element]) def maybe_append(collection, _test, element), do: Enum.concat(collection, [element])
@doc """
Generate the AST for an options function spec.
Not something you should ever need.
"""
@spec spec_for_option(keyword) :: Macro.t()
def spec_for_option(options) do
case Keyword.get(options, :type, :term) do
{:behaviour, _module} ->
{:module, [], Elixir}
:string ->
{{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
terminal ->
{terminal, [], Elixir}
end
end
@doc """ @doc """
Used within transformers to optionally build actions as needed. Used within transformers to optionally build actions as needed.
""" """

View file

@ -111,6 +111,30 @@ defmodule AshAuthentication.Validations.Action do
)} )}
end end
@doc """
Validate the presence of the named manual module on an action.
"""
@spec validate_action_has_manual(Actions.action(), module) ::
:ok | {:error, Exception.t()}
def validate_action_has_manual(action, manual_module) do
has_manual? =
action
|> Map.get(:manual)
|> then(fn {module, _args} ->
module == manual_module
end)
if has_manual?,
do: :ok,
else:
{:error,
DslError.exception(
path: [:actions, :manual],
message:
"The action `#{inspect(action.name)}` should have the `#{inspect(manual_module)}` manual present."
)}
end
@doc """ @doc """
Validate the presence of the named validation module on an action. Validate the presence of the named validation module on an action.
""" """

View file

@ -77,7 +77,7 @@ defmodule AshAuthentication.MixProject do
# Run "mix help deps" to learn about dependencies. # Run "mix help deps" to learn about dependencies.
defp deps do defp deps do
[ [
{:ash, "~> 2.3"}, {:ash, "~> 2.4"},
{:bcrypt_elixir, "~> 3.0", optional: true}, {:bcrypt_elixir, "~> 3.0", optional: true},
{:jason, "~> 1.4"}, {:jason, "~> 1.4"},
{:joken, "~> 2.5"}, {:joken, "~> 2.5"},

View file

@ -1,7 +1,7 @@
%{ %{
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
"ash": {:hex, :ash, "2.3.0", "3f47a8f1f273a8fce66ac48ef146f4f7a51a6e50d26f50c2f650fbb976e6f5a8", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.2", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1540d43533b2c9caa9602209035f33ec2e32240df53d289fc196766dc0e3b510"}, "ash": {:hex, :ash, "2.4.1", "51c6968fec4980c44c3bc667935b4790a246a16eb1581365e3ede3c6f3bdb51b", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, ">= 0.2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0b0ba024e26bd07bf48fdda20e5dc3f100dba14c8259e99e48131cd088acf234"},
"ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []}, "ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []},
"ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []}, "ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []},
"ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"}, "ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"},
@ -47,13 +47,13 @@
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"}, "picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"},
"plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"},
"plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "18746f439afc31cf3ac4bbd26a87b236d7f4dd3ee735cebaee4116e2b0f1d08d", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "1969ea00868737ceeecc7c93b2b0f3136829c38948c5109391b9c1d402062228"}, "plug_cowboy": {:hex, :plug_cowboy, "2.6.0", "d1cf12ff96a1ca4f52207c5271a6c351a4733f413803488d75b70ccf44aebec2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "073cf20b753ce6682ed72905cd62a2d4bd9bad1bf9f7feb02a1b8e525bd94fa6"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"}, "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
"spark": {:hex, :spark, "0.2.1", "4f76234fce4bf48a6236e2268fba4d33c441ed8e30944785852c483a7aed231c", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "29033cb2ebecfff5ceff5209cca06c8e1e7ce8c1da189676de19cdc07d146b43"}, "spark": {:hex, :spark, "0.2.6", "84dbfe7153dc51f988a2b43f28031be87dee724d2ac535069d05807cfacde7c4", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "f0fba891abc70d4e7431b3ed6283ee46ccd6e8045e0bfdce13bc06e8904bbd25"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},

View file

@ -159,7 +159,7 @@ defmodule AshAuthentication.PasswordAuthentication.ActionTest do
defp resource_config(%{resource: resource}) do defp resource_config(%{resource: resource}) do
config = config =
resource resource
|> Info.options() |> Info.password_authentication_options()
{:ok, config: config} {:ok, config: config}
end end

View file

@ -42,7 +42,7 @@ defmodule AshAuthentication.IdentityTest do
defp resource_config(%{resource: resource}) do defp resource_config(%{resource: resource}) do
config = config =
resource resource
|> Info.options() |> Info.password_authentication_options()
{:ok, config: config} {:ok, config: config}
end end

View file

@ -0,0 +1,71 @@
defmodule AshAuthentication.PasswordResetTest do
@moduledoc false
use AshAuthentication.DataCase, async: true
alias AshAuthentication.PasswordReset
import ExUnit.CaptureLog
describe "enabled?/1" do
test "is false when the resource doesn't support password resets" do
refute PasswordReset.enabled?(Example.TokenRevocation)
end
test "it is true when the resource does support password resets" do
assert PasswordReset.enabled?(Example.UserWithUsername)
end
end
describe "reset_password_request/1" do
test "it generates a password reset token" do
{:ok, user} =
build_user()
|> PasswordReset.request_password_reset()
assert user.__metadata__.reset_token =~ ~r/[\w.]/i
end
test "it sends the reset instructions" do
assert capture_log(fn ->
{:ok, _} =
build_user()
|> PasswordReset.request_password_reset()
end) =~ ~r/Password reset request/i
end
end
describe "reset_password/2" do
test "when the reset token is valid, it can change the password" do
{:ok, user} =
build_user()
|> PasswordReset.request_password_reset()
password = password()
attrs = %{
"reset_token" => user.__metadata__.reset_token,
"password" => password,
"password_confirmation" => password
}
{:ok, new_user} = PasswordReset.reset_password(Example.UserWithUsername, attrs)
assert new_user.hashed_password != user.hashed_password
end
test "when the reset token is invalid, it doesn't change the password" do
user = build_user()
password = password()
attrs = %{
"reset_token" => Ecto.UUID.generate(),
"password" => password,
"password_confirmation" => password
}
assert {:error, _} = PasswordReset.reset_password(Example.UserWithUsername, attrs)
{:ok, reloaded_user} = Example.get(Example.UserWithUsername, id: user.id)
assert reloaded_user.hashed_password == user.hashed_password
end
end
end

View file

@ -5,10 +5,13 @@ defmodule Example.UserWithUsername do
extensions: [ extensions: [
AshAuthentication, AshAuthentication,
AshAuthentication.PasswordAuthentication, AshAuthentication.PasswordAuthentication,
AshAuthentication.PasswordReset,
AshGraphql.Resource, AshGraphql.Resource,
AshJsonApi.Resource AshJsonApi.Resource
] ]
require Logger
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: Ecto.UUID.t(), id: Ecto.UUID.t(),
username: String.t(), username: String.t(),
@ -86,6 +89,12 @@ defmodule Example.UserWithUsername do
hashed_password_field(:hashed_password) hashed_password_field(:hashed_password)
end end
password_reset do
sender(fn user, token ->
Logger.debug("Password reset request for user #{user.username}, token #{inspect(token)}")
end)
end
identities do identities do
identity(:username, [:username]) identity(:username, [:username])
end end