improvement: add sign in tokens to password strategy (#252)

* improvement: add sign in tokens to password strategy

* chore: update `.formatter.exs`.

* chore: fix credo warnings.

* improvement: convert `sign_in_with_token` into an action.

---------

Co-authored-by: James Harton <james@harton.nz>
This commit is contained in:
Zach Daniel 2023-04-05 22:53:44 -04:00 committed by GitHub
parent 58adb090c3
commit eca8cadea0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 605 additions and 93 deletions

View file

@ -67,6 +67,8 @@ spark_locals_without_parens = [
sender: 1,
sign_in_action_name: 1,
sign_in_enabled?: 1,
sign_in_token_lifetime: 1,
sign_in_tokens_enabled?: 1,
signing_algorithm: 1,
signing_secret: 1,
single_use_token?: 1,

View file

@ -3,7 +3,7 @@ defmodule AshAuthentication.Errors.AuthenticationFailed do
A generic, authentication failed error.
"""
use Ash.Error.Exception
def_ash_error([caused_by: %{}], class: :forbidden)
def_ash_error([:strategy, caused_by: %{}], class: :forbidden)
import AshAuthentication.Debug
@type t :: Exception.t()

View file

@ -91,30 +91,34 @@ defmodule AshAuthentication.Jwt do
{purpose, opts} = Keyword.pop(opts, :purpose, :user)
default_claims = Config.default_claims(resource, opts)
signer = Config.token_signer(resource, opts)
subject = AshAuthentication.user_to_subject(user)
extra_claims =
extra_claims
|> Map.put("sub", subject)
extra_claims =
{extra_claims, action_opts} =
case Map.fetch(user.__metadata__, :tenant) do
{:ok, tenant} -> Map.put(extra_claims, "tenant", to_string(tenant))
:error -> extra_claims
{:ok, tenant} ->
tenant = to_string(tenant)
{Map.put(extra_claims, "tenant", tenant), [tenant: tenant]}
:error ->
{extra_claims, opts}
end
default_claims = Config.default_claims(resource, action_opts)
signer = Config.token_signer(resource, opts)
with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer),
:ok <- maybe_store_token(token, resource, user, purpose) do
:ok <- maybe_store_token(token, resource, user, purpose, action_opts) do
{:ok, token, claims}
else
{:error, _reason} -> :error
end
end
defp maybe_store_token(token, resource, user, purpose) do
defp maybe_store_token(token, resource, user, purpose, opts) 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(
@ -123,11 +127,11 @@ defmodule AshAuthentication.Jwt do
"token" => token,
"purpose" => to_string(purpose)
},
context: %{
Keyword.put(opts, :context, %{
ash_authentication: %{
user: user
}
}
})
)
end
else

View file

@ -45,7 +45,7 @@ defmodule AshAuthentication.Jwt.Config do
|> Config.add_claim(
"jti",
&Joken.generate_jti/0,
&validate_jti/3
&validate_jti(&1, &2, &3, opts)
)
end
@ -96,18 +96,20 @@ defmodule AshAuthentication.Jwt.Config do
resource. Requires that the subject's resource configuration be passed as the
validation context. This is automatically done by calling `Jwt.verify/2`.
"""
@spec validate_jti(String.t(), any, Resource.t() | any) :: boolean
def validate_jti(jti, _claims, resource) when is_atom(resource) do
@spec validate_jti(String.t(), any, Resource.t() | any, Keyword.t()) :: boolean
def validate_jti(jti, _claims, resource, opts \\ [])
def validate_jti(jti, _claims, resource, opts) when is_atom(resource) do
case Info.authentication_tokens_token_resource(resource) do
{:ok, token_resource} ->
TokenResource.Actions.valid_jti?(token_resource, jti)
TokenResource.Actions.valid_jti?(token_resource, jti, opts)
_ ->
false
end
end
def validate_jti(_, _, _), do: false
def validate_jti(_, _, _, _), do: false
@doc """
The signer used to sign the token on a per-resource basis.

View file

@ -47,6 +47,7 @@ defmodule AshAuthentication.Strategy.MagicLink.Actions do
{:ok, []} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -58,6 +59,7 @@ defmodule AshAuthentication.Strategy.MagicLink.Actions do
{:ok, _users} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -67,11 +69,16 @@ defmodule AshAuthentication.Strategy.MagicLink.Actions do
)}
{:error, error} when is_exception(error) ->
{:error, Errors.AuthenticationFailed.exception(caused_by: error)}
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: error
)}
{:error, error} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,

View file

@ -40,6 +40,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
{:ok, []} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -51,6 +52,7 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
{:ok, _users} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -63,11 +65,16 @@ defmodule AshAuthentication.Strategy.OAuth2.Actions do
{:error, error}
{:error, error} when is_exception(error) ->
{:error, Errors.AuthenticationFailed.exception(caused_by: error)}
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: error
)}
{:error, error} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,

View file

@ -23,6 +23,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
:error ->
{:error,
AuthenticationFailed.exception(
strategy: :unknown,
query: query,
caused_by: %{
module: __MODULE__,
@ -42,6 +43,7 @@ defmodule AshAuthentication.Strategy.OAuth2.SignInPreparation do
_, _ ->
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,

View file

@ -95,21 +95,24 @@ defmodule AshAuthentication.Strategy.Password do
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
defstruct identity_field: :username,
hashed_password_field: :hashed_password_field,
defstruct confirmation_required?: false,
hash_provider: AshAuthentication.BcryptProvider,
confirmation_required?: false,
password_field: :password,
password_confirmation_field: :password_confirmation,
register_action_name: nil,
sign_in_action_name: nil,
registration_enabled?: true,
sign_in_enabled?: true,
resettable: [],
register_action_accept: [],
hashed_password_field: :hashed_password_field,
identity_field: :username,
name: nil,
password_confirmation_field: :password_confirmation,
password_field: :password,
provider: :password,
resource: nil
register_action_accept: [],
register_action_name: nil,
registration_enabled?: true,
resettable: [],
resource: nil,
sign_in_action_name: nil,
sign_in_enabled?: true,
sign_in_token_lifetime: 60,
sign_in_tokens_enabled?: false,
sign_in_with_token_action_name: nil
alias Ash.Resource
@ -125,21 +128,24 @@ defmodule AshAuthentication.Strategy.Password do
use Custom, entity: Dsl.dsl()
@type t :: %Password{
identity_field: atom,
hashed_password_field: atom,
hash_provider: module,
confirmation_required?: boolean,
password_field: atom,
hash_provider: module,
hashed_password_field: atom,
identity_field: atom,
name: atom,
password_confirmation_field: atom,
password_field: atom,
provider: atom,
register_action_accept: [atom],
register_action_name: atom,
sign_in_action_name: atom,
registration_enabled?: boolean,
sign_in_enabled?: boolean,
resettable: [Resettable.t()],
name: atom,
provider: atom,
resource: module
resource: module,
sign_in_action_name: atom,
sign_in_enabled?: boolean,
sign_in_token_lifetime: pos_integer,
sign_in_tokens_enabled?: boolean,
sign_in_with_token_action_name: atom
}
defdelegate dsl(), to: Dsl

View file

@ -14,16 +14,24 @@ defmodule AshAuthentication.Strategy.Password.Actions do
"""
@spec sign_in(Password.t(), map, keyword) ::
{:ok, Resource.record()} | {:error, Errors.AuthenticationFailed.t()}
def sign_in(%Password{} = strategy, params, options) when strategy.sign_in_enabled? do
def sign_in(strategy, params, options)
when is_struct(strategy, Password) and strategy.sign_in_enabled? do
api = Info.authentication_api!(strategy.resource)
{context, options} = Keyword.pop(options, :context, [])
context =
context
|> Map.new()
|> Map.merge(%{
private: %{
ash_authentication?: true
}
})
strategy.resource
|> Query.new()
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Query.set_context(context)
|> Query.for_read(strategy.sign_in_action_name, params)
|> api.read(options)
|> case do
@ -33,6 +41,7 @@ defmodule AshAuthentication.Strategy.Password.Actions do
{:ok, []} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -44,6 +53,7 @@ defmodule AshAuthentication.Strategy.Password.Actions do
{:ok, _users} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -53,11 +63,16 @@ defmodule AshAuthentication.Strategy.Password.Actions do
)}
{:error, error} when is_exception(error) ->
{:error, Errors.AuthenticationFailed.exception(caused_by: error)}
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: error
)}
{:error, error} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -68,9 +83,10 @@ defmodule AshAuthentication.Strategy.Password.Actions do
end
end
def sign_in(%Password{} = strategy, _params, _options) do
def sign_in(strategy, _params, _options) when is_struct(strategy, Password) do
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -80,12 +96,57 @@ defmodule AshAuthentication.Strategy.Password.Actions do
)}
end
@doc """
Attempt to sign in a previously-authenticated user with a short-lived sign in token.
"""
@spec sign_in_with_token(Password.t(), map, keyword) :: {:ok, Resource.record()} | {:error, any}
def sign_in_with_token(strategy, params, options) when is_struct(strategy, Password) do
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Query.new()
|> Query.set_context(%{private: %{ash_authentication?: true}})
|> Query.for_read(strategy.sign_in_with_token_action_name, params)
|> api.read(options)
|> case do
{:ok, [user]} ->
{:ok, user}
{:error, error} when is_struct(error, Errors.AuthenticationFailed) ->
{:error, error}
{:error, error} when is_exception(error) ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: strategy.sign_in_with_token_action_name,
message: Exception.message(error)
}
)}
{:error, reason} ->
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
action: strategy.sign_in_with_token_action_name,
message: reason
}
)}
end
end
@doc """
Attempt to register a new user.
"""
@spec register(Password.t(), map, keyword) :: {:ok, Resource.record()} | {:error, any}
def register(%Password{} = strategy, params, options)
when strategy.registration_enabled? == true do
def register(strategy, params, options)
when is_struct(strategy, Password) and strategy.registration_enabled? == true do
api = Info.authentication_api!(strategy.resource)
strategy.resource
@ -99,9 +160,10 @@ defmodule AshAuthentication.Strategy.Password.Actions do
|> api.create(options)
end
def register(%Password{} = strategy, _params, _options) do
def register(strategy, _params, _options) when is_struct(strategy, Password) do
{:error,
Errors.AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{
module: __MODULE__,
strategy: strategy,
@ -171,6 +233,6 @@ defmodule AshAuthentication.Strategy.Password.Actions do
end
end
def reset(%Password{} = strategy, _params, _options),
def reset(strategy, _params, _options) when is_struct(strategy, Password),
do: {:error, NoSuchAction.exception(resource: strategy.resource, action: :reset, type: :read)}
end

View file

@ -130,6 +130,29 @@ defmodule AshAuthentication.Strategy.Password.Dsl do
""",
required: false,
default: true
],
sign_in_tokens_enabled?: [
type: :boolean,
doc: """
Whether or not to support generating short lived sign in tokens. Requires the resource to have
tokens enabled. There is no drawback to supporting this, and in the future this default will
change from `false` to `true`.
Sign in tokens can be generated on request by setting the `:token_type` context to `:sign_in`
when calling the sign in action. You might do this when you need to generate a short lived token
to be exchanged for a real token using the `validate_sign_in_token` route. This is used, for example,
by `ash_authentication_phoenix` (since 1.7) to support signing in in a liveview, and then redirecting
with a valid token to a controller action, allowing the liveview to show invalid username/password errors.
""",
required: false,
default: false
],
sign_in_token_lifetime: [
type: :pos_integer,
default: 60,
doc: """
A lifetime (in seconds) for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`.
"""
]
],
entities: [

View file

@ -60,12 +60,21 @@ defmodule AshAuthentication.Strategy.Password.PasswordValidation do
if strategy.hash_provider.valid?(password, hashed_password) do
:ok
else
{:error, AuthenticationFailed.exception(changeset: changeset)}
{:error,
AuthenticationFailed.exception(
strategy: strategy,
changeset: changeset
)}
end
else
:error ->
strategy.hash_provider.simulate()
{:error, AuthenticationFailed.exception(changeset: changeset)}
{:error,
AuthenticationFailed.exception(
strategy: strategy,
changeset: changeset
)}
end
end

View file

@ -28,6 +28,15 @@ defmodule AshAuthentication.Strategy.Password.Plug do
store_authentication_result(conn, result)
end
@doc "Handle a request to validate a sign in token"
@spec sign_in_with_token(Conn.t(), Password.t()) :: Conn.t()
def sign_in_with_token(conn, strategy) do
params = conn.params
opts = opts(conn)
result = Strategy.action(strategy, :sign_in_with_token, params, opts)
store_authentication_result(conn, result)
end
@doc "Handle a reset request request"
@spec reset_request(Conn.t(), Password.t()) :: Conn.t()
def reset_request(conn, strategy) do

View file

@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
"""
use Ash.Resource.Preparation
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
alias Ash.{Query, Resource, Resource.Preparation}
alias Ash.{Error.Unknown, Query, Resource, Resource.Preparation}
require Ash.Query
@doc false
@ -27,6 +27,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
query
|> Query.filter(ref(^identity_field) == ^identity)
|> check_sign_in_token_configuration(strategy)
|> Query.before_action(fn query ->
Ash.Query.ensure_selected(query, [strategy.hashed_password_field])
end)
@ -38,10 +39,19 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
password,
Map.get(record, strategy.hashed_password_field)
),
do: {:ok, [maybe_generate_token(record)]},
do:
{:ok,
[
maybe_generate_token(
query.context[:token_type] || :user,
record,
strategy
)
]},
else:
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
@ -56,6 +66,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
@ -70,6 +81,7 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
@ -81,9 +93,30 @@ defmodule AshAuthentication.Strategy.Password.SignInPreparation do
end)
end
defp maybe_generate_token(record) do
defp check_sign_in_token_configuration(query, strategy)
when query.context.token_type == :sign_in and not strategy.sign_in_tokens_enabled? do
Query.add_error(
query,
Unknown.exception(
message: """
Invalid configuration detected. A sign in token was requested for the #{strategy.name} strategy on #{inspect(query.resource)}, but that strategy
does not support sign in tokens. See `sign_in_tokens_enabled?` for more.
"""
)
)
end
defp check_sign_in_token_configuration(query, _) do
query
end
defp maybe_generate_token(purpose, record, strategy) when purpose in [:user, :sign_in] 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, %{"purpose" => to_string(purpose)},
token_lifetime: strategy.sign_in_token_lifetime
)
Resource.put_metadata(record, :token, token)
else
record

View file

@ -0,0 +1,159 @@
defmodule AshAuthentication.Strategy.Password.SignInWithTokenPreparation do
@moduledoc """
Prepare a query for sign in via token.
This preparation first validates the token argument and extracts the subject
from it and constrains the query to a matching user.
"""
use Ash.Resource.Preparation
alias AshAuthentication.{Errors.AuthenticationFailed, Info, Jwt}
alias Ash.{Error.Unknown, Query, Resource, Resource.Preparation}
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)
query
|> check_sign_in_token_configuration(strategy)
|> Query.before_action(&verify_token_and_constrain_query(&1, strategy))
|> Query.after_action(&verify_result(&1, &2, strategy))
end
defp verify_token_and_constrain_query(query, strategy) do
token = Query.get_argument(query, :token)
with {:ok, claims, _} <- Jwt.verify(token, strategy.resource),
:ok <- verify_sign_in_token_purpose(claims),
{:ok, primary_keys} <- extract_primary_keys_from_subject(claims, strategy.resource) do
Query.filter(query, ^primary_keys)
else
:error ->
Query.add_error(
query,
[:token],
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
action: query.action,
resource: query.resource,
message: "The token is invalid"
}
)
)
{:error, reason} ->
Query.add_error(
query,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
action: query.action,
resource: query.resource,
message: reason
}
)
)
end
end
defp verify_result(query, [user], strategy) do
case Jwt.token_for_user(user) do
{:ok, token, _claims} ->
{:ok, [Resource.put_metadata(user, :token, token)]}
:error ->
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
action: query.action,
resource: query.resource,
message: "Unable to generate token for user"
}
)}
end
end
defp verify_result(query, [], strategy) do
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
action: query.action,
resource: query.resource,
message: "Query returned no users"
}
)}
end
defp verify_result(query, users, strategy) when is_list(users) do
{:error,
AuthenticationFailed.exception(
strategy: strategy,
query: query,
caused_by: %{
module: __MODULE__,
action: query.action,
resource: query.resource,
message: "Query returned too many users"
}
)}
end
defp check_sign_in_token_configuration(query, strategy)
when query.context.token_type == :sign_in and not strategy.sign_in_tokens_enabled? do
Query.add_error(
query,
Unknown.exception(
message: """
Invalid configuration detected. A sign in token was requested for the #{strategy.name} strategy on #{inspect(query.resource)}, but that strategy
does not support sign in tokens. See `sign_in_tokens_enabled?` for more.
"""
)
)
end
defp check_sign_in_token_configuration(query, _), do: query
defp verify_sign_in_token_purpose(%{"purpose" => "sign_in"}), do: :ok
defp verify_sign_in_token_purpose(_), do: {:error, "The token purpose is not valid"}
defp extract_primary_keys_from_subject(%{"sub" => sub}, resource) do
primary_key_fields =
resource
|> Resource.Info.primary_key()
|> Enum.map(&to_string/1)
|> MapSet.new()
key_parts =
sub
|> URI.parse()
|> Map.get(:query, "")
|> URI.decode_query()
provided_key_fields =
key_parts
|> Map.keys()
|> MapSet.new()
if MapSet.equal?(primary_key_fields, provided_key_fields) do
{:ok, Enum.to_list(key_parts)}
else
{:error, "token subject doesn't contain correct keys"}
end
end
defp extract_primary_keys_from_subject(_, _),
do: {:error, "The token does not contain a subject"}
end

View file

@ -18,7 +18,7 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
Only the first two will be used if password resets are disabled.
"""
@type phase :: :register | :sign_in | :reset_request | :reset
@type phase :: :register | :sign_in | :reset_request | :reset | :sign_in_with_token
@doc false
@spec name(Password.t()) :: atom
@ -28,6 +28,10 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
@spec phases(Password.t()) :: [phase]
def phases(strategy) do
[]
|> maybe_append(
strategy.sign_in_tokens_enabled? && strategy.sign_in_enabled?,
:sign_in_with_token
)
|> maybe_append(strategy.registration_enabled?, :register)
|> maybe_append(strategy.sign_in_enabled?, :sign_in)
|> maybe_concat(Enum.any?(strategy.resettable), [:reset_request, :reset])
@ -69,6 +73,9 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
def plug(strategy, :reset_request, conn), do: Password.Plug.reset_request(conn, strategy)
def plug(strategy, :reset, conn), do: Password.Plug.reset(conn, strategy)
def plug(strategy, :sign_in_with_token, conn),
do: Password.Plug.sign_in_with_token(conn, strategy)
@doc """
Perform actions.
"""
@ -84,4 +91,7 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do
def action(strategy, :reset, params, options),
do: Password.Actions.reset(strategy, params, options)
def action(strategy, :sign_in_with_token, params, options),
do: Password.Actions.sign_in_with_token(strategy, params, options)
end

View file

@ -40,6 +40,20 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
&build_sign_in_action(&1, strategy)
),
:ok <- validate_sign_in_action(dsl_state, strategy),
strategy <-
maybe_set_field_lazy(
strategy,
:sign_in_with_token_action_name,
&:"sign_in_with_token_for_#{&1.name}"
),
{:ok, dsl_state} <-
maybe_maybe_build_action(
strategy.sign_in_tokens_enabled?,
dsl_state,
strategy.sign_in_with_token_action_name,
&build_sign_in_with_token_action(&1, strategy)
),
:ok <- validate_sign_in_with_token_action(dsl_state, strategy),
{:ok, dsl_state, strategy} <- maybe_transform_resettable(dsl_state, strategy),
{:ok, resource} <- persisted_option(dsl_state, :module) do
strategy = %{strategy | resource: resource}
@ -52,7 +66,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
&(Strategy.name(&1) == strategy.name)
)
|> then(fn dsl_state ->
~w[sign_in_action_name register_action_name]a
~w[sign_in_action_name register_action_name sign_in_with_token_action_name]a
|> Enum.map(&Map.get(strategy, &1))
|> register_strategy_actions(dsl_state, strategy)
end)
@ -101,7 +115,9 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Resource.Dsl,
[:actions, :create],
:argument,
Keyword.put(password_opts, :name, strategy.password_field)
password_opts
|> Keyword.put(:name, strategy.password_field)
|> Keyword.put(:description, "The proposed password for the user, in plain text.")
)
]
|> maybe_append(
@ -110,7 +126,12 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Resource.Dsl,
[:actions, :create],
:argument,
Keyword.put(password_opts, :name, strategy.password_confirmation_field)
password_opts
|> Keyword.put(:name, strategy.password_confirmation_field)
|> Keyword.put(
:description,
"The proposed password for the user (again), in plain text."
)
)
)
@ -119,15 +140,21 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
|> maybe_append(
strategy.confirmation_required?,
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate,
validation: Password.PasswordConfirmationValidation
validation: Password.PasswordConfirmationValidation,
description:
"Confirm that the values of `#{inspect(strategy.password_field)}` and `#{inspect(strategy.password_confirmation_field)}` are the same if confirmation is enabled."
)
)
|> Enum.concat([
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
change: Password.HashPasswordChange
change: Password.HashPasswordChange,
description:
"Generate a cryptographic hash of the user's plain text password and store it in the `#{inspect(strategy.hashed_password_field)}` attribute."
),
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
change: GenerateTokenChange
change: GenerateTokenChange,
description:
"If token generation is enabled, generate a token and store it in the user's metadata."
)
])
@ -137,7 +164,8 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :metadata,
name: :token,
type: :string,
allow_nil?: false
allow_nil?: false,
description: "A JWT which the user can use to authenticate to the API."
)
]
else
@ -155,7 +183,8 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
arguments: arguments,
changes: changes,
metadata: metadata,
allow_nil_input: [strategy.hashed_password_field]
allow_nil_input: [strategy.hashed_password_field],
description: "Register a new user with a username and password."
)
end
@ -201,13 +230,15 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.identity_field,
type: identity_attribute.type,
allow_nil?: false
allow_nil?: false,
description: "The identity to use for retrieving the user."
),
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.password_field,
type: Type.String,
allow_nil?: false,
sensitive?: true
sensitive?: true,
description: "The password to check for the matching user."
)
]
@ -223,7 +254,8 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
name: :token,
type: :string,
allow_nil?: false
allow_nil?: false,
description: "A JWT which the user can use to authenticate to the API."
)
]
else
@ -235,7 +267,8 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
arguments: arguments,
preparations: preparations,
metadata: metadata,
get?: true
get?: true,
description: "Attempt to sign in using a username and password."
)
end
@ -254,6 +287,61 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
validate_action_argument_option(action, identity_field, :type, [identity_attribute.type])
end
defp build_sign_in_with_token_action(_dsl_state, strategy) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: :token,
type: :string,
allow_nil?: false,
sensitive?: true,
description: "The short-lived sign in JWT."
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: Password.SignInWithTokenPreparation
)
]
metadata = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :metadata,
name: :token,
type: :string,
allow_nil?: false,
description: "A JWT which the user can use to authenticate to the API."
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: strategy.sign_in_with_token_action_name,
arguments: arguments,
preparations: preparations,
metadata: metadata,
get?: true,
description: "Attempt to sign in using a short-lived sign in token."
)
end
defp validate_sign_in_with_token_action(dsl_state, strategy)
when strategy.sign_in_tokens_enabled? == true do
with {:ok, action} <-
validate_action_exists(dsl_state, strategy.sign_in_with_token_action_name),
:ok <- validate_token_argument(action) do
validate_action_has_preparation(action, Password.SignInWithTokenPreparation)
end
end
defp validate_sign_in_with_token_action(_dsl_state, _strategy), do: :ok
defp validate_token_argument(action) do
with :ok <-
validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do
validate_action_argument_option(action, :token, :sensitive?, [true])
end
end
defp maybe_maybe_build_action(true, dsl_state, action_name, builder),
do: maybe_build_action(dsl_state, action_name, builder)
@ -310,7 +398,8 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: strategy.identity_field,
type: identity_attribute.type,
allow_nil?: false
allow_nil?: false,
description: "The proposed identity to send reset instructions to."
)
]
@ -323,7 +412,8 @@ defmodule AshAuthentication.Strategy.Password.Transformer do
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: resettable.request_password_reset_action_name,
arguments: arguments,
preparations: preparations
preparations: preparations,
description: "Send password reset instructions to a user if they exist."
)
end

View file

@ -3,26 +3,83 @@ defmodule AshAuthentication.Strategy.Password.Verifier do
DSL verifier for the password strategy.
"""
alias AshAuthentication.{HashProvider, Sender, Strategy.Password}
alias Spark.Error.DslError
alias AshAuthentication.{HashProvider, Info, Sender, Strategy.Password}
alias Spark.{Dsl.Verifier, Error.DslError}
import AshAuthentication.Validations
@doc false
@spec verify(Password.t(), map) :: :ok | {:error, Exception.t()}
def verify(strategy, _dsl_state) do
with :ok <- validate_behaviour(strategy.hash_provider, HashProvider) do
maybe_validate_resettable_sender(strategy)
def verify(strategy, dsl_state) do
with :ok <- validate_behaviour(strategy.hash_provider, HashProvider),
:ok <- validate_tokens_enabled_for_sign_in_tokens(dsl_state, strategy) do
maybe_validate_resettable_sender(dsl_state, strategy)
end
end
defp maybe_validate_resettable_sender(%{resettable: [resettable]}) do
defp validate_tokens_enabled_for_sign_in_tokens(dsl_state, strategy)
when strategy.sign_in_tokens_enabled? do
resource = Verifier.get_persisted(dsl_state, :module)
cond do
!strategy.sign_in_enabled? ->
{:error,
DslError.exception(
module: resource,
path: [
:authentication,
:strategies,
:password,
strategy.name,
:sign_in_tokens_enabled?
],
message: """
The `sign_in_tokens_enabled?` option requires that `sign_in_enabled?` be set to `true`.
"""
)}
!Info.authentication_tokens_enabled?(dsl_state) ->
{:error,
DslError.exception(
module: resource,
path: [
:authentication,
:strategies,
:password,
strategy.name,
:sign_in_tokens_enabled?
],
message: """
The `sign_in_tokens_enabled?` option requires that tokens are enabled for your resource. For example:
authentication do
...
tokens do
enabled? true
end
end
"""
)}
true ->
:ok
end
end
defp validate_tokens_enabled_for_sign_in_tokens(_, _), do: :ok
defp maybe_validate_resettable_sender(dsl_state, %{resettable: [resettable]}) do
with {:ok, {sender, _opts}} <- Map.fetch(resettable, :sender),
:ok <- validate_behaviour(sender, Sender) do
:ok
else
:error ->
resource = Verifier.get_persisted(dsl_state, :module)
{:error,
DslError.exception(
module: resource,
path: [:authentication, :strategies, :password, :resettable],
message: "A `sender` is required."
)}
@ -32,5 +89,5 @@ defmodule AshAuthentication.Strategy.Password.Verifier do
end
end
defp maybe_validate_resettable_sender(_), do: :ok
defp maybe_validate_resettable_sender(_, _), do: :ok
end

View file

@ -52,7 +52,7 @@ defprotocol AshAuthentication.Strategy do
iex> strategy = Info.strategy!(Example.User, :password)
...> phases(strategy)
[:register, :sign_in, :reset_request, :reset]
[:sign_in_with_token, :register, :sign_in, :reset_request, :reset]
"""
@spec phases(t) :: [phase]
def phases(strategy)
@ -64,7 +64,7 @@ defprotocol AshAuthentication.Strategy do
iex> strategy = Info.strategy!(Example.User, :password)
...> actions(strategy)
[:register, :sign_in, :reset_request, :reset]
[:sign_in_with_token, :register, :sign_in, :reset_request, :reset]
"""
@spec actions(t) :: [action]
def actions(strategy)
@ -78,6 +78,7 @@ defprotocol AshAuthentication.Strategy do
iex> strategy = Info.strategy!(Example.User, :password)
...> routes(strategy)
[
{"/user/password/sign_in_with_token", :sign_in_with_token},
{"/user/password/register", :register},
{"/user/password/sign_in", :sign_in},
{"/user/password/reset_request", :reset_request},

View file

@ -195,7 +195,7 @@ defmodule AshAuthentication.MixProject do
case System.get_env("ASH_VERSION") do
nil -> default_version
"local" -> [path: "../ash", override: true]
"main" -> [git: "https://github.com/ash-project/ash.git"]
"main" -> [git: "https://github.com/ash-project/ash.git", override: true]
version -> "~> #{version}"
end
end

View file

@ -2,7 +2,7 @@
"absinthe": {:hex, :absinthe, "1.7.1", "aca6f64994f0914628429ddbdfbf24212747b51780dae189dd98909da911757b", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c0c4dbd93881fa3bfbad255608234b104b877c2a901850c1fe8c53b408a72a57"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"},
"ash": {:hex, :ash, "2.6.29", "ab5aee1a0da3d3a5f3f190aadc524da961baf23691e9243dfcd9bf29a3e33555", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 1.0", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8f25f18e3fc9e698732c068b7f5262a21d125a9824619ffd771dbc0d997e8206"},
"ash_graphql": {:hex, :ash_graphql, "0.23.2", "bac23faa9e41f3c8b192055af4700acf5dddeac461cfa0d07fd72c55e321daab", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, "~> 2.6 and >= 2.6.21", [hex: :ash, repo: "hexpm", optional: false]}, {:dataloader, "~> 1.0", [hex: :dataloader, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "92ce5371fd8e950b2496b45b070252a4a82da2e549a026a88b41435e6058ce46"},
"ash_graphql": {:hex, :ash_graphql, "0.23.1", "93e6b2ada4a9c5a9cb99fed389b029850d70b914b9420456f9e780228142557a", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, "~> 2.6 and >= 2.6.21", [hex: :ash, repo: "hexpm", optional: false]}, {:dataloader, "~> 1.0", [hex: :dataloader, repo: "hexpm", optional: false]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "14e490b372630c5d2f40c712fb3cf86cba6cbe1cab873d56961e2da39b676e21"},
"ash_json_api": {:hex, :ash_json_api, "0.31.1", "861b596520cf7a86995724f20a81a3f4197ed4598044953e0f293be381796835", [:mix], [{:ash, "~> 2.3", [hex: :ash, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:json_xema, "~> 0.4.0", [hex: :json_xema, repo: "hexpm", optional: false]}, {:plug, "~> 1.11", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "03f53cbd070b779147547cee9ae0c5130d112f998eda3277cf5e6d514e78514b"},
"ash_postgres": {:hex, :ash_postgres, "1.3.18", "2f2faea220fbf6f54a6f08c01065466f5b6e2a7fd57263591d7ae9810a3ce6b5", [:mix], [{:ash, "~> 2.6 and >= 2.6.16", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "1f19fd5b1641f210ce9fe4dde46fd47d4253be71f716b87f2bb7eb67f870b106"},
"assent": {:hex, :assent, "0.2.3", "414d77ea27349dacc980b612e9edeed06c4d64a3df99a0fa8e42e6940ed20c16", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "a39bc5b57920632b003bd175fd58fcb355c10efbe614bba03682ce2a76d4133f"},
@ -53,7 +53,7 @@
"postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"sourceror": {:hex, :sourceror, "0.12.2", "2ae55efd149193572e0eb723df7c7a1bda9ab33c43373c82642931dbb2f4e428", [:mix], [], "hexpm", "7ad74ade6fb079c71f29fae10c34bcf2323542d8c51ee1bcd77a546cfa89d59c"},
"spark": {:hex, :spark, "1.0.3", "bd31519fdb68247556372e1167bbf3b1db300cde964064975129c0555eb6ae7c", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "5da2c9bc6b1d197be887f3f162316df7ba3227001c31471087bca11d50991bff"},
"spark": {:hex, :spark, "1.0.4", "973d9c02fd4a87ca1de89047521fb479ea7d9acd59be4c14afef0aa28e9c2cab", [:mix], [{:nimble_options, "~> 0.5 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "56b6d721f458bb683ead7d8870ec6cdabc8a8191feeb3f59357e6ff2adce2907"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},

View file

@ -54,6 +54,24 @@ defmodule AshAuthentication.Strategy.Password.ActionsTest do
end
end
describe "sign_in_with_token/2" do
test "it can sign in a user with a sign-in token" do
user = build_user()
{:ok, strategy} = Info.strategy(Example.User, :password)
{:ok, user} =
Actions.sign_in(
strategy,
%{"username" => user.username, "password" => user.__metadata__.password},
context: %{token_type: :sign_in}
)
assert {:ok, _user} =
Actions.sign_in_with_token(strategy, %{"token" => user.__metadata__.token}, [])
end
end
describe "register/2" do
test "it can register a new user" do
{:ok, strategy} = Info.strategy(Example.User, :password)

View file

@ -85,7 +85,8 @@ defmodule AshAuthentication.Strategy.Password.StrategyTest do
{"/user/password/register", :register},
{"/user/password/reset", :reset},
{"/user/password/reset_request", :reset_request},
{"/user/password/sign_in", :sign_in}
{"/user/password/sign_in", :sign_in},
{"/user/password/sign_in_with_token", :sign_in_with_token}
])
)
end
@ -102,7 +103,8 @@ defmodule AshAuthentication.Strategy.Password.StrategyTest do
routes,
MapSet.new([
{"/user/password/register", :register},
{"/user/password/sign_in", :sign_in}
{"/user/password/sign_in", :sign_in},
{"/user/password/sign_in_with_token", :sign_in_with_token}
])
)
end

View file

@ -92,13 +92,25 @@ defmodule Example.OnlyMartiesAtTheParty do
{:ok, user}
{:ok, []} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :no_user})}
{:error,
AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{reason: :no_user}
)}
{:ok, _users} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :too_many_users})}
{:error,
AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{reason: :too_many_users}
)}
{:error, reason} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: reason})}
{:error,
AuthenticationFailed.exception(
strategy: strategy,
caused_by: %{reason: reason}
)}
end
end
end

View file

@ -168,6 +168,7 @@ defmodule Example.User do
strategies do
password do
register_action_accept [:extra_stuff]
sign_in_tokens_enabled? true
resettable do
sender fn user, token, _opts ->
@ -231,10 +232,6 @@ defmodule Example.User do
end
end
end
tokens do
store_all_tokens? true
end
end
identities do