feat: Add new "magic link" authentication strategy. (#184)

This commit is contained in:
James Harton 2023-02-09 21:05:49 +13:00 committed by GitHub
parent 533872723e
commit cf3d227ef2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 845 additions and 26 deletions

View file

@ -199,6 +199,50 @@ defmodule DevServer.TestPage do
) )
end end
defp render_strategy(strategy, phase, options)
when is_struct(strategy, Strategy.MagicLink) and phase == :request do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend><%= @strategy.name %> request</legend>
<input type="text" name="<%= @options.subject_name %>[<%= @strategy.identity_field %>]" placeholder="<%= @strategy.identity_field %>" />
<br />
<input type="submit" value="Request" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, options)
when is_struct(strategy, Strategy.MagicLink) and phase == :sign_in do
EEx.eval_string(
~s"""
<form method="<%= @method %>" action="<%= @route %>">
<fieldset>
<legend><%= @strategy.name %> sign in</legend>
<input type="text" name="token" placeholder="token" />
<br />
<input type="submit" value="Sign in" />
</fieldset>
</form>
""",
assigns: [
strategy: strategy,
route: route_for_phase(strategy, phase),
options: options,
method: Strategy.method_for_phase(strategy, phase)
]
)
end
defp render_strategy(strategy, phase, _options) do defp render_strategy(strategy, phase, _options) do
inspect({strategy, phase}) inspect({strategy, phase})
end end

View file

@ -108,6 +108,7 @@ defmodule AshAuthentication do
Info, Info,
Strategy.Auth0, Strategy.Auth0,
Strategy.Github, Strategy.Github,
Strategy.MagicLink,
Strategy.OAuth2, Strategy.OAuth2,
Strategy.Password Strategy.Password
} }
@ -117,7 +118,10 @@ defmodule AshAuthentication do
use Spark.Dsl.Extension, use Spark.Dsl.Extension,
sections: dsl(), sections: dsl(),
dsl_patches: dsl_patches:
Enum.flat_map([Confirmation, Auth0, Github, OAuth2, Password], & &1.dsl_patches()), Enum.flat_map(
[Confirmation, Auth0, Github, OAuth2, Password, MagicLink],
& &1.dsl_patches()
),
transformers: [ transformers: [
AshAuthentication.Transformer, AshAuthentication.Transformer,
AshAuthentication.Strategy.Custom.Transformer AshAuthentication.Strategy.Custom.Transformer

View file

@ -18,7 +18,11 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
@spec transform(Confirmation.t(), map) :: @spec transform(Confirmation.t(), map) ::
{:ok, Confirmation.t() | map} | {:error, Exception.t()} {:ok, Confirmation.t() | map} | {:error, Exception.t()}
def transform(strategy, dsl_state) do def transform(strategy, dsl_state) do
with :ok <- validate_token_generation_enabled(dsl_state), with :ok <-
validate_token_generation_enabled(
dsl_state,
"Token generation must be enabled for password resets to work."
),
:ok <- validate_monitor_fields(dsl_state, strategy), :ok <- validate_monitor_fields(dsl_state, strategy),
{:ok, dsl_state} <- {:ok, dsl_state} <-
maybe_build_action( maybe_build_action(

View file

@ -89,6 +89,8 @@ defmodule AshAuthentication.Jwt do
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
resource = user.__struct__ resource = user.__struct__
{purpose, opts} = Keyword.pop(opts, :purpose, :user)
default_claims = Config.default_claims(resource, opts) default_claims = Config.default_claims(resource, opts)
signer = Config.token_signer(resource, opts) signer = Config.token_signer(resource, opts)
@ -105,21 +107,21 @@ defmodule AshAuthentication.Jwt do
end end
with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer), with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer),
:ok <- maybe_store_token(token, resource, user) do :ok <- maybe_store_token(token, resource, user, purpose) do
{:ok, token, claims} {:ok, token, claims}
else else
{:error, _reason} -> :error {:error, _reason} -> :error
end end
end end
defp maybe_store_token(token, resource, user) do defp maybe_store_token(token, resource, user, purpose) do
if Info.authentication_tokens_store_all_tokens?(resource) do if Info.authentication_tokens_store_all_tokens?(resource) do
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do
TokenResource.Actions.store_token( TokenResource.Actions.store_token(
token_resource, token_resource,
%{ %{
"token" => token, "token" => token,
"purpose" => "user" "purpose" => to_string(purpose)
}, },
context: %{ context: %{
ash_authentication: %{ ash_authentication: %{

View file

@ -84,7 +84,10 @@ defmodule AshAuthentication.Strategy.Custom do
_ -> _ ->
raise CompileError, raise CompileError,
"You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`." file: __ENV__.file,
line: __ENV__.line,
description:
"You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`."
end end
use Spark.Dsl.Extension, use Spark.Dsl.Extension,

View file

@ -0,0 +1,149 @@
defmodule AshAuthentication.Strategy.MagicLink do
alias __MODULE__.{Dsl, Transformer, Verifier}
@moduledoc """
Strategy for authentication using a magic link.
In order to use magic link authentication your resource needs to meet the
following minimum requirements:
1. Have a primary key.
2. A uniquely constrained identity field (eg `username` or `email`)
3. Have tokens enabled.
There are other options documented in the DSL.
### 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
strategies do
magic_link do
identity_field :email
sender fn user, token, _opts ->
MyApp.Emails.deliver_magic_link(user, token)
end
end
end
end
identities do
identity :unique_email, [:email]
end
end
```
## Actions
By default the magic link strategy will automatically generate the request and
sign-in actions for you, however you're free to define them yourself. If you
do, then the action will be validated to ensure that all the needed
configuration is present.
If you wish to work with the actions directly from your code you can do so via
the `AshAuthentication.Strategy` protocol.
### Examples
Requesting that a magic link token is sent for a user:
iex> strategy = Info.strategy!(Example.User, :magic_link)
...> user = build_user()
...> Strategy.action(strategy, :request, %{"username" => user.username})
:ok
Signing in using a magic link token:
...> {:ok, token} = MagicLink.request_token_for(strategy, user)
...> {:ok, signed_in_user} = Strategy.action(strategy, :sign_in, %{"token" => token})
...> signed_in_user.id == user
true
## Plugs
The magic link strategy provides plug endpoints for both request and sign-in
actions.
If you wish to work with the plugs directly, you can do so via the
`AshAuthentication.Strategy` protocol.
### Examples:
Dispatching to plugs directly:
iex> strategy = Info.strategy!(Example.User, :magic_link)
...> user = build_user()
...> conn = conn(:post, "/user/magic_link/request", %{"user" => %{"username" => user.username}})
...> conn = Strategy.plug(strategy, :request, conn)
...> {_conn, {:ok, nil}} = Plug.Helpers.get_authentication_result(conn)
...> {:ok, token} = MagicLink.request_token_for(strategy, user)
...> conn = conn(:get, "/user/magic_link", %{"token" => token})
...> conn = Strategy.plug(strategy, :sign_in, conn)
...> {_conn, {:ok, signed_in_user}} = Plug.Helpers.get_authentication_result(conn)
...> signed_in_user.id == user.id
true
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
defstruct identity_field: :username,
name: nil,
request_action_name: nil,
resource: nil,
sender: nil,
sign_in_action_name: nil,
single_use_token?: true,
token_lifetime: 10,
token_param_name: :token
use AshAuthentication.Strategy.Custom, entity: Dsl.dsl()
alias Ash.Resource
alias AshAuthentication.Jwt
@type t :: %__MODULE__{
identity_field: atom,
name: atom,
request_action_name: atom,
resource: module,
sender: {module, keyword},
single_use_token?: boolean,
sign_in_action_name: atom,
token_lifetime: pos_integer(),
token_param_name: atom
}
defdelegate transform(strategy, dsl_state), to: Transformer
defdelegate verify(strategy, dsl_state), to: Verifier
@doc """
Generate a magic link token for a user.
Used by `AshAuthentication.Strategy.MagicLink.RequestPreparation`.
"""
@spec request_token_for(t, Resource.record()) :: {:ok, binary} | :error
def request_token_for(strategy, user)
when is_struct(strategy, __MODULE__) and is_struct(user, strategy.resource) do
case Jwt.token_for_user(user, %{"act" => strategy.sign_in_action_name},
token_lifetime: strategy.token_lifetime * 60,
purpose: :magic_link
) do
{:ok, token, _claims} -> {:ok, token}
:error -> :error
end
end
end

View file

@ -0,0 +1,84 @@
defmodule AshAuthentication.Strategy.MagicLink.Actions do
@moduledoc """
Actions for the magic link strategy.
Provides the code interface for working with user resources for providing
magic links.
"""
alias Ash.{Query, Resource}
alias AshAuthentication.{Errors, Info, Strategy.MagicLink}
@doc """
Request a magic link for a user.
"""
@spec request(MagicLink.t(), map, keyword) :: :ok | {:error, any}
def request(strategy, params, options) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Query.new()
|> Query.set_context(%{private: %{ash_authentication?: true}})
|> Query.for_read(strategy.request_action_name, params)
|> api.read(options)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
@doc """
Attempt to sign a user in via magic link.
"""
@spec sign_in(MagicLink.t(), map, keyword) ::
{:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()}
def sign_in(strategy, params, options) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Query.new()
|> Query.set_context(%{private: %{ash_authentication?: true}})
|> Query.for_read(strategy.sign_in_action_name, params)
|> api.read(options)
|> case do
{:ok, [user]} ->
{:ok, user}
{:ok, []} ->
{:error,
Errors.AuthenticationFailed.exception(
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: :sign_in,
message: "Query returned no users"
}
)}
{:ok, _users} ->
{:error,
Errors.AuthenticationFailed.exception(
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: :sign_in,
message: "Query returned too many users"
}
)}
{:error, error} when is_exception(error) ->
{:error, Errors.AuthenticationFailed.exception(caused_by: error)}
{:error, error} ->
{:error,
Errors.AuthenticationFailed.exception(
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: :sign_in,
message: "Query returned error: #{inspect(error)}"
}
)}
end
end
end

View file

@ -0,0 +1,91 @@
defmodule AshAuthentication.Strategy.MagicLink.Dsl do
@moduledoc false
alias AshAuthentication.Strategy.{Custom, MagicLink}
alias Spark.Dsl.Entity
@doc false
@spec dsl :: Custom.entity()
def dsl do
%Entity{
name: :magic_link,
describe: "Strategy for authenticating using local users with a magic link",
args: [{:optional, :name, :magic_link}],
hide: [:name],
target: MagicLink,
schema: [
name: [
type: :atom,
doc: "Uniquely identifies the strategy.",
required: true
],
identity_field: [
type: :atom,
doc: """
The name of the attribute which uniquely identifies the user.
Usually something like `username` or `email_address`.
""",
default: :username
],
token_lifetime: [
type: :pos_integer,
doc: """
How long the sign in token is valid, in minutes.
""",
default: 10
],
request_action_name: [
type: :atom,
doc: """
The name to use for the request action.
If not present it will be generated by prepending the strategy name
with `request_`.
""",
required: false
],
single_use_token?: [
type: :boolean,
doc: """
Automatically revoke the token once it's been used for sign in.
""",
default: true
],
sign_in_action_name: [
type: :atom,
doc: """
The name to use for the sign in action.
If not present it will be generated by prepending the strategy name
with `sign_in_with_`.
""",
required: false
],
token_param_name: [
type: :atom,
doc: """
The name of the token parameter in the incoming sign-in request.
""",
default: :token,
required: false
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 3}},
doc: """
How to send the magic link to the user.
Allows you to glue sending of magic links 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
]
]
}
end
end

View file

@ -0,0 +1,61 @@
defmodule AshAuthentication.Strategy.MagicLink.Plug do
@moduledoc """
Plugs for the magic link strategy.
Handles requests and sign-ins.
"""
alias AshAuthentication.{Info, Strategy, Strategy.MagicLink}
alias Plug.Conn
import Ash.PlugHelpers, only: [get_actor: 1, get_tenant: 1]
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
@doc """
Handle a request for a magic link.
Retrieves form parameters from nested within the subject name, eg:
```
%{
"user" => %{
"email" => "marty@mcfly.me"
}
}
```
"""
@spec request(Conn.t(), MagicLink.t()) :: Conn.t()
def request(conn, strategy) do
params = subject_params(conn, strategy)
opts = opts(conn)
result = Strategy.action(strategy, :request, params, opts)
store_authentication_result(conn, result)
end
@doc """
Sign in via magic link.
"""
@spec sign_in(Conn.t(), MagicLink.t()) :: Conn.t()
def sign_in(conn, strategy) do
params =
conn.params
|> Map.take([to_string(strategy.token_param_name)])
opts = opts(conn)
result = Strategy.action(strategy, :sign_in, params, opts)
store_authentication_result(conn, result)
end
defp subject_params(conn, strategy) do
subject_name =
strategy.resource
|> Info.authentication_subject_name!()
|> to_string()
Map.get(conn.params, subject_name, %{})
end
defp opts(conn) do
[actor: get_actor(conn), tenant: get_tenant(conn)]
|> Enum.reject(&is_nil(elem(&1, 1)))
end
end

View file

@ -0,0 +1,43 @@
defmodule AshAuthentication.Strategy.MagicLink.RequestPreparation do
@moduledoc """
Prepare a query for a magic link request.
This preparation performs three jobs, one before the query executes and two
after:
1. it constraints the query to match the identity field passed to the action.
2. if there is a user returned by the query, then
a. generate a magic link token and
b. publish a notification.
Always returns an empty result.
"""
use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation}
alias AshAuthentication.{Info, Strategy.MagicLink}
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _opts, _context) do
strategy = Info.strategy_for_action!(query.resource, query.action.name)
identity_field = strategy.identity_field
identity = Query.get_argument(query, identity_field)
query
|> Query.filter(ref(^identity_field) == ^identity)
|> Query.after_action(&after_action(&1, &2, strategy))
end
defp after_action(_query, [user], %{sender: {sender, send_opts}} = strategy) do
case MagicLink.request_token_for(strategy, user) do
{:ok, token} -> sender.send(user, token, send_opts)
_ -> nil
end
{:ok, []}
end
defp after_action(_, _, _), do: {:ok, []}
end

View file

@ -0,0 +1,51 @@
defmodule AshAuthentication.Strategy.MagicLink.SignInPreparation do
@moduledoc """
Prepare a query for sign in.
"""
use Ash.Resource.Preparation
alias AshAuthentication.{Info, Jwt, TokenResource}
alias Ash.{Query, Resource, Resource.Preparation}
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _otps, _context) do
subject_name =
query.resource
|> Info.authentication_subject_name!()
|> to_string()
with {:ok, strategy} <- Info.strategy_for_action(query.resource, query.action.name),
token when is_binary(token) <- Query.get_argument(query, strategy.token_param_name),
{:ok, %{"act" => token_action, "sub" => subject}, _} <-
Jwt.verify(token, query.resource),
^token_action <- to_string(strategy.sign_in_action_name),
%URI{path: ^subject_name, query: primary_key} <- URI.parse(subject) do
primary_key =
primary_key
|> URI.decode_query()
|> Enum.to_list()
query
|> Query.set_context(%{private: %{ash_authentication?: true}})
|> Query.filter(^primary_key)
|> Query.after_action(fn
query, [record] ->
if strategy.single_use_token? do
token_resource = Info.authentication_tokens_token_resource!(query.resource)
:ok = TokenResource.revoke(token_resource, token)
end
{:ok, token, _claims} = Jwt.token_for_user(record)
{:ok, [Resource.put_metadata(record, :token, token)]}
_query, [] ->
{:ok, []}
end)
else
_ -> Query.limit(query, 0)
end
end
end

View file

@ -0,0 +1,48 @@
defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.MagicLink do
@moduledoc false
alias Ash.Resource
alias AshAuthentication.{Info, Strategy, Strategy.MagicLink}
alias Plug.Conn
@doc false
@spec name(MagicLink.t()) :: atom
def name(strategy), do: strategy.name
@doc false
@spec phases(MagicLink.t()) :: [Strategy.phase()]
def phases(_strategy), do: [:request, :sign_in]
@doc false
@spec actions(MagicLink.t()) :: [Strategy.action()]
def actions(_strategy), do: [:request, :sign_in]
@doc false
@spec method_for_phase(MagicLink.t(), atom) :: Strategy.http_method()
def method_for_phase(_strategy, :request), do: :post
def method_for_phase(_strategy, :sign_in), do: :get
@doc false
@spec routes(MagicLink.t()) :: [Strategy.route()]
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/#{strategy.name}/request", :request},
{"/#{subject_name}/#{strategy.name}", :sign_in}
]
end
@doc false
@spec plug(MagicLink.t(), Strategy.phase(), Conn.t()) :: Conn.t()
def plug(strategy, :request, conn), do: MagicLink.Plug.request(conn, strategy)
def plug(strategy, :sign_in, conn), do: MagicLink.Plug.sign_in(conn, strategy)
@doc false
@spec action(MagicLink.t(), Strategy.action(), map, keyword) ::
:ok | {:ok, Resource.record()} | {:error, any}
def action(strategy, :request, params, options),
do: MagicLink.Actions.request(strategy, params, options)
def action(strategy, :sign_in, params, options),
do: MagicLink.Actions.sign_in(strategy, params, options)
end

View file

@ -0,0 +1,116 @@
defmodule AshAuthentication.Strategy.MagicLink.Transformer do
@moduledoc """
DSL transformer for magic links.
"""
alias Ash.Resource
alias AshAuthentication.Strategy.MagicLink
alias Spark.Dsl.Transformer
import AshAuthentication.Utils
import AshAuthentication.Validations
import AshAuthentication.Strategy.Custom.Helpers
@doc false
@spec transform(MagicLink.t(), dsl_state) :: {:ok, MagicLink.t() | dsl_state} | {:error, any}
when dsl_state: map
def transform(strategy, dsl_state) do
with :ok <-
validate_token_generation_enabled(
dsl_state,
"Token generation must be enabled for magic links to work."
),
strategy <- maybe_set_sign_in_action_name(strategy),
strategy <- maybe_set_request_action_name(strategy),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
strategy.sign_in_action_name,
&build_sign_in_action(&1, strategy)
),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
strategy.request_action_name,
&build_request_action(&1, strategy)
) do
dsl_state =
dsl_state
|> then(
&register_strategy_actions(
[strategy.sign_in_action_name, strategy.request_action_name],
&1,
strategy
)
)
|> put_strategy(strategy)
{:ok, dsl_state}
end
end
defp maybe_set_sign_in_action_name(strategy) when is_nil(strategy.sign_in_action_name),
do: %{strategy | sign_in_action_name: :"sign_in_with_#{strategy.name}"}
defp maybe_set_sign_in_action_name(strategy), do: strategy
defp maybe_set_request_action_name(strategy) when is_nil(strategy.request_action_name),
do: %{strategy | request_action_name: :"request_#{strategy.name}"}
defp maybe_set_request_action_name(strategy), do: strategy
defp build_sign_in_action(_dsl_state, strategy) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.token_param_name,
type: :string,
allow_nil?: false
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: MagicLink.SignInPreparation
)
]
metadata = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
name: :token,
type: :string,
allow_nil?: false
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: strategy.sign_in_action_name,
arguments: arguments,
preparations: preparations,
metadata: metadata,
get?: true
)
end
defp build_request_action(dsl_state, strategy) do
identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field)
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.identity_field,
type: identity_attribute.type,
allow_nil?: false
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: MagicLink.RequestPreparation
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: strategy.request_action_name,
arguments: arguments,
preparations: preparations
)
end
end

View file

@ -0,0 +1,88 @@
defmodule AshAuthentication.Strategy.MagicLink.Verifier do
@moduledoc """
DSL verifier for magic links.
"""
alias AshAuthentication.{Strategy.MagicLink}
alias Spark.Error.DslError
import AshAuthentication.Validations
import AshAuthentication.Validations.Action
import AshAuthentication.Validations.Attribute
@doc false
@spec verify(MagicLink.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, dsl_state) do
with {:ok, identity_attribute} <- validate_identity_attribute(dsl_state, strategy),
:ok <- validate_request_action(dsl_state, strategy, identity_attribute) do
validate_sign_in_action(dsl_state, strategy)
end
end
defp validate_identity_attribute(dsl_state, strategy) do
with {:ok, identity_attribute} <- find_attribute(dsl_state, strategy.identity_field),
:ok <-
validate_attribute_unique_constraint(
dsl_state,
[strategy.identity_field],
strategy.resource
) do
{:ok, identity_attribute}
end
end
defp validate_request_action(dsl_state, strategy, identity_attribute) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.request_action_name),
:ok <- validate_action_has_argument(action, strategy.identity_field),
:ok <-
validate_action_argument_option(
action,
strategy.identity_field,
:type,
[identity_attribute.type]
),
:ok <-
validate_action_argument_option(action, strategy.identity_field, :allow_nil?, [
false
]),
:ok <- validate_action_has_preparation(action, MagicLink.RequestPreparation),
:ok <- validate_field_in_values(action, :type, [:read]) do
:ok
else
{:error, message} when is_binary(message) ->
{:error,
DslError.exception(
path: [:actions, :read, strategy.request_action_name, :type],
mesasge: message
)}
{:error, exception} when is_exception(exception) ->
{:error, exception}
end
end
defp validate_sign_in_action(dsl_state, strategy) do
with {:ok, action} <- validate_action_exists(dsl_state, strategy.sign_in_action_name),
:ok <- validate_action_has_argument(action, strategy.token_param_name),
:ok <-
validate_action_argument_option(action, strategy.token_param_name, :type, [
:string,
Ash.Type.String
]),
:ok <-
validate_action_argument_option(action, strategy.token_param_name, :allow_nil?, [false]),
:ok <- validate_action_has_preparation(action, MagicLink.SignInPreparation),
:ok <- validate_field_in_values(action, :type, [:read]) do
:ok
else
{:error, message} when is_binary(message) ->
{:error,
DslError.exception(
path: [:actions, :read, strategy.sign_in_action_name, :type],
mesasge: message
)}
{:error, exception} when is_exception(exception) ->
{:error, exception}
end
end
end

View file

@ -103,7 +103,7 @@ defmodule AshAuthentication.Strategy.Password.Dsl do
doc: """ doc: """
The name to use for the sign in action. The name to use for the sign in action.
If not present it will be generated by prependign the strategy name If not present it will be generated by prepending the strategy name
with `sign_in_with_`. with `sign_in_with_`.
""", """,
required: false required: false

View file

@ -1,12 +1,15 @@
defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
@moduledoc """ @moduledoc """
Prepare a query for a password reset request. Prepare a query for a password reset request.
This preparation performs three jobs, one before the query executes and two This preparation performs three jobs, one before the query executes and two
after. after:
Firstly, it constraints the query to match the identity field passed to the 1. it constraints the query to match the identity field passed to the action.
action. 2. if there is a user returned by the query, then
Secondly, if there is a user returned by the query, then generate a reset a. generate a reset token and
token and publish a notification. Always returns an empty result. b. publish a notification.
Always returns an empty result.
""" """
use Ash.Resource.Preparation use Ash.Resource.Preparation
alias Ash.{Query, Resource.Preparation} alias Ash.{Query, Resource.Preparation}

View file

@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
""" """
use Ash.Resource.Preparation use Ash.Resource.Preparation
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt} alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
alias Ash.{Query, Resource.Preparation} alias Ash.{Query, Resource, Resource.Preparation}
require Ash.Query require Ash.Query
@doc false @doc false
@ -81,7 +81,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
defp maybe_generate_token(record) do defp maybe_generate_token(record) do
if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do
{:ok, token, _claims} = Jwt.token_for_user(record) {:ok, token, _claims} = Jwt.token_for_user(record)
%{record | __metadata__: Map.put(record.__metadata__, :token, token)} Resource.put_metadata(record, :token, token)
else else
record record
end end

View file

@ -217,7 +217,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
metadata = metadata =
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state) do if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state) do
[ [
Transformer.build_entity!(Resource.Dsl, [:actions, :update], :metadata, Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
name: :token, name: :token,
type: :string, type: :string,
allow_nil?: false allow_nil?: false

View file

@ -111,16 +111,11 @@ defmodule AshAuthentication.Validations do
@doc """ @doc """
Ensure that token generation is enabled for the resource. Ensure that token generation is enabled for the resource.
""" """
@spec validate_token_generation_enabled(Dsl.t()) :: :ok | {:error, Exception.t()} @spec validate_token_generation_enabled(Dsl.t(), binary) :: :ok | {:error, Exception.t()}
def validate_token_generation_enabled(dsl_state) do def validate_token_generation_enabled(dsl_state, message) do
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state), if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state),
do: :ok, do: :ok,
else: else: {:error, DslError.exception(path: [:tokens], message: message)}
{:error,
DslError.exception(
path: [:tokens],
message: "Token generation must be enabled for password resets to work."
)}
end end
@doc """ @doc """

View file

@ -60,8 +60,11 @@ defmodule AshAuthentication.MixProject do
], ],
Strategies: [ Strategies: [
AshAuthentication.Strategy, AshAuthentication.Strategy,
AshAuthentication.Strategy.Password, AshAuthentication.Strategy.Auth0,
AshAuthentication.Strategy.OAuth2 AshAuthentication.Strategy.Github,
AshAuthentication.Strategy.MagicLink,
AshAuthentication.Strategy.OAuth2,
AshAuthentication.Strategy.Password
], ],
"Add ons": [ "Add ons": [
AshAuthentication.AddOn.Confirmation AshAuthentication.AddOn.Confirmation

View file

@ -0,0 +1,21 @@
defmodule AshAuthentication.Strategy.MagicLinkTest do
@moduledoc false
use DataCase, async: true
import Plug.Test
alias AshAuthentication.{Info, Jwt, Plug, Strategy, Strategy.MagicLink}
doctest MagicLink
describe "request_token_for/2" do
test "it generates a sign in token" do
user = build_user()
strategy = Info.strategy!(Example.User, :magic_link)
assert {:ok, token} = MagicLink.request_token_for(strategy, user)
assert {:ok, claims} = Jwt.peek(token)
assert claims["act"] == to_string(strategy.sign_in_action_name)
end
end
end

View file

@ -22,7 +22,6 @@ defmodule AshAuthentication.Strategy.PasswordTest do
strategy = %Password{resettable: [resettable], resource: user.__struct__} strategy = %Password{resettable: [resettable], resource: user.__struct__}
assert {:ok, token} = Password.reset_token_for(strategy, user) assert {:ok, token} = Password.reset_token_for(strategy, user)
assert {:ok, claims} = Jwt.peek(token) assert {:ok, claims} = Jwt.peek(token)
assert claims["act"] == to_string(resettable.password_reset_action_name) assert claims["act"] == to_string(resettable.password_reset_action_name)
end end

View file

@ -197,6 +197,16 @@ defmodule Example.User do
case_sensitive?(false) case_sensitive?(false)
name_field(:username) name_field(:username)
end end
magic_link do
sender fn user, token, _opts ->
Logger.debug("Magic link request for #{user.username}, token #{inspect(token)}")
end
end
end
tokens do
store_all_tokens? true
end end
end end