Skip to content

Commit

Permalink
improvement: Allow all token lifetimes to be specified with a time unit.
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jimsynz committed Sep 22, 2023
1 parent a876a44 commit 7b60789
Show file tree
Hide file tree
Showing 24 changed files with 149 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`.



Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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_`. |

Expand Down
8 changes: 5 additions & 3 deletions documentation/dsls/DSL:-AshAuthentication.cheatmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. |


Expand Down
3 changes: 1 addition & 2 deletions lib/ash_authentication/add_ons/confirmation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions lib/ash_authentication/add_ons/confirmation/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
14 changes: 11 additions & 3 deletions lib/ash_authentication/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]}]},
Expand Down
9 changes: 7 additions & 2 deletions lib/ash_authentication/jwt/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions lib/ash_authentication/strategies/magic_link.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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}
Expand Down
13 changes: 10 additions & 3 deletions lib/ash_authentication/strategies/magic_link/dsl.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions lib/ash_authentication/strategies/magic_link/transformer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")}
Expand Down
8 changes: 4 additions & 4 deletions lib/ash_authentication/strategies/password.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions lib/ash_authentication/strategies/password/actions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 7b60789

Please sign in to comment.