mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
feat: Add token-required-for-authentication feature. (#116)
* Adds the `require_token_presence_for_authentication?` DSL option to the Authentication extension which when enabled changes the following behaviour: 1. The `store_in_session` plug will store the user's token rather than their subject in the session. 2. The `retrieve_from_session` plug will look for a stored token in the session rather than a subject and ensure that it's present in the `TokenResource`. 3. The `retrieve_from_bearer` plug will ensure that the token is present in the `TokenResource`. * Adds the `get_token` action to the `TokenResource`.
This commit is contained in:
parent
c444f4583e
commit
d5c5d6b6c5
18 changed files with 517 additions and 43 deletions
|
@ -1,7 +1,6 @@
|
||||||
spark_locals_without_parens = [
|
spark_locals_without_parens = [
|
||||||
access_token_attribute_name: 1,
|
|
||||||
access_token_expires_at_attribute_name: 1,
|
|
||||||
api: 1,
|
api: 1,
|
||||||
|
auth0: 0,
|
||||||
auth0: 1,
|
auth0: 1,
|
||||||
auth0: 2,
|
auth0: 2,
|
||||||
auth_method: 1,
|
auth_method: 1,
|
||||||
|
@ -12,16 +11,17 @@ spark_locals_without_parens = [
|
||||||
confirm_action_name: 1,
|
confirm_action_name: 1,
|
||||||
confirm_on_create?: 1,
|
confirm_on_create?: 1,
|
||||||
confirm_on_update?: 1,
|
confirm_on_update?: 1,
|
||||||
|
confirmation: 0,
|
||||||
confirmation: 1,
|
confirmation: 1,
|
||||||
confirmation: 2,
|
confirmation: 2,
|
||||||
confirmation_required?: 1,
|
confirmation_required?: 1,
|
||||||
confirmed_at_field: 1,
|
confirmed_at_field: 1,
|
||||||
destroy_action_name: 1,
|
|
||||||
enabled?: 1,
|
enabled?: 1,
|
||||||
expunge_expired_action_name: 1,
|
expunge_expired_action_name: 1,
|
||||||
expunge_interval: 1,
|
expunge_interval: 1,
|
||||||
get_by_subject_action_name: 1,
|
get_by_subject_action_name: 1,
|
||||||
get_changes_action_name: 1,
|
get_changes_action_name: 1,
|
||||||
|
get_token_action_name: 1,
|
||||||
hash_provider: 1,
|
hash_provider: 1,
|
||||||
hashed_password_field: 1,
|
hashed_password_field: 1,
|
||||||
identity_field: 1,
|
identity_field: 1,
|
||||||
|
@ -31,21 +31,22 @@ spark_locals_without_parens = [
|
||||||
inhibit_updates?: 1,
|
inhibit_updates?: 1,
|
||||||
is_revoked_action_name: 1,
|
is_revoked_action_name: 1,
|
||||||
monitor_fields: 1,
|
monitor_fields: 1,
|
||||||
|
oauth2: 0,
|
||||||
oauth2: 1,
|
oauth2: 1,
|
||||||
oauth2: 2,
|
oauth2: 2,
|
||||||
|
password: 0,
|
||||||
password: 1,
|
password: 1,
|
||||||
password: 2,
|
password: 2,
|
||||||
password_confirmation_field: 1,
|
password_confirmation_field: 1,
|
||||||
password_field: 1,
|
password_field: 1,
|
||||||
password_reset_action_name: 1,
|
password_reset_action_name: 1,
|
||||||
private_key: 1,
|
private_key: 1,
|
||||||
read_action_name: 1,
|
|
||||||
read_expired_action_name: 1,
|
read_expired_action_name: 1,
|
||||||
redirect_uri: 1,
|
redirect_uri: 1,
|
||||||
refresh_token_attribute_name: 1,
|
|
||||||
register_action_name: 1,
|
register_action_name: 1,
|
||||||
registration_enabled?: 1,
|
registration_enabled?: 1,
|
||||||
request_password_reset_action_name: 1,
|
request_password_reset_action_name: 1,
|
||||||
|
require_token_presence_for_authentication?: 1,
|
||||||
resettable: 0,
|
resettable: 0,
|
||||||
resettable: 1,
|
resettable: 1,
|
||||||
revoke_token_action_name: 1,
|
revoke_token_action_name: 1,
|
||||||
|
@ -57,17 +58,11 @@ spark_locals_without_parens = [
|
||||||
store_all_tokens?: 1,
|
store_all_tokens?: 1,
|
||||||
store_changes_action_name: 1,
|
store_changes_action_name: 1,
|
||||||
store_token_action_name: 1,
|
store_token_action_name: 1,
|
||||||
strategy_attribute_name: 1,
|
|
||||||
subject_name: 1,
|
subject_name: 1,
|
||||||
token_lifetime: 1,
|
token_lifetime: 1,
|
||||||
token_path: 1,
|
token_path: 1,
|
||||||
token_resource: 1,
|
token_resource: 1,
|
||||||
uid_attribute_name: 1,
|
user_path: 1
|
||||||
upsert_action_name: 1,
|
|
||||||
user_id_attribute_name: 1,
|
|
||||||
user_path: 1,
|
|
||||||
user_relationship_name: 1,
|
|
||||||
user_resource: 1
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|
|
@ -129,7 +129,7 @@ defmodule AshAuthentication do
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
iex> authenticated_resources(:ash_authentication)
|
iex> authenticated_resources(:ash_authentication)
|
||||||
[Example.User]
|
[Example.User, Example.UserWithTokenRequired]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@spec authenticated_resources(atom) :: [Resource.t()]
|
@spec authenticated_resources(atom) :: [Resource.t()]
|
||||||
|
|
|
@ -129,6 +129,20 @@ defmodule AshAuthentication.Dsl do
|
||||||
""",
|
""",
|
||||||
default: false
|
default: false
|
||||||
],
|
],
|
||||||
|
require_token_presence_for_authentication?: [
|
||||||
|
type: :boolean,
|
||||||
|
doc: """
|
||||||
|
Require a locally-stored token for authentication?
|
||||||
|
|
||||||
|
This inverts the token validation behaviour from requiring that
|
||||||
|
tokens are not revoked to requiring any token presented by a
|
||||||
|
client to be present in the token resource to be considered
|
||||||
|
valid.
|
||||||
|
|
||||||
|
Requires `store_all_tokens?` to be `true`.
|
||||||
|
""",
|
||||||
|
default: false
|
||||||
|
],
|
||||||
signing_algorithm: [
|
signing_algorithm: [
|
||||||
type: :string,
|
type: :string,
|
||||||
doc: """
|
doc: """
|
||||||
|
|
|
@ -13,9 +13,13 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
||||||
def store_in_session(conn, user) when is_struct(user) do
|
def store_in_session(conn, user) when is_struct(user) do
|
||||||
subject_name = Info.authentication_subject_name!(user.__struct__)
|
subject_name = Info.authentication_subject_name!(user.__struct__)
|
||||||
subject = AshAuthentication.user_to_subject(user)
|
|
||||||
|
|
||||||
Conn.put_session(conn, subject_name, subject)
|
if Info.authentication_tokens_require_token_presence_for_authentication?(user.__struct__) do
|
||||||
|
Conn.put_session(conn, "#{subject_name}_token", user.__metadata__.token)
|
||||||
|
else
|
||||||
|
subject = AshAuthentication.user_to_subject(user)
|
||||||
|
Conn.put_session(conn, subject_name, subject)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def store_in_session(conn, _), do: conn
|
def store_in_session(conn, _), do: conn
|
||||||
|
@ -60,17 +64,39 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
def retrieve_from_session(conn, otp_app) do
|
def retrieve_from_session(conn, otp_app) do
|
||||||
otp_app
|
otp_app
|
||||||
|> AshAuthentication.authenticated_resources()
|
|> AshAuthentication.authenticated_resources()
|
||||||
|> Stream.map(&{&1, Info.authentication_options(&1)})
|
|> Stream.map(
|
||||||
|> Enum.reduce(conn, fn {resource, options}, conn ->
|
&{&1, Info.authentication_options(&1),
|
||||||
current_subject_name = current_subject_name(options.subject_name)
|
Info.authentication_tokens_require_token_presence_for_authentication?(&1)}
|
||||||
|
)
|
||||||
|
|> Enum.reduce(conn, fn
|
||||||
|
{resource, options, true}, conn ->
|
||||||
|
current_subject_name = current_subject_name(options.subject_name)
|
||||||
|
token_resource = Info.authentication_tokens_token_resource!(resource)
|
||||||
|
|
||||||
with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name),
|
with token when is_binary(token) <-
|
||||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
|
Conn.get_session(conn, "#{options.subject_name}_token"),
|
||||||
Conn.assign(conn, current_subject_name, user)
|
{:ok, %{"sub" => subject, "jti" => jti}, _} <- Jwt.verify(token, otp_app),
|
||||||
else
|
{:ok, [_]} <-
|
||||||
_ ->
|
TokenResource.Actions.get_token(token_resource, %{
|
||||||
Conn.assign(conn, current_subject_name, nil)
|
"jti" => jti,
|
||||||
end
|
"purpose" => "generic"
|
||||||
|
}),
|
||||||
|
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
|
||||||
|
Conn.assign(conn, current_subject_name, user)
|
||||||
|
else
|
||||||
|
_ -> Conn.assign(conn, current_subject_name, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
{resource, options, false}, conn ->
|
||||||
|
current_subject_name = current_subject_name(options.subject_name)
|
||||||
|
|
||||||
|
with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name),
|
||||||
|
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
|
||||||
|
Conn.assign(conn, current_subject_name, user)
|
||||||
|
else
|
||||||
|
_ ->
|
||||||
|
Conn.assign(conn, current_subject_name, nil)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -90,7 +116,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
|> Stream.filter(&String.starts_with?(&1, "Bearer "))
|
|> Stream.filter(&String.starts_with?(&1, "Bearer "))
|
||||||
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
||||||
|> Enum.reduce(conn, fn token, conn ->
|
|> Enum.reduce(conn, fn token, conn ->
|
||||||
with {:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, otp_app),
|
with {:ok, %{"sub" => subject, "jti" => jti}, resource} <- Jwt.verify(token, otp_app),
|
||||||
|
:ok <- validate_token(resource, jti),
|
||||||
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
|
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
|
||||||
{:ok, subject_name} <- Info.authentication_subject_name(resource),
|
{:ok, subject_name} <- Info.authentication_subject_name(resource),
|
||||||
current_subject_name <- current_subject_name(subject_name) do
|
current_subject_name <- current_subject_name(subject_name) do
|
||||||
|
@ -102,6 +129,21 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp validate_token(resource, jti) do
|
||||||
|
if Info.authentication_tokens_require_token_presence_for_authentication?(resource) do
|
||||||
|
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource),
|
||||||
|
{:ok, [_]} <-
|
||||||
|
TokenResource.Actions.get_token(token_resource, %{
|
||||||
|
"jti" => jti,
|
||||||
|
"purpose" => "generic"
|
||||||
|
}) do
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Revoke all authorization header(s).
|
Revoke all authorization header(s).
|
||||||
|
|
||||||
|
|
|
@ -44,9 +44,18 @@ defmodule AshAuthentication.TokenResource do
|
||||||
doc: """
|
doc: """
|
||||||
The name of the action to use to store a token.
|
The name of the action to use to store a token.
|
||||||
|
|
||||||
Used it `store_all_tokens?` is enabled in your authentication resource.
|
Used if `store_all_tokens?` is enabled in your authentication resource.
|
||||||
""",
|
""",
|
||||||
default: :store_token
|
default: :store_token
|
||||||
|
],
|
||||||
|
get_token_action_name: [
|
||||||
|
type: :atom,
|
||||||
|
doc: """
|
||||||
|
The name of the action used to retrieve tokens from the store.
|
||||||
|
|
||||||
|
Used if `require_token_presence_for_authentication?` is enabled in your authentication resource.
|
||||||
|
""",
|
||||||
|
default: :get_token
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
sections: [
|
sections: [
|
||||||
|
|
|
@ -102,7 +102,7 @@ defmodule AshAuthentication.TokenResource.Actions do
|
||||||
@doc """
|
@doc """
|
||||||
Revoke a token.
|
Revoke a token.
|
||||||
|
|
||||||
Extracts the JTI from the provided token and uses it to generate a revocationr
|
Extracts the JTI from the provided token and uses it to generate a revocation
|
||||||
record.
|
record.
|
||||||
"""
|
"""
|
||||||
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
|
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
|
||||||
|
@ -149,6 +149,20 @@ defmodule AshAuthentication.TokenResource.Actions do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Retrieve a token by token or JTI optionally filtering by purpose.
|
||||||
|
"""
|
||||||
|
@spec get_token(Resource.t(), map, keyword) :: {:ok, [Resource.record()]} | {:error, any}
|
||||||
|
def get_token(resource, params, opts \\ []) do
|
||||||
|
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||||
|
{:ok, api} <- Info.token_api(resource),
|
||||||
|
{:ok, get_token_action_name} <- Info.token_get_token_action_name(resource) do
|
||||||
|
resource
|
||||||
|
|> Query.for_read(get_token_action_name, params, opts)
|
||||||
|
|> api.read()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp expunge_inside_transaction(resource, expunge_expired_action_name, opts) do
|
defp expunge_inside_transaction(resource, expunge_expired_action_name, opts) do
|
||||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||||
{:ok, api} <- Info.token_api(resource),
|
{:ok, api} <- Info.token_api(resource),
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
defmodule AshAuthentication.TokenResource.GetTokenPreparation do
|
||||||
|
@moduledoc """
|
||||||
|
Constrains a query to only records which match the `jti` or `token` argument
|
||||||
|
and optionally by the `purpose` argument.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ash.Resource.Preparation
|
||||||
|
alias Ash.{Error.Query.InvalidArgument, Query, Resource.Preparation}
|
||||||
|
alias AshAuthentication.Jwt
|
||||||
|
require Ash.Query
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||||
|
def prepare(query, _, _) do
|
||||||
|
jti = get_jti(query)
|
||||||
|
purpose = Query.get_argument(query, :purpose)
|
||||||
|
|
||||||
|
query
|
||||||
|
|> Query.filter(jti: jti)
|
||||||
|
|> then(fn query ->
|
||||||
|
if purpose, do: Query.filter(query, purpose: purpose), else: query
|
||||||
|
end)
|
||||||
|
|> Query.filter(expires_at > now())
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_jti(query),
|
||||||
|
do: get_jti(Query.get_argument(query, :jti), Query.get_argument(query, :token))
|
||||||
|
|
||||||
|
defp get_jti(jti, _token) when byte_size(jti) > 0, do: jti
|
||||||
|
|
||||||
|
defp get_jti(_jti, token) when byte_size(token) > 0 do
|
||||||
|
token
|
||||||
|
|> Jwt.peek()
|
||||||
|
|> case do
|
||||||
|
{:ok, %{"jti" => jti}} -> jti
|
||||||
|
_ -> get_jti(nil, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_jti(_jti, _token),
|
||||||
|
do:
|
||||||
|
raise(
|
||||||
|
InvalidArgument.exception(
|
||||||
|
field: :jti,
|
||||||
|
message: "At least one of `jti` or `token` arguments must be present"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
|
@ -140,11 +140,66 @@ defmodule AshAuthentication.TokenResource.Transformer do
|
||||||
store_token_action_name,
|
store_token_action_name,
|
||||||
&build_store_token_action(&1, store_token_action_name)
|
&build_store_token_action(&1, store_token_action_name)
|
||||||
),
|
),
|
||||||
:ok <- validate_store_token_action(dsl_state, store_token_action_name) do
|
:ok <- validate_store_token_action(dsl_state, store_token_action_name),
|
||||||
|
{:ok, get_token_action_name} <- Info.token_get_token_action_name(dsl_state),
|
||||||
|
{:ok, dsl_state} <-
|
||||||
|
maybe_build_action(
|
||||||
|
dsl_state,
|
||||||
|
get_token_action_name,
|
||||||
|
&build_get_token_action(&1, get_token_action_name)
|
||||||
|
),
|
||||||
|
:ok <- validate_get_token_action(dsl_state, get_token_action_name) do
|
||||||
{:ok, dsl_state}
|
{:ok, dsl_state}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp validate_get_token_action(dsl_state, action_name) do
|
||||||
|
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||||
|
:ok <-
|
||||||
|
validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
|
||||||
|
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [true]),
|
||||||
|
:ok <- validate_action_argument_option(action, :token, :sensitive?, [true]),
|
||||||
|
:ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
|
||||||
|
:ok <- validate_action_argument_option(action, :jti, :allow_nil?, [true]),
|
||||||
|
:ok <-
|
||||||
|
validate_action_argument_option(action, :purpose, :type, [Ash.Type.String, :string]) do
|
||||||
|
validate_action_has_preparation(action, TokenResource.GetTokenPreparation)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_get_token_action(_dsl_state, action_name) do
|
||||||
|
arguments = [
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||||
|
name: :token,
|
||||||
|
type: :string,
|
||||||
|
sensitive?: true
|
||||||
|
),
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||||
|
name: :jti,
|
||||||
|
type: :string,
|
||||||
|
sensitive?: false
|
||||||
|
),
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||||
|
name: :purpose,
|
||||||
|
type: :string,
|
||||||
|
sensitive?: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
preparations = [
|
||||||
|
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||||
|
preparation: TokenResource.GetTokenPreparation
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||||
|
name: action_name,
|
||||||
|
arguments: arguments,
|
||||||
|
preparations: preparations,
|
||||||
|
get?: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp validate_store_token_action(dsl_state, action_name) do
|
defp validate_store_token_action(dsl_state, action_name) do
|
||||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||||
:ok <- validate_token_argument(action) do
|
:ok <- validate_token_argument(action) do
|
||||||
|
|
|
@ -215,11 +215,10 @@ defmodule AshAuthentication.Utils do
|
||||||
"""
|
"""
|
||||||
@spec assert_is_module(module) :: :ok | {:error, term}
|
@spec assert_is_module(module) :: :ok | {:error, term}
|
||||||
def assert_is_module(module) when is_atom(module) do
|
def assert_is_module(module) when is_atom(module) do
|
||||||
module.module_info()
|
case Code.ensure_compiled(module) do
|
||||||
:ok
|
{:module, _} -> :ok
|
||||||
rescue
|
_ -> {:error, "Argument `#{inspect(module)}` is not a valid module"}
|
||||||
_ ->
|
end
|
||||||
{:error, "Argument `#{inspect(module)}` is not a valid module"}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def assert_is_module(module),
|
def assert_is_module(module),
|
||||||
|
|
|
@ -18,6 +18,11 @@ defmodule AshAuthentication.Verifier do
|
||||||
@spec before?(any) :: boolean
|
@spec before?(any) :: boolean
|
||||||
def before?(_), do: false
|
def before?(_), do: false
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
@impl true
|
||||||
|
@spec after_compile? :: boolean
|
||||||
|
def after_compile?, do: true
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
@impl true
|
@impl true
|
||||||
@spec transform(map) ::
|
@spec transform(map) ::
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
defmodule Example.Repo.Migrations.AddUserWithTokenRequiredResource do
|
||||||
|
@moduledoc """
|
||||||
|
Updates resources based on their most recent snapshots.
|
||||||
|
|
||||||
|
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
create table(:user_with_token_required, primary_key: false) do
|
||||||
|
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
|
||||||
|
add :email, :citext, null: false
|
||||||
|
add :hashed_password, :text
|
||||||
|
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||||
|
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:user_with_token_required, [:email],
|
||||||
|
name: "user_with_token_required_email_index"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists unique_index(:user_with_token_required, [:email],
|
||||||
|
name: "user_with_token_required_email_index"
|
||||||
|
)
|
||||||
|
|
||||||
|
drop table(:user_with_token_required)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,78 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"uuid_generate_v4()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"primary_key?": true,
|
||||||
|
"references": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "id",
|
||||||
|
"type": "uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "email",
|
||||||
|
"type": "citext"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "hashed_password",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"now()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "created_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"now()\")",
|
||||||
|
"generated?": false,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "updated_at",
|
||||||
|
"type": "utc_datetime_usec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "601DA745C121A871D5428D2BBC269B22E7D92FBE4D0111B5468649E999F656AA",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "user_with_token_required_email_index",
|
||||||
|
"keys": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"name": "email"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Example.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "user_with_token_required"
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
defmodule AshAuthentication.Plug.HelpersTest do
|
defmodule AshAuthentication.Plug.HelpersTest do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use DataCase, async: true
|
use DataCase, async: true
|
||||||
alias AshAuthentication.{Jwt, Plug.Helpers}
|
alias AshAuthentication.{Jwt, Plug.Helpers, TokenResource}
|
||||||
import Plug.Test, only: [conn: 3]
|
import Plug.Test, only: [conn: 3]
|
||||||
alias Plug.Conn
|
alias Plug.Conn
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "store_in_session/2" do
|
describe "store_in_session/2" do
|
||||||
test "it stores the user in the session", %{conn: conn} do
|
test "when token presence is not required it stores the user in the session", %{conn: conn} do
|
||||||
user = build_user()
|
user = build_user()
|
||||||
subject = AshAuthentication.user_to_subject(user)
|
subject = AshAuthentication.user_to_subject(user)
|
||||||
|
|
||||||
|
@ -25,6 +25,17 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
||||||
|
|
||||||
assert conn.private.plug_session["user"] == subject
|
assert conn.private.plug_session["user"] == subject
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "when token presence is required it stores the token in the session", %{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Helpers.store_in_session(user)
|
||||||
|
|
||||||
|
assert conn.private.plug_session["user_with_token_required_token"] ==
|
||||||
|
user.__metadata__.token
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "load_subjects/2" do
|
describe "load_subjects/2" do
|
||||||
|
@ -39,7 +50,9 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "retrieve_from_session/2" do
|
describe "retrieve_from_session/2" do
|
||||||
test "it loads any subjects stored in the session", %{conn: conn} do
|
test "when token presence is not required it loads any subjects stored in the session", %{
|
||||||
|
conn: conn
|
||||||
|
} do
|
||||||
user = build_user()
|
user = build_user()
|
||||||
subject = AshAuthentication.user_to_subject(user)
|
subject = AshAuthentication.user_to_subject(user)
|
||||||
|
|
||||||
|
@ -50,10 +63,54 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
||||||
|
|
||||||
assert conn.assigns.current_user.id == user.id
|
assert conn.assigns.current_user.id == user.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "when token presence is required and the token is present in the token resource it loads the token's subject",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Conn.put_session("user_with_token_required_token", user.__metadata__.token)
|
||||||
|
|> Helpers.retrieve_from_session(:ash_authentication)
|
||||||
|
|
||||||
|
assert conn.assigns.current_user_with_token_required.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when token presense is required and the token is not present in the token resource it doesn't load the token's subject",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
{:ok, %{"jti" => jti}} = Jwt.peek(user.__metadata__.token)
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
Example.Repo.delete_all(from(t in Example.Token, where: t.jti == ^jti))
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Conn.put_session("user_with_token_required_token", user.__metadata__.token)
|
||||||
|
|> Helpers.retrieve_from_session(:ash_authentication)
|
||||||
|
|
||||||
|
refute conn.assigns.current_user_with_token_required
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when token presense is requried and the token has been revoked it doesn't load the token's subject",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
|
||||||
|
:ok = TokenResource.revoke(Example.Token, user.__metadata__.token)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Conn.put_session("user_with_token_required_token", user.__metadata__.token)
|
||||||
|
|> Helpers.retrieve_from_session(:ash_authentication)
|
||||||
|
|
||||||
|
refute conn.assigns.current_user_with_token_required
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "retrieve_from_bearer/2" do
|
describe "retrieve_from_bearer/2" do
|
||||||
test "it loads any subjects from authorization headers", %{conn: conn} do
|
test "when token presense is not required it loads any subjects from authorization header(s)",
|
||||||
|
%{conn: conn} do
|
||||||
user = build_user()
|
user = build_user()
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
|
@ -63,6 +120,49 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
||||||
|
|
||||||
assert conn.assigns.current_user.id == user.id
|
assert conn.assigns.current_user.id == user.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "when token presense is required and the token is present in the database it loads the subjects from the authorization header(s)",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||||
|
|> Helpers.retrieve_from_bearer(:ash_authentication)
|
||||||
|
|
||||||
|
assert conn.assigns.current_user_with_token_required.id == user.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when token presense is required and the token is not present in the token resource it doesn't load the subjects from the authorization header(s)",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
{:ok, %{"jti" => jti}} = Jwt.peek(user.__metadata__.token)
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
Example.Repo.delete_all(from(t in Example.Token, where: t.jti == ^jti))
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||||
|
|> Helpers.retrieve_from_bearer(:ash_authentication)
|
||||||
|
|
||||||
|
refute is_map_key(conn.assigns, :current_user_with_token_required)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when token presense is required and the token has been revoked it doesn't lkoad the subjects from the authorization header(s)",
|
||||||
|
%{conn: conn} do
|
||||||
|
user = build_user_with_token_required()
|
||||||
|
|
||||||
|
:ok = TokenResource.revoke(Example.Token, user.__metadata__.token)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||||
|
|> Helpers.retrieve_from_bearer(:ash_authentication)
|
||||||
|
|
||||||
|
refute is_map_key(conn.assigns, :current_user_with_token_required)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "revoke_bearer_tokens/2" do
|
describe "revoke_bearer_tokens/2" do
|
||||||
|
|
|
@ -6,7 +6,8 @@ defmodule AshAuthenticationTest do
|
||||||
|
|
||||||
describe "authenticated_resources/0" do
|
describe "authenticated_resources/0" do
|
||||||
test "it correctly locates all authenticatable resources" do
|
test "it correctly locates all authenticatable resources" do
|
||||||
assert [Example.User] = authenticated_resources(:ash_authentication)
|
assert [Example.User, Example.UserWithTokenRequired] =
|
||||||
|
authenticated_resources(:ash_authentication)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -90,4 +90,28 @@ defmodule DataCase do
|
||||||
Ash.Resource.put_metadata(user, field, value)
|
Ash.Resource.put_metadata(user, field, value)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc "User with token required factory"
|
||||||
|
@spec build_user_with_token_required(keyword) :: Example.UserWithTokenRequired.t() | no_return
|
||||||
|
def build_user_with_token_required(attrs \\ []) do
|
||||||
|
password = password()
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
attrs
|
||||||
|
|> Map.new()
|
||||||
|
|> Map.put_new(:email, Faker.Internet.email())
|
||||||
|
|> Map.put_new(:password, password)
|
||||||
|
|> Map.put_new(:password_confirmation, password)
|
||||||
|
|
||||||
|
user =
|
||||||
|
Example.UserWithTokenRequired
|
||||||
|
|> Ash.Changeset.new()
|
||||||
|
|> Ash.Changeset.for_create(:register_with_password, attrs)
|
||||||
|
|> Example.create!()
|
||||||
|
|
||||||
|
attrs
|
||||||
|
|> Enum.reduce(user, fn {field, value}, user ->
|
||||||
|
Ash.Resource.put_metadata(user, field, value)
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,10 +22,7 @@ defmodule Example.AuthPlug do
|
||||||
Jason.encode!(%{
|
Jason.encode!(%{
|
||||||
status: :success,
|
status: :success,
|
||||||
token: token,
|
token: token,
|
||||||
user: %{
|
user: Map.take(user, ~w[username id email]a),
|
||||||
id: user.id,
|
|
||||||
username: user.username
|
|
||||||
},
|
|
||||||
strategy: strategy,
|
strategy: strategy,
|
||||||
phase: phase
|
phase: phase
|
||||||
})
|
})
|
||||||
|
|
|
@ -4,6 +4,7 @@ defmodule Example.Registry do
|
||||||
|
|
||||||
entries do
|
entries do
|
||||||
entry Example.User
|
entry Example.User
|
||||||
|
entry Example.UserWithTokenRequired
|
||||||
entry Example.Token
|
entry Example.Token
|
||||||
entry Example.UserIdentity
|
entry Example.UserIdentity
|
||||||
end
|
end
|
||||||
|
|
60
test/support/example/user_with_token_required.ex
Normal file
60
test/support/example/user_with_token_required.ex
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
defmodule Example.UserWithTokenRequired do
|
||||||
|
@moduledoc false
|
||||||
|
use Ash.Resource, data_layer: AshPostgres.DataLayer, extensions: [AshAuthentication]
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: Ecto.UUID.t(),
|
||||||
|
email: String.t(),
|
||||||
|
hashed_password: String.t(),
|
||||||
|
created_at: DateTime.t(),
|
||||||
|
updated_at: DateTime.t()
|
||||||
|
}
|
||||||
|
|
||||||
|
attributes do
|
||||||
|
uuid_primary_key :id, writable?: true
|
||||||
|
attribute :email, :ci_string, allow_nil?: false
|
||||||
|
attribute :hashed_password, :string, allow_nil?: true, sensitive?: true, private?: true
|
||||||
|
create_timestamp :created_at
|
||||||
|
update_timestamp :updated_at
|
||||||
|
end
|
||||||
|
|
||||||
|
authentication do
|
||||||
|
api Example
|
||||||
|
|
||||||
|
tokens do
|
||||||
|
enabled? true
|
||||||
|
store_all_tokens? true
|
||||||
|
require_token_presence_for_authentication? true
|
||||||
|
token_resource Example.Token
|
||||||
|
signing_secret &get_config/2
|
||||||
|
end
|
||||||
|
|
||||||
|
strategies do
|
||||||
|
password do
|
||||||
|
identity_field :email
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
actions do
|
||||||
|
defaults [:create, :read, :update, :destroy]
|
||||||
|
end
|
||||||
|
|
||||||
|
identities do
|
||||||
|
identity :email, [:email], eager_check_with: Example
|
||||||
|
end
|
||||||
|
|
||||||
|
postgres do
|
||||||
|
table "user_with_token_required"
|
||||||
|
repo(Example.Repo)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_config(path, _resource) do
|
||||||
|
value =
|
||||||
|
:ash_authentication
|
||||||
|
|> Application.get_all_env()
|
||||||
|
|> get_in(path)
|
||||||
|
|
||||||
|
{:ok, value}
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue