mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
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:
parent
58adb090c3
commit
eca8cadea0
24 changed files with 605 additions and 93 deletions
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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__,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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},
|
||||
|
|
2
mix.exs
2
mix.exs
|
@ -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
|
||||
|
|
4
mix.lock
4
mix.lock
|
@ -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"},
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue