improvement(Confirmation): Confirmation is not a strategy. (#46)

* improvement(Confirmation): Confirmation is not a strategy.

* improvement(Confirmation): Support more than one confirmation entity.

* chore: move FIXME doc to issue.
This commit is contained in:
James Harton 2022-11-24 16:40:15 +13:00 committed by GitHub
parent eec87a0bea
commit e88a516b22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 330 additions and 228 deletions

View file

@ -85,7 +85,7 @@ defmodule AshAuthentication do
AshAuthentication.Transformer,
AshAuthentication.Strategy.Password.Transformer,
AshAuthentication.Strategy.OAuth2.Transformer,
AshAuthentication.Strategy.Confirmation.Transformer
AshAuthentication.AddOn.Confirmation.Transformer
]
require Ash.Query

View file

@ -0,0 +1,139 @@
defmodule AshAuthentication.AddOn.Confirmation do
import AshAuthentication.Dsl
@moduledoc """
Confirmation support.
Sometimes when creating a new user, or changing a sensitive attribute (such as
their email address) you may want to wait for the user to confirm by way of
sending them a confirmation token to prove that it was really them that took
the action.
In order to add confirmation to your resource, it must been the following
minimum requirements:
1. Have a primary key
2. Have at least one attribute you wish to confirm
3. Tokens must be enabled
## Example
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication]
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
authentication do
api MyApp.Accounts
add_ons do
confirmation :confirm do
monitor_fields [:email]
end
end
strategies do
# ...
end
end
end
```
## Attributes
A `confirmed_at` attribute will be added to your resource if it's not already
present (see `confirmed_at_field` in the DSL documentation).
## Actions
By default confirmation will add an action which updates the `confirmed_at`
attribute as well as retrieving previously stored changes and applying them to
the resource.
If you wish to perform the confirm action directly from your code you can do
so via the `AshAuthentication.Strategy` protocol.
### Example
iex> strategy = Info.strategy!(Example.User, :confirm)
...> {:ok, user} = Strategy.action(strategy, :confirm, %{"confirm" => confirmation_token()})
...> user.confirmed_at >= one_second_ago()
true
## Plugs
Confirmation provides a single endpoint for the `:confirm` phase. If you wish
to interact with the plugs directly, you can do so via the
`AshAuthentication.Strategy` protocol.
### Example
iex> strategy = Info.strategy!(Example.User, :confirm)
...> conn = conn(:get, "/user/confirm", %{"confirm" => confirmation_token()})
...> conn = Strategy.plug(strategy, :confirm, conn)
...> {_conn, {:ok, user}} = Plug.Helpers.get_authentication_result(conn)
...> user.confirmed_at >= one_second_ago()
true
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(strategy(:confirmation))}
"""
defstruct token_lifetime: nil,
monitor_fields: [],
confirmed_at_field: :confirmed_at,
confirm_on_create?: true,
confirm_on_update?: true,
inhibit_updates?: false,
sender: nil,
confirm_action_name: :confirm,
resource: nil,
provider: :confirmation,
name: :confirm
alias Ash.Changeset
alias AshAuthentication.{AddOn.Confirmation, Jwt}
@type t :: %Confirmation{
token_lifetime: hours :: pos_integer,
monitor_fields: [atom],
confirmed_at_field: atom,
confirm_on_create?: boolean,
confirm_on_update?: boolean,
inhibit_updates?: boolean,
sender: nil | {module, keyword},
confirm_action_name: atom,
resource: module,
provider: :confirmation,
name: :confirm
}
@doc """
Generate a confirmation token for a changeset.
This will generate a token with the `"act"` claim set to the confirmation
action for the strategy, and the `"chg"` claim will contain any changes.
"""
@spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error
def confirmation_token(strategy, changeset) do
changes =
strategy.monitor_fields
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|> Map.new()
claims = %{"act" => strategy.confirm_action_name, "chg" => changes}
token_lifetime = strategy.token_lifetime * 3600
case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do
{:ok, token, _claims} -> {:ok, token}
:error -> :error
end
end
end

View file

@ -1,12 +1,12 @@
defmodule AshAuthentication.Strategy.Confirmation.Actions do
defmodule AshAuthentication.AddOn.Confirmation.Actions do
@moduledoc """
Actions for the confirmation strategy.
Actions for the confirmation add-on.
Provides the code interface for working with resources via confirmation.
"""
alias Ash.{Changeset, Resource}
alias AshAuthentication.{Errors.InvalidToken, Info, Jwt, Strategy.Confirmation}
alias AshAuthentication.{AddOn.Confirmation, Errors.InvalidToken, Info, Jwt}
@doc """
Attempt to confirm a user.

View file

@ -1,4 +1,4 @@
defmodule AshAuthentication.Strategy.Confirmation.ConfirmChange do
defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
@moduledoc """
Performs a change based on the contents of a confirmation token.
"""

View file

@ -1,4 +1,4 @@
defmodule AshAuthentication.Strategy.Confirmation.ConfirmationHookChange do
defmodule AshAuthentication.AddOn.Confirmation.ConfirmationHookChange do
@moduledoc """
Triggers a confirmation flow when one of the monitored fields is changed.
@ -7,7 +7,7 @@ defmodule AshAuthentication.Strategy.Confirmation.ConfirmationHookChange do
use Ash.Resource.Change
alias Ash.{Changeset, Resource.Change}
alias AshAuthentication.{Info, Strategy.Confirmation}
alias AshAuthentication.{AddOn.Confirmation, Info}
@doc false
@impl true

View file

@ -1,9 +1,9 @@
defmodule AshAuthentication.Strategy.Confirmation.Plug do
defmodule AshAuthentication.AddOn.Confirmation.Plug do
@moduledoc """
Handlers for incoming OAuth2 HTTP requests.
"""
alias AshAuthentication.{Strategy, Strategy.Confirmation}
alias AshAuthentication.{AddOn.Confirmation, Strategy}
alias Plug.Conn
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]

View file

@ -1,11 +1,11 @@
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Confirmation do
defimpl AshAuthentication.Strategy, for: AshAuthentication.AddOn.Confirmation do
@moduledoc """
Implementation of `AshAuthentication.Strategy` for
`AshAuthentication.Strategy.Confirmation`.
`AshAuthentication.AddOn.Confirmation`.
"""
alias Ash.Resource
alias AshAuthentication.{Info, Strategy, Strategy.Confirmation}
alias AshAuthentication.{Info, Strategy, AddOn.Confirmation}
alias Plug.Conn
@typedoc "The request phases supposed by this strategy"

View file

@ -1,6 +1,6 @@
defmodule AshAuthentication.Strategy.Confirmation.Transformer do
defmodule AshAuthentication.AddOn.Confirmation.Transformer do
@moduledoc """
DSL transformer for confirmation strategy.
DSL transformer for confirmation add-on.
Ensures that there is only ever one present and that it is correctly
configured.
@ -8,7 +8,7 @@ defmodule AshAuthentication.Strategy.Confirmation.Transformer do
use Spark.Dsl.Transformer
alias Ash.{Resource, Type}
alias AshAuthentication.{GenerateTokenChange, Info, Sender, Strategy.Confirmation}
alias AshAuthentication.{AddOn.Confirmation, GenerateTokenChange, Info, Sender}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Utils
import AshAuthentication.Validations
@ -37,22 +37,14 @@ defmodule AshAuthentication.Strategy.Confirmation.Transformer do
| :halt
def transform(dsl_state) do
dsl_state
|> Info.authentication_strategies()
|> Info.authentication_add_ons()
|> Enum.filter(&is_struct(&1, Confirmation))
|> case do
[] ->
{:ok, dsl_state}
[strategy] ->
transform_strategy(strategy, dsl_state)
[_ | _] ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :confirmation],
message: "Multiple confirmation strategies are not supported"
)}
end
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
case transform_strategy(strategy, dsl_state) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp transform_strategy(strategy, dsl_state) do
@ -79,7 +71,7 @@ defmodule AshAuthentication.Strategy.Confirmation.Transformer do
dsl_state =
dsl_state
|> Transformer.replace_entity(
[:authentication, :strategies],
[:authentication, :add_ons],
%{strategy | resource: resource},
&(&1.name == strategy.name)
)
@ -88,7 +80,7 @@ defmodule AshAuthentication.Strategy.Confirmation.Transformer do
else
{:error, reason} when is_binary(reason) ->
{:error,
DslError.exception(path: [:authentication, :strategies, :confirmation], message: reason)}
DslError.exception(path: [:authentication, :add_ons, :confirmation], message: reason)}
{:error, reason} ->
{:error, reason}
@ -96,7 +88,7 @@ defmodule AshAuthentication.Strategy.Confirmation.Transformer do
:error ->
{:error,
DslError.exception(
path: [:authentication, :strategies, :confirmation],
path: [:authentication, :add_ons, :confirmation],
message: "Configuration error"
)}
end
@ -106,7 +98,7 @@ defmodule AshAuthentication.Strategy.Confirmation.Transformer do
do:
{:error,
DslError.exception(
path: [:authentication, :strategies, :confirmation],
path: [:authentication, :add_ons, :confirmation],
message: "You should be monitoring at least one field"
)}

View file

@ -11,7 +11,7 @@ defmodule AshAuthentication.Dsl do
alias Ash.{Api, Resource}
alias AshAuthentication.{
Strategy.Confirmation,
AddOn.Confirmation,
Strategy.OAuth2,
Strategy.Password
}
@ -32,7 +32,18 @@ defmodule AshAuthentication.Dsl do
]
]
@default_lifetime_days 14
@shared_addon_options [
name: [
type: :atom,
doc: """
Uniquely identifies the add-on.
""",
required: true
]
]
@default_token_lifetime_days 14
@default_confirmation_lifetime_days 3
@secret_type {:or,
[
@ -122,9 +133,9 @@ defmodule AshAuthentication.Dsl do
probably set this to a reasonably long time to ensure
a good user experience.
Defaults to #{@default_lifetime_days} days.
Defaults to #{@default_token_lifetime_days} days.
""",
default: @default_lifetime_days * 24
default: @default_token_lifetime_days * 24
],
revocation_resource: [
type: {:behaviour, Resource},
@ -144,7 +155,13 @@ defmodule AshAuthentication.Dsl do
describe: "Configure authentication strategies on this resource",
entities: [
strategy(:password),
strategy(:oauth2),
strategy(:oauth2)
]
},
%Section{
name: :add_ons,
describe: "Additional add-ons related to, but not providing authentication",
entities: [
strategy(:confirmation)
]
}
@ -525,8 +542,110 @@ defmodule AshAuthentication.Dsl do
%Entity{
name: :confirmation,
describe: "User confirmation flow",
args: [:name],
target: Confirmation,
schema: Confirmation.schema()
schema:
OptionsHelpers.merge_schemas(
[
token_lifetime: [
type: :pos_integer,
doc: """
How long should the confirmation token be valid, in hours.
Defaults to #{@default_confirmation_lifetime_days} days.
""",
default: @default_confirmation_lifetime_days * 24
],
monitor_fields: [
type: {:list, :atom},
doc: """
A list of fields to monitor for changes (eg `[:email, :phone_number]`).
The confirmation will only be sent when one of these fields are changed.
""",
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?
Will only trigger when a create action is executed _and_ one of the
monitored fields is being set.
""",
default: true
],
confirm_on_update?: [
type: :boolean,
doc: """
Generate and send a confirmation token when a resource is changed?
Will only trigger when an update action is executed _and_ one of the
monitored fields is being set.
""",
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 token resource and the changeset updated to not make the
requested change. When the token is confirmed, the change will be
applied.
This could be potentially weird for your users, but useful in the case
of a user changing their email address or phone number where you want
to verify that the new contact details are reachable.
""",
default: true
],
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.
If this action is not already present on the resource, it will be
created for you.
""",
default: :confirm
]
],
@shared_addon_options,
"Shared options"
)
}
end
end

View file

@ -15,6 +15,7 @@ defmodule AshAuthentication.Info do
def strategy(dsl_or_resource, name) do
dsl_or_resource
|> authentication_strategies()
|> Stream.concat(authentication_add_ons(dsl_or_resource))
|> Enum.find_value(:error, fn strategy ->
if strategy.name == name, do: {:ok, strategy}
end)

View file

@ -1,172 +0,0 @@
defmodule AshAuthentication.Strategy.Confirmation do
@default_lifetime_days 3
@moduledoc """
Strategy for authenticating sensitive changes.
Sometimes when creating a new user, or changing a sensitive attribute (such as
their email address) you may want to for the user to confirm by way of sending
them a confirmation token to prove that it was really them that took the
action.
See the DSL documentation for `AshAuthentication` for information on how to
configure it.
"""
defstruct token_lifetime: nil,
monitor_fields: [],
confirmed_at_field: :confirmed_at,
confirm_on_create?: true,
confirm_on_update?: true,
inhibit_updates?: false,
sender: nil,
confirm_action_name: :confirm,
resource: nil,
provider: :confirmation,
name: :confirm
alias Ash.Changeset
alias AshAuthentication.{Jwt, Strategy.Confirmation}
@type t :: %Confirmation{
token_lifetime: hours :: pos_integer,
monitor_fields: [atom],
confirmed_at_field: atom,
confirm_on_create?: boolean,
confirm_on_update?: boolean,
inhibit_updates?: boolean,
sender: nil | {module, keyword},
confirm_action_name: atom,
resource: module,
provider: :confirmation,
name: :confirm
}
@doc """
Generate a confirmation token for a changeset.
This will generate a token with the `"act"` claim set to the confirmation
action for the strategy, and the `"chg"` claim will contain any changes.
FIXME: The "chg" claim should encrypt the contents of the changes so as to not
leak users' private details.
"""
@spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error
def confirmation_token(strategy, changeset) do
changes =
strategy.monitor_fields
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|> Map.new()
claims = %{"act" => strategy.confirm_action_name, "chg" => changes}
token_lifetime = strategy.token_lifetime * 3600
case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do
{:ok, token, _claims} -> {:ok, token}
:error -> :error
end
end
@doc false
@spec schema :: keyword
def schema do
[
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]`).
The confirmation will only be sent when one of these fields are changed.
""",
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?
Will only trigger when a create action is executed _and_ one of the
monitored fields is being set.
""",
default: true
],
confirm_on_update?: [
type: :boolean,
doc: """
Generate and send a confirmation token when a resource is changed?
Will only trigger when an update action is executed _and_ one of the
monitored fields is being set.
""",
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.
This could be potentially weird for your users, but useful in the case
of a user changing their email address or phone number where you want
to verify that the new contact details are reachable.
""",
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.
If this action is not already present on the resource, it will be
created for you.
""",
default: :confirm
]
]
end
end

View file

@ -38,11 +38,13 @@ defmodule AshAuthentication.MixProject do
AshAuthentication.Strategy.Password,
AshAuthentication.Strategy.OAuth2
],
"Add ons": [
AshAuthentication.AddOn.Confirmation
],
Cryptography: [
AshAuthentication.HashProvider,
AshAuthentication.BcryptProvider,
AshAuthentication.Jwt,
AshAuthentication.Jwt.Config
AshAuthentication.Jwt
],
Plug: ~r/^AshAuthentication\.Plug.*/,
Internals: ~r/^AshAuthentication.*/

View file

@ -1,9 +1,9 @@
defmodule AshAuthentication.Strategy.Confirmation.ActionsTest do
defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do
@moduledoc false
use DataCase, async: true
alias Ash.Changeset
alias AshAuthentication.{Info, Strategy.Confirmation, Strategy.Confirmation.Actions}
alias AshAuthentication.{AddOn.Confirmation, AddOn.Confirmation.Actions, Info}
describe "confirm/2" do
test "it returns an error when there is no corresponding user" do

View file

@ -1,8 +1,10 @@
defmodule AshAuthentication.Strategy.ConfirmationTest do
defmodule AshAuthentication.AddOn.ConfirmationTest do
@moduledoc false
use DataCase, async: true
import Plug.Test
alias Ash.Changeset
alias AshAuthentication.{Info, Jwt, Strategy.Confirmation}
alias AshAuthentication.{AddOn.Confirmation, Info, Jwt, Plug, Strategy}
doctest Confirmation
describe "confirmation_token/2" do
@ -19,4 +21,21 @@ defmodule AshAuthentication.Strategy.ConfirmationTest do
assert claims["chg"] == %{"username" => new_username}
end
end
def confirmation_token do
{:ok, strategy} = Info.strategy(Example.User, :confirm)
user = build_user()
new_username = username()
changeset = Changeset.for_update(user, :update, %{"username" => new_username})
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
token
end
def one_second_ago do
DateTime.utc_now()
|> DateTime.add(-1, :second)
|> DateTime.to_unix()
end
end

View file

@ -1,4 +1,4 @@
defmodule AshAuthentication.Strategy.Confirmation.PlugTest do
defmodule AshAuthentication.AddOn.Confirmation.PlugTest do
@moduledoc false
use DataCase, async: true
import Plug.Test
@ -6,10 +6,10 @@ defmodule AshAuthentication.Strategy.Confirmation.PlugTest do
alias Ash.Changeset
alias AshAuthentication.{
AddOn.Confirmation,
AddOn.Confirmation.Plug,
Info,
Plug.Helpers,
Strategy.Confirmation,
Strategy.Confirmation.Plug
Plug.Helpers
}
describe "confirm/2" do

View file

@ -1,8 +1,8 @@
defmodule AshAuthentication.Strategy.Confirmation.StrategyTest do
defmodule AshAuthentication.AddOn.Confirmation.StrategyTest do
@moduledoc false
use ExUnit.Case, async: true
alias AshAuthentication.{Info, Strategy, Strategy.Confirmation}
alias AshAuthentication.{AddOn.Confirmation, Info, Strategy}
use Mimic
import Plug.Test

View file

@ -105,8 +105,8 @@ defmodule Example.User do
revocation_resource(Example.TokenRevocation)
end
strategies do
confirmation do
add_ons do
confirmation :confirm do
monitor_fields([:username])
inhibit_updates?(true)
@ -114,7 +114,9 @@ defmodule Example.User do
Logger.debug("Confirmation request for user #{user.username}, token #{inspect(token)}")
end)
end
end
strategies do
password :password do
resettable do
sender(fn user, token ->

View file

@ -1,7 +1,7 @@
Mimic.copy(AshAuthentication.AddOn.Confirmation.Actions)
Mimic.copy(AshAuthentication.AddOn.Confirmation.Plug)
Mimic.copy(AshAuthentication.Plug.Defaults)
Mimic.copy(AshAuthentication.Plug.Helpers)
Mimic.copy(AshAuthentication.Strategy.Confirmation.Actions)
Mimic.copy(AshAuthentication.Strategy.Confirmation.Plug)
Mimic.copy(AshAuthentication.Strategy.OAuth2.Actions)
Mimic.copy(AshAuthentication.Strategy.OAuth2.Plug)
Mimic.copy(AshAuthentication.Strategy.Password.Actions)