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.
This commit is contained in:
James Harton 2023-01-13 17:21:57 +13:00 committed by GitHub
parent 53d221aa6f
commit 948298ac1c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 165 additions and 8 deletions

View file

@ -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. 🎉

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <-

View file

@ -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

View file

@ -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"
}

View file

@ -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)