feat: Add option to store all tokens when they're created. (#91)

This commit is contained in:
James Harton 2022-12-14 15:06:13 +13:00 committed by GitHub
parent 719826d66d
commit f1cd72407a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 240 additions and 53 deletions

View file

@ -1,24 +1,80 @@
spark_locals_without_parens = [
access_token_attribute_name: 1,
access_token_expires_at_attribute_name: 1,
api: 1,
auth_method: 1,
authorization_params: 1,
authorize_path: 1,
client_id: 1,
client_secret: 1,
confirm_action_name: 1,
confirm_on_create?: 1,
confirm_on_update?: 1,
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,
hash_provider: 1,
hashed_password_field: 1,
identity_field: 1,
identity_relationship_name: 1,
identity_relationship_user_id_attribute: 1,
identity_resource: 1,
inhibit_updates?: 1,
is_revoked_action_name: 1,
monitor_fields: 1,
oauth2: 1,
oauth2: 2,
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,
resettable: 0,
resettable: 1,
revoke_token_action_name: 1,
sender: 1,
sign_in_action_name: 1,
subject_name: 1
signing_algorithm: 1,
signing_secret: 1,
site: 1,
store_all_tokens?: 1,
store_changes_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
]
[
import_deps: [:ash, :spark],
import_deps: [:ash, :spark, :ash_json_api, :ash_graphql],
inputs: [
"*.{ex,exs}",
"{dev,config,lib,test}/**/*.{ex,exs}"
],
plugins: [Spark.Formatter],
locals_without_parens: spark_locals_without_parens,
export: [
locals_without_parens: spark_locals_without_parens
]

View file

@ -19,7 +19,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
Attempt to confirm a user.
"""
@spec confirm(Confirmation.t(), map, keyword) :: {:ok, Resource.record()} | {:error, any}
def confirm(strategy, params, options) do
def confirm(strategy, params, opts \\ []) do
with {:ok, api} <- Info.authentication_api(strategy.resource),
{:ok, token} <- Map.fetch(params, "confirm"),
{:ok, %{"sub" => subject}, _} <- Jwt.verify(token, strategy.resource),
@ -27,7 +27,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
user
|> Changeset.new()
|> Changeset.for_update(strategy.confirm_action_name, params)
|> api.update(options)
|> api.update(opts)
else
:error -> {:error, InvalidToken.exception(type: :confirmation)}
{:error, reason} -> {:error, reason}
@ -37,8 +37,8 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
@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
@spec store_changes(Confirmation.t(), String.t(), Changeset.t(), keyword) :: :ok | {:error, any}
def store_changes(strategy, token, changeset, opts \\ []) do
changes =
strategy.monitor_fields
|> Stream.filter(&Changeset.changing_attribute?(changeset, &1))
@ -57,7 +57,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
extra_data: changes,
purpose: to_string(strategy.name)
})
|> api.create() do
|> api.create(Keyword.merge(opts, upsert?: true)) do
:ok
else
{:error, reason} ->
@ -74,8 +74,8 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
@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
@spec get_changes(Confirmation.t(), String.t(), keyword) :: {:ok, map} | :error
def get_changes(strategy, jti, opts \\ []) do
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(strategy.resource),
{:ok, api} <- TokenResource.Info.token_api(token_resource),
{:ok, get_changes_action} <-
@ -85,7 +85,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Actions do
|> Query.new()
|> Query.set_context(%{strategy: strategy})
|> Query.for_read(get_changes_action, %{"jti" => jti})
|> api.read() do
|> api.read(opts) do
changes =
strategy.monitor_fields
|> Stream.map(&to_string/1)

View file

@ -116,6 +116,17 @@ defmodule AshAuthentication.Dsl do
""",
default: false
],
store_all_tokens?: [
type: :boolean,
doc: """
Store all tokens in the `token_resource`?
Some applications need to keep track of all tokens issued to
any user. This is optional behaviour with `ash_authentication`
in order to preserve as much performance as possible.
""",
default: false
],
signing_algorithm: [
type: :string,
doc: """

View file

@ -56,7 +56,7 @@ defmodule AshAuthentication.Jwt do
"""
alias Ash.Resource
alias AshAuthentication.{Info, Jwt.Config}
alias AshAuthentication.{Info, Jwt.Config, TokenResource}
@typedoc """
A string likely to contain a valid JWT.
@ -104,12 +104,27 @@ defmodule AshAuthentication.Jwt do
:error -> extra_claims
end
case Joken.generate_and_sign(default_claims, extra_claims, signer) do
{:ok, token, claims} -> {:ok, token, claims}
with {:ok, token, claims} <- Joken.generate_and_sign(default_claims, extra_claims, signer),
:ok <- maybe_store_token(token, resource) do
{:ok, token, claims}
else
{:error, _reason} -> :error
end
end
defp maybe_store_token(token, resource) do
if Info.authentication_tokens_store_all_tokens?(resource) do
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource) do
TokenResource.Actions.store_token(token_resource, %{
"token" => token,
"purpose" => "generic"
})
end
else
:ok
end
end
@doc """
Given a token, read it's claims without validating.
"""

View file

@ -38,6 +38,15 @@ defmodule AshAuthentication.TokenResource do
How often to scan this resource for records which have expired, and thus can be removed.
""",
default: @default_expunge_interval_hrs
],
store_token_action_name: [
type: :atom,
doc: """
The name of the action to use to store a token.
Used it `store_all_tokens?` is enabled in your authentication resource.
""",
default: :store_token
]
],
sections: [

View file

@ -112,7 +112,35 @@ defmodule AshAuthentication.TokenResource.Actions do
{:ok, revoke_token_action_name} <-
Info.token_revocation_revoke_token_action_name(resource) do
resource
|> Changeset.for_create(revoke_token_action_name, %{"token" => token}, opts)
|> Changeset.for_create(
revoke_token_action_name,
%{"token" => token},
Keyword.merge(opts, upsert?: true)
)
|> api.create()
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, reason}
end
end
end
@doc """
Store a token.
Stores a token for any purpose.
"""
@spec store_token(Resource.t(), map, keyword) :: :ok | {:error, any}
def store_token(resource, params, opts \\ []) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, api} <- Info.token_api(resource),
{:ok, store_token_action_name} <- Info.token_store_token_action_name(resource) do
resource
|> Changeset.for_create(
store_token_action_name,
params,
Keyword.merge(opts, upsert?: true)
)
|> api.create()
|> case do
{:ok, _} -> :ok

View file

@ -0,0 +1,27 @@
defmodule AshAuthentication.TokenResource.StoreTokenChange do
@moduledoc """
Stores an arbitrary token.
"""
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

View file

@ -131,11 +131,51 @@ defmodule AshAuthentication.TokenResource.Transformer do
validate_store_confirmation_changes_action(
dsl_state,
store_confirmation_changes_action_name
) do
),
{:ok, store_token_action_name} <-
Info.token_store_token_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
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, dsl_state}
end
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
validate_action_has_change(action, TokenResource.StoreTokenChange)
end
end
defp build_store_token_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.StoreTokenChange
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :create,
name: action_name,
arguments: arguments,
changes: changes,
accept: [:extra_data, :purpose]
)
end
defp build_store_confirmation_changes_action(_dsl_state, action_name) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,

View file

@ -133,7 +133,7 @@ defmodule AshAuthentication.MixProject do
{:absinthe_plug, "~> 1.5", only: [:dev, :test]},
{:ash_graphql, "~> 0.21", only: [:dev, :test]},
{:ash_json_api, "~> 0.30", only: [:dev, :test]},
{:ash_postgres, "~> 1.1", only: [:dev, :test]},
{:ash_postgres, "~> 1.2.1", only: [:dev, :test]},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:doctor, "~> 0.18", only: [:dev, :test]},

View file

@ -4,7 +4,7 @@
"ash": {:hex, :ash, "2.4.24", "fb74aaf9ee8d9c8397c1c57d2d2ebf48cc0b3a933736eab66b25cea72826ac9f", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:spark, "~> 0.2.18", [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", "e1b7ac0cf41af75f954bfb9500d1aeb54e4b3b34ec41dbe71c685b92ef8304ae"},
"ash_graphql": {:hex, :ash_graphql, "0.22.2", "35f49776864e0d7abfd7cdded61d6b600e63792cad77cac24475c3606d2ea85e", [: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", "fee44d457986587617d5ad11b262594a1ed689c81861f255dabbe628f1cde877"},
"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.3", "d025ec75a0c64dccc58ff0fce7ec4a8a800f59aab1723c29dda28c2f9f068357", [: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", "59d92075052defe30744ee7eb721da3ffcb2cd8d8081766af2d6cd70619274a9"},
"ash_postgres": {:hex, :ash_postgres, "1.2.1", "b4dfcda819e18e64c5d97bb23589e2d248f2fae303309aa766b46ebfdd655ae9", [:mix], [{:ash, ">= 2.4.0", [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]}, {:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "824441b3b01cada477a5a81d08aa4409b365bb447720132edde65679da2f6a2b"},
"assent": {:hex, :assent, "0.2.1", "46ad0ed92b72330f38c60bc03c528e8408475dc386f48d4ecd18833cfa581b9f", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "58c558b6029ffa287e15b38c8e07cd99f0b24e4846c52abad0c0a6225c4873bc"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},

View file

@ -100,7 +100,7 @@ defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do
},
~w[jti expires_at purpose extra_data]a
)
|> Example.Repo.insert!()
|> Example.Repo.insert!(on_conflict: :replace_all, conflict_target: :jti)
{:ok, changes} = Actions.get_changes(strategy, jti)

View file

@ -19,13 +19,13 @@ defmodule Example.User do
}
attributes do
uuid_primary_key(:id, writable?: true)
uuid_primary_key :id, writable?: true
attribute(:username, :ci_string, allow_nil?: false)
attribute(:hashed_password, :string, allow_nil?: true, sensitive?: true, private?: true)
attribute :username, :ci_string, allow_nil?: false
attribute :hashed_password, :string, allow_nil?: true, sensitive?: true, private?: true
create_timestamp(:created_at)
update_timestamp(:updated_at)
create_timestamp :created_at
update_timestamp :updated_at
end
actions do
@ -70,9 +70,9 @@ defmodule Example.User do
type :user
queries do
get(:get_user, :read)
list(:list_users, :read)
read_one(:current_user, :current_user)
get :get_user, :read
list :list_users, :read
read_one :current_user, :current_user
end
mutations do
@ -84,67 +84,68 @@ defmodule Example.User do
type "user"
routes do
base("/users")
get(:read)
get(:current_user, route: "/me")
index(:read)
post(:register_with_password)
base "/users"
get :read
get :current_user, route: "/me"
index :read
post :register_with_password
end
end
postgres do
table("user")
table "user"
repo(Example.Repo)
end
authentication do
api(Example)
api Example
tokens do
enabled?(true)
token_resource(Example.Token)
signing_secret(&get_config/2)
enabled? true
store_all_tokens? true
token_resource Example.Token
signing_secret &get_config/2
end
add_ons do
confirmation :confirm do
monitor_fields([:username])
inhibit_updates?(true)
monitor_fields [:username]
inhibit_updates? true
sender(fn user, token ->
sender fn user, token ->
Logger.debug("Confirmation request for user #{user.username}, token #{inspect(token)}")
end)
end
end
end
strategies do
password :password do
resettable do
sender(fn user, token ->
sender fn user, token ->
Logger.debug(
"Password reset request for user #{user.username}, token #{inspect(token)}"
)
end)
end
end
end
oauth2 :oauth2 do
client_id(&get_config/2)
redirect_uri(&get_config/2)
client_secret(&get_config/2)
site(&get_config/2)
authorize_path(&get_config/2)
token_path(&get_config/2)
user_path(&get_config/2)
authorization_params(scope: "openid profile email")
auth_method(:client_secret_post)
identity_resource(Example.UserIdentity)
client_id &get_config/2
redirect_uri &get_config/2
client_secret &get_config/2
site &get_config/2
authorize_path &get_config/2
token_path &get_config/2
user_path &get_config/2
authorization_params scope: "openid profile email"
auth_method :client_secret_post
identity_resource Example.UserIdentity
end
end
end
identities do
identity(:username, [:username], eager_check_with: Example)
identity :username, [:username], eager_check_with: Example
end
def get_config(path, _resource) do