mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 12:52:55 +12:00
improvement(Confirmation): Store confirmation changes in the token resource.
This prevents possible PII leakage as per #47.
This commit is contained in:
parent
776bd8ea6c
commit
5febe36527
21 changed files with 530 additions and 29 deletions
|
@ -120,20 +120,16 @@ defmodule AshAuthentication.AddOn.Confirmation do
|
|||
This will generate a token with the `"act"` claim set to the confirmation
|
||||
action for the strategy, and the `"chg"` claim will contain any changes.
|
||||
"""
|
||||
@spec confirmation_token(Confirmation.t(), Changeset.t()) :: {:ok, String.t()} | :error
|
||||
@spec confirmation_token(Confirmation.t(), Changeset.t()) ::
|
||||
{:ok, String.t()} | :error | {:error, any}
|
||||
def confirmation_token(strategy, changeset) do
|
||||
changes =
|
||||
strategy.monitor_fields
|
||||
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|
||||
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|
||||
|> Map.new()
|
||||
|
||||
claims = %{"act" => strategy.confirm_action_name, "chg" => changes}
|
||||
claims = %{"act" => strategy.confirm_action_name}
|
||||
token_lifetime = strategy.token_lifetime * 3600
|
||||
|
||||
case Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime) do
|
||||
{:ok, token, _claims} -> {:ok, token}
|
||||
:error -> :error
|
||||
with {:ok, token, _claims} <-
|
||||
Jwt.token_for_user(changeset.data, claims, token_lifetime: token_lifetime),
|
||||
:ok <- Confirmation.Actions.store_changes(strategy, token, changeset) do
|
||||
{:ok, token}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,8 +5,15 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
|
|||
Provides the code interface for working with resources via confirmation.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, Resource}
|
||||
alias AshAuthentication.{AddOn.Confirmation, Errors.InvalidToken, Info, Jwt}
|
||||
alias Ash.{Changeset, Error.Framework.AssumptionFailed, Query, Resource}
|
||||
|
||||
alias AshAuthentication.{
|
||||
AddOn.Confirmation,
|
||||
Errors.InvalidToken,
|
||||
Info,
|
||||
Jwt,
|
||||
TokenResource
|
||||
}
|
||||
|
||||
@doc """
|
||||
Attempt to confirm a user.
|
||||
|
@ -27,4 +34,70 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
|
|||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Store changes in the tokens resource for later re-use.
|
||||
"""
|
||||
@spec store_changes(Confirmation.t(), String.t(), Changeset.t()) :: :ok | {:error, any}
|
||||
def store_changes(strategy, token, changeset) do
|
||||
changes =
|
||||
strategy.monitor_fields
|
||||
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
|
||||
|> Stream.map(&{to_string(&1), to_string(Changeset.get_attribute(changeset, &1))})
|
||||
|> Map.new()
|
||||
|
||||
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
|
||||
{:ok, api} <- TokenResource.Info.token_api(token_resource),
|
||||
{:ok, store_changes_action} <-
|
||||
TokenResource.Info.token_confirmation_store_changes_action_name(token_resource),
|
||||
{:ok, _token_record} <-
|
||||
token_resource
|
||||
|> Changeset.new()
|
||||
|> Changeset.set_context(%{strategy: strategy})
|
||||
|> Changeset.for_create(store_changes_action, %{
|
||||
token: token,
|
||||
extra_data: changes,
|
||||
purpose: to_string(strategy.name)
|
||||
})
|
||||
|> api.create() do
|
||||
:ok
|
||||
else
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
:error ->
|
||||
{:error,
|
||||
AssumptionFailed.exception(
|
||||
message: "Configuration error storing confirmation token data"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get changes from the tokens resource for application.
|
||||
"""
|
||||
@spec get_changes(Confirmation.t(), String.t()) :: {:ok, map} | :error
|
||||
def get_changes(strategy, jti) do
|
||||
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
|
||||
{:ok, api} <- TokenResource.Info.token_api(token_resource),
|
||||
{:ok, get_changes_action} <-
|
||||
TokenResource.Info.token_confirmation_get_changes_action_name(token_resource),
|
||||
{:ok, [token_record]} <-
|
||||
token_resource
|
||||
|> Query.new()
|
||||
|> Query.set_context(%{strategy: strategy})
|
||||
|> Query.for_read(get_changes_action, %{"jti" => jti})
|
||||
|> api.read() do
|
||||
changes =
|
||||
strategy.monitor_fields
|
||||
|> Stream.map(&to_string/1)
|
||||
|> Stream.map(&{&1, Map.get(token_record.extra_data, &1)})
|
||||
|> Stream.reject(&is_nil(elem(&1, 1)))
|
||||
|> Map.new()
|
||||
|
||||
{:ok, changes}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,7 +4,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
|
|||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias AshAuthentication.Jwt
|
||||
alias AshAuthentication.{AddOn.Confirmation.Actions, Jwt}
|
||||
|
||||
alias Ash.{
|
||||
Changeset,
|
||||
|
@ -30,9 +30,10 @@ defmodule AshAuthentication.AddOn.Confirmation.ConfirmChange do
|
|||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
with token when is_binary(token) <- Changeset.get_argument(changeset, :confirm),
|
||||
{:ok, %{"act" => action, "chg" => changes}, _} <-
|
||||
{:ok, %{"act" => action, "jti" => jti}, _} <-
|
||||
Jwt.verify(token, changeset.resource),
|
||||
true <- to_string(strategy.confirm_action_name) == action do
|
||||
true <- to_string(strategy.confirm_action_name) == action,
|
||||
{:ok, changes} <- Actions.get_changes(strategy, jti) do
|
||||
allowed_changes =
|
||||
if strategy.inhibit_updates?,
|
||||
do: Map.take(changes, Enum.map(strategy.monitor_fields, &to_string/1)),
|
||||
|
|
|
@ -170,6 +170,9 @@ defmodule AshAuthentication.Dsl do
|
|||
]
|
||||
end
|
||||
|
||||
# The result spec should be changed to `Entity.t` when Spark 0.2.18 goes out.
|
||||
@doc false
|
||||
@spec strategy(:confirmation | :oauth2 | :password) :: map
|
||||
def strategy(:password) do
|
||||
%Entity{
|
||||
name: :password,
|
||||
|
|
|
@ -59,6 +59,26 @@ defmodule AshAuthentication.TokenResource do
|
|||
default: :revoked?
|
||||
]
|
||||
]
|
||||
},
|
||||
%Spark.Dsl.Section{
|
||||
name: :confirmation,
|
||||
describe: "Configuration options for confirmation tokens",
|
||||
schema: [
|
||||
store_changes_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action used to store confirmation changes.
|
||||
""",
|
||||
default: :store_confirmation_changes
|
||||
],
|
||||
get_changes_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action used to get confirmation changes.
|
||||
""",
|
||||
default: :get_confirmation_changes
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -25,7 +25,12 @@ defmodule AshAuthentication.TokenResource.Actions do
|
|||
"""
|
||||
@spec expunge_expired(Resource.t(), keyword) :: :ok | {:error, any}
|
||||
def expunge_expired(resource, opts \\ []) do
|
||||
DataLayer.transaction(resource, fn -> expunge_inside_transaction(resource, opts) end, 5000)
|
||||
resource
|
||||
|> DataLayer.transaction(fn -> expunge_inside_transaction(resource, opts) end, 5000)
|
||||
|> case do
|
||||
{:ok, :ok} -> :ok
|
||||
{:errore, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
@ -110,7 +115,7 @@ defmodule AshAuthentication.TokenResource.Actions do
|
|||
|> Changeset.for_destroy(expunge_expired_action_name, opts)
|
||||
|> api.destroy()
|
||||
|> case do
|
||||
{:ok, _} -> {:cont, :ok}
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
defmodule AshAuthentication.TokenResource.GetConfirmationChangesPreparation do
|
||||
@moduledoc """
|
||||
Constrains a query to only records which are confirmations that match the jti
|
||||
argument.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
alias Ash.{Query, Resource.Preparation}
|
||||
require Ash.Query
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
|
||||
def prepare(query, _, _) do
|
||||
jti = Query.get_argument(query, :jti)
|
||||
strategy = query.context.strategy
|
||||
|
||||
query
|
||||
|> Query.filter(purpose: to_string(strategy.name), jti: jti)
|
||||
|> Query.filter(expires_at >= now())
|
||||
end
|
||||
end
|
|
@ -0,0 +1,27 @@
|
|||
defmodule AshAuthentication.TokenResource.StoreConfirmationChangesChange do
|
||||
@moduledoc """
|
||||
Populates the JTI based on the token argument.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Change
|
||||
alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change}
|
||||
alias AshAuthentication.Jwt
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
|
||||
def change(changeset, _opts, _context) do
|
||||
with token when byte_size(token) > 0 <- Changeset.get_argument(changeset, :token),
|
||||
{:ok, %{"jti" => jti, "exp" => exp}} <- Jwt.peek(token),
|
||||
{:ok, expires_at} <- DateTime.from_unix(exp) do
|
||||
changeset
|
||||
|> Changeset.change_attributes(jti: jti, expires_at: expires_at)
|
||||
else
|
||||
_ ->
|
||||
changeset
|
||||
|> Changeset.add_error([
|
||||
InvalidArgument.exception(field: :token, message: "is not a valid token")
|
||||
])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -60,6 +60,19 @@ defmodule AshAuthentication.TokenResource.Transformer do
|
|||
allow_nil?: true,
|
||||
writable?: true
|
||||
),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :created_at, :utc_datetime_usec,
|
||||
allow_nil?: false,
|
||||
private?: true,
|
||||
default: &DateTime.utc_now/0
|
||||
),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :updated_at, :utc_datetime_usec,
|
||||
allow_nil?: false,
|
||||
private?: true,
|
||||
default: &DateTime.utc_now/0,
|
||||
update_default: &DateTime.utc_now/0
|
||||
),
|
||||
:ok <- validate_extra_data_field(dsl_state),
|
||||
{:ok, expunge_expired_action_name} <- Info.token_expunge_expired_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
|
@ -93,11 +106,104 @@ defmodule AshAuthentication.TokenResource.Transformer do
|
|||
is_revoked_action_name,
|
||||
&build_is_revoked_action(&1, is_revoked_action_name)
|
||||
),
|
||||
:ok <- validate_is_revoked_action(dsl_state, is_revoked_action_name) do
|
||||
:ok <- validate_is_revoked_action(dsl_state, is_revoked_action_name),
|
||||
{:ok, get_confirmation_changes_action_name} <-
|
||||
Info.token_confirmation_get_changes_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
get_confirmation_changes_action_name,
|
||||
&build_get_confirmation_changes_action(&1, get_confirmation_changes_action_name)
|
||||
),
|
||||
:ok <-
|
||||
validate_get_confirmation_changes_action(
|
||||
dsl_state,
|
||||
get_confirmation_changes_action_name
|
||||
),
|
||||
{:ok, store_confirmation_changes_action_name} <-
|
||||
Info.token_confirmation_store_changes_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
store_confirmation_changes_action_name,
|
||||
&build_store_confirmation_changes_action(&1, store_confirmation_changes_action_name)
|
||||
),
|
||||
:ok <-
|
||||
validate_store_confirmation_changes_action(
|
||||
dsl_state,
|
||||
store_confirmation_changes_action_name
|
||||
) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_store_confirmation_changes_action(_dsl_state, action_name) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,
|
||||
name: :token,
|
||||
type: :string,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
changes = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
|
||||
change: TokenResource.StoreConfirmationChangesChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: action_name,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: [:extra_data, :purpose]
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_store_confirmation_changes_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_token_argument(action) do
|
||||
validate_action_has_change(action, TokenResource.StoreConfirmationChangesChange)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_get_confirmation_changes_action(_dsl_state, action_name) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: :jti,
|
||||
type: :string,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
preparations = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||
preparation: TokenResource.GetConfirmationChangesPreparation
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: action_name,
|
||||
arguments: arguments,
|
||||
preparations: preparations,
|
||||
get?: true
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_get_confirmation_changes_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
|
||||
:ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
|
||||
:ok <-
|
||||
validate_action_has_preparation(
|
||||
action,
|
||||
TokenResource.GetConfirmationChangesPreparation
|
||||
) do
|
||||
validate_field_in_values(action, :type, [:read])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_read_expired_action(_dsl_state, action_name) do
|
||||
import Ash.Filter.TemplateHelpers
|
||||
|
||||
|
@ -186,7 +292,6 @@ defmodule AshAuthentication.TokenResource.Transformer do
|
|||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: action_name,
|
||||
primary?: true,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: [:extra_data]
|
||||
|
|
|
@ -7,7 +7,7 @@ defmodule AshAuthentication.Transformer do
|
|||
|
||||
use Spark.Dsl.Transformer
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, TokenResource}
|
||||
alias AshAuthentication.Info
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
|
@ -78,11 +78,21 @@ defmodule AshAuthentication.Transformer do
|
|||
if_tokens_enabled(dsl_state, fn dsl_state ->
|
||||
with {:ok, resource} when is_truthy(resource) <-
|
||||
Info.authentication_tokens_token_resource(dsl_state),
|
||||
:ok <- assert_resource_has_extension(resource, TokenResource) do
|
||||
true <- is_atom(resource) do
|
||||
:ok
|
||||
else
|
||||
{:ok, falsy} when is_falsy(falsy) -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
{:ok, falsy} when is_falsy(falsy) ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
|
||||
false ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:authentication, :tokens, :token_resource],
|
||||
message: "is not a valid module name"
|
||||
)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
|
|
@ -149,6 +149,10 @@ defmodule AshAuthentication.Utils do
|
|||
|
||||
def maybe_set_field(map, _field, _value), do: map
|
||||
|
||||
@doc """
|
||||
Like `maybe_set_field/3` except that the value is lazily generated.
|
||||
"""
|
||||
@spec maybe_set_field_lazy(input, any, (input -> value)) :: map when input: map, value: any
|
||||
def maybe_set_field_lazy(map, field, generator)
|
||||
when is_falsy(:erlang.map_get(field, map)) and is_function(generator, 1),
|
||||
do: Map.put(map, field, generator.(map))
|
||||
|
@ -177,7 +181,8 @@ defmodule AshAuthentication.Utils do
|
|||
Resource <- module.spark_is() do
|
||||
:ok
|
||||
else
|
||||
_ -> {:error, "Module `#{inspect(module)}` is not an Ash resource"}
|
||||
_ ->
|
||||
{:error, "Module `#{inspect(module)}` is not an Ash resource"}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
2
mix.lock
2
mix.lock
|
@ -1,7 +1,7 @@
|
|||
%{
|
||||
"absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [: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, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"},
|
||||
"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.4.11", "a9fd2616b4ade692361140a0501b03533e60ad9a7a67615041b697daf802efd5", [: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]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, 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, "~> 0.2 and >= 0.2.10", [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", "0da0b4a8da4247cc14e2ba139c6b8175e33416fa962faba80725bdbf12da355e"},
|
||||
"ash": {:hex, :ash, "2.4.16", "249d2490ddd02448910b70ad9ff3abb827a0ac64c678e0209fc9d0a75609350a", [: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]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, 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, ">= 0.2.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", "ecd41ef4f7167d6f9e102f20530e38f4771c52b19fdcba06f733224ea28db1a8"},
|
||||
"ash_graphql": {:hex, :ash_graphql, "0.22.0", "3817a55e4571a8317b2ef5217635e9b188bb85c273f8101ee488897901863078", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, "~> 2.4", [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", "775ab8898aed4b762a08c9d49bfbff5c41e31643b7262253ed5ff911d85c0ee5"},
|
||||
"ash_json_api": {:hex, :ash_json_api, "0.30.1", "54e60c4862eee35ed8a9a925e5c99be2b80e36a2507355bdb0f0974defe82a8d", [:mix], [{:ash, "~> 2.0", [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", "b8b4827aa02de75a9a48d2941e813947da46b7dbfdd84cd20959dbaec103f830"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "1.1.2", "1afd8ac43e68de8a92d22c8e8f3c36552665bc1c91dcdc4e5945d4e88c606bbe", [:mix], [{:ash, "~> 2.1", [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", "0877e0d5e7ff36b7c6b0f22ce95ed8980695a0ba309cba77c60cf911c9678854"},
|
||||
|
|
|
@ -47,6 +47,8 @@ defmodule Example.Repo.Migrations.MigrateResources1 do
|
|||
create unique_index(:user, [:username], name: "user_username_index")
|
||||
|
||||
create table(:tokens, primary_key: false) do
|
||||
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||
add :extra_data, :map
|
||||
add :purpose, :text, null: false
|
||||
add :expires_at, :utc_datetime, null: false
|
|
@ -1,5 +1,25 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"now()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "created_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
|
@ -46,7 +66,7 @@
|
|||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "C03B9937AB0DBCCD13164A0772F6A052FB2DAEFBB6F125584F36399531AB5C43",
|
||||
"hash": "8E544287AF1E39AA15A68F9B7A98AA11E0E089030F01748B7C576CB75BF05A72",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
|
@ -2,8 +2,10 @@ defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do
|
|||
@moduledoc false
|
||||
use DataCase, async: true
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Ash.Changeset
|
||||
alias AshAuthentication.{AddOn.Confirmation, AddOn.Confirmation.Actions, Info}
|
||||
alias AshAuthentication.{AddOn.Confirmation, AddOn.Confirmation.Actions, Info, Jwt}
|
||||
|
||||
describe "confirm/2" do
|
||||
test "it returns an error when there is no corresponding user" do
|
||||
|
@ -50,4 +52,60 @@ defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do
|
|||
1.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "store_changes/3" do
|
||||
test "it stores only the changes in the strategy's monitored fields" do
|
||||
{:ok, strategy} = Info.strategy(Example.User, :confirm)
|
||||
user = build_user()
|
||||
|
||||
changeset =
|
||||
user
|
||||
|> Changeset.for_update(:update, %{
|
||||
"username" => username(),
|
||||
"hashed_password" => password()
|
||||
})
|
||||
|
||||
{:ok, token, _} = Jwt.token_for_user(user)
|
||||
|
||||
:ok = Actions.store_changes(strategy, token, changeset)
|
||||
|
||||
query =
|
||||
from(t in Example.Token,
|
||||
where: t.purpose == "confirm",
|
||||
order_by: [desc: t.created_at],
|
||||
limit: 1
|
||||
)
|
||||
|
||||
token = Example.Repo.one(query)
|
||||
|
||||
assert Map.has_key?(token.extra_data, "username")
|
||||
refute Map.has_key?(token.extra_data, "hashed_password")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_changes/2" do
|
||||
test "it retrieves only the changes in the strategy's monitored fields" do
|
||||
{:ok, strategy} = Info.strategy(Example.User, :confirm)
|
||||
user = build_user()
|
||||
|
||||
{:ok, _token, %{"jti" => jti, "exp" => exp}} = Jwt.token_for_user(user)
|
||||
|
||||
%Example.Token{}
|
||||
|> Ecto.Changeset.cast(
|
||||
%{
|
||||
"jti" => jti,
|
||||
"expires_at" => DateTime.from_unix!(exp),
|
||||
"purpose" => "confirm",
|
||||
"extra_data" => %{"username" => username(), "hashed_password" => password()}
|
||||
},
|
||||
~w[jti expires_at purpose extra_data]a
|
||||
)
|
||||
|> Example.Repo.insert!()
|
||||
|
||||
{:ok, changes} = Actions.get_changes(strategy, jti)
|
||||
|
||||
assert Map.has_key?(changes, "username")
|
||||
refute Map.has_key?(changes, "hashed_password")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,7 +18,33 @@ defmodule AshAuthentication.AddOn.ConfirmationTest do
|
|||
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
|
||||
assert {:ok, claims} = Jwt.peek(token)
|
||||
assert claims["act"] == to_string(strategy.confirm_action_name)
|
||||
assert claims["chg"] == %{"username" => new_username}
|
||||
end
|
||||
|
||||
test "it stores changes in the token resource" do
|
||||
{:ok, strategy} = Info.strategy(Example.User, :confirm)
|
||||
user = build_user()
|
||||
|
||||
new_username = username()
|
||||
changeset = Changeset.for_update(user, :update, %{"username" => new_username})
|
||||
|
||||
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
|
||||
assert {:ok, claims} = Jwt.peek(token)
|
||||
assert {:ok, changes} = Confirmation.Actions.get_changes(strategy, claims["jti"])
|
||||
|
||||
assert [{"username", new_username}] == Enum.to_list(changes)
|
||||
end
|
||||
|
||||
test "it does not store the changes in the confirmation token" do
|
||||
{:ok, strategy} = Info.strategy(Example.User, :confirm)
|
||||
user = build_user()
|
||||
|
||||
new_username = username()
|
||||
changeset = Changeset.for_update(user, :update, %{"username" => new_username})
|
||||
|
||||
assert {:ok, token} = Confirmation.confirmation_token(strategy, changeset)
|
||||
assert {:ok, claims} = Jwt.peek(token)
|
||||
|
||||
refute Map.has_key?(claims, "chg")
|
||||
end
|
||||
end
|
||||
|
||||
|
|
126
test/ash_authentication/token_resource/actions_test.exs
Normal file
126
test/ash_authentication/token_resource/actions_test.exs
Normal file
|
@ -0,0 +1,126 @@
|
|||
defmodule AshAuthentication.TokenResource.ActionsTest do
|
||||
@moduledoc false
|
||||
use DataCase, async: true
|
||||
alias AshAuthentication.{Jwt, TokenResource.Actions}
|
||||
|
||||
describe "read_expired/1..2" do
|
||||
test "it errors when passed a non-token resource" do
|
||||
assert {:error, _} = Actions.read_expired(Example.User)
|
||||
end
|
||||
|
||||
test "it returns all expired token records" do
|
||||
user = build_user()
|
||||
|
||||
now =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.to_unix()
|
||||
|
||||
jtis =
|
||||
-3..-1
|
||||
|> Enum.concat(1..3)
|
||||
|> Enum.map(fn i ->
|
||||
{:ok, token, %{"jti" => jti}} = Jwt.token_for_user(user, %{"exp" => now + i})
|
||||
:ok = Actions.revoke(Example.Token, token)
|
||||
{jti, i}
|
||||
end)
|
||||
|> Enum.filter(&(elem(&1, 1) <= 0))
|
||||
|> Enum.map(&elem(&1, 0))
|
||||
|> Enum.sort()
|
||||
|
||||
assert {:ok, records} = Actions.read_expired(Example.Token)
|
||||
|
||||
record_jtis =
|
||||
records
|
||||
|> Enum.map(& &1.jti)
|
||||
|> Enum.sort()
|
||||
|
||||
assert record_jtis == jtis
|
||||
end
|
||||
end
|
||||
|
||||
describe "expunge_expired/1..2" do
|
||||
test "it removes any expired tokens" do
|
||||
user = build_user()
|
||||
|
||||
now =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.to_unix()
|
||||
|
||||
10..1
|
||||
|> Enum.each(fn i ->
|
||||
{:ok, token, _} = Jwt.token_for_user(user, %{"exp" => now - i})
|
||||
:ok = Actions.revoke(Example.Token, token)
|
||||
end)
|
||||
|
||||
assert {:ok, expired} = Actions.read_expired(Example.Token)
|
||||
assert length(expired) == 10
|
||||
|
||||
assert :ok = Actions.expunge_expired(Example.Token)
|
||||
assert {:ok, []} = Actions.read_expired(Example.Token)
|
||||
end
|
||||
|
||||
test "it doesn't remove any unexpired tokens" do
|
||||
user = build_user()
|
||||
|
||||
now =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.to_unix()
|
||||
|
||||
10..19
|
||||
|> Enum.each(fn i ->
|
||||
{:ok, token, _} = Jwt.token_for_user(user, %{"exp" => now + i})
|
||||
:ok = Actions.revoke(Example.Token, token)
|
||||
end)
|
||||
|
||||
assert :ok = Actions.expunge_expired(Example.Token)
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
query = from(t in Example.Token, where: t.purpose == "revocation")
|
||||
tokens = Example.Repo.all(query)
|
||||
|
||||
assert length(tokens) == 10
|
||||
end
|
||||
end
|
||||
|
||||
describe "token_revoked?" do
|
||||
test "it returns true when the token has been revoked" do
|
||||
user = build_user()
|
||||
token = user.__metadata__.token
|
||||
|
||||
refute Actions.token_revoked?(Example.Token, token)
|
||||
|
||||
:ok = Actions.revoke(Example.Token, token)
|
||||
|
||||
assert Actions.token_revoked?(Example.Token, token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "jti_revoked?" do
|
||||
test "it returns true when the token jti has been revoked" do
|
||||
user = build_user()
|
||||
token = user.__metadata__.token
|
||||
{:ok, %{"jti" => jti}} = Jwt.peek(token)
|
||||
|
||||
refute Actions.jti_revoked?(Example.Token, jti)
|
||||
|
||||
:ok = Actions.revoke(Example.Token, token)
|
||||
|
||||
assert Actions.jti_revoked?(Example.Token, jti)
|
||||
end
|
||||
end
|
||||
|
||||
describe "valid_jti?" do
|
||||
test "it returns true when the token jti has not revoked" do
|
||||
user = build_user()
|
||||
token = user.__metadata__.token
|
||||
{:ok, %{"jti" => jti}} = Jwt.peek(token)
|
||||
|
||||
assert Actions.valid_jti?(Example.Token, jti)
|
||||
|
||||
:ok = Actions.revoke(Example.Token, token)
|
||||
|
||||
refute Actions.valid_jti?(Example.Token, jti)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -8,10 +8,12 @@ defmodule AshAuthentication.TokenResourceTest do
|
|||
test "it revokes tokens" do
|
||||
{token, %{"jti" => jti}} = build_token()
|
||||
refute TokenResource.jti_revoked?(Example.Token, jti)
|
||||
refute TokenResource.token_revoked?(Example.Token, token)
|
||||
|
||||
assert :ok = TokenResource.revoke(Example.Token, token)
|
||||
|
||||
assert TokenResource.jti_revoked?(Example.Token, jti)
|
||||
assert TokenResource.token_revoked?(Example.Token, token)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
Loading…
Reference in a new issue