From 948298ac1c56c6b17ea35292b4b8bc1b72a968dd Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Fri, 13 Jan 2023 17:21:57 +1300 Subject: [PATCH] improvement(TokenResource)!: Store the token subject in the token resource. (#133) * improvement(TokenResource)!: Store the token subject in the token resource. This is a breaking change because you may have to delete tokens in your database so that you can avoid the non-null constraint on subject. * docs: Add upgrading documentation. --- documentation/topics/upgrading.md | 30 +++++++ .../token_resource/revoke_token_change.ex | 9 +- .../store_confirmation_changes_change.ex | 4 +- .../token_resource/store_token_change.ex | 4 +- .../token_resource/transformer.ex | 11 +++ ...13020413_add_subject_to_token_resource.exs | 21 +++++ .../repo/tokens/20230113020413.json | 89 +++++++++++++++++++ .../add_ons/confirmation/actions_test.exs | 5 +- 8 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 documentation/topics/upgrading.md create mode 100644 priv/repo/migrations/20230113020413_add_subject_to_token_resource.exs create mode 100644 priv/resource_snapshots/repo/tokens/20230113020413.json diff --git a/documentation/topics/upgrading.md b/documentation/topics/upgrading.md new file mode 100644 index 0000000..9409bc2 --- /dev/null +++ b/documentation/topics/upgrading.md @@ -0,0 +1,30 @@ +# Upgrading + +## Upgrading to version 3.6.0. + +As of version 3.6.0 the `TokenResource` extension adds the `subject` attribute +which allows us to more easily match tokens to specific users. This unlocks +some new use-cases (eg sign out everywhere). + +This means that you will need to generate new migrations and migrate your +database. + +### Upgrade steps: + +> ### Warning {: .warning} +> +> If you already have tokens stored in your database then the migration will +> likely throw a migration error due to the new `NOT NULL` constraint on +> `subject`. If this happens then you can either delete all your tokens or +> explicitly add the `subject` attribute to your resource with `allow_nil?` set +> to `true`. eg: +> +> ```elixir +> attributes do +> attribute :subject, :string, allow_nil?: true +> end +> ``` + +1. Run `mix ash_postgres.generate_migrations --name=add_subject_to_token_resource` +2. Run `mix ash_postgres.migrate` +3. 🎉 diff --git a/lib/ash_authentication/token_resource/revoke_token_change.ex b/lib/ash_authentication/token_resource/revoke_token_change.ex index d62485e..f4069f4 100644 --- a/lib/ash_authentication/token_resource/revoke_token_change.ex +++ b/lib/ash_authentication/token_resource/revoke_token_change.ex @@ -12,10 +12,15 @@ defmodule AshAuthentication.TokenResource.RevokeTokenChange do @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, %{"jti" => jti, "exp" => exp, "sub" => subject}} <- Jwt.peek(token), {:ok, expires_at} <- DateTime.from_unix(exp) do changeset - |> Changeset.change_attributes(jti: jti, purpose: "revocation", expires_at: expires_at) + |> Changeset.change_attributes( + jti: jti, + purpose: "revocation", + expires_at: expires_at, + subject: subject + ) else _ -> changeset diff --git a/lib/ash_authentication/token_resource/store_confirmation_changes_change.ex b/lib/ash_authentication/token_resource/store_confirmation_changes_change.ex index de51e78..4c27c70 100644 --- a/lib/ash_authentication/token_resource/store_confirmation_changes_change.ex +++ b/lib/ash_authentication/token_resource/store_confirmation_changes_change.ex @@ -12,10 +12,10 @@ defmodule AshAuthentication.TokenResource.StoreConfirmationChangesChange do @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, %{"jti" => jti, "exp" => exp, "sub" => subject}} <- Jwt.peek(token), {:ok, expires_at} <- DateTime.from_unix(exp) do changeset - |> Changeset.change_attributes(jti: jti, expires_at: expires_at) + |> Changeset.change_attributes(jti: jti, expires_at: expires_at, subject: subject) else _ -> changeset diff --git a/lib/ash_authentication/token_resource/store_token_change.ex b/lib/ash_authentication/token_resource/store_token_change.ex index 266542b..d4a6b16 100644 --- a/lib/ash_authentication/token_resource/store_token_change.ex +++ b/lib/ash_authentication/token_resource/store_token_change.ex @@ -12,10 +12,10 @@ defmodule AshAuthentication.TokenResource.StoreTokenChange do @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, %{"jti" => jti, "exp" => exp, "sub" => subject}} <- Jwt.peek(token), {:ok, expires_at} <- DateTime.from_unix(exp) do changeset - |> Changeset.change_attributes(jti: jti, expires_at: expires_at) + |> Changeset.change_attributes(jti: jti, expires_at: expires_at, subject: subject) else _ -> changeset diff --git a/lib/ash_authentication/token_resource/transformer.ex b/lib/ash_authentication/token_resource/transformer.ex index 33e3d99..51b3e57 100644 --- a/lib/ash_authentication/token_resource/transformer.ex +++ b/lib/ash_authentication/token_resource/transformer.ex @@ -42,6 +42,9 @@ defmodule AshAuthentication.TokenResource.Transformer do writable?: true ), :ok <- validate_jti_field(dsl_state), + {:ok, dsl_state} <- + maybe_build_attribute(dsl_state, :subject, :string, allow_nil?: false, writable?: true), + :ok <- validate_subject_field(dsl_state), {:ok, dsl_state} <- maybe_build_attribute(dsl_state, :expires_at, :utc_datetime, allow_nil?: false, @@ -153,6 +156,14 @@ defmodule AshAuthentication.TokenResource.Transformer do end end + defp validate_subject_field(dsl_state) do + with {:ok, resource} <- persisted_option(dsl_state, :module), + {:ok, attribute} <- find_attribute(dsl_state, :subject), + :ok <- validate_attribute_option(attribute, resource, :type, [Type.String, :string]) do + validate_attribute_option(attribute, resource, :writable?, [true]) + end + end + defp validate_get_token_action(dsl_state, action_name) do with {:ok, action} <- validate_action_exists(dsl_state, action_name), :ok <- diff --git a/priv/repo/migrations/20230113020413_add_subject_to_token_resource.exs b/priv/repo/migrations/20230113020413_add_subject_to_token_resource.exs new file mode 100644 index 0000000..499c6d7 --- /dev/null +++ b/priv/repo/migrations/20230113020413_add_subject_to_token_resource.exs @@ -0,0 +1,21 @@ +defmodule Example.Repo.Migrations.AddSubjectToTokenResource 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(:tokens) do + add :subject, :text, null: false + end + end + + def down do + alter table(:tokens) do + remove :subject + end + end +end diff --git a/priv/resource_snapshots/repo/tokens/20230113020413.json b/priv/resource_snapshots/repo/tokens/20230113020413.json new file mode 100644 index 0000000..c0ae113 --- /dev/null +++ b/priv/resource_snapshots/repo/tokens/20230113020413.json @@ -0,0 +1,89 @@ +{ + "attributes": [ + { + "allow_nil?": false, + "default": "fragment(\"now()\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "updated_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": false, + "default": "fragment(\"now()\")", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "created_at", + "type": "utc_datetime_usec" + }, + { + "allow_nil?": true, + "default": "nil", + "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", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "expires_at", + "type": "utc_datetime" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": false, + "references": null, + "size": null, + "source": "subject", + "type": "text" + }, + { + "allow_nil?": false, + "default": "nil", + "generated?": false, + "primary_key?": true, + "references": null, + "size": null, + "source": "jti", + "type": "text" + } + ], + "base_filter": null, + "check_constraints": [], + "custom_indexes": [], + "custom_statements": [], + "has_create_action": true, + "hash": "0936DAD6ABE123BBF0A4A703002999D621BCA7C1FAF53FE38BFCB1AAE0784449", + "identities": [], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Example.Repo", + "schema": null, + "table": "tokens" +} \ No newline at end of file diff --git a/test/ash_authentication/add_ons/confirmation/actions_test.exs b/test/ash_authentication/add_ons/confirmation/actions_test.exs index e59d997..3ee5b42 100644 --- a/test/ash_authentication/add_ons/confirmation/actions_test.exs +++ b/test/ash_authentication/add_ons/confirmation/actions_test.exs @@ -88,17 +88,18 @@ defmodule AshAuthentication.AddOn.Confirmation.ActionsTest do {:ok, strategy} = Info.strategy(Example.User, :confirm) user = build_user() - {:ok, _token, %{"jti" => jti, "exp" => exp}} = Jwt.token_for_user(user) + {:ok, _token, %{"jti" => jti, "exp" => exp, "sub" => subject}} = Jwt.token_for_user(user) %Example.Token{} |> Ecto.Changeset.cast( %{ "jti" => jti, + "subject" => subject, "expires_at" => DateTime.from_unix!(exp), "purpose" => "confirm", "extra_data" => %{"username" => username(), "hashed_password" => password()} }, - ~w[jti expires_at purpose extra_data]a + ~w[jti subject expires_at purpose extra_data]a ) |> Example.Repo.insert!(on_conflict: :replace_all, conflict_target: :jti)