From 7b607896eb83b42b6585c9cc3db0292c35950f47 Mon Sep 17 00:00:00 2001 From: James Harton Date: Fri, 22 Sep 2023 12:15:47 +1200 Subject: [PATCH] improvement: Allow all token lifetimes to be specified with a time unit. Now any DSL option which allows the configuring of a token lifetime can take _either_ a positive integer in it's previous default unit or a tuple containing a positive integer and a unit. Closes #376. Additionally includes switching the resettable entity to being a singleton since that feature didn't exist when I started. --- ...hAuthentication.AddOn.Confirmation.cheatmd | 7 ++-- ...hAuthentication.Strategy.MagicLink.cheatmd | 5 +-- ...shAuthentication.Strategy.Password.cheatmd | 12 ++++--- .../dsls/DSL:-AshAuthentication.cheatmd | 8 +++-- .../add_ons/confirmation.ex | 3 +- .../add_ons/confirmation/dsl.ex | 13 +++++-- lib/ash_authentication/dsl.ex | 14 ++++++-- lib/ash_authentication/jwt/config.ex | 9 +++-- .../strategies/magic_link.ex | 4 +-- .../strategies/magic_link/dsl.ex | 13 +++++-- .../strategies/magic_link/transformer.ex | 6 ++++ lib/ash_authentication/strategies/password.ex | 8 ++--- .../strategies/password/actions.ex | 4 +-- .../strategies/password/dsl.ex | 27 ++++++++++---- .../request_password_reset_preparation.ex | 4 +-- .../password/reset_token_validation.ex | 2 +- .../strategies/password/strategy.ex | 2 +- .../strategies/password/transformer.ex | 35 ++++++++++--------- .../strategies/password/verifier.ex | 3 +- lib/ash_authentication/transformer.ex | 24 +++++++++++++ mix.exs | 1 + .../strategies/password/actions_test.exs | 4 +-- .../strategies/password/strategy_test.exs | 6 ++-- .../strategies/password_test.exs | 4 +-- 24 files changed, 149 insertions(+), 69 deletions(-) diff --git a/documentation/dsls/DSL:-AshAuthentication.AddOn.Confirmation.cheatmd b/documentation/dsls/DSL:-AshAuthentication.AddOn.Confirmation.cheatmd index 73bd5cfb..f1abbff9 100644 --- a/documentation/dsls/DSL:-AshAuthentication.AddOn.Confirmation.cheatmd +++ b/documentation/dsls/DSL:-AshAuthentication.AddOn.Confirmation.cheatmd @@ -95,8 +95,9 @@ User confirmation flow * `:name` (`t:atom/0`) - Required. Uniquely identifies the add-on. -* `:token_lifetime` (`t:pos_integer/0`) - How long should the confirmation token be valid, in hours. - Defaults to 3 days. The default value is `72`. +* `:token_lifetime` - How long should the confirmation token be valid. + If no unit is provided, then hours is assumed. + Defaults to 3 days. The default value is `{3, :days}`. * `:monitor_fields` (list of `t:atom/0`) - Required. A list of fields to monitor for changes (eg `[:email, :phone_number]`). The confirmation will only be sent when one of these fields are changed. @@ -169,7 +170,7 @@ User confirmation flow | --- | --- | --- | --- | | `monitor_fields`* | `list(atom)` | | A list of fields to monitor for changes (eg `[:email, :phone_number]`). The confirmation will only be sent when one of these fields are changed. | | `sender`* | `(any, any, any -> any) \| module` | | How to send the confirmation instructions to the user. Allows you to glue sending of confirmation instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. Accepts a module, module and opts, or a function that takes a record, reset token and options. The options will be a keyword list containing the original changeset, before any changes were inhibited. This allows you to send an email to the user's new email address if it is being changed for example. See `AshAuthentication.Sender` for more information. | -| `token_lifetime` | `pos_integer` | `72` | How long should the confirmation token be valid, in hours. Defaults to 3 days. | +| `token_lifetime` | `pos_integer \| {pos_integer, :days \| :hours \| :minutes \| :seconds}` | `{3, :days}` | How long should the confirmation token be valid. If no unit is provided, then hours is assumed. Defaults to 3 days. | | `confirmed_at_field` | `atom` | `:confirmed_at` | The name of a field to store the time that the last confirmation took place. This attribute will be dynamically added to the resource if not already present. | | `confirm_on_create?` | `boolean` | `true` | Generate and send a confirmation token when a new resource is created? Will only trigger when a create action is executed _and_ one of the monitored fields is being set. | | `confirm_on_update?` | `boolean` | `true` | Generate and send a confirmation token when a resource is changed? Will only trigger when an update action is executed _and_ one of the monitored fields is being set. | diff --git a/documentation/dsls/DSL:-AshAuthentication.Strategy.MagicLink.cheatmd b/documentation/dsls/DSL:-AshAuthentication.Strategy.MagicLink.cheatmd index 19cc30fe..0929e21a 100644 --- a/documentation/dsls/DSL:-AshAuthentication.Strategy.MagicLink.cheatmd +++ b/documentation/dsls/DSL:-AshAuthentication.Strategy.MagicLink.cheatmd @@ -104,7 +104,8 @@ Strategy for authenticating using local users with a magic link * `:identity_field` (`t:atom/0`) - The name of the attribute which uniquely identifies the user. Usually something like `username` or `email_address`. The default value is `:username`. -* `:token_lifetime` (`t:pos_integer/0`) - How long the sign in token is valid, in minutes. The default value is `10`. +* `:token_lifetime` - How long the sign in token is valid. + If no unit is provided, then `minutes` is assumed. The default value is `{10, :minutes}`. * `:request_action_name` (`t:atom/0`) - The name to use for the request action. If not present it will be generated by prepending the strategy name @@ -148,7 +149,7 @@ Strategy for authenticating using local users with a magic link | --- | --- | --- | --- | | `sender`* | `(any, any, any -> any) \| module` | | How to send the magic link to the user. Allows you to glue sending of magic links to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. Accepts a module, module and opts, or a function that takes a record, reset token and options. See `AshAuthentication.Sender` for more information. | | `identity_field` | `atom` | `:username` | The name of the attribute which uniquely identifies the user. Usually something like `username` or `email_address`. | -| `token_lifetime` | `pos_integer` | `10` | How long the sign in token is valid, in minutes. | +| `token_lifetime` | `pos_integer \| {pos_integer, :days \| :hours \| :minutes \| :seconds}` | `{10, :minutes}` | How long the sign in token is valid. If no unit is provided, then `minutes` is assumed. | | `request_action_name` | `atom` | | The name to use for the request action. If not present it will be generated by prepending the strategy name with `request_`. | | `single_use_token?` | `boolean` | `true` | Automatically revoke the token once it's been used for sign in. | | `sign_in_action_name` | `atom` | | The name to use for the sign in action. If not present it will be generated by prepending the strategy name with `sign_in_with_`. | diff --git a/documentation/dsls/DSL:-AshAuthentication.Strategy.Password.cheatmd b/documentation/dsls/DSL:-AshAuthentication.Strategy.Password.cheatmd index 0aad2f42..b5781a70 100644 --- a/documentation/dsls/DSL:-AshAuthentication.Strategy.Password.cheatmd +++ b/documentation/dsls/DSL:-AshAuthentication.Strategy.Password.cheatmd @@ -150,7 +150,8 @@ end by `ash_authentication_phoenix` (since 1.7) to support signing in in a liveview, and then redirecting with a valid token to a controller action, allowing the liveview to show invalid username/password errors. The default value is `false`. -* `:sign_in_token_lifetime` (`t:pos_integer/0`) - A lifetime (in seconds) for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`. The default value is `60`. +* `:sign_in_token_lifetime` - A lifetime for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`. + If no unit is specified, defaults to `:seconds`. The default value is `{60, :seconds}`. @@ -162,8 +163,9 @@ Configure password reset options for the resource -* `:token_lifetime` (`t:pos_integer/0`) - How long should the reset token be valid, in hours. - Defaults to 3 days. The default value is `72`. +* `:token_lifetime` - How long should the reset token be valid. + If no unit is provided `:hours` is assumed. + Defaults to 3 days. The default value is `{3, :days}`. * `:request_password_reset_action_name` (`t:atom/0`) - The name to use for the action which generates a password reset token. If not present it will be generated by prepending the strategy name @@ -228,7 +230,7 @@ end | `sign_in_action_name` | `atom` | | The name to use for the sign in action. If not present it will be generated by prepending the strategy name with `sign_in_with_`. | | `sign_in_enabled?` | `boolean` | `true` | If you do not want new users to be able to sign in using this strategy, set this to false. | | `sign_in_tokens_enabled?` | `boolean` | `false` | Whether or not to support generating short lived sign in tokens. Requires the resource to have tokens enabled. There is no drawback to supporting this, and in the future this default will change from `false` to `true`. Sign in tokens can be generated on request by setting the `:token_type` context to `:sign_in` when calling the sign in action. You might do this when you need to generate a short lived token to be exchanged for a real token using the `validate_sign_in_token` route. This is used, for example, by `ash_authentication_phoenix` (since 1.7) to support signing in in a liveview, and then redirecting with a valid token to a controller action, allowing the liveview to show invalid username/password errors. | -| `sign_in_token_lifetime` | `pos_integer` | `60` | A lifetime (in seconds) for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`. | +| `sign_in_token_lifetime` | `pos_integer \| {pos_integer, :days \| :hours \| :minutes \| :seconds}` | `{60, :seconds}` | A lifetime for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`. If no unit is specified, defaults to `:seconds`. | ## authentication.strategies.password.resettable @@ -245,7 +247,7 @@ Configure password reset options for the resource | Name | Type | Default | Docs | | --- | --- | --- | --- | | `sender`* | `(any, any, any -> any) \| module` | | How to send the password reset instructions to the user. Allows you to glue sending of reset instructions to [swoosh](https://hex.pm/packages/swoosh), [ex_twilio](https://hex.pm/packages/ex_twilio) or whatever notification system is appropriate for your application. Accepts a module, module and opts, or a function that takes a record, reset token and options. See `AshAuthentication.Sender` for more information. | -| `token_lifetime` | `pos_integer` | `72` | How long should the reset token be valid, in hours. Defaults to 3 days. | +| `token_lifetime` | `pos_integer \| {pos_integer, :days \| :hours \| :minutes \| :seconds}` | `{3, :days}` | How long should the reset token be valid. If no unit is provided `:hours` is assumed. Defaults to 3 days. | | `request_password_reset_action_name` | `atom` | | The name to use for the action which generates a password reset token. If not present it will be generated by prepending the strategy name with `request_password_reset_with_`. | | `password_reset_action_name` | `atom` | | The name to use for the action which actually resets the user's password. If not present it will be generated by prepending the strategy name with `password_reset_with_`. | diff --git a/documentation/dsls/DSL:-AshAuthentication.cheatmd b/documentation/dsls/DSL:-AshAuthentication.cheatmd index 79afecd1..9d21c221 100644 --- a/documentation/dsls/DSL:-AshAuthentication.cheatmd +++ b/documentation/dsls/DSL:-AshAuthentication.cheatmd @@ -163,11 +163,13 @@ Configure JWT settings for this resource Available signing algorithms are; EdDSA, Ed448ph, Ed448, Ed25519ph, Ed25519, PS512, PS384, PS256, ES512, ES384, ES256, RS512, RS384, RS256, HS512, HS384 and HS256. The default value is `"HS256"`. -* `:token_lifetime` (`t:pos_integer/0`) - How long a token should be valid, in hours. +* `:token_lifetime` - How long a token should be valid. Since refresh tokens are not yet supported, you should probably set this to a reasonably long time to ensure a good user experience. - Defaults to 14 days. The default value is `336`. + You can either provide a tuple with a time unit, or a positive + integer, in which case the unit is assumed to be hours. + Defaults to 14 days. The default value is `{14, :days}`. * `:token_resource` - Required. The resource used to store token information. If token generation is enabled for this resource, we need a place to @@ -266,7 +268,7 @@ Configure JWT settings for this resource | `store_all_tokens?` | `boolean` | `false` | Store all tokens in the `token_resource`? Some applications need to keep track of all tokens issued to any user. This is optional behaviour with `ash_authentication` in order to preserve as much performance as possible. | | `require_token_presence_for_authentication?` | `boolean` | `false` | 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`. | | `signing_algorithm` | `String.t` | `"HS256"` | The algorithm to use for token signing. Available signing algorithms are; EdDSA, Ed448ph, Ed448, Ed25519ph, Ed25519, PS512, PS384, PS256, ES512, ES384, ES256, RS512, RS384, RS256, HS512, HS384 and HS256. | -| `token_lifetime` | `pos_integer` | `336` | How long a token should be valid, in hours. Since refresh tokens are not yet supported, you should probably set this to a reasonably long time to ensure a good user experience. Defaults to 14 days. | +| `token_lifetime` | `pos_integer \| {pos_integer, :days \| :hours \| :minutes \| :seconds}` | `{14, :days}` | How long a token should be valid. Since refresh tokens are not yet supported, you should probably set this to a reasonably long time to ensure a good user experience. You can either provide a tuple with a time unit, or a positive integer, in which case the unit is assumed to be hours. Defaults to 14 days. | | `signing_secret` | `(any, any -> any) \| module \| String.t` | | The secret used to sign tokens. Takes either a module which implements the `AshAuthentication.Secret` behaviour, a 2 arity anonymous function or a string. See the module documentation for `AshAuthentication.Secret` for more information. | diff --git a/lib/ash_authentication/add_ons/confirmation.ex b/lib/ash_authentication/add_ons/confirmation.ex index 322660a0..99d4e3a5 100644 --- a/lib/ash_authentication/add_ons/confirmation.ex +++ b/lib/ash_authentication/add_ons/confirmation.ex @@ -138,10 +138,9 @@ defmodule AshAuthentication.AddOn.Confirmation do {:ok, String.t()} | :error | {:error, any} def confirmation_token(strategy, changeset, user) do claims = %{"act" => strategy.confirm_action_name} - token_lifetime = strategy.token_lifetime * 3600 with {:ok, token, _claims} <- - Jwt.token_for_user(user, claims, token_lifetime: token_lifetime), + Jwt.token_for_user(user, claims, token_lifetime: strategy.token_lifetime), :ok <- Confirmation.Actions.store_changes(strategy, token, changeset) do {:ok, token} end diff --git a/lib/ash_authentication/add_ons/confirmation/dsl.ex b/lib/ash_authentication/add_ons/confirmation/dsl.ex index 1002d593..dd63af72 100644 --- a/lib/ash_authentication/add_ons/confirmation/dsl.ex +++ b/lib/ash_authentication/add_ons/confirmation/dsl.ex @@ -31,12 +31,19 @@ defmodule AshAuthentication.AddOn.Confirmation.Dsl do required: true ], token_lifetime: [ - type: :pos_integer, + type: + {:or, + [ + :pos_integer, + {:tuple, [:pos_integer, {:in, [:days, :hours, :minutes, :seconds]}]} + ]}, doc: """ - How long should the confirmation token be valid, in hours. + How long should the confirmation token be valid. + If no unit is provided, then hours is assumed. + Defaults to #{@default_confirmation_lifetime_days} days. """, - default: @default_confirmation_lifetime_days * 24 + default: {@default_confirmation_lifetime_days, :days} ], monitor_fields: [ type: {:list, :atom}, diff --git a/lib/ash_authentication/dsl.ex b/lib/ash_authentication/dsl.ex index 9dbbc10f..1f4f0658 100644 --- a/lib/ash_authentication/dsl.ex +++ b/lib/ash_authentication/dsl.ex @@ -141,17 +141,25 @@ defmodule AshAuthentication.Dsl do default: hd(algorithms()) ], token_lifetime: [ - type: :pos_integer, + type: + {:or, + [ + :pos_integer, + {:tuple, [:pos_integer, {:in, [:days, :hours, :minutes, :seconds]}]} + ]}, doc: """ - How long a token should be valid, in hours. + How long a token should be valid. Since refresh tokens are not yet supported, you should probably set this to a reasonably long time to ensure a good user experience. + You can either provide a tuple with a time unit, or a positive + integer, in which case the unit is assumed to be hours. + Defaults to #{@default_token_lifetime_days} days. """, - default: @default_token_lifetime_days * 24 + default: {@default_token_lifetime_days, :days} ], token_resource: [ type: {:or, [{:behaviour, Resource}, {:in, [false]}]}, diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex index 1278d879..e22a12e0 100644 --- a/lib/ash_authentication/jwt/config.ex +++ b/lib/ash_authentication/jwt/config.ex @@ -20,7 +20,7 @@ defmodule AshAuthentication.Jwt.Config do opts |> Keyword.fetch(:token_lifetime) |> case do - {:ok, hours} -> hours * 60 * 60 + {:ok, lifetime} -> lifetime_to_seconds(lifetime) :error -> token_lifetime(resource) end @@ -157,8 +157,13 @@ defmodule AshAuthentication.Jwt.Config do resource |> Info.authentication_tokens_token_lifetime() |> case do - {:ok, hours} -> hours * 60 * 60 + {:ok, lifetime} -> lifetime_to_seconds(lifetime) :error -> Jwt.default_lifetime_hrs() * 60 * 60 end end + + defp lifetime_to_seconds({seconds, :seconds}), do: seconds + defp lifetime_to_seconds({minutes, :minutes}), do: minutes * 60 + defp lifetime_to_seconds({hours, :hours}), do: hours * 60 * 60 + defp lifetime_to_seconds({days, :days}), do: days * 60 * 60 * 24 end diff --git a/lib/ash_authentication/strategies/magic_link.ex b/lib/ash_authentication/strategies/magic_link.ex index f00179d7..ffaf75dd 100644 --- a/lib/ash_authentication/strategies/magic_link.ex +++ b/lib/ash_authentication/strategies/magic_link.ex @@ -108,7 +108,7 @@ defmodule AshAuthentication.Strategy.MagicLink do sign_in_action_name: nil, single_use_token?: true, strategy_module: __MODULE__, - token_lifetime: 10, + token_lifetime: {10, :minutes}, token_param_name: :token use AshAuthentication.Strategy.Custom, entity: Dsl.dsl() @@ -141,7 +141,7 @@ defmodule AshAuthentication.Strategy.MagicLink do def request_token_for(strategy, user) when is_struct(strategy, __MODULE__) and is_struct(user, strategy.resource) do case Jwt.token_for_user(user, %{"act" => strategy.sign_in_action_name}, - token_lifetime: strategy.token_lifetime * 60, + token_lifetime: strategy.token_lifetime, purpose: :magic_link ) do {:ok, token, _claims} -> {:ok, token} diff --git a/lib/ash_authentication/strategies/magic_link/dsl.ex b/lib/ash_authentication/strategies/magic_link/dsl.ex index 6b447fdf..28db1075 100644 --- a/lib/ash_authentication/strategies/magic_link/dsl.ex +++ b/lib/ash_authentication/strategies/magic_link/dsl.ex @@ -29,11 +29,18 @@ defmodule AshAuthentication.Strategy.MagicLink.Dsl do default: :username ], token_lifetime: [ - type: :pos_integer, + type: + {:or, + [ + :pos_integer, + {:tuple, [:pos_integer, {:in, [:days, :hours, :minutes, :seconds]}]} + ]}, doc: """ - How long the sign in token is valid, in minutes. + How long the sign in token is valid. + + If no unit is provided, then `minutes` is assumed. """, - default: 10 + default: {10, :minutes} ], request_action_name: [ type: :atom, diff --git a/lib/ash_authentication/strategies/magic_link/transformer.ex b/lib/ash_authentication/strategies/magic_link/transformer.ex index 100c613e..10ba3d60 100644 --- a/lib/ash_authentication/strategies/magic_link/transformer.ex +++ b/lib/ash_authentication/strategies/magic_link/transformer.ex @@ -21,6 +21,7 @@ defmodule AshAuthentication.Strategy.MagicLink.Transformer do ), strategy <- maybe_set_sign_in_action_name(strategy), strategy <- maybe_set_request_action_name(strategy), + strategy <- maybe_transform_token_lifetime(strategy), {:ok, dsl_state} <- maybe_build_action( dsl_state, @@ -48,6 +49,11 @@ defmodule AshAuthentication.Strategy.MagicLink.Transformer do end end + defp maybe_transform_token_lifetime(strategy) when is_integer(strategy.token_lifetime), + do: %{strategy | token_lifetime: {strategy.token_lifetime, :minutes}} + + defp maybe_transform_token_lifetime(strategy), do: strategy + # sobelow_skip ["DOS.StringToAtom"] defp maybe_set_sign_in_action_name(strategy) when is_nil(strategy.sign_in_action_name), do: %{strategy | sign_in_action_name: String.to_atom("sign_in_with_#{strategy.name}")} diff --git a/lib/ash_authentication/strategies/password.ex b/lib/ash_authentication/strategies/password.ex index 4544499f..1013bf2d 100644 --- a/lib/ash_authentication/strategies/password.ex +++ b/lib/ash_authentication/strategies/password.ex @@ -106,7 +106,7 @@ defmodule AshAuthentication.Strategy.Password do register_action_accept: [], register_action_name: nil, registration_enabled?: true, - resettable: [], + resettable: nil, resource: nil, sign_in_action_name: nil, sign_in_enabled?: true, @@ -140,7 +140,7 @@ defmodule AshAuthentication.Strategy.Password do register_action_accept: [atom], register_action_name: atom, registration_enabled?: boolean, - resettable: [Resettable.t()], + resettable: nil | Resettable.t(), resource: module, sign_in_action_name: atom, sign_in_enabled?: boolean, @@ -161,11 +161,11 @@ defmodule AshAuthentication.Strategy.Password do """ @spec reset_token_for(t(), Resource.record()) :: {:ok, String.t()} | :error def reset_token_for( - %Password{resettable: [%Resettable{} = resettable]} = _strategy, + %Password{resettable: %Resettable{} = resettable} = _strategy, user ) do case Jwt.token_for_user(user, %{"act" => resettable.password_reset_action_name}, - token_lifetime: resettable.token_lifetime * 3600 + token_lifetime: resettable.token_lifetime ) do {:ok, token, _claims} -> {:ok, token} :error -> :error diff --git a/lib/ash_authentication/strategies/password/actions.ex b/lib/ash_authentication/strategies/password/actions.ex index 165b268a..adabf5cf 100644 --- a/lib/ash_authentication/strategies/password/actions.ex +++ b/lib/ash_authentication/strategies/password/actions.ex @@ -178,7 +178,7 @@ defmodule AshAuthentication.Strategy.Password.Actions do """ @spec reset_request(Password.t(), map, keyword) :: :ok | {:error, any} def reset_request( - %Password{resettable: [%Password.Resettable{} = resettable]} = strategy, + %Password{resettable: %Password.Resettable{} = resettable} = strategy, params, options ) do @@ -209,7 +209,7 @@ defmodule AshAuthentication.Strategy.Password.Actions do """ @spec reset(Password.t(), map, keyword) :: {:ok, Resource.record()} | {:error, any} def reset( - %Password{resettable: [%Password.Resettable{} = resettable]} = strategy, + %Password{resettable: %Password.Resettable{} = resettable} = strategy, params, options ) do diff --git a/lib/ash_authentication/strategies/password/dsl.ex b/lib/ash_authentication/strategies/password/dsl.ex index 9f5e3eae..07c3facc 100644 --- a/lib/ash_authentication/strategies/password/dsl.ex +++ b/lib/ash_authentication/strategies/password/dsl.ex @@ -28,6 +28,7 @@ defmodule AshAuthentication.Strategy.Password.Dsl do hide: [:name], target: Password, modules: [:hash_provider], + singleton_entity_keys: [:resettable], schema: [ name: [ type: :atom, @@ -148,10 +149,17 @@ defmodule AshAuthentication.Strategy.Password.Dsl do default: false ], sign_in_token_lifetime: [ - type: :pos_integer, - default: 60, + type: + {:or, + [ + :pos_integer, + {:tuple, [:pos_integer, {:in, [:days, :hours, :minutes, :seconds]}]} + ]}, + default: {60, :seconds}, doc: """ - A lifetime (in seconds) for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`. + A lifetime for which a generated sign in token will be valid, if `sign_in_tokens_enabled?`. + + If no unit is specified, defaults to `:seconds`. """ ] ], @@ -163,13 +171,20 @@ defmodule AshAuthentication.Strategy.Password.Dsl do target: Password.Resettable, schema: [ token_lifetime: [ - type: :pos_integer, + type: + {:or, + [ + :pos_integer, + {:tuple, [:pos_integer, {:in, [:days, :hours, :minutes, :seconds]}]} + ]}, doc: """ - How long should the reset token be valid, in hours. + How long should the reset token be valid. + + If no unit is provided `:hours` is assumed. Defaults to #{@default_token_lifetime_days} days. """, - default: @default_token_lifetime_days * 24 + default: {@default_token_lifetime_days, :days} ], request_password_reset_action_name: [ type: :atom, diff --git a/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex b/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex index 413fe9f9..b3cbc455 100644 --- a/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex +++ b/lib/ash_authentication/strategies/password/request_password_reset_preparation.ex @@ -22,7 +22,7 @@ defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do def prepare(query, _opts, _context) do strategy = Info.strategy_for_action!(query.resource, query.action.name) - if Enum.any?(strategy.resettable) do + if strategy.resettable do identity_field = strategy.identity_field identity = Query.get_argument(query, identity_field) select_for_senders = Info.authentication_select_for_senders!(query.resource) @@ -38,7 +38,7 @@ defmodule AshAuthentication.Strategy.Password.RequestPasswordResetPreparation do end end - defp after_action(_query, [user], %{resettable: [%{sender: {sender, send_opts}}]} = strategy) do + defp after_action(_query, [user], %{resettable: %{sender: {sender, send_opts}}} = strategy) do case Password.reset_token_for(strategy, user) do {:ok, token} -> sender.send(user, token, send_opts) _ -> nil diff --git a/lib/ash_authentication/strategies/password/reset_token_validation.ex b/lib/ash_authentication/strategies/password/reset_token_validation.ex index 2b8b9382..cd2f2efe 100644 --- a/lib/ash_authentication/strategies/password/reset_token_validation.ex +++ b/lib/ash_authentication/strategies/password/reset_token_validation.ex @@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.ResetTokenValidation do with {:ok, strategy} <- Info.strategy_for_action(changeset.resource, changeset.action.name), token when is_binary(token) <- Changeset.get_argument(changeset, :reset_token), {:ok, %{"act" => token_action}, _} <- Jwt.verify(token, changeset.resource), - {:ok, [resettable]} <- Map.fetch(strategy, :resettable), + {:ok, resettable} <- Map.fetch(strategy, :resettable), true <- to_string(resettable.password_reset_action_name) == token_action do :ok else diff --git a/lib/ash_authentication/strategies/password/strategy.ex b/lib/ash_authentication/strategies/password/strategy.ex index 0266463a..fcae87a9 100644 --- a/lib/ash_authentication/strategies/password/strategy.ex +++ b/lib/ash_authentication/strategies/password/strategy.ex @@ -34,7 +34,7 @@ defimpl AshAuthentication.Strategy, for: AshAuthentication.Strategy.Password do ) |> maybe_append(strategy.registration_enabled?, :register) |> maybe_append(strategy.sign_in_enabled?, :sign_in) - |> maybe_concat(Enum.any?(strategy.resettable), [:reset_request, :reset]) + |> maybe_concat(strategy.resettable, [:reset_request, :reset]) end @doc false diff --git a/lib/ash_authentication/strategies/password/transformer.ex b/lib/ash_authentication/strategies/password/transformer.ex index 0410d3af..e9b3153b 100644 --- a/lib/ash_authentication/strategies/password/transformer.ex +++ b/lib/ash_authentication/strategies/password/transformer.ex @@ -8,7 +8,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do alias Ash.{Resource, Type} alias AshAuthentication.{GenerateTokenChange, Strategy, Strategy.Password} - alias Spark.{Dsl.Transformer, Error.DslError} + alias Spark.Dsl.Transformer import AshAuthentication.Strategy.Custom.Helpers import AshAuthentication.Utils import AshAuthentication.Validations @@ -21,6 +21,7 @@ defmodule AshAuthentication.Strategy.Password.Transformer do def transform(strategy, dsl_state) do with :ok <- validate_identity_field(strategy.identity_field, dsl_state), :ok <- validate_hashed_password_field(strategy.hashed_password_field, dsl_state), + strategy <- maybe_transform_token_lifetime(strategy, :sign_in_token_lifetime, :seconds), strategy <- maybe_set_field_lazy(strategy, :register_action_name, &:"register_with_#{&1.name}"), {:ok, dsl_state} <- @@ -73,11 +74,9 @@ defmodule AshAuthentication.Strategy.Password.Transformer do end) |> then(fn dsl_state -> strategy - |> Map.get(:resettable, []) - |> Enum.flat_map(fn resettable -> - ~w[request_password_reset_action_name password_reset_action_name]a - |> Enum.map(&Map.get(resettable, &1)) - end) + |> Map.get(:resettable, %{}) + |> Map.take(~w[request_password_reset_action_name password_reset_action_name]a) + |> Map.values() |> register_strategy_actions(dsl_state, strategy) end) @@ -85,6 +84,13 @@ defmodule AshAuthentication.Strategy.Password.Transformer do end end + defp maybe_transform_token_lifetime(strategy, field, default_unit) do + case Map.get(strategy, field) do + ttl when is_integer(ttl) -> Map.put(strategy, field, {ttl, default_unit}) + _ -> strategy + end + end + defp validate_identity_field(identity_field, dsl_state) do with {:ok, resource} <- persisted_option(dsl_state, :module), {:ok, attribute} <- find_attribute(dsl_state, identity_field), @@ -348,11 +354,11 @@ defmodule AshAuthentication.Strategy.Password.Transformer do defp maybe_maybe_build_action(false, dsl_state, _action_name, _builder), do: {:ok, dsl_state} - defp maybe_transform_resettable(dsl_state, %{resettable: []} = strategy), + defp maybe_transform_resettable(dsl_state, %{resettable: nil} = strategy), do: {:ok, dsl_state, strategy} # sobelow_skip ["DOS.BinToAtom"] - defp maybe_transform_resettable(dsl_state, %{resettable: [resettable]} = strategy) do + defp maybe_transform_resettable(dsl_state, %{resettable: resettable} = strategy) do with resettable <- maybe_set_field_lazy( resettable, @@ -378,21 +384,16 @@ defmodule AshAuthentication.Strategy.Password.Transformer do resettable.password_reset_action_name, &build_reset_action(&1, resettable, strategy) ), - :ok <- validate_reset_action(dsl_state, resettable, strategy) do - {:ok, dsl_state, %{strategy | resettable: [resettable]}} + resettable <- maybe_transform_token_lifetime(resettable, :token_lifetime, :hours), + :ok <- + validate_reset_action(dsl_state, resettable, strategy) do + {:ok, dsl_state, %{strategy | resettable: resettable}} else {:error, reason} -> {:error, reason} end end - defp maybe_transform_resettable(_dsl_state, %{resettable: [_ | _]}), - do: - DslError.exception( - path: [:authentication, :strategies, :password], - message: "Only one `resettable` entity may be present." - ) - defp build_reset_request_action(dsl_state, resettable, strategy) do identity_attribute = Resource.Info.attribute(dsl_state, strategy.identity_field) diff --git a/lib/ash_authentication/strategies/password/verifier.ex b/lib/ash_authentication/strategies/password/verifier.ex index 5d118d6b..f7d48a67 100644 --- a/lib/ash_authentication/strategies/password/verifier.ex +++ b/lib/ash_authentication/strategies/password/verifier.ex @@ -69,7 +69,8 @@ defmodule AshAuthentication.Strategy.Password.Verifier do defp validate_tokens_enabled_for_sign_in_tokens(_, _), do: :ok - defp maybe_validate_resettable_sender(dsl_state, %{resettable: [resettable]}) do + defp maybe_validate_resettable_sender(dsl_state, %{resettable: resettable}) + when is_struct(resettable) do with {:ok, {sender, _opts}} <- Map.fetch(resettable, :sender), :ok <- validate_behaviour(sender, Sender) do :ok diff --git a/lib/ash_authentication/transformer.ex b/lib/ash_authentication/transformer.ex index b4a81f80..cfc653b7 100644 --- a/lib/ash_authentication/transformer.ex +++ b/lib/ash_authentication/transformer.ex @@ -33,6 +33,7 @@ defmodule AshAuthentication.Transformer do with :ok <- validate_at_least_one_strategy(dsl_state), :ok <- validate_unique_strategy_names(dsl_state), :ok <- validate_unique_add_on_names(dsl_state), + {:ok, dsl_state} <- maybe_transform_token_lifetime(dsl_state), {:ok, get_by_subject_action_name} <- Info.authentication_get_by_subject_action_name(dsl_state), {:ok, dsl_state} <- @@ -52,6 +53,29 @@ defmodule AshAuthentication.Transformer do end end + defp maybe_transform_token_lifetime(dsl_state) do + case Info.authentication_tokens_token_lifetime(dsl_state) do + {:ok, {_ttl, unit}} when unit in ~w[days hours minutes seconds]a -> + {:ok, dsl_state} + + {:ok, ttl} when is_integer(ttl) and ttl > 0 -> + {:ok, + Transformer.set_option( + dsl_state, + [:authentication, :tokens], + :token_lifetime, + {ttl, :hours} + )} + + _ -> + {:error, + DslError.exception( + path: [:authentication, :tokens], + message: "Invalid token lifetime" + )} + end + end + 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 diff --git a/mix.exs b/mix.exs index 3e783391..1a9f44f3 100644 --- a/mix.exs +++ b/mix.exs @@ -16,6 +16,7 @@ defmodule AshAuthentication.MixProject do deps: deps(), package: package(), elixirc_paths: elixirc_paths(Mix.env()), + consolidate_protocols: Mix.env() == :prod, dialyzer: [ plt_add_apps: [:mix, :ex_unit], plt_core_path: "priv/plts", diff --git a/test/ash_authentication/strategies/password/actions_test.exs b/test/ash_authentication/strategies/password/actions_test.exs index d394c7ab..f15dde57 100644 --- a/test/ash_authentication/strategies/password/actions_test.exs +++ b/test/ash_authentication/strategies/password/actions_test.exs @@ -209,7 +209,7 @@ defmodule AshAuthentication.Strategy.Password.ActionsTest do params = %{"username" => user.username} options = [] api = Info.authentication_api!(strategy.resource) - resettable = strategy.resettable |> Enum.at(0) + resettable = strategy.resettable result = strategy.resource @@ -246,7 +246,7 @@ defmodule AshAuthentication.Strategy.Password.ActionsTest do test "it returns an error when the strategy is not resettable" do {:ok, strategy} = Info.strategy(Example.User, :password) - strategy = %{strategy | resettable: []} + strategy = %{strategy | resettable: nil} assert {:error, error} = Actions.reset_request(strategy, %{"username" => username()}, []) assert Exception.message(error) =~ ~r/no such action/i diff --git a/test/ash_authentication/strategies/password/strategy_test.exs b/test/ash_authentication/strategies/password/strategy_test.exs index 95487704..940fc3e5 100644 --- a/test/ash_authentication/strategies/password/strategy_test.exs +++ b/test/ash_authentication/strategies/password/strategy_test.exs @@ -14,7 +14,7 @@ defmodule AshAuthentication.Strategy.Password.StrategyTest do describe "Strategy.phases/1" do test "it returns the correct phases when the strategy supports resetting" do - strategy = %Password{resettable: [%Resettable{}]} + strategy = %Password{resettable: %Resettable{}} phases = strategy @@ -38,7 +38,7 @@ defmodule AshAuthentication.Strategy.Password.StrategyTest do describe "Strategy.actions/1" do test "it returns the correct actions when the strategy supports resetting" do - strategy = %Password{resettable: [%Resettable{}]} + strategy = %Password{resettable: %Resettable{}} actions = strategy @@ -95,7 +95,7 @@ defmodule AshAuthentication.Strategy.Password.StrategyTest do {:ok, strategy} = Info.strategy(Example.User, :password) routes = - %{strategy | resettable: []} + %{strategy | resettable: nil} |> Strategy.routes() |> MapSet.new() diff --git a/test/ash_authentication/strategies/password_test.exs b/test/ash_authentication/strategies/password_test.exs index fcec60f4..0cb15931 100644 --- a/test/ash_authentication/strategies/password_test.exs +++ b/test/ash_authentication/strategies/password_test.exs @@ -18,8 +18,8 @@ defmodule AshAuthentication.Strategy.PasswordTest do describe "reset_token_for/1" do test "it generates a token when resets are enabled" do user = build_user() - resettable = %Resettable{password_reset_action_name: :reset, token_lifetime: 72} - strategy = %Password{resettable: [resettable], resource: user.__struct__} + resettable = %Resettable{password_reset_action_name: :reset, token_lifetime: {72, :hours}} + strategy = %Password{resettable: resettable, resource: user.__struct__} assert {:ok, token} = Password.reset_token_for(strategy, user) assert {:ok, claims} = Jwt.peek(token)