mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 21:03:23 +12:00
feat(Confirmation): Add extension that allows a user to be confirmed when created or updated. (#27)
This commit is contained in:
parent
50c4e832e3
commit
1d4bb00617
27 changed files with 963 additions and 112 deletions
|
@ -4,4 +4,6 @@ config :mime, :types, %{
|
|||
"application/vnd.api+json" => ["json"]
|
||||
}
|
||||
|
||||
config :ash, :utc_datetime_type, :datetime
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
|
|
225
lib/ash_authentication/confirmation.ex
Normal file
225
lib/ash_authentication/confirmation.ex
Normal file
|
@ -0,0 +1,225 @@
|
|||
defmodule AshAuthentication.Confirmation do
|
||||
@default_lifetime_days 3
|
||||
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :confirmation,
|
||||
describe: "User confirmation behaviour",
|
||||
schema: [
|
||||
token_lifetime: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How long should the confirmation token be valid, in hours.
|
||||
|
||||
Defaults to #{@default_lifetime_days} days.
|
||||
""",
|
||||
default: @default_lifetime_days * 24
|
||||
],
|
||||
monitor_fields: [
|
||||
type: {:list, :atom},
|
||||
doc: """
|
||||
A list of fields to monitor for changes (eg `[:email, :phone_number]`).
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
confirmed_at_field: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of a field to store the time that the last confirmation took place.
|
||||
|
||||
This attribute will be dynamically added to the resource if not already present.
|
||||
""",
|
||||
default: :confirmed_at
|
||||
],
|
||||
confirm_on_create?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Generate and send a confirmation token when a new resource is created?
|
||||
""",
|
||||
default: true
|
||||
],
|
||||
confirm_on_update?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Generate and send a confirmation token when a resource is changed?
|
||||
""",
|
||||
default: true
|
||||
],
|
||||
inhibit_updates?: [
|
||||
type: :boolean,
|
||||
doc: """
|
||||
Wait until confirmation is received before actually changing a monitored field?
|
||||
|
||||
If a change to a monitored field is detected, then the change is stored in the confirmation token and the changeset updated to not make the requested change. When the token is confirmed, the change will be applied.
|
||||
""",
|
||||
default: false
|
||||
],
|
||||
sender: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.Sender,
|
||||
{AshAuthentication.SenderFunction, 2}},
|
||||
doc: """
|
||||
How to send the confirmation instructions to the user.
|
||||
|
||||
Allows you to glue sending of confirmation 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.Sender` for more information.
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
confirm_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action to use when performing confirmation.
|
||||
""",
|
||||
default: :confirm
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
Add a confirmation steps to creates and updates.
|
||||
|
||||
This extension provides a mechanism to force users to confirm some of their
|
||||
details upon create as in your typical "email confirmation" flow.
|
||||
|
||||
## Senders
|
||||
|
||||
You can set the DSL's `sender` key to be either a three-arity anonymous
|
||||
function or a module which implements the `AshAuthentication.Sender`
|
||||
behaviour. This callback can be used to send confirmation instructions to the
|
||||
user via the system of your choice. See `AshAuthentication.Sender` for more
|
||||
information.
|
||||
|
||||
## Usage
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.Users do
|
||||
use Ash.Resource, extensions: [AshAuthentication.Confirmation]
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
attribute :email, :ci_string, allow_nil?: false
|
||||
end
|
||||
|
||||
confirmation do
|
||||
monitor_fields [:email]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
A confirmation can be sent to either the `request` or `callback` endpoints.
|
||||
The only required parameter is `"confirm"` which should contain the
|
||||
confirmation token.
|
||||
|
||||
## DSL Documentation
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.Confirmation.Transformer]
|
||||
|
||||
use AshAuthentication.Provider
|
||||
|
||||
alias Ash.{Changeset, Resource}
|
||||
alias AshAuthentication.{Confirmation, Jwt}
|
||||
|
||||
@doc """
|
||||
Generate a confirmation token for the changes in the changeset.
|
||||
|
||||
## Example
|
||||
|
||||
iex> changeset = Ash.Changeset.for_create(MyApp.Accounts.User, :register, %{"email" => "marty@myfly.me", # ... })
|
||||
...> confirmation_token_for(changeset)
|
||||
{:ok, "abc123"}
|
||||
"""
|
||||
@spec confirmation_token_for(Changeset.t(), Resource.record()) ::
|
||||
{:ok, String.t()} | {:error, any}
|
||||
def confirmation_token_for(changeset, user) when changeset.resource == user.__struct__ do
|
||||
resource = changeset.resource
|
||||
|
||||
with true <- enabled?(resource),
|
||||
{:ok, monitored_fields} <- Confirmation.Info.monitor_fields(resource),
|
||||
changes <- get_changes(changeset, monitored_fields),
|
||||
{:ok, action} <- Confirmation.Info.confirm_action_name(resource),
|
||||
{:ok, lifetime} <- Confirmation.Info.token_lifetime(resource),
|
||||
{:ok, token, _claims} <-
|
||||
Jwt.token_for_record(user, %{"act" => action, "chg" => changes},
|
||||
token_lifetime: lifetime
|
||||
) do
|
||||
{:ok, token}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
_ -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_changes(changeset, monitored_fields) do
|
||||
monitored_fields
|
||||
|> Enum.filter(&Changeset.changing_attribute?(changeset, &1))
|
||||
|> Enum.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirm a creation or change.
|
||||
|
||||
## Example
|
||||
|
||||
iex> confirm(MyApp.Accounts.User, %{"confirm" => "abc123"})
|
||||
{:ok, user}
|
||||
"""
|
||||
@spec confirm(Resource.t(), params) :: {:ok, Resource.record()} | {:error, any}
|
||||
when params: %{required(String.t()) => String.t()}
|
||||
def confirm(resource, params) do
|
||||
with true <- enabled?(resource),
|
||||
{:ok, token} <- Map.fetch(params, "confirm"),
|
||||
{:ok, %{"sub" => subject}} <- Jwt.peek(token),
|
||||
config <- AshAuthentication.resource_config(resource),
|
||||
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config),
|
||||
{:ok, action} <- Confirmation.Info.confirm_action_name(resource),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
|
||||
user
|
||||
|> Changeset.for_update(action, %{"confirm" => token})
|
||||
|> api.update()
|
||||
else
|
||||
false -> {:error, "Confirmation not supported by resource `#{inspect(resource)}`"}
|
||||
{:ok, _} -> {:error, "Invalid confirmation token"}
|
||||
:error -> {:error, "Invalid confirmation token"}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles confirmation via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate callback_plug(conn, opts), to: Confirmation.Plug, as: :handle
|
||||
|
||||
@doc """
|
||||
Handle the request phase.
|
||||
|
||||
Handles confirmation via the same endpoint.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate request_plug(conn, opts), to: Confirmation.Plug, as: :handle
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def provides(_), do: "confirm"
|
||||
end
|
35
lib/ash_authentication/confirmation/confirm_change.ex
Normal file
35
lib/ash_authentication/confirmation/confirm_change.ex
Normal file
|
@ -0,0 +1,35 @@
|
|||
defmodule AshAuthentication.Confirmation.ConfirmChange do
|
||||
@moduledoc """
|
||||
Performs a change based on the contents of a confirmation token.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.{Confirmation.Info, Jwt}
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
|
||||
{:ok, %{"act" => token_action, "chg" => changes}, _} <-
|
||||
Jwt.verify(token, changeset.resource),
|
||||
{:ok, resource_action} <- Info.confirm_action_name(changeset.resource),
|
||||
true <- to_string(resource_action) == token_action,
|
||||
{:ok, allowed_fields} <- Info.monitor_fields(changeset.resource),
|
||||
{:ok, confirmed_at} <- Info.confirmed_at_field(changeset.resource) do
|
||||
allowed_changes =
|
||||
changes
|
||||
|> Map.take(Enum.map(allowed_fields, &to_string/1))
|
||||
|
||||
changeset
|
||||
|> Changeset.change_attributes(allowed_changes)
|
||||
|> Changeset.change_attribute(confirmed_at, DateTime.utc_now())
|
||||
else
|
||||
_ -> {:error, InvalidArgument.exception(field: :confirm, message: "is not valid")}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,88 @@
|
|||
defmodule AshAuthentication.Confirmation.ConfirmationHookChange do
|
||||
@moduledoc """
|
||||
Triggers a confirmation flow when one of the monitored fields is changed.
|
||||
|
||||
Optionally inhibits changes to monitored fields on update.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.{Confirmation, Confirmation.Info}
|
||||
alias Ash.{Changeset, Resource.Change}
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
options = Info.options(changeset.resource)
|
||||
|
||||
changeset
|
||||
|> not_confirm_action(options)
|
||||
|> should_confirm_action_type(options)
|
||||
|> monitored_field_changing(options)
|
||||
|> changes_would_be_valid()
|
||||
|> maybe_inhibit_updates(options)
|
||||
|> maybe_perform_confirmation(options, changeset)
|
||||
end)
|
||||
end
|
||||
|
||||
defp not_confirm_action(changeset, options)
|
||||
when changeset.action != options.confirm_action_name,
|
||||
do: changeset
|
||||
|
||||
defp not_confirm_action(_changeset, _options), do: nil
|
||||
|
||||
defp should_confirm_action_type(changeset, options)
|
||||
when changeset.action_type == :create and options.confirm_on_create?,
|
||||
do: changeset
|
||||
|
||||
defp should_confirm_action_type(changeset, options)
|
||||
when changeset.action_type == :update and options.confirm_on_update?,
|
||||
do: changeset
|
||||
|
||||
defp should_confirm_action_type(_changeset, _options), do: nil
|
||||
|
||||
defp monitored_field_changing(nil, _options), do: nil
|
||||
|
||||
defp monitored_field_changing(changeset, options) do
|
||||
if Enum.any?(options.monitor_fields, &Changeset.changing_attribute?(changeset, &1)),
|
||||
do: changeset,
|
||||
else: nil
|
||||
end
|
||||
|
||||
defp changes_would_be_valid(changeset) when changeset.valid?, do: changeset
|
||||
defp changes_would_be_valid(_), do: nil
|
||||
|
||||
defp maybe_inhibit_updates(changeset, options)
|
||||
when changeset.action_type == :update and options.inhibit_updates? do
|
||||
options.monitor_fields
|
||||
|> Enum.reduce(changeset, &Changeset.clear_change(&2, &1))
|
||||
end
|
||||
|
||||
defp maybe_inhibit_updates(changeset, _options), do: changeset
|
||||
|
||||
defp maybe_perform_confirmation(nil, _options, original_changeset), do: original_changeset
|
||||
|
||||
defp maybe_perform_confirmation(changeset, options, original_changeset) do
|
||||
changeset
|
||||
|> Changeset.after_action(fn _changeset, user ->
|
||||
original_changeset
|
||||
|> Confirmation.confirmation_token_for(user)
|
||||
|> case do
|
||||
{:ok, token} ->
|
||||
{sender, send_opts} = options.sender
|
||||
sender.send(user, token, send_opts)
|
||||
|
||||
metadata =
|
||||
user.__metadata__
|
||||
|> Map.put(:confirmation_token, token)
|
||||
|
||||
{:ok, %{user | __metadata__: metadata}}
|
||||
|
||||
_ ->
|
||||
{:ok, user}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
65
lib/ash_authentication/confirmation/html.ex
Normal file
65
lib/ash_authentication/confirmation/html.ex
Normal file
|
@ -0,0 +1,65 @@
|
|||
defmodule AshAuthentication.Confirmation.Html do
|
||||
@moduledoc """
|
||||
Renders a very basic form for handling a confirmation token.
|
||||
|
||||
These are mainly used for testing, and you should instead write your own or
|
||||
use the widgets in `ash_authentication_phoenix`.
|
||||
"""
|
||||
|
||||
require EEx
|
||||
alias AshAuthentication.Confirmation
|
||||
|
||||
EEx.function_from_string(
|
||||
:defp,
|
||||
:render,
|
||||
~s"""
|
||||
<form method="<%= @method %>" action="<%= @action %>">
|
||||
<fieldset>
|
||||
<%= if @legend do %><legend><%= @legend %></legend><% end %>
|
||||
<input type="text" name="confirm" placeholder="Confirmation token" />
|
||||
<br />
|
||||
<input type="submit" value="Confirm" />
|
||||
</fieldset>
|
||||
</form>
|
||||
""",
|
||||
[:assigns]
|
||||
)
|
||||
|
||||
@defaults [method: "POST", legend: "Confirm"]
|
||||
|
||||
@type options :: [method_option | action_option]
|
||||
|
||||
@typedoc """
|
||||
The HTTP method used to submit the form.
|
||||
|
||||
Defaults to `#{inspect(Keyword.get(@defaults, :method))}`.
|
||||
"""
|
||||
@type method_option :: {:method, String.t()}
|
||||
|
||||
@typedoc """
|
||||
The path/URL to which the form should be submitted.
|
||||
"""
|
||||
@type action_option :: {:action, String.t()}
|
||||
|
||||
@doc false
|
||||
@spec callback(module, options) :: String.t()
|
||||
def callback(_module, _options), do: ""
|
||||
|
||||
@doc """
|
||||
Render a basic HTML confirmation form.
|
||||
"""
|
||||
@spec request(module, options) :: String.t()
|
||||
def request(resource, options) do
|
||||
resource
|
||||
|> build_assigns(options)
|
||||
|> render()
|
||||
end
|
||||
|
||||
defp build_assigns(resource, options) do
|
||||
@defaults
|
||||
|> Keyword.merge(options)
|
||||
|> Map.new()
|
||||
|> Map.merge(Confirmation.Info.options(resource))
|
||||
|> Map.merge(AshAuthentication.Info.authentication_options(resource))
|
||||
end
|
||||
end
|
9
lib/ash_authentication/confirmation/info.ex
Normal file
9
lib/ash_authentication/confirmation/info.ex
Normal file
|
@ -0,0 +1,9 @@
|
|||
defmodule AshAuthentication.Confirmation.Info do
|
||||
@moduledoc """
|
||||
Generated configuration functions based on a resource's DSL configuration.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.Confirmation,
|
||||
sections: [:confirmation]
|
||||
end
|
23
lib/ash_authentication/confirmation/plug.ex
Normal file
23
lib/ash_authentication/confirmation/plug.ex
Normal file
|
@ -0,0 +1,23 @@
|
|||
defmodule AshAuthentication.Confirmation.Plug do
|
||||
@moduledoc """
|
||||
Handlers for incoming HTTP requests.
|
||||
"""
|
||||
|
||||
import AshAuthentication.Plug.Helpers, only: [private_store: 2]
|
||||
alias AshAuthentication.Confirmation
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
Handle an inbound confirmation request.
|
||||
"""
|
||||
@spec handle(Conn.t(), any) :: Conn.t()
|
||||
def handle(%{params: params, private: %{authenticator: config}} = conn, _opts) do
|
||||
case Confirmation.confirm(config.resource, params) do
|
||||
{:ok, user} ->
|
||||
private_store(conn, {:success, user})
|
||||
|
||||
{:error, reason} ->
|
||||
private_store(conn, {:failure, reason})
|
||||
end
|
||||
end
|
||||
end
|
223
lib/ash_authentication/confirmation/transformer.ex
Normal file
223
lib/ash_authentication/confirmation/transformer.ex
Normal file
|
@ -0,0 +1,223 @@
|
|||
defmodule AshAuthentication.Confirmation.Transformer do
|
||||
@moduledoc """
|
||||
The Confirmation transformer.
|
||||
|
||||
Scans the resource and checks that all the fields and actions needed are present.
|
||||
"""
|
||||
use Spark.Dsl.Transformer
|
||||
|
||||
alias AshAuthentication.Confirmation.{
|
||||
ConfirmationHookChange,
|
||||
ConfirmChange,
|
||||
Info
|
||||
}
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.PasswordAuthentication.GenerateTokenChange
|
||||
alias AshAuthentication.Sender
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@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_extension(dsl_state, AshAuthentication),
|
||||
:ok <- validate_token_generation_enabled(dsl_state),
|
||||
{:ok, {sender, _opts}} <- Info.sender(dsl_state),
|
||||
:ok <- validate_behaviour(sender, Sender),
|
||||
:ok <- validate_monitor_fields(dsl_state),
|
||||
{:ok, action_name} <- Info.confirm_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, action_name, &build_confirm_action(&1, action_name)),
|
||||
:ok <- validate_confirm_action(dsl_state, action_name),
|
||||
{:ok, confirmed_at} <- Info.confirmed_at_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(
|
||||
dsl_state,
|
||||
confirmed_at,
|
||||
&build_confirmed_at_attribute(&1, confirmed_at)
|
||||
),
|
||||
:ok <- validate_confirmed_at_attribute(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_change(dsl_state, ConfirmationHookChange) do
|
||||
authentication =
|
||||
Transformer.get_persisted(dsl_state, :authentication)
|
||||
|> Map.update(
|
||||
:providers,
|
||||
[AshAuthentication.Confirmation],
|
||||
&[AshAuthentication.Confirmation | &1]
|
||||
)
|
||||
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|> Transformer.persist(:authentication, authentication)
|
||||
|
||||
{: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?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec before?(module) :: boolean
|
||||
def before?(Resource.Transformers.DefaultAccept), do: true
|
||||
def before?(_), do: false
|
||||
|
||||
defp validate_confirmed_at_attribute(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, field_name} <- Info.confirmed_at_field(dsl_state),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, field_name),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.UtcDatetimeUsec]) do
|
||||
:ok
|
||||
else
|
||||
:error ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:confirmation],
|
||||
message: "The `confirmed_at_field` option must be set."
|
||||
)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitor_fields(dsl_state) do
|
||||
case Info.monitor_fields(dsl_state) do
|
||||
{:ok, [_ | _] = fields} ->
|
||||
Enum.reduce_while(fields, :ok, &validate_monitored_field_reducer(dsl_state, &1, &2))
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:confirmation],
|
||||
message:
|
||||
"The `AshAuthentication.Confirmation` extension requires at least one monitored field to be configured."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitored_field_reducer(dsl_state, field, _) do
|
||||
case validate_monitored_field(dsl_state, field) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_monitored_field(dsl_state, field) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, field),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]) do
|
||||
maybe_validate_eager_checking(dsl_state, field, resource)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_validate_eager_checking(dsl_state, field, resource) do
|
||||
if Info.inhibit_updates?(dsl_state) do
|
||||
dsl_state
|
||||
|> Resource.Info.identities()
|
||||
|> Enum.find(&(&1.keys == [field]))
|
||||
|> case do
|
||||
%{eager_check_with: nil} ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:identities, :identity],
|
||||
message:
|
||||
"The attribute `#{inspect(field)}` on the resource `#{inspect(resource)}` needs the `eager_check_with` property set so that inhibited changes are still validated."
|
||||
)}
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_confirm_action(dsl_state, action_name) do
|
||||
with {:ok, fields} <- Info.monitor_fields(dsl_state) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :argument,
|
||||
name: :confirm,
|
||||
type: Type.String,
|
||||
allow_nil?: false
|
||||
)
|
||||
]
|
||||
|
||||
changes = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: ConfirmChange
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :change,
|
||||
change: GenerateTokenChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :update,
|
||||
name: action_name,
|
||||
accept: fields,
|
||||
arguments: arguments,
|
||||
changes: changes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_build_attribute(dsl_state, attribute_name, builder) do
|
||||
with {:error, _} <- find_attribute(dsl_state, attribute_name),
|
||||
{:ok, attribute} <- builder.(dsl_state) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
|
||||
else
|
||||
{:ok, attribute} when is_struct(attribute, Resource.Attribute) -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_confirmed_at_attribute(_dsl_state, attribute_name) do
|
||||
Transformer.build_entity(Resource.Dsl, [:attributes], :attribute,
|
||||
name: attribute_name,
|
||||
type: Type.UtcDatetimeUsec,
|
||||
allow_nil?: true,
|
||||
writable?: true
|
||||
)
|
||||
end
|
||||
|
||||
defp maybe_build_change(dsl_state, change_module) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
changes <- Resource.Info.changes(resource),
|
||||
false <- change_module in changes,
|
||||
{:ok, change} <-
|
||||
Transformer.build_entity(Resource.Dsl, [:changes], :change, change: change_module) do
|
||||
{:ok, Transformer.add_entity(dsl_state, [:changes], change)}
|
||||
else
|
||||
true -> {:ok, dsl_state}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_confirm_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_action_has_change(action, ConfirmChange),
|
||||
:ok <- validate_action_argument_option(action, :confirm, :type, [Type.String]) do
|
||||
validate_action_argument_option(action, :confirm, :allow_nil?, [false])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -193,6 +193,8 @@ defmodule AshAuthentication.InfoGenerator do
|
|||
{:|, [], Enum.map(choices, &spec_for_type/1)}
|
||||
end
|
||||
|
||||
defp spec_for_type({:list, subtype}), do: [spec_for_type(subtype)]
|
||||
|
||||
defp spec_for_type(:string),
|
||||
do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
|
||||
|
||||
|
|
|
@ -50,7 +50,7 @@ defmodule AshAuthentication.Jwt do
|
|||
"claims" are the decoded contents of a JWT. A map of (short) string keys to
|
||||
string values.
|
||||
"""
|
||||
@type claims :: %{required(String.t()) => String.t()}
|
||||
@type claims :: %{required(String.t()) => String.t() | number | boolean | claims}
|
||||
|
||||
@doc "The default signing algorithm"
|
||||
@spec default_algorithm :: String.t()
|
||||
|
@ -90,6 +90,12 @@ defmodule AshAuthentication.Jwt do
|
|||
Joken.generate_and_sign(default_claims, extra_claims, signer)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Given a token, read it's claims without validating.
|
||||
"""
|
||||
@spec peek(token) :: {:ok, claims} | {:error, any}
|
||||
def peek(token), do: Joken.peek_claims(token)
|
||||
|
||||
@doc """
|
||||
Given a token, verify it's signature and validate it's claims.
|
||||
"""
|
||||
|
@ -138,7 +144,7 @@ defmodule AshAuthentication.Jwt do
|
|||
"""
|
||||
@spec token_to_resource(token, module) :: {:ok, AshAuthentication.resource_config()} | :error
|
||||
def token_to_resource(token, otp_app) do
|
||||
with {:ok, %{"sub" => subject}} <- Joken.peek_claims(token),
|
||||
with {:ok, %{"sub" => subject}} <- peek(token),
|
||||
%URI{path: subject_name} <- URI.parse(subject) do
|
||||
config_for_subject_name(subject_name, otp_app)
|
||||
else
|
||||
|
|
|
@ -59,6 +59,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
|
|||
alias Spark.Dsl.Transformer
|
||||
import AshAuthentication.PasswordAuthentication.UserValidations
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
|
@ -69,7 +70,7 @@ defmodule AshAuthentication.PasswordAuthentication.Transformer do
|
|||
| {:warn, map(), String.t() | [String.t()]}
|
||||
| :halt
|
||||
def transform(dsl_state) do
|
||||
with :ok <- validate_authentication_extension(dsl_state),
|
||||
with :ok <- validate_extension(dsl_state, AshAuthentication),
|
||||
{:ok, dsl_state} <- validate_identity_field(dsl_state),
|
||||
{:ok, dsl_state} <- validate_hashed_password_field(dsl_state),
|
||||
{:ok, register_action_name} <-
|
||||
|
|
|
@ -21,25 +21,6 @@ defmodule AshAuthentication.PasswordAuthentication.UserValidations do
|
|||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@doc """
|
||||
Validates at the `AshAuthentication` extension is also present on the
|
||||
resource.
|
||||
"""
|
||||
@spec validate_authentication_extension(Dsl.t()) :: :ok | {:error, Exception.t()}
|
||||
def 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 for password authentication to work."
|
||||
)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validate that the configured hash provider implements the `HashProvider`
|
||||
behaviour.
|
||||
|
|
|
@ -31,8 +31,8 @@ defmodule AshAuthentication.PasswordReset do
|
|||
],
|
||||
sender: [
|
||||
type:
|
||||
{:spark_function_behaviour, AshAuthentication.PasswordReset.Sender,
|
||||
{AshAuthentication.PasswordReset.SenderFunction, 2}},
|
||||
{:spark_function_behaviour, AshAuthentication.Sender,
|
||||
{AshAuthentication.SenderFunction, 2}},
|
||||
doc: """
|
||||
How to send the password reset instructions to the user.
|
||||
|
||||
|
@ -40,7 +40,7 @@ defmodule AshAuthentication.PasswordReset do
|
|||
|
||||
Accepts a module, module and opts, or a function that takes a record, reset token and options.
|
||||
|
||||
See `AshAuthentication.PasswordReset.Sender` for more information.
|
||||
See `AshAuthentication.Sender` for more information.
|
||||
""",
|
||||
required: true
|
||||
]
|
||||
|
@ -59,10 +59,11 @@ defmodule AshAuthentication.PasswordReset do
|
|||
|
||||
## 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`
|
||||
You can set the DSL's `sender` key to be either a three-arity anonymous
|
||||
function or a module which implements the `AshAuthentication.Sender`
|
||||
behaviour. This callback can be used to send password reset instructions to
|
||||
the user via the system of your choice.
|
||||
the user via the system of your choice. See `AshAuthentication.Sender` for
|
||||
more information.
|
||||
|
||||
## Usage
|
||||
|
||||
|
@ -130,13 +131,15 @@ defmodule AshAuthentication.PasswordReset do
|
|||
iex> request_password_reset(MyApp.Accounts.User, %{"email" => "marty@mcfly.me"})
|
||||
:ok
|
||||
"""
|
||||
@spec request_password_reset(Resource.t(), params) :: :ok | {:error, any}
|
||||
when params: %{required(String.t()) => String.t()}
|
||||
def request_password_reset(resource, params) do
|
||||
with true <- enabled?(resource),
|
||||
{:ok, action} <- PasswordReset.Info.request_password_reset_action_name(resource),
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(resource) do
|
||||
resource
|
||||
|> Query.for_read(action, params)
|
||||
|> api.read()
|
||||
{:ok, api} <- AshAuthentication.Info.authentication_api(resource),
|
||||
query <- Query.for_read(resource, action, params),
|
||||
{:ok, _} <- api.read(query) do
|
||||
:ok
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
_ -> {:error, "Password resets not supported by resource `#{inspect(resource)}`"}
|
||||
|
@ -191,6 +194,19 @@ defmodule AshAuthentication.PasswordReset do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handle the request phase.
|
||||
|
||||
Handles a HTTP request for a password reset.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate request_plug(conn, any), to: PasswordReset.Plug, as: :request
|
||||
|
||||
@doc """
|
||||
Handle the callback phase.
|
||||
|
||||
Handles a HTTP password change request.
|
||||
"""
|
||||
@impl true
|
||||
defdelegate callback_plug(conn, any), to: PasswordReset.Plug, as: :callback
|
||||
end
|
||||
|
|
|
@ -17,7 +17,7 @@ defmodule AshAuthentication.PasswordReset.Plug do
|
|||
|> Map.get(to_string(config.subject_name), %{})
|
||||
|
||||
case PasswordReset.request_password_reset(config.resource, params) do
|
||||
{:ok, _} ->
|
||||
:ok ->
|
||||
private_store(conn, {:success, nil})
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
|
@ -11,16 +11,16 @@ defmodule AshAuthentication.PasswordReset.Transformer do
|
|||
alias AshAuthentication.PasswordReset.{
|
||||
Info,
|
||||
RequestPasswordResetPreparation,
|
||||
ResetTokenValidation,
|
||||
Sender
|
||||
ResetTokenValidation
|
||||
}
|
||||
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.PasswordAuthentication, as: PA
|
||||
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
alias AshAuthentication.Sender
|
||||
alias Spark.Dsl.Transformer
|
||||
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
|
||||
@doc false
|
||||
|
@ -32,10 +32,11 @@ defmodule AshAuthentication.PasswordReset.Transformer do
|
|||
| {: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),
|
||||
with :ok <- validate_extension(dsl_state, AshAuthentication),
|
||||
:ok <- validate_extension(dsl_state, PA),
|
||||
:ok <- validate_token_generation_enabled(dsl_state),
|
||||
:ok <- validate_sender(dsl_state),
|
||||
{:ok, {sender, _opts}} <- Info.sender(dsl_state),
|
||||
:ok <- validate_behaviour(sender, Sender),
|
||||
{:ok, request_action_name} <- Info.request_password_reset_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
|
@ -84,60 +85,6 @@ defmodule AshAuthentication.PasswordReset.Transformer do
|
|||
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
|
||||
with {:ok, identity_field} <- PA.Info.password_authentication_identity_field(dsl_state) do
|
||||
identity_attribute = Resource.Info.attribute(dsl_state, identity_field)
|
||||
|
|
|
@ -172,7 +172,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
"""
|
||||
@spec private_store(
|
||||
Conn.t(),
|
||||
{:success, nil | Resource.record()} | {:failure, nil | Changeset.t() | Error.t()}
|
||||
{:success, nil | Resource.record()}
|
||||
| {:failure, nil | String.t() | Changeset.t() | Error.t()}
|
||||
) ::
|
||||
Conn.t()
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
defmodule AshAuthentication.PasswordReset.Sender do
|
||||
defmodule AshAuthentication.Sender do
|
||||
@moduledoc ~S"""
|
||||
A module to implement sending of the password reset token to a user.
|
||||
A module to implement sending of a token to a user.
|
||||
|
||||
Allows you to glue sending of reset instructions to
|
||||
Allows you to glue sending of 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.
|
||||
|
@ -74,7 +74,7 @@ defmodule AshAuthentication.PasswordReset.Sender do
|
|||
@spec __using__(any) :: Macro.t()
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
@behaviour AshAuthentication.PasswordReset.Sender
|
||||
@behaviour AshAuthentication.Sender
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,10 +1,10 @@
|
|||
defmodule AshAuthentication.PasswordReset.SenderFunction do
|
||||
defmodule AshAuthentication.SenderFunction do
|
||||
@moduledoc """
|
||||
Implements `AshAuthentication.PasswordReset.Sender` for functions that are
|
||||
provided to the DSL instead of modules.
|
||||
Implements `AshAuthentication.Sender` for functions that are provided to the
|
||||
DSL instead of modules.
|
||||
"""
|
||||
|
||||
use AshAuthentication.PasswordReset.Sender
|
||||
use AshAuthentication.Sender
|
||||
alias Ash.Resource
|
||||
|
||||
@doc false
|
|
@ -5,6 +5,7 @@ defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
|
|||
|
||||
use Ash.Resource.Change
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change}
|
||||
alias AshAuthentication.Jwt
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
|
@ -14,7 +15,7 @@ defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
|
|||
|> Changeset.before_action(fn changeset ->
|
||||
changeset
|
||||
|> Changeset.get_argument(:token)
|
||||
|> Joken.peek_claims()
|
||||
|> Jwt.peek()
|
||||
|> case do
|
||||
{:ok, %{"jti" => jti, "exp" => exp}} ->
|
||||
expires_at =
|
||||
|
@ -24,6 +25,9 @@ defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
|
|||
changeset
|
||||
|> Changeset.change_attributes(jti: jti, expires_at: expires_at)
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, "Invalid token"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, InvalidArgument.exception(field: :token, message: to_string(reason))}
|
||||
end
|
||||
|
|
|
@ -3,7 +3,7 @@ defmodule AshAuthentication.Validations do
|
|||
Common validations shared by several transformers.
|
||||
"""
|
||||
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.{Sender, Utils}
|
||||
alias Ash.Resource.Attribute
|
||||
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
|
||||
|
||||
|
@ -84,4 +84,53 @@ defmodule AshAuthentication.Validations do
|
|||
value -> {:ok, value}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ensure that token generation is enabled for the resource.
|
||||
"""
|
||||
@spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()}
|
||||
def 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
|
||||
|
||||
@doc """
|
||||
Ensure that the named module implements a specific behaviour.
|
||||
"""
|
||||
@spec validate_behaviour(module, module) :: :ok | {:error, Exception.t()}
|
||||
def validate_behaviour(module, behaviour) do
|
||||
if Spark.implements_behaviour?(module, behaviour) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:password_reset],
|
||||
message: "`#{inspect(module)}` must implement the `#{inspect(behaviour)}` behaviour."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates that `extension` is present on the resource.
|
||||
"""
|
||||
@spec validate_extension(Dsl.t(), module) :: :ok | {:error, Exception.t()}
|
||||
def validate_extension(dsl_state, extension) do
|
||||
extensions = Transformer.get_persisted(dsl_state, :extensions, [])
|
||||
|
||||
if extension in extensions,
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:extensions],
|
||||
message:
|
||||
"The `#{inspect(extension)}` extension must also be present on this resource for password authentication to work."
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Example.Repo.Migrations.AddConfirmedAtToUserWuthUsername do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:user_with_username) do
|
||||
add :confirmed_at, :utc_datetime_usec
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:user_with_username) do
|
||||
remove :confirmed_at
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "confirmed_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"uuid_generate_v4()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "username",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "hashed_password",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "created_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "12E7392CC92EAE2AB421A89BA0C03F0A92D72FD6732BB8CA6914408B1C77F471",
|
||||
"identities": [
|
||||
{
|
||||
"base_filter": null,
|
||||
"index_name": "user_with_username_username_index",
|
||||
"keys": [
|
||||
"username"
|
||||
],
|
||||
"name": "username"
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user_with_username"
|
||||
}
|
47
test/ash_authentication/confirmation_test.exs
Normal file
47
test/ash_authentication/confirmation_test.exs
Normal file
|
@ -0,0 +1,47 @@
|
|||
defmodule AshAuthentication.ConfirmationTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias Ash.Changeset
|
||||
alias AshAuthentication.Confirmation
|
||||
|
||||
describe "confirmation_token_for/2" do
|
||||
test "it returns an error when passed a resource which doesn't support confirmation" do
|
||||
token_revocation = build_token_revocation()
|
||||
changeset = Changeset.for_update(token_revocation, :update, %{jti: Ecto.UUID.generate()})
|
||||
|
||||
assert {:error, reason} = Confirmation.confirmation_token_for(changeset, token_revocation)
|
||||
assert reason =~ ~r/confirmation not supported/i
|
||||
end
|
||||
|
||||
test "it returns a confirmation token" do
|
||||
user = build_user()
|
||||
changeset = Changeset.for_update(user, :update, %{username: username()})
|
||||
|
||||
assert {:ok, token} = Confirmation.confirmation_token_for(changeset, user)
|
||||
assert token =~ ~r/^[\w\._-]+$/
|
||||
end
|
||||
end
|
||||
|
||||
describe "confirm/2" do
|
||||
test "creates can be confirmed" do
|
||||
user = build_user()
|
||||
|
||||
refute user.confirmed_at
|
||||
|
||||
token = user.__metadata__.confirmation_token
|
||||
|
||||
assert token =~ ~r/^[\w\._-]+$/
|
||||
|
||||
assert {:ok, updated_user} =
|
||||
Confirmation.confirm(Example.UserWithUsername, %{"confirm" => token})
|
||||
|
||||
assert updated_user.id == user.id
|
||||
|
||||
assert_in_delta(
|
||||
DateTime.to_unix(updated_user.confirmed_at),
|
||||
DateTime.to_unix(DateTime.utc_now()),
|
||||
1.0
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -15,17 +15,17 @@ defmodule AshAuthentication.PasswordResetTest do
|
|||
end
|
||||
|
||||
describe "reset_password_request/1" do
|
||||
test "when the user is found, it returns an empty list" do
|
||||
test "when the user is found, it returns ok" do
|
||||
user = build_user()
|
||||
|
||||
assert {:ok, []} =
|
||||
assert :ok =
|
||||
PasswordReset.request_password_reset(Example.UserWithUsername, %{
|
||||
"username" => user.username
|
||||
})
|
||||
end
|
||||
|
||||
test "when the user is not found, it returns an empty list" do
|
||||
assert {:ok, []} =
|
||||
test "when the user is not found, it returns ok" do
|
||||
assert :ok =
|
||||
PasswordReset.request_password_reset(Example.UserWithUsername, %{
|
||||
"username" => username()
|
||||
})
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
defmodule AshAuthentication.Plug.HelpersTest do
|
||||
@moduledoc false
|
||||
use AshAuthentication.DataCase, async: true
|
||||
alias AshAuthentication.{Plug.Helpers, SessionPipeline}
|
||||
alias AshAuthentication.{Jwt, Plug.Helpers, SessionPipeline}
|
||||
import Plug.Test, only: [conn: 3]
|
||||
alias Plug.Conn
|
||||
|
||||
|
@ -71,7 +71,7 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
|
||||
{:ok, %{"jti" => jti}} =
|
||||
user.__metadata__.token
|
||||
|> Joken.peek_claims()
|
||||
|> Jwt.peek()
|
||||
|
||||
conn
|
||||
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||
|
|
|
@ -11,6 +11,10 @@ defmodule Example.TokenRevocation do
|
|||
|
||||
actions do
|
||||
destroy :expire
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
end
|
||||
end
|
||||
|
||||
postgres do
|
||||
|
|
|
@ -4,6 +4,7 @@ defmodule Example.UserWithUsername do
|
|||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [
|
||||
AshAuthentication,
|
||||
AshAuthentication.Confirmation,
|
||||
AshAuthentication.PasswordAuthentication,
|
||||
AshAuthentication.PasswordReset,
|
||||
AshGraphql.Resource,
|
||||
|
@ -43,12 +44,25 @@ defmodule Example.UserWithUsername do
|
|||
get? true
|
||||
manual Example.CurrentUserRead
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
end
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define_for(Example)
|
||||
end
|
||||
|
||||
confirmation do
|
||||
monitor_fields([:username])
|
||||
inhibit_updates?(true)
|
||||
|
||||
sender(fn user, token ->
|
||||
Logger.debug("Confirmation request for user #{user.username}, token #{inspect(token)}")
|
||||
end)
|
||||
end
|
||||
|
||||
graphql do
|
||||
type :user
|
||||
|
||||
|
@ -96,7 +110,7 @@ defmodule Example.UserWithUsername do
|
|||
end
|
||||
|
||||
identities do
|
||||
identity(:username, [:username])
|
||||
identity(:username, [:username], eager_check_with: Example)
|
||||
end
|
||||
|
||||
tokens do
|
||||
|
|
Loading…
Reference in a new issue