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: [
~r/^Inspect\./,
~r/.Plug$/,
~r/^Example/,
AshAuthentication.InfoGenerator,
AshAuthentication.Plug.Macros
],

View file

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

View file

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

View file

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

View file

@ -68,14 +68,17 @@ defmodule AshAuthentication.Jwt do
Given a record, generate a signed JWT for use while authenticating.
"""
@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__
default_claims = Config.default_claims(resource)
signer = Config.token_signer(resource)
default_claims = Config.default_claims(resource, opts)
signer = Config.token_signer(resource, opts)
subject = AshAuthentication.resource_to_subject(record)
extra_claims = %{"sub" => subject}
extra_claims =
extra_claims
|> Map.put("sub", subject)
extra_claims =
case Map.fetch(record.__metadata__, :tenant) do
@ -89,9 +92,30 @@ defmodule AshAuthentication.Jwt do
@doc """
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
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),
signer <- Config.token_signer(config.resource),
{:ok, claims} <- Joken.verify(token, signer),

View file

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

View file

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

View file

@ -20,7 +20,9 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
"""
@spec sign_in(module, map) :: {:ok, struct} | {:error, term}
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)
resource
@ -43,7 +45,9 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
"""
@spec register(module, map) :: {:ok, struct} | {:error, term}
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)
resource

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
an "action" parameter along with the form data.
"""
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
alias AshAuthentication.PasswordAuthentication
alias AshAuthentication.{PasswordAuthentication, PasswordReset}
alias Plug.Conn
@doc """
@ -52,4 +52,9 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
defp do_action(%{"action" => "register"} = attrs, resource),
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

View file

@ -21,9 +21,9 @@ defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _) do
{:ok, identity_field} = Info.identity_field(query.resource)
{:ok, password_field} = Info.password_field(query.resource)
{:ok, hasher} = Info.hash_provider(query.resource)
{:ok, identity_field} = Info.password_authentication_identity_field(query.resource)
{:ok, password_field} = Info.password_authentication_password_field(query.resource)
{:ok, hasher} = Info.password_authentication_hash_provider(query.resource)
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),
{:ok, dsl_state} <- validate_identity_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} <- 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 <- validate_hash_provider(dsl_state) do
authentication =
@ -105,11 +119,13 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
def before?(Resource.Transformers.DefaultAccept), do: true
def before?(_), do: false
defp build_register_action(dsl_state) do
with {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state),
{:ok, password_field} <- Info.password_field(dsl_state),
{:ok, confirm_field} <- Info.password_confirmation_field(dsl_state),
confirmation_required? <- Info.confirmation_required?(dsl_state) do
defp build_register_action(dsl_state, action_name) do
with {:ok, hashed_password_field} <-
Info.password_authentication_hashed_password_field(dsl_state),
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{:ok, confirm_field} <-
Info.password_authentication_password_confirmation_field(dsl_state),
confirmation_required? <- Info.password_authentication_confirmation_required?(dsl_state) do
password_opts = [
type: Type.String,
allow_nil?: false,
@ -154,7 +170,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
])
Transformer.build_entity(Resource.Dsl, [:actions], :create,
name: :register,
name: action_name,
arguments: arguments,
changes: changes,
allow_nil_input: [hashed_password_field]
@ -162,9 +178,9 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
end
end
defp build_sign_in_action(dsl_state) do
with {:ok, identity_field} <- Info.identity_field(dsl_state),
{:ok, password_field} <- Info.password_field(dsl_state) do
defp build_sign_in_action(dsl_state, action_name) do
with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state) do
identity_attribute = Resource.Info.attribute(dsl_state, identity_field)
arguments = [
@ -188,7 +204,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: :sign_in,
name: action_name,
arguments: arguments,
preparations: preparations,
get?: true

View file

@ -46,7 +46,7 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
"""
@spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()}
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} ->
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()}
def validate_sign_in_action(dsl_state) do
with {:ok, identity_field} <- Info.identity_field(dsl_state),
{:ok, password_field} <- Info.password_field(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, :sign_in),
with {:ok, identity_field} <- Info.password_authentication_identity_field(dsl_state),
{:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{: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_password_argument(action, password_field),
: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()}
def validate_register_action(dsl_state) do
with {:ok, password_field} <- Info.password_field(dsl_state),
{:ok, password_confirmation_field} <- Info.password_confirmation_field(dsl_state),
{:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state),
confirmation_required? <- Info.confirmation_required?(dsl_state),
{:ok, action} <- validate_action_exists(dsl_state, :register),
with {:ok, password_field} <- Info.password_authentication_password_field(dsl_state),
{:ok, password_confirmation_field} <-
Info.password_authentication_password_confirmation_field(dsl_state),
{:ok, hashed_password_field} <-
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_password_argument(action, password_field),
:ok <-
@ -169,7 +173,7 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
@spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
def validate_identity_field(dsl_state) do
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 <- validate_attribute_option(attribute, resource, :writable?, [true]),
: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), 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 """
Used within transformers to optionally build actions as needed.
"""

View file

@ -111,6 +111,30 @@ defmodule AshAuthentication.Validations.Action do
)}
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 """
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.
defp deps do
[
{:ash, "~> 2.3"},
{:ash, "~> 2.4"},
{:bcrypt_elixir, "~> 3.0", optional: true},
{:jason, "~> 1.4"},
{: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_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_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"},
@ -47,13 +47,13 @@
"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"},
"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"},
"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"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"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"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"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
config =
resource
|> Info.options()
|> Info.password_authentication_options()
{:ok, config: config}
end

View file

@ -42,7 +42,7 @@ defmodule AshAuthentication.IdentityTest do
defp resource_config(%{resource: resource}) do
config =
resource
|> Info.options()
|> Info.password_authentication_options()
{:ok, config: config}
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: [
AshAuthentication,
AshAuthentication.PasswordAuthentication,
AshAuthentication.PasswordReset,
AshGraphql.Resource,
AshJsonApi.Resource
]
require Logger
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
username: String.t(),
@ -86,6 +89,12 @@ defmodule Example.UserWithUsername do
hashed_password_field(:hashed_password)
end
password_reset do
sender(fn user, token ->
Logger.debug("Password reset request for user #{user.username}, token #{inspect(token)}")
end)
end
identities do
identity(:username, [:username])
end