mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-21 05:43:05 +12:00
improvement(TokenResource)!: Move TokenRevocation
-> TokenResource
.
This paves the way to fix #47.
This commit is contained in:
parent
e88a516b22
commit
776bd8ea6c
44 changed files with 994 additions and 988 deletions
|
@ -7,7 +7,7 @@ defmodule AshAuthentication.Application do
|
|||
@doc false
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
[AshAuthentication.TokenRevocation.Expunger]
|
||||
[AshAuthentication.TokenResource.Expunger]
|
||||
|> maybe_append(start_dev_server?(), {DevServer, []})
|
||||
|> maybe_append(start_repo?(), {Example.Repo, []})
|
||||
|> Supervisor.start_link(strategy: :one_for_one, name: AshAuthentication.Supervisor)
|
||||
|
|
|
@ -137,16 +137,16 @@ defmodule AshAuthentication.Dsl do
|
|||
""",
|
||||
default: @default_token_lifetime_days * 24
|
||||
],
|
||||
revocation_resource: [
|
||||
type: {:behaviour, Resource},
|
||||
token_resource: [
|
||||
type: {:or, [{:behaviour, Resource}, {:in, [false]}]},
|
||||
doc: """
|
||||
The resource used to store token revocation information.
|
||||
The resource used to store token information.
|
||||
|
||||
If token generation is enabled for this resource, we need a place to
|
||||
store revocation information. This option is the name of an Ash
|
||||
Resource which has the `AshAuthentication.TokenRevocation` extension
|
||||
present.
|
||||
"""
|
||||
store information about tokens, such as revocations and in-flight
|
||||
confirmations.
|
||||
""",
|
||||
required: true
|
||||
]
|
||||
]
|
||||
},
|
||||
|
|
|
@ -8,7 +8,7 @@ defmodule AshAuthentication.Jwt.Config do
|
|||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, Jwt, TokenRevocation}
|
||||
alias AshAuthentication.{Info, Jwt, TokenResource}
|
||||
alias Joken.{Config, Signer}
|
||||
|
||||
@doc """
|
||||
|
@ -95,9 +95,9 @@ defmodule AshAuthentication.Jwt.Config do
|
|||
"""
|
||||
@spec validate_jti(String.t(), any, Resource.t() | any) :: boolean
|
||||
def validate_jti(jti, _claims, resource) when is_atom(resource) do
|
||||
case Info.authentication_tokens_revocation_resource(resource) do
|
||||
{:ok, revocation_resource} ->
|
||||
TokenRevocation.valid?(revocation_resource, jti)
|
||||
case Info.authentication_tokens_token_resource(resource) do
|
||||
{:ok, token_resource} ->
|
||||
TokenResource.Actions.valid_jti?(token_resource, jti)
|
||||
|
||||
_ ->
|
||||
false
|
||||
|
|
|
@ -4,7 +4,7 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
"""
|
||||
|
||||
alias Ash.{PlugHelpers, Resource}
|
||||
alias AshAuthentication.{Info, Jwt, TokenRevocation}
|
||||
alias AshAuthentication.{Info, Jwt, TokenResource}
|
||||
alias Plug.Conn
|
||||
|
||||
@doc """
|
||||
|
@ -116,8 +116,8 @@ defmodule AshAuthentication.Plug.Helpers do
|
|||
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
||||
|> Enum.reduce(conn, fn token, conn ->
|
||||
with {:ok, resource} <- Jwt.token_to_resource(token, otp_app),
|
||||
{:ok, revocation_resource} <- Info.authentication_tokens_revocation_resource(resource),
|
||||
:ok <- TokenRevocation.revoke(revocation_resource, token) do
|
||||
{:ok, token_resource} <- Info.authentication_tokens_token_resource(resource),
|
||||
:ok <- TokenResource.Actions.revoke(token_resource, token) do
|
||||
conn
|
||||
else
|
||||
_ -> conn
|
||||
|
|
161
lib/ash_authentication/token_resource.ex
Normal file
161
lib/ash_authentication/token_resource.ex
Normal file
|
@ -0,0 +1,161 @@
|
|||
defmodule AshAuthentication.TokenResource do
|
||||
@default_expunge_interval_hrs 12
|
||||
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :token,
|
||||
describe: "Configuration options for this token resource",
|
||||
schema: [
|
||||
api: [
|
||||
type: {:behaviour, Ash.Api},
|
||||
doc: """
|
||||
The Ash API to use to access this resource.
|
||||
""",
|
||||
required: true
|
||||
],
|
||||
expunge_expired_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action used to remove expired tokens.
|
||||
""",
|
||||
default: :expunge_expired
|
||||
],
|
||||
read_expired_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action use to find all expired tokens.
|
||||
|
||||
Used internally by the `expunge_expired` action.
|
||||
""",
|
||||
default: :read_expired
|
||||
],
|
||||
expunge_interval: [
|
||||
type: :pos_integer,
|
||||
doc: """
|
||||
How often to remove expired records.
|
||||
|
||||
How often to scan this resource for records which have expired, and thus can be removed.
|
||||
""",
|
||||
default: @default_expunge_interval_hrs
|
||||
]
|
||||
],
|
||||
sections: [
|
||||
%Spark.Dsl.Section{
|
||||
name: :revocation,
|
||||
describe: "Configuration options for token revocation",
|
||||
schema: [
|
||||
revoke_token_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action used to revoke tokens.
|
||||
""",
|
||||
default: :revoke_token
|
||||
],
|
||||
is_revoked_action_name: [
|
||||
type: :atom,
|
||||
doc: """
|
||||
The name of the action used to check if a token is revoked.
|
||||
""",
|
||||
default: :revoked?
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
This is an Ash resource extension which generates the default token resource.
|
||||
|
||||
The token resource is used to store information about tokens that should not
|
||||
be shared with the end user. It does not actually contain any tokens.
|
||||
|
||||
For example:
|
||||
|
||||
* When an authentication token has been revoked
|
||||
* When a confirmation token has changes to apply
|
||||
|
||||
## Storage
|
||||
|
||||
The information stored in this resource is essentially ephemeral - all tokens
|
||||
have an expiry date, so it doesn't make sense to keep them after that time has
|
||||
passed. However, if you have any tokens with very long expiry times then we
|
||||
suggest you store this resource in a resilient data-layer such as Postgres.
|
||||
|
||||
## Usage
|
||||
|
||||
There is no need to define any attributes or actions (although you can if you
|
||||
want). The extension will wire up everything that's needed for the token
|
||||
system to function.
|
||||
|
||||
```
|
||||
defmodule MyApp.Accounts.Token do
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenResource]
|
||||
|
||||
token do
|
||||
api MyApp.Accounts
|
||||
end
|
||||
|
||||
postgres do
|
||||
table "tokens"
|
||||
repo MyApp.Repo
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Whilst it is possible to have multiple token resources, there is no need to do
|
||||
so.
|
||||
|
||||
## Dsl
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.TokenResource
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [TokenResource.Transformer]
|
||||
|
||||
@doc """
|
||||
Has the token been revoked?
|
||||
|
||||
Similar to `jti_revoked?/2..3` except that it extracts the JTI from the token,
|
||||
rather than relying on it to be passed in.
|
||||
"""
|
||||
@spec token_revoked?(Resource.t(), String.t(), keyword) :: boolean
|
||||
defdelegate token_revoked?(resource, token, opts \\ []), to: TokenResource.Actions
|
||||
|
||||
@doc """
|
||||
Has the token been revoked?
|
||||
|
||||
Similar to `token-revoked?/2..3` except that rather than extracting the JTI
|
||||
from the token, assumes that it's being passed in directly.
|
||||
"""
|
||||
@spec jti_revoked?(Resource.t(), String.t(), keyword) :: boolean
|
||||
defdelegate jti_revoked?(resource, jti, opts \\ []), to: TokenResource.Actions
|
||||
|
||||
@doc """
|
||||
Revoke a token.
|
||||
|
||||
Extracts the JTI from the provided token and uses it to generate a revocationr
|
||||
record.
|
||||
"""
|
||||
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
|
||||
defdelegate revoke(resource, token, opts \\ []), to: TokenResource.Actions
|
||||
|
||||
@doc """
|
||||
Remove all expired records.
|
||||
"""
|
||||
@spec expunge_expired(Resource.t(), keyword) :: :ok | {:error, any}
|
||||
defdelegate expunge_expired(resource, opts \\ []), to: TokenResource.Actions
|
||||
end
|
119
lib/ash_authentication/token_resource/actions.ex
Normal file
119
lib/ash_authentication/token_resource/actions.ex
Normal file
|
@ -0,0 +1,119 @@
|
|||
defmodule AshAuthentication.TokenResource.Actions do
|
||||
@moduledoc """
|
||||
The code interface for interacting with the token resource.
|
||||
"""
|
||||
|
||||
alias Ash.{Changeset, DataLayer, Query, Resource}
|
||||
alias AshAuthentication.{TokenResource, TokenResource.Info}
|
||||
|
||||
import AshAuthentication.Utils
|
||||
|
||||
@doc false
|
||||
@spec read_expired(Resource.t(), keyword) :: {:ok, [Resource.record()]} | {:error, any}
|
||||
def read_expired(resource, opts \\ []) do
|
||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||
{:ok, api} <- Info.token_api(resource),
|
||||
{:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource) do
|
||||
resource
|
||||
|> Query.for_read(read_expired_action_name, opts)
|
||||
|> api.read()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Remove all expired records.
|
||||
"""
|
||||
@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)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Has the token been revoked?
|
||||
|
||||
Similar to `jti_revoked?/2..3` except that it extracts the JTI from the token,
|
||||
rather than relying on it to be passed in.
|
||||
"""
|
||||
@spec token_revoked?(Resource.t(), String.t(), keyword) :: boolean
|
||||
def token_revoked?(resource, token, opts \\ []) do
|
||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||
{:ok, api} <- Info.token_api(resource),
|
||||
{:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do
|
||||
resource
|
||||
|> Query.for_read(is_revoked_action_name, %{"token" => token}, opts)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, []} -> false
|
||||
{:ok, _} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Has the token been revoked?
|
||||
|
||||
Similar to `token-revoked?/2..3` except that rather than extracting the JTI
|
||||
from the token, assumes that it's being passed in directly.
|
||||
"""
|
||||
@spec jti_revoked?(Resource.t(), String.t(), keyword) :: boolean
|
||||
def jti_revoked?(resource, jti, opts \\ []) do
|
||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||
{:ok, api} <- Info.token_api(resource),
|
||||
{:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(resource) do
|
||||
resource
|
||||
|> Query.for_read(is_revoked_action_name, %{"jti" => jti}, opts)
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, []} -> false
|
||||
{:ok, _} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec valid_jti?(Resource.t(), String.t(), keyword) :: boolean
|
||||
def valid_jti?(resource, jti, opts \\ []), do: !jti_revoked?(resource, jti, opts)
|
||||
|
||||
@doc """
|
||||
Revoke a token.
|
||||
|
||||
Extracts the JTI from the provided token and uses it to generate a revocationr
|
||||
record.
|
||||
"""
|
||||
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
|
||||
def revoke(resource, token, opts \\ []) do
|
||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||
{:ok, api} <- Info.token_api(resource),
|
||||
{: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)
|
||||
|> api.create()
|
||||
|> case do
|
||||
{:ok, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp expunge_inside_transaction(resource, opts) do
|
||||
with :ok <- assert_resource_has_extension(resource, TokenResource),
|
||||
{:ok, api} <- Info.token_api(resource),
|
||||
{:ok, read_expired_action_name} <- Info.token_read_expired_action_name(resource),
|
||||
query <- Query.for_read(resource, read_expired_action_name, opts),
|
||||
{:ok, expired} <- api.read(query),
|
||||
{:ok, expunge_expired_action_name} <- Info.token_expunge_expired_action_name(resource) do
|
||||
Enum.reduce_while(expired, :ok, fn record, :ok ->
|
||||
record
|
||||
|> Changeset.for_destroy(expunge_expired_action_name, opts)
|
||||
|> api.destroy()
|
||||
|> case do
|
||||
{:ok, _} -> {:cont, :ok}
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
94
lib/ash_authentication/token_resource/expunger.ex
Normal file
94
lib/ash_authentication/token_resource/expunger.ex
Normal file
|
@ -0,0 +1,94 @@
|
|||
defmodule AshAuthentication.TokenResource.Expunger do
|
||||
@refresh_interval_hrs 1
|
||||
|
||||
@moduledoc """
|
||||
A `GenServer` which periodically removes expired token revocations.
|
||||
|
||||
Scans all token revocation resources based on their configured expunge
|
||||
interval and removes any expired records.
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.Token do
|
||||
use Ash.Resource,
|
||||
extensions: [AshAuthentication.TokenResource]
|
||||
|
||||
token do
|
||||
api MyApp.Accounts
|
||||
expunge_interval 12
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
This server is started automatically as part of the `:ash_authentication`
|
||||
supervision tree.
|
||||
|
||||
Scans through all resources every #{if @refresh_interval_hrs == 1,
|
||||
do: "hour",
|
||||
else: "#{@refresh_interval_hrs} hours"} checking to make sure that no
|
||||
resources have been added or removed which need checking. This allows us to
|
||||
support dynamically loaded and hot-reloaded modules.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
alias AshAuthentication.{TokenResource, TokenResource.Actions, TokenResource.Info}
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: GenServer.on_start()
|
||||
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(any) :: {:ok, :timer.tref()}
|
||||
def init(_) do
|
||||
state =
|
||||
%{}
|
||||
|> refresh_state()
|
||||
|
||||
{:ok, _} = :timer.send_interval(@refresh_interval_hrs * 60 * 60 * 1000, :refresh_state)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec handle_info(any, map) :: {:noreply, map}
|
||||
def handle_info(:refresh_state, state) do
|
||||
state =
|
||||
state
|
||||
|> refresh_state()
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info({:expunge, resource}, state) when :erlang.is_map_key(resource, state) do
|
||||
resource
|
||||
|> Actions.expunge_expired()
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_info(_, state), do: {:noreply, state}
|
||||
|
||||
defp refresh_state(state) do
|
||||
:code.all_loaded()
|
||||
|> Stream.map(&elem(&1, 0))
|
||||
|> Stream.filter(&function_exported?(&1, :spark_dsl_config, 0))
|
||||
|> Stream.filter(&(TokenResource in Spark.extensions(&1)))
|
||||
|> Stream.map(&{&1, Info.token_expunge_interval!(&1) * 60 * 60 * 1000})
|
||||
|> Enum.reduce(state, fn {module, interval}, state ->
|
||||
case Map.get(state, module) do
|
||||
%{interval: ^interval, timer: timer} when not is_nil(timer) ->
|
||||
state
|
||||
|
||||
%{timer: timer} when not is_nil(timer) ->
|
||||
:timer.cancel(timer)
|
||||
{:ok, timer} = :timer.send_interval(interval, {:expunge, module})
|
||||
Map.put(state, module, %{interval: interval, timer: timer})
|
||||
|
||||
_ ->
|
||||
{:ok, timer} = :timer.send_interval(interval, {:expunge, module})
|
||||
Map.put(state, module, %{interval: interval, timer: timer})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
10
lib/ash_authentication/token_resource/info.ex
Normal file
10
lib/ash_authentication/token_resource/info.ex
Normal file
|
@ -0,0 +1,10 @@
|
|||
defmodule AshAuthentication.TokenResource.Info do
|
||||
@moduledoc """
|
||||
Introspection functions for the `AshAuthentication.TokenResource` Ash
|
||||
extension.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.TokenResource,
|
||||
sections: [:token]
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
defmodule AshAuthentication.TokenResource.IsRevokedPreparation do
|
||||
@moduledoc """
|
||||
Constrains a query to only records which are revocations that match the token
|
||||
or jti argument.
|
||||
"""
|
||||
|
||||
use Ash.Resource.Preparation
|
||||
alias Ash.{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, _opts, _context) do
|
||||
case get_jti(query) do
|
||||
{:ok, jti} ->
|
||||
query
|
||||
|> Query.filter(purpose: "revocation", jti: jti)
|
||||
|> Query.limit(1)
|
||||
|
||||
:error ->
|
||||
Query.limit(query, 0)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_jti(query) do
|
||||
[:jti, :token]
|
||||
|> Stream.map(&{&1, Query.get_argument(query, &1)})
|
||||
|> Stream.filter(&elem(&1, 1))
|
||||
|> Enum.reduce_while(:error, fn
|
||||
{:jti, jti}, _ ->
|
||||
{:halt, {:ok, jti}}
|
||||
|
||||
{:token, token}, _ ->
|
||||
case Jwt.peek(token) do
|
||||
{:ok, %{"jti" => jti}} -> {:halt, {:ok, jti}}
|
||||
_ -> {:cont, :error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
27
lib/ash_authentication/token_resource/revoke_token_change.ex
Normal file
27
lib/ash_authentication/token_resource/revoke_token_change.ex
Normal file
|
@ -0,0 +1,27 @@
|
|||
defmodule AshAuthentication.TokenResource.RevokeTokenChange do
|
||||
@moduledoc """
|
||||
Generates a revocation record for a given 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, purpose: "revocation", expires_at: expires_at)
|
||||
else
|
||||
_ ->
|
||||
changeset
|
||||
|> Changeset.add_error([
|
||||
InvalidArgument.exception(field: :token, message: "is not a valid token")
|
||||
])
|
||||
end
|
||||
end
|
||||
end
|
258
lib/ash_authentication/token_resource/transformer.ex
Normal file
258
lib/ash_authentication/token_resource/transformer.ex
Normal file
|
@ -0,0 +1,258 @@
|
|||
defmodule AshAuthentication.TokenResource.Transformer do
|
||||
@moduledoc """
|
||||
The token resource transformer.
|
||||
|
||||
Sets up the default schema and actions for the token resource.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
require Ash.Expr
|
||||
alias Ash.{Resource, Type}
|
||||
alias AshAuthentication.{TokenResource, TokenResource.Info}
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec after?(any) :: boolean()
|
||||
def after?(Resource.Transformers.ValidatePrimaryActions), do: true
|
||||
def after?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec before?(any) :: boolean
|
||||
def before?(Resource.Transformers.CachePrimaryKey), do: true
|
||||
def before?(Resource.Transformers.DefaultAccept), do: true
|
||||
def before?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec transform(map) ::
|
||||
:ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
|
||||
def transform(dsl_state) do
|
||||
with {:ok, _api} <- validate_api_presence(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :jti, :string,
|
||||
primary_key?: true,
|
||||
allow_nil?: false,
|
||||
sensitive?: true,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_jti_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :expires_at, :utc_datetime,
|
||||
allow_nil?: false,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_expires_at_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :purpose, :string,
|
||||
allow_nil?: false,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_purpose_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :extra_data, :map,
|
||||
allow_nil?: true,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_extra_data_field(dsl_state),
|
||||
{:ok, expunge_expired_action_name} <- Info.token_expunge_expired_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
expunge_expired_action_name,
|
||||
&build_expunge_expired_action(&1, expunge_expired_action_name)
|
||||
),
|
||||
:ok <- validate_expunge_expired_action(dsl_state, expunge_expired_action_name),
|
||||
{:ok, read_expired_action_name} <- Info.token_read_expired_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
read_expired_action_name,
|
||||
&build_read_expired_action(&1, read_expired_action_name)
|
||||
),
|
||||
:ok <- validate_read_expired_action(dsl_state, read_expired_action_name),
|
||||
{:ok, revoke_token_action_name} <-
|
||||
Info.token_revocation_revoke_token_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
revoke_token_action_name,
|
||||
&build_revoke_token_action(&1, revoke_token_action_name)
|
||||
),
|
||||
:ok <- validate_revoke_token_action(dsl_state, revoke_token_action_name),
|
||||
{:ok, is_revoked_action_name} <- Info.token_revocation_is_revoked_action_name(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(
|
||||
dsl_state,
|
||||
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, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_read_expired_action(_dsl_state, action_name) do
|
||||
import Ash.Filter.TemplateHelpers
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: action_name,
|
||||
filter: expr(expires_at < now())
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_read_expired_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name) do
|
||||
validate_field_in_values(action, :type, [:read])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_is_revoked_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, :jti, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_action_has_preparation(action, TokenResource.IsRevokedPreparation) do
|
||||
validate_field_in_values(action, :type, [:read])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_is_revoked_action(_dsl_state, action_name) do
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: :token,
|
||||
type: :string,
|
||||
allow_nil?: true,
|
||||
sensitive?: true
|
||||
),
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: :jti,
|
||||
type: :string,
|
||||
allow_nil?: true,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
preparations = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
|
||||
preparation: TokenResource.IsRevokedPreparation
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: action_name,
|
||||
get?: true,
|
||||
preparations: preparations,
|
||||
arguments: arguments
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_token_argument(action) do
|
||||
with :ok <-
|
||||
validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do
|
||||
validate_action_argument_option(action, :token, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_revoke_token_action(dsl_state, revoke_token_action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, revoke_token_action_name),
|
||||
:ok <- validate_token_argument(action) do
|
||||
validate_action_has_change(action, TokenResource.RevokeTokenChange)
|
||||
end
|
||||
end
|
||||
|
||||
defp build_revoke_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.RevokeTokenChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: action_name,
|
||||
primary?: true,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: [:extra_data]
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_expunge_expired_action(dsl_state, action_name) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, action_name) do
|
||||
validate_field_in_values(action, :type, [:destroy])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_expunge_expired_action(_dsl_state, action_name),
|
||||
do: Transformer.build_entity(Resource.Dsl, [:actions], :destroy, name: action_name)
|
||||
|
||||
defp validate_api_presence(dsl_state) do
|
||||
case Transformer.get_option(dsl_state, [:token], :api) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:token, :api],
|
||||
message: "An API module must be present"
|
||||
)}
|
||||
|
||||
api ->
|
||||
{:ok, api}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_jti_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :jti),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :primary_key?, [true]) do
|
||||
validate_attribute_option(attribute, resource, :private?, [false])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_expires_at_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :expires_at),
|
||||
:ok <-
|
||||
validate_attribute_option(attribute, resource, :type, [Type.UtcDatetime, :utc_datetime]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do
|
||||
validate_attribute_option(attribute, resource, :writable?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_purpose_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :purpose),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do
|
||||
validate_attribute_option(attribute, resource, :writable?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_extra_data_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :extra_data),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Type.Map, :map]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [true]) do
|
||||
validate_attribute_option(attribute, resource, :writable?, [true])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,191 +0,0 @@
|
|||
defmodule AshAuthentication.TokenRevocation do
|
||||
@dsl [
|
||||
%Spark.Dsl.Section{
|
||||
name: :revocation,
|
||||
describe: "Configure revocation options for this resource",
|
||||
schema: [
|
||||
api: [
|
||||
type: {:behaviour, Ash.Api},
|
||||
doc: """
|
||||
The Ash API to use to access this resource.
|
||||
""",
|
||||
required: true
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@moduledoc """
|
||||
An Ash extension which generates the defaults for a token revocation resource.
|
||||
|
||||
The token revocation resource is used to store the Json Web Token ID (jti) and
|
||||
expiry times of any tokens which have been revoked. These will be removed
|
||||
once the expiry date has passed, so should only ever be a fairly small number
|
||||
of rows.
|
||||
|
||||
## Storage
|
||||
|
||||
Token revocations are ephemeral, but their lifetime directly correlates to the
|
||||
lifetime of your tokens - ie if you have a long expiry time on your tokens you
|
||||
have to keep the revocation records for longer. Therefore we suggest a (semi)
|
||||
permanent data layer, such as Postgres.
|
||||
|
||||
## Usage
|
||||
|
||||
There is no need to define any attributes, etc. The extension will generate
|
||||
them all for you. As there is no other use-case for this resource, it's
|
||||
unlikely that you will need to customise it.
|
||||
|
||||
```elixir
|
||||
defmodule MyApp.Accounts.TokenRevocation do
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenRevocation]
|
||||
|
||||
revocation do
|
||||
api MyApp.Accounts
|
||||
end
|
||||
|
||||
postgres do
|
||||
table "token_revocations"
|
||||
repo MyApp.Repo
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Whilst it's possible to have multiple token revocation resources, in practice
|
||||
there is no need to.
|
||||
|
||||
## Dsl
|
||||
|
||||
### Index
|
||||
|
||||
#{Spark.Dsl.Extension.doc_index(@dsl)}
|
||||
|
||||
### Docs
|
||||
|
||||
#{Spark.Dsl.Extension.doc(@dsl)}
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Extension,
|
||||
sections: @dsl,
|
||||
transformers: [AshAuthentication.TokenRevocation.Transformer]
|
||||
|
||||
alias AshAuthentication.TokenRevocation.Info
|
||||
alias Ash.{Changeset, DataLayer, Query, Resource}
|
||||
|
||||
@doc """
|
||||
Revoke a token.
|
||||
|
||||
## Example
|
||||
|
||||
iex> {token, _} = build_token()
|
||||
...> revoke(Example.TokenRevocation, token)
|
||||
:ok
|
||||
|
||||
"""
|
||||
@spec revoke(Resource.t(), token :: String.t()) :: :ok | {:error, any}
|
||||
def revoke(resource, token) do
|
||||
with {:ok, api} <- Info.revocation_api(resource) do
|
||||
resource
|
||||
|> Changeset.for_create(:revoke_token, %{token: token})
|
||||
|> api.create(upsert?: true)
|
||||
|> case do
|
||||
{:ok, _} -> :ok
|
||||
{:ok, _, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find out if (via it's JTI) a token has been revoked?
|
||||
|
||||
## Example
|
||||
|
||||
iex> {token, %{"jti" => jti}} = build_token()
|
||||
...> revoked?(Example.TokenRevocation, jti)
|
||||
false
|
||||
...> revoke(Example.TokenRevocation, token)
|
||||
...> revoked?(Example.TokenRevocation, jti)
|
||||
true
|
||||
"""
|
||||
@spec revoked?(Resource.t(), jti :: String.t()) :: boolean
|
||||
def revoked?(resource, jti) do
|
||||
with {:ok, api} <- Info.revocation_api(resource) do
|
||||
resource
|
||||
|> Query.for_read(:revoked, %{jti: jti})
|
||||
|> api.read()
|
||||
|> case do
|
||||
{:ok, []} -> false
|
||||
_ -> true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
The opposite of `revoked?/2`
|
||||
"""
|
||||
@spec valid?(Resource.t(), jti :: String.t()) :: boolean
|
||||
def valid?(resource, jti), do: not revoked?(resource, jti)
|
||||
|
||||
@doc """
|
||||
Expunge expired revocations.
|
||||
|
||||
## Note
|
||||
|
||||
Sadly this function iterates over all expired revocations and deletes them
|
||||
individually because Ash (as of v2.1.0) does not yet support bulk actions and
|
||||
we can't just drop down to Ecto because we can't assume that the user's
|
||||
resource uses an Ecto-backed data layer.
|
||||
|
||||
Luckily, this function is only run periodically, so it shouldn't be a huge
|
||||
cost. Contact the maintainers if it becomes a problem for you.
|
||||
"""
|
||||
@spec expunge(Resource.t()) :: :ok | {:error, any}
|
||||
def expunge(resource) do
|
||||
DataLayer.transaction(
|
||||
resource,
|
||||
fn ->
|
||||
with {:ok, api} <- Info.revocation_api(resource),
|
||||
query <- Query.for_read(resource, :expired),
|
||||
{:ok, expired} <- api.read(query) do
|
||||
expired
|
||||
|> Stream.map(&remove_revocation/1)
|
||||
|> Enum.reduce_while(:ok, fn
|
||||
:ok, _ -> {:cont, :ok}
|
||||
{:error, reason}, _ -> {:halt, {:error, reason}}
|
||||
end)
|
||||
end
|
||||
end,
|
||||
5000
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a revocation.
|
||||
|
||||
## Warning
|
||||
|
||||
If the revocation in question is not yet expired, then this has the effect of
|
||||
making this token valid again.
|
||||
|
||||
You are unlikely to need to do this, as `AshAuthentication` will periodically
|
||||
remove all expired revocations automatically, however it is provided here in
|
||||
case you need it.
|
||||
"""
|
||||
@spec remove_revocation(Resource.record()) :: :ok | {:error, any}
|
||||
def remove_revocation(revocation) do
|
||||
with {:ok, api} <- Info.revocation_api(revocation.__struct__) do
|
||||
revocation
|
||||
|> Changeset.for_destroy(:expire)
|
||||
|> api.destroy()
|
||||
|> case do
|
||||
:ok -> :ok
|
||||
{:ok, _} -> :ok
|
||||
{:ok, _, _} -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,53 +0,0 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Expunger do
|
||||
@default_period_hrs 12
|
||||
|
||||
@moduledoc """
|
||||
A `GenServer` which periodically removes expired token revocations.
|
||||
|
||||
Scans all token revocation resources every #{@default_period_hrs} hours and removes
|
||||
any expired token revocations.
|
||||
|
||||
You can change the expunger period by configuring it in your application
|
||||
environment:
|
||||
|
||||
```elixir
|
||||
config :ash_authentication, #{inspect(__MODULE__)},
|
||||
period_hrs: #{@default_period_hrs}
|
||||
```
|
||||
|
||||
This server is started automatically as part of the `:ash_authentication`
|
||||
supervision tree.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
alias AshAuthentication.TokenRevocation
|
||||
|
||||
@doc false
|
||||
@spec start_link(any) :: GenServer.on_start()
|
||||
def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec init(any) :: {:ok, :timer.tref()}
|
||||
def init(_) do
|
||||
period =
|
||||
:ash_authentication
|
||||
|> Application.get_env(__MODULE__, [])
|
||||
|> Keyword.get(:period_hrs, @default_period_hrs)
|
||||
|> then(&(&1 * 60 * 60 * 1000))
|
||||
|
||||
:timer.send_interval(period, :expunge)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
def handle_info(:expunge, tref) do
|
||||
:code.all_loaded()
|
||||
|> Stream.map(&elem(&1, 0))
|
||||
|> Stream.filter(&function_exported?(&1, :spark_dsl_config, 0))
|
||||
|> Stream.filter(&(TokenRevocation in Spark.extensions(&1)))
|
||||
|> Enum.each(&TokenRevocation.expunge/1)
|
||||
|
||||
{:noreply, tref}
|
||||
end
|
||||
end
|
|
@ -1,10 +0,0 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Info do
|
||||
@moduledoc """
|
||||
Introspection functions for the `AshAuthentication.TokenRevocation` Ash
|
||||
extension.
|
||||
"""
|
||||
|
||||
use AshAuthentication.InfoGenerator,
|
||||
extension: AshAuthentication.TokenRevocation,
|
||||
sections: [:revocation]
|
||||
end
|
|
@ -1,36 +0,0 @@
|
|||
defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
|
||||
@moduledoc """
|
||||
Decode the passed in token and build a revocation based on it's claims.
|
||||
"""
|
||||
|
||||
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, _) do
|
||||
changeset
|
||||
|> Changeset.before_action(fn changeset ->
|
||||
changeset
|
||||
|> Changeset.get_argument(:token)
|
||||
|> Jwt.peek()
|
||||
|> case do
|
||||
{:ok, %{"jti" => jti, "exp" => exp}} ->
|
||||
expires_at =
|
||||
exp
|
||||
|> DateTime.from_unix!()
|
||||
|
||||
changeset
|
||||
|> Changeset.change_attributes(jti: jti, expires_at: expires_at)
|
||||
|
||||
{:ok, _} ->
|
||||
{:error, "Invalid token"}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, InvalidArgument.exception(field: :token, message: to_string(reason))}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
|
@ -1,197 +0,0 @@
|
|||
defmodule AshAuthentication.TokenRevocation.Transformer do
|
||||
@moduledoc """
|
||||
The token revocation transformer.
|
||||
|
||||
Sets up the default schema and actions for the token revocation resource.
|
||||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
require Ash.Expr
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.TokenRevocation
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
import AshAuthentication.Validations.Action
|
||||
import AshAuthentication.Validations.Attribute
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec after?(any) :: boolean()
|
||||
def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
|
||||
def after?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec before?(any) :: boolean
|
||||
def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true
|
||||
def before?(Resource.Transformers.DefaultAccept), do: true
|
||||
def before?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec transform(map) ::
|
||||
:ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
|
||||
def transform(dsl_state) do
|
||||
with {:ok, _api} <- validate_api_presence(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :jti, :string,
|
||||
primary_key?: true,
|
||||
allow_nil?: false,
|
||||
sensitive?: true,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_jti_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_attribute(dsl_state, :expires_at, :utc_datetime,
|
||||
allow_nil?: false,
|
||||
writable?: true
|
||||
),
|
||||
:ok <- validate_expires_at_field(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, :revoke_token, &build_create_revoke_token_action/1),
|
||||
:ok <- validate_revoke_token_action(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_revoked_action/1),
|
||||
:ok <- validate_read_revoked_action(dsl_state),
|
||||
{:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_expired_action/1),
|
||||
:ok <- validate_read_expired_action(dsl_state),
|
||||
{:ok, dsl_state} <-
|
||||
maybe_build_action(dsl_state, :destroy, &build_destroy_expire_action/1),
|
||||
:ok <- validate_destroy_expire_action(dsl_state) do
|
||||
{:ok, dsl_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_create_revoke_token_action(_dsl_state) 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: TokenRevocation.RevokeTokenChange
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :create,
|
||||
name: :revoke_token,
|
||||
primary?: true,
|
||||
arguments: arguments,
|
||||
changes: changes,
|
||||
accept: []
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_revoke_token_action(dsl_state) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, :revoke_token),
|
||||
:ok <- validate_token_argument(action) do
|
||||
validate_action_has_change(action, TokenRevocation.RevokeTokenChange)
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_token_argument(action) do
|
||||
with :ok <-
|
||||
validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do
|
||||
validate_action_argument_option(action, :token, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_read_revoked_action(_dsl_state) do
|
||||
import Ash.Filter.TemplateHelpers
|
||||
|
||||
arguments = [
|
||||
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
|
||||
name: :jti,
|
||||
type: :string,
|
||||
allow_nil?: false,
|
||||
sensitive?: true
|
||||
)
|
||||
]
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: :revoked,
|
||||
get?: true,
|
||||
filter: expr(jti == ^arg(:jti)),
|
||||
arguments: arguments
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_read_revoked_action(dsl_state) do
|
||||
with {:ok, action} <- validate_action_exists(dsl_state, :revoked),
|
||||
:ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_action_argument_option(action, :jti, :allow_nil?, [false]) do
|
||||
validate_action_argument_option(action, :jti, :sensitive?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp build_read_expired_action(_dsl_state) do
|
||||
import Ash.Filter.TemplateHelpers
|
||||
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: :expired,
|
||||
get?: true,
|
||||
filter: expr(expires_at < now())
|
||||
)
|
||||
end
|
||||
|
||||
defp validate_read_expired_action(dsl_state) do
|
||||
with {:ok, _} <- validate_action_exists(dsl_state, :expired) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_destroy_expire_action(_dsl_state),
|
||||
do:
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :destroy, name: :expire, primary?: true)
|
||||
|
||||
defp validate_destroy_expire_action(dsl_state) do
|
||||
with {:ok, _} <- validate_action_exists(dsl_state, :expire) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_jti_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :jti),
|
||||
:ok <- validate_attribute_option(attribute, resource, :type, [Ash.Type.String, :string]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :primary_key?, [true]) do
|
||||
validate_attribute_option(attribute, resource, :private?, [false])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_expires_at_field(dsl_state) do
|
||||
with {:ok, resource} <- persisted_option(dsl_state, :module),
|
||||
{:ok, attribute} <- find_attribute(dsl_state, :expires_at),
|
||||
:ok <-
|
||||
validate_attribute_option(attribute, resource, :type, [
|
||||
Ash.Type.UtcDatetime,
|
||||
:utc_datetime
|
||||
]),
|
||||
:ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do
|
||||
validate_attribute_option(attribute, resource, :writable?, [true])
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_api_presence(dsl_state) do
|
||||
case Transformer.get_option(dsl_state, [:revocation], :api) do
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:revocation, :api],
|
||||
message: "An API module must be present"
|
||||
)}
|
||||
|
||||
api ->
|
||||
{:ok, api}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,7 +6,8 @@ defmodule AshAuthentication.Transformer do
|
|||
"""
|
||||
|
||||
use Spark.Dsl.Transformer
|
||||
alias AshAuthentication.Info
|
||||
alias Ash.Resource
|
||||
alias AshAuthentication.{Info, TokenResource}
|
||||
alias Spark.{Dsl.Transformer, Error.DslError}
|
||||
import AshAuthentication.Utils
|
||||
import AshAuthentication.Validations
|
||||
|
@ -15,13 +16,13 @@ defmodule AshAuthentication.Transformer do
|
|||
@doc false
|
||||
@impl true
|
||||
@spec after?(any) :: boolean()
|
||||
def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
|
||||
def after?(Resource.Transformers.ValidatePrimaryActions), do: true
|
||||
def after?(_), do: false
|
||||
|
||||
@doc false
|
||||
@impl true
|
||||
@spec before?(any) :: boolean
|
||||
def before?(Ash.Resource.Transformers.DefaultAccept), do: true
|
||||
def before?(Resource.Transformers.DefaultAccept), do: true
|
||||
def before?(_), do: false
|
||||
|
||||
@doc false
|
||||
|
@ -40,7 +41,7 @@ defmodule AshAuthentication.Transformer do
|
|||
&build_get_by_subject_action/1
|
||||
),
|
||||
:ok <- validate_read_action(dsl_state, get_by_subject_action_name),
|
||||
:ok <- validate_token_revocation_resource(dsl_state),
|
||||
:ok <- validate_token_resource(dsl_state),
|
||||
subject_name <- find_or_generate_subject_name(dsl_state) do
|
||||
dsl_state =
|
||||
dsl_state
|
||||
|
@ -53,7 +54,7 @@ defmodule AshAuthentication.Transformer do
|
|||
defp build_get_by_subject_action(dsl_state) do
|
||||
with {:ok, get_by_subject_action_name} <-
|
||||
Info.authentication_get_by_subject_action_name(dsl_state) do
|
||||
Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read,
|
||||
Transformer.build_entity(Resource.Dsl, [:actions], :read,
|
||||
name: get_by_subject_action_name,
|
||||
get?: true
|
||||
)
|
||||
|
@ -73,29 +74,22 @@ defmodule AshAuthentication.Transformer do
|
|||
end
|
||||
end
|
||||
|
||||
defp validate_token_revocation_resource(dsl_state) do
|
||||
if Transformer.get_option(dsl_state, [:tokens], :enabled?) do
|
||||
with resource when not is_nil(resource) <-
|
||||
Transformer.get_option(dsl_state, [:tokens], :revocation_resource),
|
||||
Ash.Resource <- resource.spark_is(),
|
||||
true <- AshAuthentication.TokenRevocation in Spark.extensions(resource) do
|
||||
defp validate_token_resource(dsl_state) 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
|
||||
:ok
|
||||
else
|
||||
nil ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:tokens, :revocation_resource],
|
||||
message: "A revocation resource must be configured when tokens are enabled"
|
||||
)}
|
||||
|
||||
_ ->
|
||||
{:error,
|
||||
DslError.exception(
|
||||
path: [:tokens, :revocation_resource],
|
||||
message:
|
||||
"The revocation resource must be an Ash resource with the `AshAuthentication.TokenRevocation` extension"
|
||||
)}
|
||||
{:ok, falsy} when is_falsy(falsy) -> :ok
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp if_tokens_enabled(dsl_state, validator) when is_function(validator, 1) do
|
||||
if Info.authentication_tokens_enabled?(dsl_state) do
|
||||
validator.(dsl_state)
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
|
|
@ -4,11 +4,17 @@ defmodule AshAuthentication.Utils do
|
|||
alias Spark.{Dsl, Dsl.Transformer}
|
||||
|
||||
@doc """
|
||||
Returns true if `falsy` is either `nil` or `false`.
|
||||
Returns `true` if `falsy` is either `nil` or `false`.
|
||||
"""
|
||||
@spec is_falsy(any) :: Macro.t()
|
||||
defguard is_falsy(falsy) when falsy in [nil, false]
|
||||
|
||||
@doc """
|
||||
Returns `false` if `truthy` is either `nil` or `false`.
|
||||
"""
|
||||
@spec is_truthy(any) :: Macro.t()
|
||||
defguard is_truthy(truthy) when truthy not in [nil, false]
|
||||
|
||||
@doc """
|
||||
Convert a list of `String.Chars.t` into a sentence.
|
||||
|
||||
|
@ -148,4 +154,79 @@ defmodule AshAuthentication.Utils do
|
|||
do: Map.put(map, field, generator.(map))
|
||||
|
||||
def maybe_set_field_lazy(map, _field, _generator), do: map
|
||||
|
||||
@doc """
|
||||
Asserts that `resource` is an Ash resource and `extension` is a Spark DSL
|
||||
extension.
|
||||
"""
|
||||
@spec assert_resource_has_extension(Resource.t(), Spark.Dsl.Extension.t()) ::
|
||||
:ok | {:error, term}
|
||||
def assert_resource_has_extension(resource, extension) do
|
||||
with :ok <- assert_is_resource(resource) do
|
||||
assert_has_extension(resource, extension)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Asserts that `module` is actually an Ash resource.
|
||||
"""
|
||||
@spec assert_is_resource(Resource.t()) :: :ok | {:error, term}
|
||||
def assert_is_resource(module) do
|
||||
with :ok <- assert_is_module(module),
|
||||
true <- function_exported?(module, :spark_is, 0),
|
||||
Resource <- module.spark_is() do
|
||||
:ok
|
||||
else
|
||||
_ -> {:error, "Module `#{inspect(module)}` is not an Ash resource"}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Asserts that `module` is a Spark DSL extension.
|
||||
"""
|
||||
@spec assert_is_extension(Spark.Dsl.Extension.t()) :: :ok | {:error, term}
|
||||
def assert_is_extension(extension) do
|
||||
with :ok <- assert_is_module(extension) do
|
||||
assert_has_behaviour(extension, Spark.Dsl.Extension)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Asserts that `module` is actually a module.
|
||||
"""
|
||||
@spec assert_is_module(module) :: :ok | {:error, term}
|
||||
def assert_is_module(module) when is_atom(module) do
|
||||
case Code.ensure_loaded(module) do
|
||||
{:module, _module} -> :ok
|
||||
{:error, _} -> {:error, "Argument `#{inspect(module)}` is not a valid module name"}
|
||||
end
|
||||
end
|
||||
|
||||
def assert_is_module(module),
|
||||
do: {:error, "Argument `#{inspect(module)}` is not a valid module name"}
|
||||
|
||||
@doc """
|
||||
Asserts that `module` is extended by `extension`.
|
||||
"""
|
||||
@spec assert_has_extension(Resource.t(), Spark.Dsl.Extension.t()) :: :ok | {:error, term}
|
||||
def assert_has_extension(module, extension) do
|
||||
if extension in Spark.extensions(module) do
|
||||
:ok
|
||||
else
|
||||
{:error, "Module `#{inspect(module)}` is not extended by `#{inspect(extension)}`"}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Asserts that `module` implements `behaviour`.
|
||||
"""
|
||||
@spec assert_has_behaviour(module, module) :: :ok | {:error, term}
|
||||
def assert_has_behaviour(module, behaviour) do
|
||||
if Spark.implements_behaviour?(module, behaviour) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
"Module `#{inspect(module)}` does not implement the `#{inspect(behaviour)}` behaviour"}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
2
mix.exs
2
mix.exs
|
@ -30,7 +30,7 @@ defmodule AshAuthentication.MixProject do
|
|||
groups_for_modules: [
|
||||
Extensions: [
|
||||
AshAuthentication,
|
||||
AshAuthentication.TokenRevocation,
|
||||
AshAuthentication.TokenResource,
|
||||
AshAuthentication.UserIdentity
|
||||
],
|
||||
Strategies: [
|
||||
|
|
2
mix.lock
2
mix.lock
|
@ -57,7 +57,7 @@
|
|||
"providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"},
|
||||
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
|
||||
"spark": {:hex, :spark, "0.2.12", "03ebab9ed1ecc577c65fd1ae8b88c41d5ba8420b393658616a657d6d0fc2996f", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "22dfba98a9a6ebb5a21d520fa79cf3e67f9f549fff1c6ade55aa6c1d26814463"},
|
||||
"spark": {:hex, :spark, "0.2.13", "6a27fa40830cfdeb51ca417cb775c9b9b7583ab3b55c9b70ea9dae04b92a767a", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "8bc0c5e226c2d6be6365a7a9d5bdd394cd9fe4bc0c2074a1ad32b17bc8610c61"},
|
||||
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
|
||||
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
|
||||
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
defmodule Example.Repo.Migrations.MigrateResources1 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, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
|
||||
add :username, :citext, null: false
|
||||
add :hashed_password, :text, null: false
|
||||
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:user)
|
||||
end
|
||||
end
|
|
@ -1,28 +0,0 @@
|
|||
defmodule Example.Repo.Migrations.AddTokenRevocationTable 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 unique_index(:user, [:username],
|
||||
name: "user_with_username_username_index"
|
||||
)
|
||||
|
||||
create table(:token_revocations, primary_key: false) do
|
||||
add :expires_at, :utc_datetime, null: false
|
||||
add :jti, :text, null: false, primary_key: true
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:token_revocations)
|
||||
|
||||
drop_if_exists unique_index(:user, [:username],
|
||||
name: "user_with_username_username_index"
|
||||
)
|
||||
end
|
||||
end
|
|
@ -1,21 +0,0 @@
|
|||
defmodule Example.Repo.Migrations.AddConfirmedAtToUserWuthUsername 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
|
||||
alter table(:user) do
|
||||
add :confirmed_at, :utc_datetime_usec
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:user) do
|
||||
remove :confirmed_at
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,21 +0,0 @@
|
|||
defmodule Example.Repo.Migrations.RemoveNonNullFromHashedPassword 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
|
||||
alter table(:user) do
|
||||
modify :hashed_password, :text, null: true
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:user) do
|
||||
modify :hashed_password, :text, null: false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,42 +0,0 @@
|
|||
defmodule Example.Repo.Migrations.AddUserIdentitisTable 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_identities, primary_key: false) do
|
||||
add :refresh_token, :text
|
||||
add :access_token_expires_at, :utc_datetime_usec
|
||||
add :access_token, :text
|
||||
add :uid, :text, null: false
|
||||
add :strategy, :text, null: false
|
||||
add :id, :uuid, null: false, primary_key: true
|
||||
|
||||
add :user_id,
|
||||
references(:user,
|
||||
column: :id,
|
||||
name: "user_identities_user_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
end
|
||||
|
||||
create unique_index(:user_identities, [:strategy, :uid, :user_id],
|
||||
name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists unique_index(:user_identities, [:strategy, :uid, :user_id],
|
||||
name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
|
||||
)
|
||||
|
||||
drop constraint(:user_identities, "user_identities_user_id_fkey")
|
||||
|
||||
drop table(:user_identities)
|
||||
end
|
||||
end
|
83
priv/repo/migrations/20221129214830_migrate_resources1.exs
Normal file
83
priv/repo/migrations/20221129214830_migrate_resources1.exs
Normal file
|
@ -0,0 +1,83 @@
|
|||
defmodule Example.Repo.Migrations.MigrateResources1 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_identities, primary_key: false) do
|
||||
add :refresh_token, :text
|
||||
add :access_token_expires_at, :utc_datetime_usec
|
||||
add :access_token, :text
|
||||
add :uid, :text, null: false
|
||||
add :strategy, :text, null: false
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
|
||||
add :user_id, :uuid
|
||||
end
|
||||
|
||||
create table(:user, primary_key: false) do
|
||||
add :confirmed_at, :utc_datetime_usec
|
||||
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
|
||||
end
|
||||
|
||||
alter table(:user_identities) do
|
||||
modify :user_id,
|
||||
references(:user,
|
||||
column: :id,
|
||||
prefix: "public",
|
||||
name: "user_identities_user_id_fkey",
|
||||
type: :uuid
|
||||
)
|
||||
end
|
||||
|
||||
create unique_index(:user_identities, [:strategy, :uid, :user_id],
|
||||
name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
|
||||
)
|
||||
|
||||
alter table(:user) do
|
||||
add :username, :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, [:username], name: "user_username_index")
|
||||
|
||||
create table(:tokens, primary_key: false) do
|
||||
add :extra_data, :map
|
||||
add :purpose, :text, null: false
|
||||
add :expires_at, :utc_datetime, null: false
|
||||
add :jti, :text, null: false, primary_key: true
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:tokens)
|
||||
|
||||
drop_if_exists unique_index(:user, [:username], name: "user_username_index")
|
||||
|
||||
alter table(:user) do
|
||||
remove :updated_at
|
||||
remove :created_at
|
||||
remove :hashed_password
|
||||
remove :username
|
||||
end
|
||||
|
||||
drop_if_exists unique_index(:user_identities, [:strategy, :uid, :user_id],
|
||||
name: "user_identities_unique_on_strategy_and_uid_and_user_id_index"
|
||||
)
|
||||
|
||||
drop constraint(:user_identities, "user_identities_user_id_fkey")
|
||||
|
||||
alter table(:user_identities) do
|
||||
modify :user_id, :uuid
|
||||
end
|
||||
|
||||
drop table(:user)
|
||||
|
||||
drop table(:user_identities)
|
||||
end
|
||||
end
|
|
@ -1,5 +1,25 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "extra_data",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "purpose",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
|
@ -26,7 +46,7 @@
|
|||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "98092CB4D3ED441847CB38BC76408D77082FA08266FC8224FDF994A08A42CECB",
|
||||
"hash": "C03B9937AB0DBCCD13164A0772F6A052FB2DAEFBB6F125584F36399531AB5C43",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
|
@ -35,5 +55,5 @@
|
|||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "token_revocations"
|
||||
"table": "tokens"
|
||||
}
|
|
@ -66,11 +66,11 @@
|
|||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "7CBDF6DCD8CC589215A49B2F4D2749581BC72D1E4FD27DFBC4460FCDCD6087A6",
|
||||
"hash": "D72CF1073FC9EAAFA2C59CFAB17301530972DD0A7F030A1DDD006F62F75C1CDA",
|
||||
"identities": [
|
||||
{
|
||||
"base_filter": null,
|
||||
"index_name": "user_with_username_username_index",
|
||||
"index_name": "user_username_index",
|
||||
"keys": [
|
||||
"username"
|
||||
],
|
||||
|
@ -85,4 +85,4 @@
|
|||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user"
|
||||
}
|
||||
}
|
|
@ -47,12 +47,12 @@
|
|||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "provider",
|
||||
"source": "strategy",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"default": "fragment(\"uuid_generate_v4()\")",
|
||||
"generated?": false,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
|
@ -90,17 +90,17 @@
|
|||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "721BB418378550EA1ABD21F538EFD59A02FEE3E20A96EBCBAF6D2C99E1EC0672",
|
||||
"hash": "17B4CEEA4E648DF739AD38C1A090B8A97EB9B2A0C9A8F1DFE0B9C3A1842785BE",
|
||||
"identities": [
|
||||
{
|
||||
"base_filter": null,
|
||||
"index_name": "user_identities_unique_on_provider_and_uid_and_user_id_index",
|
||||
"index_name": "user_identities_unique_on_strategy_and_uid_and_user_id_index",
|
||||
"keys": [
|
||||
"provider",
|
||||
"strategy",
|
||||
"uid",
|
||||
"user_id"
|
||||
],
|
||||
"name": "unique_on_provider_and_uid_and_user_id"
|
||||
"name": "unique_on_strategy_and_uid_and_user_id"
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
|
@ -111,4 +111,4 @@
|
|||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user_identities"
|
||||
}
|
||||
}
|
|
@ -1,69 +0,0 @@
|
|||
{
|
||||
"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": "username",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"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": false,
|
||||
"hash": "F14A731ABBE8055A62D20F13B89906CC1EA14CB22BE344DC4E542A17429D55C0",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user"
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
{
|
||||
"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": "username",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"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": "EFE086DA7BA408EF8829E710CD8F250BE7CDFB956F0AC86EF83726A54210E933",
|
||||
"identities": [
|
||||
{
|
||||
"base_filter": null,
|
||||
"index_name": "user_with_username_username_index",
|
||||
"keys": [
|
||||
"username"
|
||||
],
|
||||
"name": "username"
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user"
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"size": null,
|
||||
"source": "confirmed_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"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": "username",
|
||||
"type": "citext"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"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": "12E7392CC92EAE2AB421A89BA0C03F0A92D72FD6732BB8CA6914408B1C77F471",
|
||||
"identities": [
|
||||
{
|
||||
"base_filter": null,
|
||||
"index_name": "user_with_username_username_index",
|
||||
"keys": [
|
||||
"username"
|
||||
],
|
||||
"name": "username"
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Example.Repo",
|
||||
"schema": null,
|
||||
"table": "user"
|
||||
}
|
|
@ -2,7 +2,7 @@ defmodule AshAuthentication.Jwt.ConfigTest do
|
|||
@moduledoc false
|
||||
use ExUnit.Case, async: true
|
||||
use Mimic
|
||||
alias AshAuthentication.{Jwt.Config, TokenRevocation}
|
||||
alias AshAuthentication.{Jwt.Config, TokenResource}
|
||||
|
||||
describe "default_claims/1" do
|
||||
test "it is a token config" do
|
||||
|
@ -51,15 +51,15 @@ defmodule AshAuthentication.Jwt.ConfigTest do
|
|||
|
||||
describe "validate_jti/3" do
|
||||
test "is true when the token has not been revoked" do
|
||||
TokenRevocation
|
||||
|> stub(:revoked?, fn _, _ -> false end)
|
||||
TokenResource
|
||||
|> stub(:jti_revoked?, fn _, _ -> false end)
|
||||
|
||||
assert Config.validate_jti("fake jti", nil, Example.User)
|
||||
end
|
||||
|
||||
test "is false when the token has been revoked" do
|
||||
TokenRevocation
|
||||
|> stub(:revoked?, fn _, _ -> true end)
|
||||
TokenResource
|
||||
|> stub(:jti_revoked?, fn _, _ -> true end)
|
||||
|
||||
assert Config.validate_jti("fake jti", nil, Example.User)
|
||||
end
|
||||
|
|
|
@ -69,7 +69,7 @@ defmodule AshAuthentication.JwtTest do
|
|||
test "it is unsuccessful when the token has been revoked" do
|
||||
{:ok, token, _} = build_user() |> Jwt.token_for_user()
|
||||
|
||||
AshAuthentication.TokenRevocation.revoke(Example.TokenRevocation, token)
|
||||
AshAuthentication.TokenResource.revoke(Example.Token, token)
|
||||
|
||||
assert :error = Jwt.verify(token, :ash_authentication)
|
||||
end
|
||||
|
|
|
@ -77,7 +77,7 @@ defmodule AshAuthentication.Plug.HelpersTest do
|
|||
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|
||||
|> Helpers.revoke_bearer_tokens(:ash_authentication)
|
||||
|
||||
assert AshAuthentication.TokenRevocation.revoked?(user.__struct__, jti)
|
||||
assert AshAuthentication.TokenResource.jti_revoked?(user.__struct__, jti)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
25
test/ash_authentication/token_resource_test.exs
Normal file
25
test/ash_authentication/token_resource_test.exs
Normal file
|
@ -0,0 +1,25 @@
|
|||
defmodule AshAuthentication.TokenResourceTest do
|
||||
@moduledoc false
|
||||
use DataCase, async: true
|
||||
alias AshAuthentication.{Jwt, TokenResource}
|
||||
doctest AshAuthentication.TokenResource
|
||||
|
||||
describe "revoke/2" do
|
||||
test "it revokes tokens" do
|
||||
{token, %{"jti" => jti}} = build_token()
|
||||
refute TokenResource.jti_revoked?(Example.Token, jti)
|
||||
|
||||
assert :ok = TokenResource.revoke(Example.Token, token)
|
||||
|
||||
assert TokenResource.jti_revoked?(Example.Token, jti)
|
||||
end
|
||||
end
|
||||
|
||||
def build_token do
|
||||
{:ok, token, claims} =
|
||||
build_user()
|
||||
|> Jwt.token_for_user()
|
||||
|
||||
{token, claims}
|
||||
end
|
||||
end
|
|
@ -1,26 +0,0 @@
|
|||
defmodule AshAuthentication.TokenRevocationTest do
|
||||
@moduledoc false
|
||||
use DataCase, async: true
|
||||
import AshAuthentication.TokenRevocation
|
||||
alias AshAuthentication.{Jwt, TokenRevocation}
|
||||
doctest AshAuthentication.TokenRevocation
|
||||
|
||||
describe "revoke/2" do
|
||||
test "it revokes tokens" do
|
||||
{token, %{"jti" => jti}} = build_token()
|
||||
refute TokenRevocation.revoked?(Example.TokenRevocation, jti)
|
||||
|
||||
assert :ok = TokenRevocation.revoke(Example.TokenRevocation, token)
|
||||
|
||||
assert TokenRevocation.revoked?(Example.TokenRevocation, jti)
|
||||
end
|
||||
end
|
||||
|
||||
def build_token do
|
||||
{:ok, token, claims} =
|
||||
build_user()
|
||||
|> Jwt.token_for_user()
|
||||
|
||||
{token, claims}
|
||||
end
|
||||
end
|
|
@ -93,16 +93,4 @@ defmodule DataCase do
|
|||
Ash.Resource.put_metadata(user, field, value)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Token revocation factory"
|
||||
@spec build_token_revocation :: Example.TokenRevocation.t() | no_return
|
||||
def build_token_revocation do
|
||||
{:ok, token, _claims} =
|
||||
build_user()
|
||||
|> AshAuthentication.Jwt.token_for_user()
|
||||
|
||||
Example.TokenRevocation
|
||||
|> Ash.Changeset.for_create(:revoke_token, %{token: token})
|
||||
|> Example.create!()
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,7 +4,7 @@ defmodule Example.Registry do
|
|||
|
||||
entries do
|
||||
entry Example.User
|
||||
entry Example.TokenRevocation
|
||||
entry Example.Token
|
||||
entry Example.UserIdentity
|
||||
end
|
||||
end
|
||||
|
|
15
test/support/example/token.ex
Normal file
15
test/support/example/token.ex
Normal file
|
@ -0,0 +1,15 @@
|
|||
defmodule Example.Token do
|
||||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenResource]
|
||||
|
||||
postgres do
|
||||
table("tokens")
|
||||
repo(Example.Repo)
|
||||
end
|
||||
|
||||
token do
|
||||
api Example
|
||||
end
|
||||
end
|
|
@ -1,28 +0,0 @@
|
|||
defmodule Example.TokenRevocation do
|
||||
@moduledoc false
|
||||
use Ash.Resource,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshAuthentication.TokenRevocation]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
jti: String.t(),
|
||||
expires_at: DateTime.t()
|
||||
}
|
||||
|
||||
actions do
|
||||
destroy :expire
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
end
|
||||
end
|
||||
|
||||
postgres do
|
||||
table("token_revocations")
|
||||
repo(Example.Repo)
|
||||
end
|
||||
|
||||
revocation do
|
||||
api Example
|
||||
end
|
||||
end
|
|
@ -102,7 +102,7 @@ defmodule Example.User do
|
|||
|
||||
tokens do
|
||||
enabled?(true)
|
||||
revocation_resource(Example.TokenRevocation)
|
||||
token_resource(Example.Token)
|
||||
end
|
||||
|
||||
add_ons do
|
||||
|
|
|
@ -6,5 +6,5 @@ Mimic.copy(AshAuthentication.Strategy.OAuth2.Actions)
|
|||
Mimic.copy(AshAuthentication.Strategy.OAuth2.Plug)
|
||||
Mimic.copy(AshAuthentication.Strategy.Password.Actions)
|
||||
Mimic.copy(AshAuthentication.Strategy.Password.Plug)
|
||||
Mimic.copy(AshAuthentication.TokenRevocation)
|
||||
Mimic.copy(AshAuthentication.TokenResource)
|
||||
ExUnit.start(capture_log: true)
|
||||
|
|
Loading…
Reference in a new issue