mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 04:43:04 +12:00
feat: Add new "magic link" authentication strategy. (#184)
This commit is contained in:
parent
533872723e
commit
cf3d227ef2
23 changed files with 845 additions and 26 deletions
|
@ -199,6 +199,50 @@ defmodule DevServer.TestPage do
|
|||
)
|
||||
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
|
||||
inspect({strategy, phase})
|
||||
end
|
||||
|
|
|
@ -108,6 +108,7 @@ defmodule AshAuthentication do
|
|||
Info,
|
||||
Strategy.Auth0,
|
||||
Strategy.Github,
|
||||
Strategy.MagicLink,
|
||||
Strategy.OAuth2,
|
||||
Strategy.Password
|
||||
}
|
||||
|
@ -117,7 +118,10 @@ defmodule AshAuthentication do
|
|||
use Spark.Dsl.Extension,
|
||||
sections: dsl(),
|
||||
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: [
|
||||
AshAuthentication.Transformer,
|
||||
AshAuthentication.Strategy.Custom.Transformer
|
||||
|
|
|
@ -18,7 +18,11 @@ defmodule AshAuthentication.AddOn.Confirmation.Transformer do
|
|||
@spec transform(Confirmation.t(), map) ::
|
||||
{:ok, Confirmation.t() | map} | {:error, Exception.t()}
|
||||
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, dsl_state} <-
|
||||
maybe_build_action(
|
||||
|
|
|
@ -89,6 +89,8 @@ defmodule AshAuthentication.Jwt do
|
|||
def token_for_user(user, extra_claims \\ %{}, opts \\ []) do
|
||||
resource = user.__struct__
|
||||
|
||||
{purpose, opts} = Keyword.pop(opts, :purpose, :user)
|
||||
|
||||
default_claims = Config.default_claims(resource, opts)
|
||||
signer = Config.token_signer(resource, opts)
|
||||
|
||||
|
@ -105,21 +107,21 @@ defmodule AshAuthentication.Jwt do
|
|||
end
|
||||
|
||||
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}
|
||||
else
|
||||
{:error, _reason} -> :error
|
||||
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
|
||||
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do
|
||||
TokenResource.Actions.store_token(
|
||||
token_resource,
|
||||
%{
|
||||
"token" => token,
|
||||
"purpose" => "user"
|
||||
"purpose" => to_string(purpose)
|
||||
},
|
||||
context: %{
|
||||
ash_authentication: %{
|
||||
|
|
|
@ -84,7 +84,10 @@ defmodule AshAuthentication.Strategy.Custom do
|
|||
|
||||
_ ->
|
||||
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
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
|
|
149
lib/ash_authentication/strategies/magic_link.ex
Normal file
149
lib/ash_authentication/strategies/magic_link.ex
Normal 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
|
84
lib/ash_authentication/strategies/magic_link/actions.ex
Normal file
84
lib/ash_authentication/strategies/magic_link/actions.ex
Normal 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
|
91
lib/ash_authentication/strategies/magic_link/dsl.ex
Normal file
91
lib/ash_authentication/strategies/magic_link/dsl.ex
Normal 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
|
61
lib/ash_authentication/strategies/magic_link/plug.ex
Normal file
61
lib/ash_authentication/strategies/magic_link/plug.ex
Normal 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
|
|
@ -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
|
|
@ -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
|
48
lib/ash_authentication/strategies/magic_link/strategy.ex
Normal file
48
lib/ash_authentication/strategies/magic_link/strategy.ex
Normal 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
|
116
lib/ash_authentication/strategies/magic_link/transformer.ex
Normal file
116
lib/ash_authentication/strategies/magic_link/transformer.ex
Normal 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(
|
||||
®ister_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
|
88
lib/ash_authentication/strategies/magic_link/verifier.ex
Normal file
88
lib/ash_authentication/strategies/magic_link/verifier.ex
Normal 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
|
|
@ -103,7 +103,7 @@ defmodule AshAuthentication.Strategy.Password.Dsl do
|
|||
doc: """
|
||||
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_`.
|
||||
""",
|
||||
required: false
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do
|
||||
@moduledoc """
|
||||
Prepare a query for a password reset request.
|
||||
|
||||
This preparation performs three jobs, one before the query executes and two
|
||||
after.
|
||||
Firstly, it constraints the query to match the identity field passed to the
|
||||
action.
|
||||
Secondly, if there is a user returned by the query, then generate a reset
|
||||
token and publish a notification. Always returns an empty result.
|
||||
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 reset token and
|
||||
b. publish a notification.
|
||||
|
||||
Always returns an empty result.
|
||||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
|
|
|
@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
|
|||
"""
|
||||
use Ash.Resource.Preparation
|
||||
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
alias Ash.{Query, Resource, Resource.Preparation}
|
||||
require Ash.Query
|
||||
|
||||
@doc false
|
||||
|
@ -81,7 +81,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
|
|||
defp maybe_generate_token(record) do
|
||||
if AshAuthentication.Info.authentication_tokens_enabled?(record.__struct__) do
|
||||
{:ok, token, _claims} = Jwt.token_for_user(record)
|
||||
%{record | __metadata__: Map.put(record.__metadata__, :token, token)}
|
||||
Resource.put_metadata(record, :token, token)
|
||||
else
|
||||
record
|
||||
end
|
||||
|
|
|
@ -217,7 +217,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
|
|||
metadata =
|
||||
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,
|
||||
type: :string,
|
||||
allow_nil?: false
|
||||
|
|
|
@ -111,16 +111,11 @@ defmodule AshAuthentication.Validations do
|
|||
@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
|
||||
@spec validate_token_generation_enabled(Dsl.t(), binary) :: :ok | {:error, Exception.t()}
|
||||
def validate_token_generation_enabled(dsl_state, message) do
|
||||
if AshAuthentication.Info.authentication_tokens_enabled?(dsl_state),
|
||||
do: :ok,
|
||||
else:
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:tokens],
|
||||
message: "Token generation must be enabled for password resets to work."
|
||||
)}
|
||||
else: {:error, DslError.exception(path: [:tokens], message: message)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
7
mix.exs
7
mix.exs
|
@ -60,8 +60,11 @@ defmodule AshAuthentication.MixProject do
|
|||
],
|
||||
Strategies: [
|
||||
AshAuthentication.Strategy,
|
||||
AshAuthentication.Strategy.Password,
|
||||
AshAuthentication.Strategy.OAuth2
|
||||
AshAuthentication.Strategy.Auth0,
|
||||
AshAuthentication.Strategy.Github,
|
||||
AshAuthentication.Strategy.MagicLink,
|
||||
AshAuthentication.Strategy.OAuth2,
|
||||
AshAuthentication.Strategy.Password
|
||||
],
|
||||
"Add ons": [
|
||||
AshAuthentication.AddOn.Confirmation
|
||||
|
|
21
test/ash_authentication/strategies/magic_link_test.exs
Normal file
21
test/ash_authentication/strategies/magic_link_test.exs
Normal 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
|
|
@ -22,7 +22,6 @@ defmodule AshAuthentication.Strategy.PasswordTest do
|
|||
strategy = %Password{resettable: [resettable], resource: user.__struct__}
|
||||
|
||||
assert {:ok, token} = Password.reset_token_for(strategy, user)
|
||||
|
||||
assert {:ok, claims} = Jwt.peek(token)
|
||||
assert claims["act"] == to_string(resettable.password_reset_action_name)
|
||||
end
|
||||
|
|
|
@ -197,6 +197,16 @@ defmodule Example.User do
|
|||
case_sensitive?(false)
|
||||
name_field(:username)
|
||||
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
|
||||
|
||||
|
|
Loading…
Reference in a new issue