mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-08 05:40:58 +12:00
feat(PasswordReset): allow users to request and reset their password. (#22)
This commit is contained in:
parent
c4732c12a3
commit
0eca3274f0
32 changed files with 1126 additions and 327 deletions
|
@ -2,6 +2,7 @@
|
|||
ignore_modules: [
|
||||
~r/^Inspect\./,
|
||||
~r/.Plug$/,
|
||||
~r/^Example/,
|
||||
AshAuthentication.InfoGenerator,
|
||||
AshAuthentication.Plug.Macros
|
||||
],
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
elixir 1.14.0
|
||||
erlang 25.1
|
||||
elixir 1.14.1
|
||||
erlang 25.1.2
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -5,5 +5,6 @@ defmodule AshAuthentication.PasswordAuthentication.Info do
|
|||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.PasswordAuthentication,
|
||||
sections: [:password_authentication]
|
||||
sections: [:password_authentication],
|
||||
prefix?: true
|
||||
end
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]),
|
||||
|
|
166
lib/ash_authentication/password_reset.ex
Normal file
166
lib/ash_authentication/password_reset.ex
Normal 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
|
9
lib/ash_authentication/password_reset/info.ex
Normal file
9
lib/ash_authentication/password_reset/info.ex
Normal 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
|
21
lib/ash_authentication/password_reset/notifier.ex
Normal file
21
lib/ash_authentication/password_reset/notifier.ex
Normal 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
|
|
@ -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
|
|
@ -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
|
80
lib/ash_authentication/password_reset/sender.ex
Normal file
80
lib/ash_authentication/password_reset/sender.ex
Normal 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
|
28
lib/ash_authentication/password_reset/sender_function.ex
Normal file
28
lib/ash_authentication/password_reset/sender_function.ex
Normal 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
|
246
lib/ash_authentication/password_reset/transformer.ex
Normal file
246
lib/ash_authentication/password_reset/transformer.ex
Normal 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
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
2
mix.exs
2
mix.exs
|
@ -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"},
|
||||
|
|
6
mix.lock
6
mix.lock
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
71
test/ash_authentication/password_reset_test.exs
Normal file
71
test/ash_authentication/password_reset_test.exs
Normal 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
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue