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 = [
|
||||
access_token_attribute_name: 1,
|
||||
access_token_expires_at_attribute_name: 1,
|
||||
api: 1,
|
||||
auth0: 0,
|
||||
auth0: 1,
|
||||
auth0: 2,
|
||||
auth_method: 1,
|
||||
|
@ -12,16 +11,17 @@ spark_locals_without_parens = [
|
|||
confirm_action_name: 1,
|
||||
confirm_on_create?: 1,
|
||||
confirm_on_update?: 1,
|
||||
confirmation: 0,
|
||||
confirmation: 1,
|
||||
confirmation: 2,
|
||||
confirmation_required?: 1,
|
||||
confirmed_at_field: 1,
|
||||
destroy_action_name: 1,
|
||||
enabled?: 1,
|
||||
expunge_expired_action_name: 1,
|
||||
expunge_interval: 1,
|
||||
get_by_subject_action_name: 1,
|
||||
get_changes_action_name: 1,
|
||||
get_token_action_name: 1,
|
||||
hash_provider: 1,
|
||||
hashed_password_field: 1,
|
||||
identity_field: 1,
|
||||
|
@ -31,21 +31,22 @@ spark_locals_without_parens = [
|
|||
inhibit_updates?: 1,
|
||||
is_revoked_action_name: 1,
|
||||
monitor_fields: 1,
|
||||
oauth2: 0,
|
||||
oauth2: 1,
|
||||
oauth2: 2,
|
||||
password: 0,
|
||||
password: 1,
|
||||
password: 2,
|
||||
password_confirmation_field: 1,
|
||||
password_field: 1,
|
||||
password_reset_action_name: 1,
|
||||
private_key: 1,
|
||||
read_action_name: 1,
|
||||
read_expired_action_name: 1,
|
||||
redirect_uri: 1,
|
||||
refresh_token_attribute_name: 1,
|
||||
register_action_name: 1,
|
||||
registration_enabled?: 1,
|
||||
request_password_reset_action_name: 1,
|
||||
require_token_presence_for_authentication?: 1,
|
||||
resettable: 0,
|
||||
resettable: 1,
|
||||
revoke_token_action_name: 1,
|
||||
|
@ -57,17 +58,11 @@ spark_locals_without_parens = [
|
|||
store_all_tokens?: 1,
|
||||
store_changes_action_name: 1,
|
||||
store_token_action_name: 1,
|
||||
strategy_attribute_name: 1,
|
||||
subject_name: 1,
|
||||
token_lifetime: 1,
|
||||
token_path: 1,
|
||||
token_resource: 1,
|
||||
uid_attribute_name: 1,
|
||||
upsert_action_name: 1,
|
||||
user_id_attribute_name: 1,
|
||||
user_path: 1,
|
||||
user_relationship_name: 1,
|
||||
user_resource: 1
|
||||
user_path: 1
|
||||
]
|
||||
|
||||
[
|
||||
|
|
|
@ -129,7 +129,7 @@ defmodule AshAuthentication do
|
|||
## Example
|
||||
|
||||
iex> authenticated_resources(:ash_authentication)
|
||||
[Example.User]
|
||||
[Example.User, Example.UserWithTokenRequired]
|
||||
|
||||
"""
|
||||
@spec authenticated_resources(atom) :: [Resource.t()]
|
||||
|
|
|
@ -129,6 +129,20 @@ defmodule AshAuthentication.Dsl do
|
|||
""",
|
||||
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: [
|
||||
type: :string,
|
||||
doc: """
|
||||
|
|
|
@ -13,9 +13,13 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
||||
def store_in_session(conn, user) when is_struct(user) do
|
||||
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
|
||||
|
||||
def store_in_session(conn, _), do: conn
|
||||
|
@ -60,17 +64,39 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
def retrieve_from_session(conn, otp_app) do
|
||||
otp_app
|
||||
|> AshAuthentication.authenticated_resources()
|
||||
|> Stream.map(&{&1, Info.authentication_options(&1)})
|
||||
|> Enum.reduce(conn, fn {resource, options}, conn ->
|
||||
current_subject_name = current_subject_name(options.subject_name)
|
||||
|> Stream.map(
|
||||
&{&1, Info.authentication_options(&1),
|
||||
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),
|
||||
{: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
|
||||
with token when is_binary(token) <-
|
||||
Conn.get_session(conn, "#{options.subject_name}_token"),
|
||||
{:ok, %{"sub" => subject, "jti" => jti}, _} <- Jwt.verify(token, otp_app),
|
||||
{:ok, [_]} <-
|
||||
TokenResource.Actions.get_token(token_resource, %{
|
||||
"jti" => jti,
|
||||
"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
|
||||
|
||||
|
@ -90,7 +116,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
|> Stream.filter(&String.starts_with?(&1, "Bearer "))
|
||||
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
||||
|> 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, subject_name} <- Info.authentication_subject_name(resource),
|
||||
current_subject_name <- current_subject_name(subject_name) do
|
||||
|
@ -102,6 +129,21 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
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 """
|
||||
Revoke all authorization header(s).
|
||||
|
||||
|
|
|
@ -44,9 +44,18 @@ defmodule AshAuthentication.TokenResource do
|
|||
doc: """
|
||||
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
|
||||
],
|
||||
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: [
|
||||
|
|
|
@ -102,7 +102,7 @@ defmodule AshAuthentication.TokenResource.Actions do
|
|||
@doc """
|
||||
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.
|
||||
"""
|
||||
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
|
||||
|
@ -149,6 +149,20 @@ defmodule AshAuthentication.TokenResource.Actions do
|
|||
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
|
||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||
{: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,
|
||||
&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}
|
||||
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
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_token_argument(action) do
|
||||
|
|
|
@ -215,11 +215,10 @@ defmodule AshAuthentication.Utils do
|
|||
"""
|
||||
@spec assert_is_module(module) :: :ok | {:error, term}
|
||||
def assert_is_module(module) when is_atom(module) do
|
||||
module.module_info()
|
||||
:ok
|
||||
rescue
|
||||
_ ->
|
||||
{:error, "Argument `#{inspect(module)}` is not a valid module"}
|
||||
case Code.ensure_compiled(module) do
|
||||
{:module, _} -> :ok
|
||||
_ -> {:error, "Argument `#{inspect(module)}` is not a valid module"}
|
||||
end
|
||||
end
|
||||
|
||||
def assert_is_module(module),
|
||||
|
|
|
@ -18,6 +18,11 @@ defmodule AshAuthentication.Verifier do
|
|||
@spec before?(any) :: boolean
|
||||
def before?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec after_compile? :: boolean
|
||||
def after_compile?, do: true
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@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
|
||||
@moduledoc false
|
||||
use DataCase, async: true
|
||||
alias AshAuthentication.{Jwt, Plug.Helpers}
|
||||
alias AshAuthentication.{Jwt, Plug.Helpers, TokenResource}
|
||||
import Plug.Test, only: [conn: 3]
|
||||
alias Plug.Conn
|
||||
|
||||
|
@ -15,7 +15,7 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
end
|
||||
|
||||
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()
|
||||
subject = AshAuthentication.user_to_subject(user)
|
||||
|
||||
|
@ -25,6 +25,17 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
|
||||
assert conn.private.plug_session["user"] == subject
|
||||
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
|
||||
|
||||
describe "load_subjects/2" do
|
||||
|
@ -39,7 +50,9 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
end
|
||||
|
||||
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()
|
||||
subject = AshAuthentication.user_to_subject(user)
|
||||
|
||||
|
@ -50,10 +63,54 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
|
||||
assert conn.assigns.current_user.id == user.id
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
conn =
|
||||
|
@ -63,6 +120,49 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
|
||||
assert conn.assigns.current_user.id == user.id
|
||||
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
|
||||
|
||||
describe "revoke_bearer_tokens/2" do
|
||||
|
|
|
@ -6,7 +6,8 @@ defmodule AshAuthenticationTest do
|
|||
|
||||
describe "authenticated_resources/0" 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
|
||||
|
|
|
@ -90,4 +90,28 @@ defmodule DataCase do
|
|||
Ash.Resource.put_metadata(user, field, value)
|
||||
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
|
||||
|
|
|
@ -22,10 +22,7 @@ defmodule Example.AuthPlug do
|
|||
Jason.encode!(%{
|
||||
status: :success,
|
||||
token: token,
|
||||
user: %{
|
||||
id: user.id,
|
||||
username: user.username
|
||||
},
|
||||
user: Map.take(user, ~w[username id email]a),
|
||||
strategy: strategy,
|
||||
phase: phase
|
||||
})
|
||||
|
|
|
@ -4,6 +4,7 @@ defmodule Example.Registry do
|
|||
|
||||
entries do
|
||||
entry Example.User
|
||||
entry Example.UserWithTokenRequired
|
||||
entry Example.Token
|
||||
entry Example.UserIdentity
|
||||
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