feat: Add token-required-for-authentication feature. (#116)

* Adds the `require_token_presence_for_authentication?` DSL option to the Authentication extension which when enabled changes the following behaviour:
  1. The `store_in_session` plug will store the user's token rather than their subject in the session.
  2. The `retrieve_from_session` plug will look for a stored token in the session rather than a subject and ensure that it's present in the `TokenResource`.
  3. The `retrieve_from_bearer` plug will ensure that the token is present in the `TokenResource`.
* Adds the `get_token` action to the `TokenResource`.
This commit is contained in:
James Harton 2023-01-11 15:12:53 +13:00 committed by GitHub
parent c444f4583e
commit d5c5d6b6c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 517 additions and 43 deletions

View file

@ -1,7 +1,6 @@
spark_locals_without_parens = [
access_token_attribute_name: 1,
access_token_expires_at_attribute_name: 1,
api: 1,
auth0: 0,
auth0: 1,
auth0: 2,
auth_method: 1,
@ -12,16 +11,17 @@ spark_locals_without_parens = [
confirm_action_name: 1,
confirm_on_create?: 1,
confirm_on_update?: 1,
confirmation: 0,
confirmation: 1,
confirmation: 2,
confirmation_required?: 1,
confirmed_at_field: 1,
destroy_action_name: 1,
enabled?: 1,
expunge_expired_action_name: 1,
expunge_interval: 1,
get_by_subject_action_name: 1,
get_changes_action_name: 1,
get_token_action_name: 1,
hash_provider: 1,
hashed_password_field: 1,
identity_field: 1,
@ -31,21 +31,22 @@ spark_locals_without_parens = [
inhibit_updates?: 1,
is_revoked_action_name: 1,
monitor_fields: 1,
oauth2: 0,
oauth2: 1,
oauth2: 2,
password: 0,
password: 1,
password: 2,
password_confirmation_field: 1,
password_field: 1,
password_reset_action_name: 1,
private_key: 1,
read_action_name: 1,
read_expired_action_name: 1,
redirect_uri: 1,
refresh_token_attribute_name: 1,
register_action_name: 1,
registration_enabled?: 1,
request_password_reset_action_name: 1,
require_token_presence_for_authentication?: 1,
resettable: 0,
resettable: 1,
revoke_token_action_name: 1,
@ -57,17 +58,11 @@ spark_locals_without_parens = [
store_all_tokens?: 1,
store_changes_action_name: 1,
store_token_action_name: 1,
strategy_attribute_name: 1,
subject_name: 1,
token_lifetime: 1,
token_path: 1,
token_resource: 1,
uid_attribute_name: 1,
upsert_action_name: 1,
user_id_attribute_name: 1,
user_path: 1,
user_relationship_name: 1,
user_resource: 1
user_path: 1
]
[

View file

@ -129,7 +129,7 @@ defmodule AshAuthentication do
## Example
iex> authenticated_resources(:ash_authentication)
[Example.User]
[Example.User, Example.UserWithTokenRequired]
"""
@spec authenticated_resources(atom) :: [Resource.t()]

View file

@ -129,6 +129,20 @@ defmodule AshAuthentication.Dsl do
""",
default: false
],
require_token_presence_for_authentication?: [
type: :boolean,
doc: """
Require a locally-stored token for authentication?
This inverts the token validation behaviour from requiring that
tokens are not revoked to requiring any token presented by a
client to be present in the token resource to be considered
valid.
Requires `store_all_tokens?` to be `true`.
""",
default: false
],
signing_algorithm: [
type: :string,
doc: """

View file

@ -13,9 +13,13 @@ defmodule AshAuthentication.Plug.Helpers do
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
def store_in_session(conn, user) when is_struct(user) do
subject_name = Info.authentication_subject_name!(user.__struct__)
subject = AshAuthentication.user_to_subject(user)
Conn.put_session(conn, subject_name, subject)
if Info.authentication_tokens_require_token_presence_for_authentication?(user.__struct__) do
Conn.put_session(conn, "#{subject_name}_token", user.__metadata__.token)
else
subject = AshAuthentication.user_to_subject(user)
Conn.put_session(conn, subject_name, subject)
end
end
def store_in_session(conn, _), do: conn
@ -60,17 +64,39 @@ defmodule AshAuthentication.Plug.Helpers do
def retrieve_from_session(conn, otp_app) do
otp_app
|> AshAuthentication.authenticated_resources()
|> Stream.map(&{&1, Info.authentication_options(&1)})
|> Enum.reduce(conn, fn {resource, options}, conn ->
current_subject_name = current_subject_name(options.subject_name)
|> Stream.map(
&{&1, Info.authentication_options(&1),
Info.authentication_tokens_require_token_presence_for_authentication?(&1)}
)
|> Enum.reduce(conn, fn
{resource, options, true}, conn ->
current_subject_name = current_subject_name(options.subject_name)
token_resource = Info.authentication_tokens_token_resource!(resource)
with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
Conn.assign(conn, current_subject_name, user)
else
_ ->
Conn.assign(conn, current_subject_name, nil)
end
with token when is_binary(token) <-
Conn.get_session(conn, "#{options.subject_name}_token"),
{:ok, %{"sub" => subject, "jti" => jti}, _} <- Jwt.verify(token, otp_app),
{:ok, [_]} <-
TokenResource.Actions.get_token(token_resource, %{
"jti" => jti,
"purpose" => "generic"
}),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
Conn.assign(conn, current_subject_name, user)
else
_ -> Conn.assign(conn, current_subject_name, nil)
end
{resource, options, false}, conn ->
current_subject_name = current_subject_name(options.subject_name)
with subject when is_binary(subject) <- Conn.get_session(conn, options.subject_name),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource) do
Conn.assign(conn, current_subject_name, user)
else
_ ->
Conn.assign(conn, current_subject_name, nil)
end
end)
end
@ -90,7 +116,8 @@ defmodule AshAuthentication.Plug.Helpers do
|> Stream.filter(&String.starts_with?(&1, "Bearer "))
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|> Enum.reduce(conn, fn token, conn ->
with {:ok, %{"sub" => subject}, resource} <- Jwt.verify(token, otp_app),
with {:ok, %{"sub" => subject, "jti" => jti}, resource} <- Jwt.verify(token, otp_app),
:ok <- validate_token(resource, jti),
{:ok, user} <- AshAuthentication.subject_to_user(subject, resource),
{:ok, subject_name} <- Info.authentication_subject_name(resource),
current_subject_name <- current_subject_name(subject_name) do
@ -102,6 +129,21 @@ defmodule AshAuthentication.Plug.Helpers do
end)
end
defp validate_token(resource, jti) do
if Info.authentication_tokens_require_token_presence_for_authentication?(resource) do
with {:ok, token_resource} <- Info.authentication_tokens_token_resource(resource),
{:ok, [_]} <-
TokenResource.Actions.get_token(token_resource, %{
"jti" => jti,
"purpose" => "generic"
}) do
:ok
end
else
:ok
end
end
@doc """
Revoke all authorization header(s).

View file

@ -44,9 +44,18 @@ defmodule AshAuthentication.TokenResource do
doc: """
The name of the action to use to store a token.
Used it `store_all_tokens?` is enabled in your authentication resource.
Used if `store_all_tokens?` is enabled in your authentication resource.
""",
default: :store_token
],
get_token_action_name: [
type: :atom,
doc: """
The name of the action used to retrieve tokens from the store.
Used if `require_token_presence_for_authentication?` is enabled in your authentication resource.
""",
default: :get_token
]
],
sections: [

View file

@ -102,7 +102,7 @@ defmodule AshAuthentication.TokenResource.Actions do
@doc """
Revoke a token.
Extracts the JTI from the provided token and uses it to generate a revocationr
Extracts the JTI from the provided token and uses it to generate a revocation
record.
"""
@spec revoke(Resource.t(), String.t(), keyword) :: :ok | {:error, any}
@ -149,6 +149,20 @@ defmodule AshAuthentication.TokenResource.Actions do
end
end
@doc """
Retrieve a token by token or JTI optionally filtering by purpose.
"""
@spec get_token(Resource.t(), map, keyword) :: {:ok, [Resource.record()]} | {:error, any}
def get_token(resource, params, opts \\ []) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, api} <- Info.token_api(resource),
{:ok, get_token_action_name} <- Info.token_get_token_action_name(resource) do
resource
|> Query.for_read(get_token_action_name, params, opts)
|> api.read()
end
end
defp expunge_inside_transaction(resource, expunge_expired_action_name, opts) do
with :ok <- assert_resource_has_extension(resource, TokenResource),
{:ok, api} <- Info.token_api(resource),

View file

@ -0,0 +1,49 @@
defmodule AshAuthentication.TokenResource.GetTokenPreparation do
@moduledoc """
Constrains a query to only records which match the `jti` or `token` argument
and optionally by the `purpose` argument.
"""
use Ash.Resource.Preparation
alias Ash.{Error.Query.InvalidArgument, Query, Resource.Preparation}
alias AshAuthentication.Jwt
require Ash.Query
@doc false
@impl true
@spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
def prepare(query, _, _) do
jti = get_jti(query)
purpose = Query.get_argument(query, :purpose)
query
|> Query.filter(jti: jti)
|> then(fn query ->
if purpose, do: Query.filter(query, purpose: purpose), else: query
end)
|> Query.filter(expires_at > now())
end
defp get_jti(query),
do: get_jti(Query.get_argument(query, :jti), Query.get_argument(query, :token))
defp get_jti(jti, _token) when byte_size(jti) > 0, do: jti
defp get_jti(_jti, token) when byte_size(token) > 0 do
token
|> Jwt.peek()
|> case do
{:ok, %{"jti" => jti}} -> jti
_ -> get_jti(nil, nil)
end
end
defp get_jti(_jti, _token),
do:
raise(
InvalidArgument.exception(
field: :jti,
message: "At least one of `jti` or `token` arguments must be present"
)
)
end

View file

@ -140,11 +140,66 @@ defmodule AshAuthentication.TokenResource.Transformer do
store_token_action_name,
&build_store_token_action(&1, store_token_action_name)
),
:ok <- validate_store_token_action(dsl_state, store_token_action_name) do
:ok <- validate_store_token_action(dsl_state, store_token_action_name),
{:ok, get_token_action_name} <- Info.token_get_token_action_name(dsl_state),
{:ok, dsl_state} <-
maybe_build_action(
dsl_state,
get_token_action_name,
&build_get_token_action(&1, get_token_action_name)
),
:ok <- validate_get_token_action(dsl_state, get_token_action_name) do
{:ok, dsl_state}
end
end
defp validate_get_token_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <-
validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
:ok <- validate_action_argument_option(action, :token, :allow_nil?, [true]),
:ok <- validate_action_argument_option(action, :token, :sensitive?, [true]),
:ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
:ok <- validate_action_argument_option(action, :jti, :allow_nil?, [true]),
:ok <-
validate_action_argument_option(action, :purpose, :type, [Ash.Type.String, :string]) do
validate_action_has_preparation(action, TokenResource.GetTokenPreparation)
end
end
defp build_get_token_action(_dsl_state, action_name) do
arguments = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: :token,
type: :string,
sensitive?: true
),
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: :jti,
type: :string,
sensitive?: false
),
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
name: :purpose,
type: :string,
sensitive?: false
)
]
preparations = [
Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
preparation: TokenResource.GetTokenPreparation
)
]
Transformer.build_entity(Resource.Dsl, [:actions], :read,
name: action_name,
arguments: arguments,
preparations: preparations,
get?: true
)
end
defp validate_store_token_action(dsl_state, action_name) do
with {:ok, action} <- validate_action_exists(dsl_state, action_name),
:ok <- validate_token_argument(action) do

View file

@ -215,11 +215,10 @@ defmodule AshAuthentication.Utils do
"""
@spec assert_is_module(module) :: :ok | {:error, term}
def assert_is_module(module) when is_atom(module) do
module.module_info()
:ok
rescue
_ ->
{:error, "Argument `#{inspect(module)}` is not a valid module"}
case Code.ensure_compiled(module) do
{:module, _} -> :ok
_ -> {:error, "Argument `#{inspect(module)}` is not a valid module"}
end
end
def assert_is_module(module),

View file

@ -18,6 +18,11 @@ defmodule AshAuthentication.Verifier do
@spec before?(any) :: boolean
def before?(_), do: false
@doc false
@impl true
@spec after_compile? :: boolean
def after_compile?, do: true
@doc false
@impl true
@spec transform(map) ::

View file

@ -0,0 +1,31 @@
defmodule Example.Repo.Migrations.AddUserWithTokenRequiredResource do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:user_with_token_required, primary_key: false) do
add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
add :email, :citext, null: false
add :hashed_password, :text
add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
end
create unique_index(:user_with_token_required, [:email],
name: "user_with_token_required_email_index"
)
end
def down do
drop_if_exists unique_index(:user_with_token_required, [:email],
name: "user_with_token_required_email_index"
)
drop table(:user_with_token_required)
end
end

View file

@ -0,0 +1,78 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"uuid_generate_v4()\")",
"generated?": false,
"primary_key?": true,
"references": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "hashed_password",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"now()\")",
"generated?": false,
"primary_key?": false,
"references": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "601DA745C121A871D5428D2BBC269B22E7D92FBE4D0111B5468649E999F656AA",
"identities": [
{
"base_filter": null,
"index_name": "user_with_token_required_email_index",
"keys": [
"email"
],
"name": "email"
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Example.Repo",
"schema": null,
"table": "user_with_token_required"
}

View file

@ -1,7 +1,7 @@
defmodule AshAuthentication.Plug.HelpersTest do
@moduledoc false
use DataCase, async: true
alias AshAuthentication.{Jwt, Plug.Helpers}
alias AshAuthentication.{Jwt, Plug.Helpers, TokenResource}
import Plug.Test, only: [conn: 3]
alias Plug.Conn
@ -15,7 +15,7 @@ defmodule AshAuthentication.Plug.HelpersTest do
end
describe "store_in_session/2" do
test "it stores the user in the session", %{conn: conn} do
test "when token presence is not required it stores the user in the session", %{conn: conn} do
user = build_user()
subject = AshAuthentication.user_to_subject(user)
@ -25,6 +25,17 @@ defmodule AshAuthentication.Plug.HelpersTest do
assert conn.private.plug_session["user"] == subject
end
test "when token presence is required it stores the token in the session", %{conn: conn} do
user = build_user_with_token_required()
conn =
conn
|> Helpers.store_in_session(user)
assert conn.private.plug_session["user_with_token_required_token"] ==
user.__metadata__.token
end
end
describe "load_subjects/2" do
@ -39,7 +50,9 @@ defmodule AshAuthentication.Plug.HelpersTest do
end
describe "retrieve_from_session/2" do
test "it loads any subjects stored in the session", %{conn: conn} do
test "when token presence is not required it loads any subjects stored in the session", %{
conn: conn
} do
user = build_user()
subject = AshAuthentication.user_to_subject(user)
@ -50,10 +63,54 @@ defmodule AshAuthentication.Plug.HelpersTest do
assert conn.assigns.current_user.id == user.id
end
test "when token presence is required and the token is present in the token resource it loads the token's subject",
%{conn: conn} do
user = build_user_with_token_required()
conn =
conn
|> Conn.put_session("user_with_token_required_token", user.__metadata__.token)
|> Helpers.retrieve_from_session(:ash_authentication)
assert conn.assigns.current_user_with_token_required.id == user.id
end
test "when token presense is required and the token is not present in the token resource it doesn't load the token's subject",
%{conn: conn} do
user = build_user_with_token_required()
{:ok, %{"jti" => jti}} = Jwt.peek(user.__metadata__.token)
import Ecto.Query
Example.Repo.delete_all(from(t in Example.Token, where: t.jti == ^jti))
conn =
conn
|> Conn.put_session("user_with_token_required_token", user.__metadata__.token)
|> Helpers.retrieve_from_session(:ash_authentication)
refute conn.assigns.current_user_with_token_required
end
test "when token presense is requried and the token has been revoked it doesn't load the token's subject",
%{conn: conn} do
user = build_user_with_token_required()
:ok = TokenResource.revoke(Example.Token, user.__metadata__.token)
conn =
conn
|> Conn.put_session("user_with_token_required_token", user.__metadata__.token)
|> Helpers.retrieve_from_session(:ash_authentication)
refute conn.assigns.current_user_with_token_required
end
end
describe "retrieve_from_bearer/2" do
test "it loads any subjects from authorization headers", %{conn: conn} do
test "when token presense is not required it loads any subjects from authorization header(s)",
%{conn: conn} do
user = build_user()
conn =
@ -63,6 +120,49 @@ defmodule AshAuthentication.Plug.HelpersTest do
assert conn.assigns.current_user.id == user.id
end
test "when token presense is required and the token is present in the database it loads the subjects from the authorization header(s)",
%{conn: conn} do
user = build_user_with_token_required()
conn =
conn
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|> Helpers.retrieve_from_bearer(:ash_authentication)
assert conn.assigns.current_user_with_token_required.id == user.id
end
test "when token presense is required and the token is not present in the token resource it doesn't load the subjects from the authorization header(s)",
%{conn: conn} do
user = build_user_with_token_required()
{:ok, %{"jti" => jti}} = Jwt.peek(user.__metadata__.token)
import Ecto.Query
Example.Repo.delete_all(from(t in Example.Token, where: t.jti == ^jti))
conn =
conn
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|> Helpers.retrieve_from_bearer(:ash_authentication)
refute is_map_key(conn.assigns, :current_user_with_token_required)
end
test "when token presense is required and the token has been revoked it doesn't lkoad the subjects from the authorization header(s)",
%{conn: conn} do
user = build_user_with_token_required()
:ok = TokenResource.revoke(Example.Token, user.__metadata__.token)
conn =
conn
|> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}")
|> Helpers.retrieve_from_bearer(:ash_authentication)
refute is_map_key(conn.assigns, :current_user_with_token_required)
end
end
describe "revoke_bearer_tokens/2" do

View file

@ -6,7 +6,8 @@ defmodule AshAuthenticationTest do
describe "authenticated_resources/0" do
test "it correctly locates all authenticatable resources" do
assert [Example.User] = authenticated_resources(:ash_authentication)
assert [Example.User, Example.UserWithTokenRequired] =
authenticated_resources(:ash_authentication)
end
end
end

View file

@ -90,4 +90,28 @@ defmodule DataCase do
Ash.Resource.put_metadata(user, field, value)
end)
end
@doc "User with token required factory"
@spec build_user_with_token_required(keyword) :: Example.UserWithTokenRequired.t() | no_return
def build_user_with_token_required(attrs \\ []) do
password = password()
attrs =
attrs
|> Map.new()
|> Map.put_new(:email, Faker.Internet.email())
|> Map.put_new(:password, password)
|> Map.put_new(:password_confirmation, password)
user =
Example.UserWithTokenRequired
|> Ash.Changeset.new()
|> Ash.Changeset.for_create(:register_with_password, attrs)
|> Example.create!()
attrs
|> Enum.reduce(user, fn {field, value}, user ->
Ash.Resource.put_metadata(user, field, value)
end)
end
end

View file

@ -22,10 +22,7 @@ defmodule Example.AuthPlug do
Jason.encode!(%{
status: :success,
token: token,
user: %{
id: user.id,
username: user.username
},
user: Map.take(user, ~w[username id email]a),
strategy: strategy,
phase: phase
})

View file

@ -4,6 +4,7 @@ defmodule Example.Registry do
entries do
entry Example.User
entry Example.UserWithTokenRequired
entry Example.Token
entry Example.UserIdentity
end

View file

@ -0,0 +1,60 @@
defmodule Example.UserWithTokenRequired do
@moduledoc false
use Ash.Resource, data_layer: AshPostgres.DataLayer, extensions: [AshAuthentication]
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
email: String.t(),
hashed_password: String.t(),
created_at: DateTime.t(),
updated_at: DateTime.t()
}
attributes do
uuid_primary_key :id, writable?: true
attribute :email, :ci_string, allow_nil?: false
attribute :hashed_password, :string, allow_nil?: true, sensitive?: true, private?: true
create_timestamp :created_at
update_timestamp :updated_at
end
authentication do
api Example
tokens do
enabled? true
store_all_tokens? true
require_token_presence_for_authentication? true
token_resource Example.Token
signing_secret &get_config/2
end
strategies do
password do
identity_field :email
end
end
end
actions do
defaults [:create, :read, :update, :destroy]
end
identities do
identity :email, [:email], eager_check_with: Example
end
postgres do
table "user_with_token_required"
repo(Example.Repo)
end
def get_config(path, _resource) do
value =
:ash_authentication
|> Application.get_all_env()
|> get_in(path)
{:ok, value}
end
end