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.
This commit is contained in:
James Harton 2023-09-22 12:15:47 +12:00
parent a876a4453d
commit 7b607896eb
Signed by: james
GPG key ID: 90E82DAA13F624F4
24 changed files with 149 additions and 69 deletions

View file

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

View file

@ -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_`. |

View file

@ -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_`. |

View file

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

View file

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

View file

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

View file

@ -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]}]},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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